├── .gitignore
├── dredge
├── config.py
├── gcp_ir
│ ├── models.py
│ ├── __init__.py
│ ├── config.py
│ ├── services.py
│ └── hunt.py
├── github_ir
│ ├── __init__.py
│ ├── models.py
│ ├── services.py
│ ├── config.py
│ └── hunt.py
├── aws_ir
│ ├── models.py
│ ├── __init__.py
│ ├── services.py
│ ├── forensics.py
│ ├── hunt.py
│ └── response.py
├── __init__.py
├── auth.py
└── cli.py
├── tests
├── conftest.py
├── test_operation_result.py
├── test_cli_parsing.py
├── test_github_hunt_phrase.py
└── test_github_hunt_pagination.py
├── pyproject.toml
├── requirements.txt
├── Dockerfile
├── README.md
└── LICENSE
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__*
2 | vbdemo*
3 | logs_dump_dredge/*
4 |
--------------------------------------------------------------------------------
/dredge/config.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 | from typing import Optional, Dict
3 |
4 | @dataclass
5 | class DredgeConfig:
6 | region_name: Optional[str] = None
7 | default_tags: Dict[str, str] = field(default_factory=dict)
8 | dry_run: bool = False
9 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | # tests/conftest.py
2 | import sys
3 | from pathlib import Path
4 |
5 | # Add repo root to sys.path so `import dredge` works when running pytest
6 | ROOT = Path(__file__).resolve().parents[1]
7 | if str(ROOT) not in sys.path:
8 | sys.path.insert(0, str(ROOT))
9 |
--------------------------------------------------------------------------------
/dredge/gcp_ir/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass, field
4 | from typing import Any, Dict, List
5 |
6 |
7 | @dataclass
8 | class OperationResult:
9 | operation: str
10 | target: str
11 | success: bool
12 | details: Dict[str, Any] = field(default_factory=dict)
13 | errors: List[str] = field(default_factory=list)
14 |
15 | def add_error(self, message: str) -> None:
16 | self.errors.append(message)
17 | self.success = False
--------------------------------------------------------------------------------
/dredge/github_ir/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from .config import GitHubIRConfig
4 | from .services import GitHubServiceRegistry
5 | from .hunt import GitHubIRHunt
6 |
7 |
8 | class GitHubIRNamespace:
9 | """
10 | Grouping for GitHub Incident Response functionality.
11 |
12 | dredge.github_ir.hunt.search_audit_log(...)
13 | """
14 |
15 | def __init__(self, config: GitHubIRConfig) -> None:
16 | self._services = GitHubServiceRegistry(config)
17 | self.hunt = GitHubIRHunt(self._services, config)
--------------------------------------------------------------------------------
/dredge/gcp_ir/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from .config import GcpIRConfig
4 | from .services import GcpLoggingService
5 | from .hunt import GcpIRHunt
6 |
7 |
8 | class GcpIRNamespace:
9 | """
10 | Grouping for GCP Incident Response / log hunt functionality:
11 |
12 | dredge.gcp_ir.hunt.search_logs(...)
13 | dredge.gcp_ir.hunt.search_today(...)
14 | """
15 |
16 | def __init__(self, config: GcpIRConfig) -> None:
17 | services = GcpLoggingService(config)
18 | self.hunt = GcpIRHunt(services, config)
19 |
--------------------------------------------------------------------------------
/dredge/github_ir/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass, field
4 | from typing import Any, Dict, List
5 |
6 |
7 | @dataclass
8 | class OperationResult:
9 | """
10 | Simple result wrapper (mirrors the AWS one for consistency).
11 | """
12 | operation: str
13 | target: str
14 | success: bool
15 | details: Dict[str, Any] = field(default_factory=dict)
16 | errors: List[str] = field(default_factory=list)
17 |
18 | def add_error(self, message: str) -> None:
19 | self.errors.append(message)
20 | self.success = False
21 |
--------------------------------------------------------------------------------
/tests/test_operation_result.py:
--------------------------------------------------------------------------------
1 | # tests/test_operation_result.py
2 |
3 | from dredge.aws_ir.models import OperationResult
4 |
5 |
6 | def test_operation_result_initial_state():
7 | r = OperationResult(operation="test-op", target="foo", success=True)
8 | assert r.success is True
9 | assert r.errors == []
10 | assert isinstance(r.details, dict)
11 |
12 |
13 | def test_operation_result_add_error_marks_failure():
14 | r = OperationResult(operation="test-op", target="foo", success=True)
15 | r.add_error("something went wrong")
16 | assert r.success is False
17 | assert r.errors == ["something went wrong"]
18 |
--------------------------------------------------------------------------------
/dredge/aws_ir/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass, field
4 | from typing import Any, Dict, List, Optional
5 |
6 |
7 | @dataclass
8 | class OperationResult:
9 | """
10 | Standard result for an action. Helps you keep everything
11 | consistent and easy to log/serialize.
12 | """
13 | operation: str
14 | target: str
15 | success: bool
16 | details: Dict[str, Any] = field(default_factory=dict)
17 | errors: List[str] = field(default_factory=list)
18 |
19 | def add_error(self, message: str) -> None:
20 | self.errors.append(message)
21 | self.success = False
22 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "dredge"
3 | version = "0.1.1"
4 | description = "Cloud Incident Response & Threat Hunting for AWS + GitHub"
5 | readme = "README.md"
6 | requires-python = ">=3.9"
7 | authors = [
8 | { name="Solidarity Labs", email="sabastante@solidaritylabs.io" }
9 | ]
10 |
11 | dependencies = [
12 | "boto3>=1.33.0",
13 | "requests>=2.31.0",
14 | "python-dateutil>=2.8.2",
15 | "urllib3>=2.0.0",
16 | "google-cloud-logging>=3.10",
17 | "google-auth>=2.0",
18 | ]
19 |
20 | [project.scripts]
21 | dredge = "dredge.cli:main"
22 |
23 | [build-system]
24 | requires = ["setuptools>=64", "wheel"]
25 | build-backend = "setuptools.build_meta"
26 |
27 |
--------------------------------------------------------------------------------
/dredge/aws_ir/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import boto3
4 |
5 | from ..config import DredgeConfig
6 | from .services import AwsServiceRegistry
7 | from .response import AwsIRResponse
8 | from .forensics import AwsIRForensics
9 | from .hunt import AwsIRHunt # <-- add this
10 |
11 |
12 | class AwsIRNamespace:
13 | """
14 | Grouping for AWS IR related functionality:
15 |
16 | dredge.aws_ir.response...
17 | dredge.aws_ir.forensics...
18 | dredge.aws_ir.hunt...
19 | """
20 |
21 | def __init__(self, session: boto3.Session, config: DredgeConfig) -> None:
22 | self._services = AwsServiceRegistry(session)
23 |
24 | self.response = AwsIRResponse(self._services, config)
25 | self.forensics = AwsIRForensics(self._services, config)
26 | self.hunt = AwsIRHunt(self._services, config) # <-- add this
27 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # -----------------------------
2 | # AWS
3 | # -----------------------------
4 | boto3>=1.34.0
5 | botocore>=1.34.0
6 |
7 | # -----------------------------
8 | # GitHub API
9 | # -----------------------------
10 | requests>=2.32.0
11 |
12 | # -----------------------------
13 | # Google Cloud Logging
14 | # -----------------------------
15 | google-cloud-logging>=3.9.0
16 | google-cloud-core>=2.4.1
17 | google-api-core>=2.17.0
18 | google-auth>=2.29.0
19 | google-auth-oauthlib>=1.2.0
20 | google-auth-httplib2>=0.2.0
21 |
22 | # If domain-wide delegation or admin APIs return later:
23 | google-api-python-client>=2.117.0
24 |
25 | # -----------------------------
26 | # Date & Time Handling
27 | # -----------------------------
28 | python-dateutil>=2.8.2
29 | pytz>=2024.1
30 |
31 | # -----------------------------
32 | # Typing / Helpers
33 | # -----------------------------
34 | typing_extensions>=4.10.0
35 |
--------------------------------------------------------------------------------
/dredge/gcp_ir/config.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from typing import Optional
5 |
6 |
7 | @dataclass
8 | class GcpIRConfig:
9 | """
10 | Configuration for GCP Incident Response / log hunting.
11 |
12 | project_id: GCP project ID to query logs from.
13 | credentials_file: Optional path to a service account JSON file.
14 | If omitted, uses ADC (Application Default Credentials).
15 | default_log_id: Default Cloud Logging log ID. For audit logs, useful values are:
16 | - "cloudaudit.googleapis.com/activity"
17 | - "cloudaudit.googleapis.com/data_access"
18 | - "cloudaudit.googleapis.com/system_event"
19 | - "cloudaudit.googleapis.com/access_transparency"
20 | """
21 | project_id: str
22 | credentials_file: Optional[str] = None
23 | default_log_id: str = "cloudaudit.googleapis.com/activity"
24 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.13-slim AS builder
2 |
3 | ENV PYTHONDONTWRITEBYTECODE=1 \
4 | PYTHONUNBUFFERED=1 \
5 | PIP_NO_CACHE_DIR=1 \
6 | PIP_DISABLE_PIP_VERSION_CHECK=1
7 |
8 | WORKDIR /app
9 |
10 | RUN apt-get update && \
11 | apt-get install -y --no-install-recommends \
12 | ca-certificates \
13 | && rm -rf /var/lib/apt/lists/*
14 |
15 | # Copy metadata first (for caching)
16 | COPY pyproject.toml README.md ./
17 |
18 | # Create venv and upgrade pip
19 | RUN python -m venv /opt/venv && \
20 | /opt/venv/bin/pip install --upgrade pip
21 |
22 | # Copy the actual package
23 | COPY dredge/ ./dredge/
24 |
25 | # Install this project (and all dependencies from pyproject.toml)
26 | RUN /opt/venv/bin/pip install .
27 |
28 | # -------------------------
29 | FROM python:3.13-slim AS runtime
30 |
31 | ENV PYTHONDONTWRITEBYTECODE=1 \
32 | PYTHONUNBUFFERED=1
33 |
34 | WORKDIR /app
35 |
36 | RUN apt-get update && \
37 | apt-get install -y --no-install-recommends \
38 | ca-certificates \
39 | && rm -rf /var/lib/apt/lists/*
40 |
41 | COPY --from=builder /opt/venv /opt/venv
42 | ENV PATH="/opt/venv/bin:$PATH"
43 |
44 | RUN useradd --create-home --shell /usr/sbin/nologin dredge && \
45 | chown -R dredge:dredge /app
46 | USER dredge
47 |
48 | ENTRYPOINT ["dredge"]
49 | CMD ["--help"]
50 |
--------------------------------------------------------------------------------
/dredge/github_ir/services.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Dict, Optional
4 | import requests
5 |
6 | from .config import GitHubIRConfig
7 |
8 |
9 | class GitHubServiceRegistry:
10 | """
11 | Wraps a requests.Session configured for the GitHub API.
12 | """
13 |
14 | def __init__(self, config: GitHubIRConfig) -> None:
15 | self._config = config
16 | token = config.resolve_token()
17 |
18 | self._session = requests.Session()
19 | self._session.headers.update({
20 | "Authorization": f"Bearer {token}",
21 | "Accept": "application/vnd.github+json",
22 | })
23 | self._base_url = config.base_url.rstrip("/")
24 |
25 | @property
26 | def audit_log_path_base(self) -> str:
27 | org = getattr(self._config, "org", None)
28 | enterprise = getattr(self._config, "enterprise", None)
29 |
30 | if org:
31 | return f"/orgs/{org}/audit-log"
32 | if enterprise:
33 | return f"/enterprises/{enterprise}/audit-log"
34 | raise RuntimeError("GitHubIRConfig must define either 'org' or 'enterprise'")
35 |
36 | def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> requests.Response:
37 | url = f"{self._base_url}{path}"
38 | return self._session.get(url, params=params)
39 |
--------------------------------------------------------------------------------
/tests/test_cli_parsing.py:
--------------------------------------------------------------------------------
1 | # tests/test_cli_parsing.py
2 |
3 | import argparse
4 |
5 | from dredge import cli as dredge_cli
6 |
7 | def test_cli_has_expected_subcommands():
8 | parser = dredge_cli.build_parser()
9 |
10 | # Find the existing subparsers action without creating a new one
11 | subparsers_action = next(
12 | a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
13 | )
14 | subparsers = subparsers_action.choices
15 |
16 | # Basic commands we expect to exist
17 | for cmd in [
18 | "aws-disable-access-key",
19 | "aws-disable-user",
20 | "aws-hunt-cloudtrail",
21 | "github-hunt-audit",
22 | ]:
23 | assert cmd in subparsers
24 |
25 |
26 | def test_cli_parses_aws_hunt_cloudtrail_args():
27 | parser = dredge_cli.build_parser()
28 | args = parser.parse_args(
29 | [
30 | "--aws-profile",
31 | "backdoor",
32 | "--region",
33 | "us-east-1",
34 | "aws-hunt-cloudtrail",
35 | "--user",
36 | "alice",
37 | "--max-events",
38 | "10",
39 | ]
40 | )
41 |
42 | assert args.command == "aws-hunt-cloudtrail"
43 | assert args.user == "alice"
44 | assert args.max_events == 10
45 | assert args.aws_profile == "backdoor"
46 | assert args.aws_region == "us-east-1"
47 |
--------------------------------------------------------------------------------
/dredge/aws_ir/services.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import boto3
4 |
5 |
6 | class AwsServiceRegistry:
7 | """
8 | Central place to create and share boto3 clients/resources.
9 | """
10 |
11 | def __init__(self, session: boto3.Session) -> None:
12 | self._session = session
13 |
14 | # Lazily initialized clients
15 | self._iam = None
16 | self._ec2 = None
17 | self._s3control = None
18 | self._s3 = None
19 | self._lambda = None
20 | self._cloudtrail = None # <-- add this
21 |
22 | @property
23 | def iam(self):
24 | if self._iam is None:
25 | self._iam = self._session.client("iam")
26 | return self._iam
27 |
28 | @property
29 | def ec2(self):
30 | if self._ec2 is None:
31 | self._ec2 = self._session.client("ec2")
32 | return self._ec2
33 |
34 | @property
35 | def s3control(self):
36 | if self._s3control is None:
37 | self._s3control = self._session.client("s3control")
38 | return self._s3control
39 |
40 | @property
41 | def s3(self):
42 | if self._s3 is None:
43 | self._s3 = self._session.client("s3")
44 | return self._s3
45 |
46 | @property
47 | def lambda_(self):
48 | if self._lambda is None:
49 | self._lambda = self._session.client("lambda")
50 | return self._lambda
51 |
52 | @property
53 | def cloudtrail(self):
54 | if self._cloudtrail is None:
55 | self._cloudtrail = self._session.client("cloudtrail")
56 | return self._cloudtrail
57 |
--------------------------------------------------------------------------------
/dredge/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import boto3
4 | from typing import Optional
5 |
6 | from .config import DredgeConfig
7 | from .auth import AwsAuthConfig, AwsSessionFactory
8 | from .aws_ir import AwsIRNamespace
9 | from .github_ir import GitHubIRNamespace
10 | from .gcp_ir import GcpIRNamespace
11 |
12 | class Dredge:
13 | def __init__(
14 | self,
15 | *,
16 | session: Optional[boto3.Session] = None,
17 | auth: Optional[AwsAuthConfig] = None,
18 | config: Optional[DredgeConfig] = None,
19 | github_config: Optional["GitHubIRConfig"] = None, # type: ignore[name-defined]
20 | gcp_config: Optional["GcpIRConfig"] = None,
21 | ) -> None:
22 | self.config = config or DredgeConfig(
23 | region_name=(auth.region_name if auth else None)
24 | )
25 |
26 | if session is not None and auth is not None:
27 | raise ValueError("Provide either 'session' or 'auth', not both.")
28 |
29 | if session is not None:
30 | self._session = session
31 | else:
32 | auth_cfg = auth or AwsAuthConfig(region_name=self.config.region_name)
33 | factory = AwsSessionFactory(auth_cfg)
34 | self._session = factory.get_session()
35 |
36 | # AWS IR namespace
37 | self.aws_ir = AwsIRNamespace(self._session, self.config)
38 |
39 | # GitHub IR namespace (optional; only if config is provided)
40 | self.github_ir: Optional[GitHubIRNamespace]
41 | if github_config is not None:
42 | self.github_ir = GitHubIRNamespace(github_config)
43 | else:
44 | self.github_ir = None
45 |
46 | self.gcp_ir = GcpIRNamespace(gcp_config) if gcp_config else None
--------------------------------------------------------------------------------
/dredge/gcp_ir/services.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Dict, Iterable, Optional
4 |
5 | from google.cloud import logging_v2 as logging
6 | from google.oauth2 import service_account
7 |
8 | from .config import GcpIRConfig
9 |
10 |
11 | class GcpLoggingService:
12 | """
13 | Thin wrapper around google.cloud.logging_v2.Client.
14 | """
15 |
16 | def __init__(self, config: GcpIRConfig) -> None:
17 | self._config = config
18 |
19 | if config.credentials_file:
20 | creds = service_account.Credentials.from_service_account_file(
21 | config.credentials_file
22 | )
23 | self._client = logging.Client(
24 | project=config.project_id,
25 | credentials=creds,
26 | )
27 | else:
28 | # Uses Application Default Credentials:
29 | # - GOOGLE_APPLICATION_CREDENTIALS
30 | # - or metadata if running in GCP
31 | self._client = logging.Client(project=config.project_id)
32 |
33 | @property
34 | def project_id(self) -> str:
35 | return self._config.project_id
36 |
37 | @property
38 | def client(self) -> logging.Client:
39 | return self._client
40 |
41 | def list_entries(
42 | self,
43 | *,
44 | filter_: str,
45 | page_size: int,
46 | order_by: str,
47 | page_token: Optional[str] = None,
48 | ) -> logging.entries.Iterator:
49 | """
50 | Call client.list_entries() with our defaults.
51 | """
52 | return self._client.list_entries(
53 | filter_=filter_,
54 | page_size=page_size,
55 | page_token=page_token,
56 | order_by=order_by,
57 | )
58 |
--------------------------------------------------------------------------------
/dredge/github_ir/config.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from typing import Callable, Optional
5 | import os
6 |
7 |
8 | TokenProvider = Callable[[], str]
9 |
10 |
11 | @dataclass
12 | class GitHubIRConfig:
13 | """
14 | Configuration + auth for GitHub incident response.
15 |
16 | Exactly one of `org` or `enterprise` must be set.
17 |
18 | Priority for token resolution:
19 | 1) token (explicit)
20 | 2) token_provider (callable)
21 | 3) token from env var `token_env_var` (default: GITHUB_TOKEN)
22 | """
23 | org: Optional[str] = None
24 | enterprise: Optional[str] = None
25 |
26 | # Auth
27 | token: Optional[str] = None
28 | token_env_var: str = "GITHUB_TOKEN"
29 | token_provider: Optional[TokenProvider] = None
30 |
31 | # API base URL (override for GitHub Enterprise Server)
32 | base_url: str = "https://api.github.com"
33 |
34 | # Default audit-log include flag ("web", "git", or "all")
35 | include: str = "web"
36 |
37 | def __post_init__(self) -> None:
38 | if bool(self.org) == bool(self.enterprise):
39 | raise ValueError("GitHubIRConfig: set exactly one of 'org' or 'enterprise'")
40 |
41 | def resolve_token(self) -> str:
42 | if self.token:
43 | return self.token
44 |
45 | if self.token_provider:
46 | t = self.token_provider()
47 | if not t:
48 | raise ValueError("GitHub token_provider returned an empty token")
49 | return t
50 |
51 | env_val = os.getenv(self.token_env_var)
52 | if not env_val:
53 | raise ValueError(
54 | f"No GitHub token provided. Set token, token_provider, "
55 | f"or export {self.token_env_var}."
56 | )
57 | return env_val
58 |
--------------------------------------------------------------------------------
/tests/test_github_hunt_phrase.py:
--------------------------------------------------------------------------------
1 | # tests/test_github_hunt_phrase.py
2 |
3 | from datetime import datetime, timezone
4 |
5 | from dredge.github_ir.hunt import GitHubIRHunt
6 | from dredge.github_ir.config import GitHubIRConfig
7 | from dredge.github_ir.services import GitHubServiceRegistry
8 |
9 |
10 | class DummyService(GitHubServiceRegistry):
11 | """Minimal service subclass we can instantiate without real HTTP calls."""
12 | def __init__(self):
13 | cfg = GitHubIRConfig(org="dummy-org", token="dummy-token")
14 | super().__init__(cfg)
15 |
16 |
17 | def test_build_phrase_basic_actor_action_repo():
18 | cfg = GitHubIRConfig(org="dummy-org", token="dummy-token")
19 | hunt = GitHubIRHunt(services=DummyService(), config=cfg)
20 |
21 | phrase = hunt._build_phrase(
22 | actor="alice",
23 | action="repo.create",
24 | repo="solidarity-labs/dredge",
25 | source_ip=None,
26 | start_time=None,
27 | end_time=None,
28 | )
29 |
30 | assert "actor:alice" in phrase
31 | assert "action:repo.create" in phrase
32 | assert "repo:solidarity-labs/dredge" in phrase
33 |
34 |
35 | def test_build_phrase_with_ip_and_date_range_same_day():
36 | cfg = GitHubIRConfig(org="dummy-org", token="dummy-token")
37 | hunt = GitHubIRHunt(services=DummyService(), config=cfg)
38 |
39 | start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
40 | end = datetime(2025, 1, 1, 23, 59, tzinfo=timezone.utc)
41 |
42 | phrase = hunt._build_phrase(
43 | actor=None,
44 | action=None,
45 | repo=None,
46 | source_ip="203.0.113.10",
47 | start_time=start,
48 | end_time=end,
49 | )
50 |
51 | assert "actor_ip:203.0.113.10" in phrase
52 | # Depending on your implementation; adjust if you use >=/<=
53 | assert "created:2025-01-01" in phrase
54 |
--------------------------------------------------------------------------------
/tests/test_github_hunt_pagination.py:
--------------------------------------------------------------------------------
1 | # tests/test_github_hunt_pagination.py
2 |
3 | from typing import Any, Dict, List
4 |
5 | from dredge.github_ir.hunt import GitHubIRHunt
6 | from dredge.github_ir.config import GitHubIRConfig
7 | from dredge.github_ir.services import GitHubServiceRegistry
8 |
9 |
10 | class FakeResponse:
11 | def __init__(self, status_code: int, json_data: Any, headers: Dict[str, str] | None = None):
12 | self.status_code = status_code
13 | self._json_data = json_data
14 | self.headers = headers or {}
15 |
16 | def json(self):
17 | return self._json_data
18 |
19 | @property
20 | def text(self):
21 | return str(self._json_data)
22 |
23 |
24 | class FakeServices(GitHubServiceRegistry):
25 | def __init__(self, pages: List[List[Dict[str, Any]]]):
26 | cfg = GitHubIRConfig(org="dummy-org", token="dummy-token")
27 | super().__init__(cfg)
28 | self._pages = pages
29 | self._calls = 0
30 |
31 | def get(self, path: str, params=None):
32 | if self._calls >= len(self._pages):
33 | return FakeResponse(200, [])
34 | data = self._pages[self._calls]
35 | self._calls += 1
36 | return FakeResponse(200, data)
37 |
38 |
39 | def test_search_audit_log_pagination_collects_events():
40 | pages = [
41 | [
42 | {"action": "repo.create", "actor": "alice", "actor_ip": "1.1.1.1", "repo": "org/repo", "org": "org"},
43 | {"action": "repo.delete", "actor": "bob", "actor_ip": "2.2.2.2", "repo": "org/repo", "org": "org"},
44 | ],
45 | [
46 | {"action": "org.invite_member", "actor": "carol", "actor_ip": "3.3.3.3", "repo": None, "org": "org"},
47 | ],
48 | ]
49 |
50 | services = FakeServices(pages=pages)
51 | cfg = GitHubIRConfig(org="dummy-org", token="dummy-token")
52 | hunt = GitHubIRHunt(services=services, config=cfg)
53 |
54 | result = hunt.search_audit_log(
55 | actor=None,
56 | action=None,
57 | repo=None,
58 | source_ip=None,
59 | start_time=None,
60 | end_time=None,
61 | include="all",
62 | max_events=10,
63 | per_page=2,
64 | )
65 |
66 | assert result.success is True
67 | events = result.details["events"]
68 | assert len(events) == 3
69 | assert events[0]["action"] == "repo.create"
70 | assert events[1]["actor"] == "bob"
71 | assert result.details["statistics"]["total_events_returned"] == 3
72 |
73 |
--------------------------------------------------------------------------------
/dredge/auth.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from typing import Callable, Optional
5 |
6 | import boto3
7 |
8 |
9 | MfaTokenProvider = Callable[[], str]
10 |
11 |
12 | @dataclass
13 | class AwsAuthConfig:
14 | """
15 | Defines how Dredge should authenticate to AWS.
16 |
17 | Precedence (if multiple fields are set):
18 | 1) Explicit access keys
19 | 2) Profile name
20 | 3) Default environment / instance role
21 |
22 | After base credentials are resolved, an optional `role_arn` can be assumed.
23 | """
24 | # Explicit credentials
25 | access_key_id: Optional[str] = None
26 | secret_access_key: Optional[str] = None
27 | session_token: Optional[str] = None
28 |
29 | # Profile-based auth
30 | profile_name: Optional[str] = None
31 |
32 | # Optional role to assume on top of the base auth
33 | role_arn: Optional[str] = None
34 | external_id: Optional[str] = None
35 | session_name: str = "dredge-session"
36 |
37 | # Region
38 | region_name: Optional[str] = None
39 |
40 | # (Optional) MFA
41 | mfa_serial: Optional[str] = None
42 | mfa_token_provider: Optional[MfaTokenProvider] = None
43 |
44 | # Session duration for assumed roles (seconds; AWS default 3600)
45 | role_session_duration: int = 3600
46 |
47 |
48 | class AwsSessionFactory:
49 | """
50 | Responsible for building a boto3.Session according to AwsAuthConfig.
51 |
52 | - If explicit keys are provided: use them.
53 | - Else if profile_name is provided: use that profile.
54 | - Else: rely on default credential chain on the server.
55 |
56 | If role_arn is provided, we assume that role on top of the base credentials.
57 | """
58 |
59 | def __init__(self, config: AwsAuthConfig) -> None:
60 | self._config = config
61 | self._cached_session: Optional[boto3.Session] = None
62 |
63 | def get_session(self) -> boto3.Session:
64 | if self._cached_session is None:
65 | self._cached_session = self._build_session()
66 | return self._cached_session
67 |
68 | # ------------ internal helpers ------------
69 |
70 | def _build_session(self) -> boto3.Session:
71 | base_session = self._build_base_session()
72 |
73 | if not self._config.role_arn:
74 | return base_session
75 |
76 | return self._assume_role_session(base_session)
77 |
78 | def _build_base_session(self) -> boto3.Session:
79 | cfg = self._config
80 |
81 | # 1) Explicit credentials
82 | if cfg.access_key_id and cfg.secret_access_key:
83 | return boto3.Session(
84 | aws_access_key_id=cfg.access_key_id,
85 | aws_secret_access_key=cfg.secret_access_key,
86 | aws_session_token=cfg.session_token,
87 | region_name=cfg.region_name,
88 | )
89 |
90 | # 2) Profile-based
91 | if cfg.profile_name:
92 | return boto3.Session(
93 | profile_name=cfg.profile_name,
94 | region_name=cfg.region_name,
95 | )
96 |
97 | # 3) Default chain on the server
98 | return boto3.Session(region_name=cfg.region_name)
99 |
100 | def _assume_role_session(self, base_session: boto3.Session) -> boto3.Session:
101 | cfg = self._config
102 | sts = base_session.client("sts")
103 |
104 | assume_args = {
105 | "RoleArn": cfg.role_arn,
106 | "RoleSessionName": cfg.session_name,
107 | "DurationSeconds": cfg.role_session_duration,
108 | }
109 |
110 | if cfg.external_id:
111 | assume_args["ExternalId"] = cfg.external_id
112 |
113 | if cfg.mfa_serial:
114 | if not cfg.mfa_token_provider:
115 | raise ValueError("mfa_serial is set but mfa_token_provider is None")
116 |
117 | assume_args["SerialNumber"] = cfg.mfa_serial
118 | assume_args["TokenCode"] = cfg.mfa_token_provider()
119 |
120 | resp = sts.assume_role(**assume_args)
121 | creds = resp["Credentials"]
122 |
123 | return boto3.Session(
124 | aws_access_key_id=creds["AccessKeyId"],
125 | aws_secret_access_key=creds["SecretAccessKey"],
126 | aws_session_token=creds["SessionToken"],
127 | region_name=cfg.region_name,
128 | )
129 |
--------------------------------------------------------------------------------
/dredge/aws_ir/forensics.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Dict, List
4 |
5 | from .. import DredgeConfig
6 | from .services import AwsServiceRegistry
7 | from .models import OperationResult
8 |
9 | class AwsIRForensics:
10 | """
11 | Forensics-focused actions (snapshots, evidence collection, etc.).
12 |
13 | Example:
14 | dredge.aws_ir.forensics.get_ebs_snapshot(volume_id="vol-123", description="IR case X")
15 | """
16 |
17 | def __init__(self, services: AwsServiceRegistry, config: DredgeConfig) -> None:
18 | self._services = services
19 | self._config = config
20 |
21 | def get_ebs_snapshot(
22 | self,
23 | volume_id: str,
24 | *,
25 | description: str = "Dredge forensic snapshot",
26 | ) -> OperationResult:
27 | """
28 | Create a snapshot of the specified EBS volume.
29 |
30 | (Name matches your intent: `dredge.aws_ir.forensics.get_ebs_snapshoot`
31 | but with `snapshot` spelled correctly.)
32 | """
33 | result = OperationResult(
34 | operation="get_ebs_snapshot",
35 | target=f"volume={volume_id}",
36 | success=True,
37 | )
38 |
39 | if self._config.dry_run:
40 | result.details["dry_run"] = True
41 | return result
42 |
43 | ec2 = self._services.ec2
44 |
45 | try:
46 | resp = ec2.create_snapshot(
47 | VolumeId=volume_id,
48 | Description=description,
49 | )
50 | snapshot_id = resp["SnapshotId"]
51 | result.details["snapshot_id"] = snapshot_id
52 | except Exception as exc:
53 | result.add_error(str(exc))
54 |
55 | return result
56 |
57 | def snapshot_instance_volumes(
58 | self,
59 | instance_id: str,
60 | *,
61 | include_root: bool = True,
62 | description_prefix: str = "Dredge forensic snapshot",
63 | ) -> OperationResult:
64 | """
65 | Snapshot all (or non-root) EBS volumes attached to an instance.
66 |
67 | This is a higher-level helper built on top of per-volume snapshotting.
68 |
69 | Rough mapping to your "Get Forensic Image from EC2 instance volume",
70 | but generalized to all volumes for a given instance.
71 | """
72 | result = OperationResult(
73 | operation="snapshot_instance_volumes",
74 | target=f"instance={instance_id}",
75 | success=True,
76 | )
77 |
78 | if self._config.dry_run:
79 | result.details["dry_run"] = True
80 | return result
81 |
82 | ec2 = self._services.ec2
83 | snapshot_ids: Dict[str, str] = {} # volume_id -> snapshot_id
84 |
85 | try:
86 | desc = ec2.describe_instances(InstanceIds=[instance_id])
87 | reservations = desc.get("Reservations", [])
88 | if not reservations or not reservations[0]["Instances"]:
89 | raise RuntimeError(f"No instance found: {instance_id}")
90 |
91 | instance = reservations[0]["Instances"][0]
92 | block_devices = instance.get("BlockDeviceMappings", [])
93 |
94 | # Identify root device name (e.g. /dev/xvda) if we need to filter
95 | root_device_name = instance.get("RootDeviceName")
96 |
97 | for mapping in block_devices:
98 | device_name = mapping.get("DeviceName")
99 | ebs = mapping.get("Ebs")
100 | if not ebs:
101 | continue
102 |
103 | volume_id = ebs["VolumeId"]
104 |
105 | if not include_root and device_name == root_device_name:
106 | continue
107 |
108 | try:
109 | desc_text = f"{description_prefix} for {instance_id} ({device_name})"
110 | snap_resp = ec2.create_snapshot(
111 | VolumeId=volume_id,
112 | Description=desc_text,
113 | )
114 | snapshot_id = snap_resp["SnapshotId"]
115 | snapshot_ids[volume_id] = snapshot_id
116 | except Exception as exc:
117 | result.add_error(
118 | f"Failed to snapshot volume {volume_id} on {device_name}: {exc}"
119 | )
120 |
121 | result.details["snapshots"] = snapshot_ids
122 |
123 | except Exception as exc:
124 | result.add_error(f"Fatal error snapshotting instance volumes: {exc}")
125 |
126 | return result
127 |
128 | def get_lambda_environment(
129 | self,
130 | function_name: str,
131 | *,
132 | qualifier: str | None = None,
133 | ) -> OperationResult:
134 | """
135 | Fetch environment variables for a Lambda function.
136 |
137 | This corresponds to your 'Get Lambda env vars' IR helper.
138 |
139 | NOTE: This returns env vars in cleartext in the result.details,
140 | so handle logs and outputs carefully.
141 | """
142 | result = OperationResult(
143 | operation="get_lambda_environment",
144 | target=f"function={function_name},qualifier={qualifier or 'LATEST'}",
145 | success=True,
146 | )
147 |
148 | if self._config.dry_run:
149 | # For dry-run, we do *not* call AWS but just mark as dry-run
150 | result.details["dry_run"] = True
151 | return result
152 |
153 | lambda_client = self._services.lambda_
154 |
155 | try:
156 | kwargs = {"FunctionName": function_name}
157 | if qualifier:
158 | kwargs["Qualifier"] = qualifier
159 |
160 | resp = lambda_client.get_function_configuration(**kwargs)
161 | env = resp.get("Environment", {}).get("Variables", {})
162 |
163 | result.details["environment_variables"] = env
164 | except Exception as exc:
165 | result.add_error(f"Failed to fetch lambda environment: {exc}")
166 |
167 | return result
168 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Dredge - 0.1.1
5 |
6 |
7 |
8 |
9 |
10 |
11 | ⚡ Log collection, analysis, and rapid response in the cloud... pa' la hinchada⚡
12 |
13 |
14 |
15 | ---
16 |
17 | ### TL;DR
18 |
19 | - This is a **rewritten / refactored** version of Dredge – still a work in progress.
20 | - It currently focuses on:
21 | - **AWS Incident Response** (disable users/keys, isolate EC2, lock down S3, etc.).
22 | - **AWS Threat Hunting** via **CloudTrail lookup**.
23 | - **GitHub Threat Hunting** via **Org/Enterprise Audit Logs**.
24 | - It’s implemented as:
25 | - A **Python library** (`dredge`) you can import.
26 | - A **CLI** (dredge) entrypoint you can run from the terminal.
27 |
28 | Older features (GCP, Kubernetes, Shodan, VirusTotal, config-file log collection, etc.) are **not in prod right now** – they’re listed in the **Next steps / roadmap** section.
29 |
30 | ---
31 |
32 |
33 |
34 | Dredge is a tool designed to identify and respond quickly to attacks in cloud environments, especially when you don’t have all the IR plumbing ready.
35 |
36 |
37 |
38 | The new Dredge library focuses on a clean, composable API for:
39 |
40 |
41 |
42 | - AWS Incident Response: disable IAM users and access keys, lock down S3, and network-isolate EC2 instances.
43 | - Log-centric Threat Hunting for AWS (CloudTrail) and GitHub (Audit Logs).
44 |
45 |
46 |
47 | It’s meant to be usable both as a library in your own IR tooling and as a CLI for “oh-shit-it’s-3AM” response.
48 |
49 |
50 |
51 | ---
52 |
53 | ## Current Features
54 |
55 | ### 🔥 Incident Response (AWS)
56 |
57 | - Disable / delete IAM access keys.
58 | - Disable / delete IAM users.
59 | - Disable IAM roles (detach policies, break trust relationships).
60 | - Block S3 public access (account-level, bucket-level, object-level).
61 | - Network-isolate EC2 instances (forensic security group).
62 |
63 | ### 🎯 Threat Hunting
64 |
65 | - AWS CloudTrail hunt:
66 | - Filter by `user_name`, `access_key_id`, `event_name`, `source_ip`, time ranges.
67 | - Handles pagination and AWS rate limiting.
68 | - GitHub Audit Log hunt:
69 | - Org or Enterprise audit logs.
70 | - Filter by `actor`, `action`, `repo`, `source_ip`, time ranges or “today”.
71 | - Handles pagination and basic rate limiting.
72 |
73 | ---
74 |
75 | # 📦 Installation
76 |
77 | 1. **Clone the repo**
78 |
79 | ```bash
80 | git clone https://github.com/solidarity-labs/dredge-cli.git
81 | cd dredge-cli
82 | ```
83 |
84 | 2. **Install via editable local development**
85 | ```bash
86 | pip install -e .
87 | ```
88 |
89 | 3. **Run tests**
90 | ```bash
91 | pytest -q
92 | ```
93 |
94 | 4. **See what’s available**
95 |
96 | ```bash
97 | dredge --help
98 | ```
99 |
100 | ---
101 |
102 | # 🐳 Docker Usage
103 |
104 | ## Build image
105 | ```bash
106 | podman build -t dredge:latest .
107 | ```
108 | OR
109 | ```bash
110 | docker build -t dredge:latest .
111 | ```
112 |
113 | ---
114 |
115 |
116 | ## AWS Integration
117 |
118 | ### Authentication
119 |
120 | Dredge uses standard AWS auth mechanisms via `boto3`:
121 |
122 | - **Default credential chain** (env vars, `~/.aws/credentials`, EC2/ECS role, etc.).
123 | - **Named profile** via `--aws-profile`.
124 | - **Explicit keys** via CLI flags.
125 | - **Assume role** via `--aws-role-arn` (optionally with `--aws-external-id`).
126 |
127 | You can combine these in the usual way; precedence is:
128 |
129 | 1. Explicit keys
130 | 2. Profile
131 | 3. Default chain
132 |
133 | **Common setup in `~/.aws/credentials`:**
134 |
135 | ```ini
136 | [dredge-role]
137 | aws_access_key_id = AKIA...
138 | aws_secret_access_key = SUPER_SECRET
139 | ```
140 |
141 | **Region can be provided via:**
142 |
143 | - `--aws-region`
144 | - `AWS_REGION` / `AWS_DEFAULT_REGION` env vars
145 | - Your AWS profile config (`~/.aws/config`)
146 |
147 | ---
148 |
149 | ### Global AWS Flags (CLI)
150 |
151 | - `--aws-region` or `--region` – AWS region, e.g. `us-east-1`.
152 | - `--aws-profile` – AWS named profile.
153 | - `--aws-access-key-id` – explicit key ID.
154 | - `--aws-secret-access-key` – explicit secret.
155 | - `--aws-session-token` – session token (if using STS).
156 | - `--aws-role-arn` – role to assume.
157 | - `--aws-external-id` – external ID for role assumption.
158 | - `--dry-run` – simulate without making changes.
159 |
160 | ---
161 |
162 | ### AWS Incident Response – CLI Examples
163 |
164 | #### Disable an IAM access key
165 |
166 | ```bash
167 | dredge --aws-profile dredge-role --region us-east-1 aws-disable-access-key --user compromised-user --access-key-id AKIA123456789
168 | ```
169 |
170 | #### Disable an IAM user
171 |
172 | ```bash
173 | dredge --aws-profile dredge-role --region us-east-1 aws-disable-user --user compromised-user
174 | ```
175 |
176 | #### Disable an IAM role
177 |
178 | ```bash
179 | dredge --aws-profile dredge-role --region us-east-1 aws-disable-role --role OldAccessRole
180 | ```
181 |
182 | #### Block S3 public access
183 |
184 | ```bash
185 | dredge --aws-profile dredge-role --region us-east-1 aws-block-s3-account --account-id 111122223333
186 | ```
187 |
188 | #### Make a bucket private
189 |
190 | ```bash
191 | dredge --aws-profile dredge-role --region us-east-1 aws-block-s3-bucket --bucket my-sus-bucket
192 | ```
193 |
194 | #### Network-isolate EC2 instances
195 |
196 | ```bash
197 | dredge --aws-profile dredge-role --region us-east-1 aws-isolate-ec2 i-0123456789abcdef0 i-0abcdef1234567890
198 | ```
199 |
200 | ---
201 |
202 | ## GitHub Integration
203 |
204 | ### Authentication
205 |
206 | Dredge uses a **GitHub personal access token**.
207 |
208 | For **Org audit logs**, token requires:
209 |
210 | - `admin:org`
211 | - `audit_log` (or `read:audit_log`)
212 |
213 | For **Enterprise audit logs**, token requires:
214 |
215 | - `admin:enterprise`
216 | - `audit_log`
217 |
218 | Provide token via:
219 |
220 | ```bash
221 | --github-token "$GITHUB_TOKEN"
222 | ```
223 |
224 | Or rely on your environment variable via `GitHubIRConfig`.
225 |
226 | Also specify either:
227 |
228 | - `--github-org `
229 | - `--github-enterprise `
230 |
231 | ---
232 |
233 | ### GitHub Threat Hunting – CLI Examples
234 |
235 | #### Today’s logs for a user
236 |
237 | ```bash
238 | dredge --github-org solidarity-labs --github-token "$GITHUB_TOKEN" github-hunt-audit --actor sabastante --today --include all
239 | ```
240 |
241 | #### Hunt an action
242 |
243 | ```bash
244 | dredge --github-enterprise solidaritylabs --github-token "$GITHUB_TOKEN" github-hunt-audit --action repo.create --start-time 2025-01-01T00:00:00Z --end-time 2025-01-07T23:59:59Z --include web
245 | ```
246 |
247 | #### Hunt suspicious IP activity
248 |
249 | ```bash
250 | dredge --github-org solidarity-labs --github-token "$GITHUB_TOKEN" github-hunt-audit --source-ip 203.0.113.50 --today --include all
251 | ```
252 |
253 | ---
254 |
255 | ## Library Usage
256 |
257 | ### AWS Example
258 |
259 | ```python
260 | from dredge import Dredge
261 | from dredge.auth import AwsAuthConfig
262 |
263 | auth = AwsAuthConfig(profile_name="dredge-role", region_name="us-east-1")
264 | d = Dredge(auth=auth)
265 |
266 | result = d.aws_ir.response.disable_user("compromised-user")
267 | print(result.success, result.details)
268 | ```
269 |
270 | ### GitHub Example
271 |
272 | ```python
273 | from dredge import Dredge
274 | from dredge.github_ir.config import GitHubIRConfig
275 |
276 | cfg = GitHubIRConfig(org="solidarity-labs", token="ghp_xxx")
277 | d = Dredge(github_config=cfg)
278 |
279 | res = d.github_ir.hunt.search_today(actor="sabastante")
280 | print(res.details["events"])
281 | ```
282 |
283 | ---
284 |
285 | ## 🧭 Next Steps / Roadmap
286 |
287 | (Not yet implemented in the new architecture)
288 |
289 | ### Log Collection
290 | - AWS EventHistory, GuardDuty, VPC Flow Logs, LB logs, WAF logs, S3 CloudTrail, CloudWatch Logs.
291 | - GCP log retrieval.
292 | - Kubernetes log retrieval.
293 |
294 | ### Threat Hunting
295 | - IoC search, custom rules.
296 | - Shodan + VirusTotal reintegration.
297 | - AWS dangerous API heuristics.
298 |
299 | ### Incident Response
300 | - Forensic imaging, tagging, deeper IR workflows.
301 | - GitHub IR actions beyond hunting.
302 | ---
303 |
304 | # ❤️ Contributing
305 | PRs welcome!
306 | If you want help adding modules (Azure, Okta, Datadog, JumpCloud), open an issue.
307 |
308 | ---
309 |
--------------------------------------------------------------------------------
/dredge/github_ir/hunt.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import time
4 | from datetime import datetime, timezone
5 | from typing import Any, Dict, List, Optional
6 |
7 | import requests
8 |
9 | from .config import GitHubIRConfig
10 | from .services import GitHubServiceRegistry
11 | from .models import OperationResult
12 |
13 |
14 | _RATE_LIMIT_STATUS = {403, 429}
15 |
16 |
17 | class GitHubIRHunt:
18 | """
19 | Hunt / search utilities over GitHub org audit logs.
20 |
21 | Uses:
22 | GET /orgs/{org}/audit-log
23 | """
24 |
25 | def __init__(self, services: GitHubServiceRegistry, config: GitHubIRConfig) -> None:
26 | self._services = services
27 | self._config = config
28 |
29 | def search_audit_log(
30 | self,
31 | *,
32 | actor: Optional[str] = None,
33 | action: Optional[str] = None,
34 | repo: Optional[str] = None,
35 | source_ip: Optional[str] = None,
36 | start_time: Optional[datetime] = None,
37 | end_time: Optional[datetime] = None,
38 | include: Optional[str] = None, # "web" | "git" | "all"
39 | max_events: int = 500,
40 | per_page: int = 100,
41 | throttle_max_retries: int = 5,
42 | throttle_base_delay: float = 1.0,
43 | ) -> OperationResult:
44 | """
45 | Search GitHub org audit log with simple filters.
46 |
47 | Args:
48 | actor: GitHub username (actor) filter.
49 | action: Audit action, e.g. "repo.create", "org.add_member".
50 | repo: Repository filter, e.g. "org/repo".
51 | source_ip: IP address filter (actor_ip).
52 | start_time: Earliest event time (UTC). If set, used in `created:` range.
53 | end_time: Latest event time (UTC). If set, used in `created:` range.
54 | include: "web" (default), "git", or "all". If None, uses config.include.
55 | max_events: Max events to return.
56 | per_page: GitHub per_page parameter (<=100).
57 | """
58 | now = datetime.now(timezone.utc)
59 | if start_time and start_time.tzinfo is None:
60 | start_time = start_time.replace(tzinfo=timezone.utc)
61 | if end_time and end_time.tzinfo is None:
62 | end_time = end_time.replace(tzinfo=timezone.utc)
63 |
64 | phrase = self._build_phrase(
65 | actor=actor,
66 | action=action,
67 | repo=repo,
68 | source_ip=source_ip,
69 | start_time=start_time,
70 | end_time=end_time,
71 | )
72 |
73 | result = OperationResult(
74 | operation="github_search_audit_log",
75 | target=self._target_string(
76 | actor=actor,
77 | action=action,
78 | repo=repo,
79 | source_ip=source_ip,
80 | start_time=start_time,
81 | end_time=end_time,
82 | ),
83 | success=True,
84 | )
85 |
86 | events: List[Dict[str, Any]] = []
87 | page = 1
88 | include_value = include or self._config.include
89 |
90 | while len(events) < max_events:
91 | params: Dict[str, Any] = {
92 | "page": page,
93 | "per_page": min(max(per_page, 1), 100),
94 | "include": include_value,
95 | }
96 | if phrase:
97 | params["phrase"] = phrase
98 |
99 | try:
100 | resp = self._call_with_backoff(
101 | path=self._services.audit_log_path_base,
102 | params=params,
103 | throttle_max_retries=throttle_max_retries,
104 | throttle_base_delay=throttle_base_delay,
105 | )
106 | except Exception as exc:
107 | result.add_error(f"GitHub audit log API failed: {exc}")
108 | break
109 |
110 | page_events = resp.json()
111 | if not isinstance(page_events, list) or not page_events:
112 | break
113 |
114 | for ev in page_events:
115 | if len(events) >= max_events:
116 | break
117 | events.append(self._normalize_event(ev))
118 |
119 | # GitHub org audit log uses standard page-based pagination
120 | # stop when fewer than requested are returned
121 | if len(page_events) < params["per_page"]:
122 | break
123 |
124 | page += 1
125 |
126 | result.details["events"] = events
127 | result.details["statistics"] = {
128 | "total_events_returned": len(events),
129 | "pages_fetched": page,
130 | "phrase": phrase,
131 | "include": include_value,
132 | }
133 |
134 | return result
135 |
136 | # ----------------- internal helpers -----------------
137 |
138 | @staticmethod
139 | def _target_string(
140 | *,
141 | actor: Optional[str],
142 | action: Optional[str],
143 | repo: Optional[str],
144 | source_ip: Optional[str],
145 | start_time: Optional[datetime],
146 | end_time: Optional[datetime],
147 | ) -> str:
148 | bits = []
149 | if actor:
150 | bits.append(f"actor={actor}")
151 | if action:
152 | bits.append(f"action={action}")
153 | if repo:
154 | bits.append(f"repo={repo}")
155 | if source_ip:
156 | bits.append(f"source_ip={source_ip}")
157 | if start_time or end_time:
158 | bits.append(
159 | f"created={start_time.isoformat() if start_time else ''}"
160 | f"..{end_time.isoformat() if end_time else ''}"
161 | )
162 | return ",".join(bits) if bits else "github_audit_log"
163 |
164 | @staticmethod
165 | def _build_phrase(
166 | *,
167 | actor: Optional[str],
168 | action: Optional[str],
169 | repo: Optional[str],
170 | source_ip: Optional[str],
171 | start_time: Optional[datetime],
172 | end_time: Optional[datetime],
173 | ) -> str:
174 | """
175 | Build GitHub audit log search phrase.
176 |
177 | Based on GitHub's audit log search syntax: actor:, action:, repo:, created:, etc.:contentReference[oaicite:2]{index=2}
178 | """
179 | parts: List[str] = []
180 |
181 | if actor:
182 | parts.append(f"actor:{actor}")
183 | if action:
184 | parts.append(f"action:{action}")
185 | if repo:
186 | parts.append(f"repo:{repo}")
187 | if source_ip:
188 | # field is usually "actor_ip:"
189 | parts.append(f"actor_ip:{source_ip}")
190 | if start_time or end_time:
191 | def fmt(dt: datetime) -> str:
192 | # GitHub expects YYYY-MM-DD or full timestamp; keep it simple.
193 | return dt.date().isoformat()
194 |
195 | if start_time and end_time:
196 | parts.append(f"created:{fmt(start_time)}..{fmt(end_time)}")
197 | elif start_time:
198 | parts.append(f"created:>={fmt(start_time)}")
199 | elif end_time:
200 | parts.append(f"created:<={fmt(end_time)}")
201 |
202 | return " ".join(parts)
203 |
204 | @staticmethod
205 | def _normalize_event(ev: Dict[str, Any]) -> Dict[str, Any]:
206 | """
207 | Normalize a GitHub audit log event into a stable dict.
208 | """
209 | return {
210 | "action": ev.get("action"),
211 | "actor": ev.get("actor"),
212 | "actor_ip": ev.get("actor_ip"),
213 | "repository": ev.get("repo"),
214 | "org": ev.get("org"),
215 | "created_at": ev.get("@timestamp") or ev.get("created_at"),
216 | "raw": ev,
217 | }
218 |
219 | def _call_with_backoff(
220 | self,
221 | *,
222 | path: str,
223 | params: Dict[str, Any],
224 | throttle_max_retries: int,
225 | throttle_base_delay: float,
226 | ) -> requests.Response:
227 | """
228 | Call GitHub with basic backoff on rate limiting.
229 |
230 | Audit log endpoint has its own rate limit (e.g. 1,750/h).:contentReference[oaicite:3]{index=3}
231 | """
232 | attempt = 0
233 | while True:
234 | resp = self._services.get(path, params=params)
235 |
236 | # OK
237 | if 200 <= resp.status_code < 300:
238 | return resp
239 |
240 | # Handle rate limiting
241 | if resp.status_code in _RATE_LIMIT_STATUS and attempt < throttle_max_retries:
242 | reset_header = resp.headers.get("X-RateLimit-Reset")
243 | remaining_header = resp.headers.get("X-RateLimit-Remaining")
244 |
245 | delay = throttle_base_delay * (2 ** attempt)
246 |
247 | # If the reset time is provided and we're actually at 0 remaining, wait until then.
248 | if remaining_header == "0" and reset_header:
249 | try:
250 | reset_epoch = int(reset_header)
251 | now = int(time.time())
252 | wait = max(reset_epoch - now, delay)
253 | time.sleep(wait)
254 | except ValueError:
255 | time.sleep(delay)
256 | else:
257 | time.sleep(delay)
258 |
259 | attempt += 1
260 | continue
261 |
262 | # Other errors → raise with context
263 | try:
264 | msg = resp.json()
265 | except Exception:
266 | msg = resp.text
267 |
268 | raise RuntimeError(f"GitHub API error {resp.status_code}: {msg}")
269 |
--------------------------------------------------------------------------------
/dredge/aws_ir/hunt.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | import time
5 | from dataclasses import asdict
6 | from datetime import datetime, timedelta, timezone
7 | from typing import Any, Dict, List, Optional
8 |
9 | import botocore.exceptions
10 |
11 | from ..config import DredgeConfig
12 | from .services import AwsServiceRegistry
13 | from .models import OperationResult
14 |
15 |
16 | _THROTTLE_ERROR_CODES = {
17 | "Throttling",
18 | "ThrottlingException",
19 | "RequestLimitExceeded",
20 | "TooManyRequestsException",
21 | }
22 |
23 |
24 | class AwsIRHunt:
25 | """
26 | Hunt / search utilities over CloudTrail LookupEvents.
27 |
28 | Example:
29 | dredge.aws_ir.hunt.lookup_events(
30 | user_name="alice",
31 | event_name="ConsoleLogin",
32 | max_events=100,
33 | )
34 | """
35 |
36 | def __init__(self, services: AwsServiceRegistry, config: DredgeConfig) -> None:
37 | self._services = services
38 | self._config = config
39 |
40 | def lookup_events(
41 | self,
42 | *,
43 | user_name: Optional[str] = None,
44 | access_key_id: Optional[str] = None,
45 | event_name: Optional[str] = None,
46 | source_ip: Optional[str] = None,
47 | start_time: Optional[datetime] = None,
48 | end_time: Optional[datetime] = None,
49 | max_events: int = 500,
50 | page_size: int = 50,
51 | throttle_max_retries: int = 5,
52 | throttle_base_delay: float = 0.5,
53 | ) -> OperationResult:
54 | """
55 | Search CloudTrail LookupEvents by simple filters.
56 |
57 | CloudTrail LookupEvents only supports ONE LookupAttribute per call.
58 | We choose the most specific one (access_key_id > user_name > event_name)
59 | and then apply additional filters (e.g., source_ip) client-side.
60 |
61 | Args:
62 | user_name: Filter by CloudTrail Username.
63 | access_key_id: Filter by AccessKeyId.
64 | event_name: Filter by EventName (e.g., "ConsoleLogin").
65 | source_ip: Filter by sourceIPAddress (client-side).
66 | start_time: Earliest event time (UTC). Defaults to now - 24h.
67 | end_time: Latest event time (UTC). Defaults to now.
68 | max_events: Maximum number of events to return.
69 | page_size: CloudTrail MaxResults per request (<= 50).
70 | throttle_max_retries: Max retries on throttling.
71 | throttle_base_delay: Base seconds for exponential backoff.
72 |
73 | Returns:
74 | OperationResult with:
75 | - details["events"]: list of normalized event dicts
76 | - details["statistics"]: counts and filter info
77 | """
78 | now = datetime.now(timezone.utc)
79 |
80 | if start_time is None:
81 | start_time = now - timedelta(hours=24)
82 | if end_time is None:
83 | end_time = now
84 |
85 | result = OperationResult(
86 | operation="lookup_events",
87 | target=self._build_target_string(
88 | user_name=user_name,
89 | access_key_id=access_key_id,
90 | event_name=event_name,
91 | source_ip=source_ip,
92 | start_time=start_time,
93 | end_time=end_time,
94 | ),
95 | success=True,
96 | )
97 |
98 | cloudtrail = self._services.cloudtrail
99 |
100 | lookup_attributes = self._build_lookup_attributes(
101 | user_name=user_name,
102 | access_key_id=access_key_id,
103 | event_name=event_name,
104 | )
105 |
106 | events: List[Dict[str, Any]] = []
107 | total_api_calls = 0
108 | next_token: Optional[str] = None
109 |
110 | # Main pagination loop
111 | while True:
112 | if len(events) >= max_events:
113 | break
114 |
115 | params: Dict[str, Any] = {
116 | "StartTime": start_time,
117 | "EndTime": end_time,
118 | "MaxResults": min(page_size, 50),
119 | }
120 | if lookup_attributes:
121 | params["LookupAttributes"] = lookup_attributes
122 | if next_token:
123 | params["NextToken"] = next_token
124 |
125 | try:
126 | resp = self._call_with_backoff(
127 | cloudtrail.lookup_events,
128 | params=params,
129 | throttle_max_retries=throttle_max_retries,
130 | throttle_base_delay=throttle_base_delay,
131 | )
132 | total_api_calls += 1
133 | except Exception as exc:
134 | result.add_error(f"Failed to lookup CloudTrail events: {exc}")
135 | break
136 |
137 | raw_events = resp.get("Events", [])
138 | for e in raw_events:
139 | if len(events) >= max_events:
140 | break
141 |
142 | normalized = self._normalize_event(e)
143 |
144 | # Client-side filters (for fields not supported by LookupAttributes)
145 | if event_name and lookup_attributes and lookup_attributes[0]["AttributeKey"] != "EventName":
146 | if normalized.get("event_name") != event_name:
147 | continue
148 |
149 | if source_ip and normalized.get("source_ip_address") != source_ip:
150 | continue
151 |
152 | events.append(normalized)
153 |
154 | next_token = resp.get("NextToken")
155 | if not next_token:
156 | break
157 |
158 | result.details["events"] = events
159 | result.details["statistics"] = {
160 | "total_events_returned": len(events),
161 | "api_calls": total_api_calls,
162 | "lookup_attributes": lookup_attributes,
163 | "time_range": {
164 | "start_time": start_time.isoformat(),
165 | "end_time": end_time.isoformat(),
166 | },
167 | }
168 |
169 | return result
170 |
171 | # ----------------- internal helpers -----------------
172 |
173 | @staticmethod
174 | def _build_target_string(
175 | *,
176 | user_name: Optional[str],
177 | access_key_id: Optional[str],
178 | event_name: Optional[str],
179 | source_ip: Optional[str],
180 | start_time: datetime,
181 | end_time: datetime,
182 | ) -> str:
183 | bits = []
184 | if user_name:
185 | bits.append(f"user={user_name}")
186 | if access_key_id:
187 | bits.append(f"access_key_id={access_key_id}")
188 | if event_name:
189 | bits.append(f"event_name={event_name}")
190 | if source_ip:
191 | bits.append(f"source_ip={source_ip}")
192 | bits.append(f"time={start_time.isoformat()}..{end_time.isoformat()}")
193 | return ",".join(bits)
194 |
195 | @staticmethod
196 | def _build_lookup_attributes(
197 | *,
198 | user_name: Optional[str],
199 | access_key_id: Optional[str],
200 | event_name: Optional[str],
201 | ) -> List[Dict[str, str]]:
202 | """
203 | Choose the primary CloudTrail LookupAttribute.
204 |
205 | Priority:
206 | 1) AccessKeyId
207 | 2) Username
208 | 3) EventName
209 | """
210 | if access_key_id:
211 | return [{"AttributeKey": "AccessKeyId", "AttributeValue": access_key_id}]
212 | if user_name:
213 | return [{"AttributeKey": "Username", "AttributeValue": user_name}]
214 | if event_name:
215 | return [{"AttributeKey": "EventName", "AttributeValue": event_name}]
216 | return []
217 |
218 | @staticmethod
219 | def _normalize_event(event: Dict[str, Any]) -> Dict[str, Any]:
220 | """
221 | Normalize a CloudTrail event from LookupEvents into a simple dict.
222 | """
223 | cloudtrail_event_raw = event.get("CloudTrailEvent")
224 | source_ip = None
225 |
226 | if cloudtrail_event_raw:
227 | try:
228 | ct = json.loads(cloudtrail_event_raw)
229 | source_ip = ct.get("sourceIPAddress")
230 | except Exception:
231 | # If parsing fails, we just leave source_ip as None
232 | pass
233 |
234 | # Some SDKs may include SourceIPAddress at top-level; prefer that.
235 | source_ip = event.get("SourceIPAddress", source_ip)
236 |
237 | return {
238 | "event_id": event.get("EventId"),
239 | "event_name": event.get("EventName"),
240 | "event_time": (
241 | event["EventTime"].isoformat() if event.get("EventTime") else None
242 | ),
243 | "username": event.get("Username"),
244 | "event_source": event.get("EventSource"),
245 | "aws_region": event.get("AwsRegion"),
246 | "read_only": event.get("ReadOnly"),
247 | "access_key_id": event.get("AccessKeyId"),
248 | "source_ip_address": source_ip,
249 | "resources": event.get("Resources", []),
250 | "raw_cloudtrail_event": cloudtrail_event_raw,
251 | }
252 |
253 | @staticmethod
254 | def _call_with_backoff(
255 | func,
256 | *,
257 | params: Dict[str, Any],
258 | throttle_max_retries: int,
259 | throttle_base_delay: float,
260 | ) -> Dict[str, Any]:
261 | """
262 | Call an AWS API with basic exponential backoff on throttling.
263 | """
264 | attempt = 0
265 | while True:
266 | try:
267 | return func(**params)
268 | except botocore.exceptions.ClientError as e:
269 | code = e.response.get("Error", {}).get("Code")
270 | if code not in _THROTTLE_ERROR_CODES or attempt >= throttle_max_retries:
271 | raise
272 |
273 | delay = throttle_base_delay * (2**attempt)
274 | time.sleep(delay)
275 | attempt += 1
276 |
--------------------------------------------------------------------------------
/dredge/gcp_ir/hunt.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import time
4 | from datetime import datetime, timezone
5 | from typing import Any, Dict, List, Optional
6 |
7 | from google.api_core import exceptions as g_exceptions
8 | from google.cloud import logging_v2 as logging
9 |
10 | from .config import GcpIRConfig
11 | from .services import GcpLoggingService
12 | from .models import OperationResult
13 |
14 |
15 | class GcpIRHunt:
16 | """
17 | Hunt / search utilities over Google Cloud Logging.
18 |
19 | Primarily aimed at Cloud Audit Logs, but works for any logs
20 | you can filter with the Logging query language.:contentReference[oaicite:1]{index=1}
21 | """
22 |
23 | def __init__(self, services: GcpLoggingService, config: GcpIRConfig) -> None:
24 | self._services = services
25 | self._config = config
26 |
27 | # ------------------- public API -------------------
28 |
29 | def search_logs(
30 | self,
31 | *,
32 | principal_email: Optional[str] = None,
33 | method_name: Optional[str] = None,
34 | resource_name: Optional[str] = None,
35 | source_ip: Optional[str] = None,
36 | log_id: Optional[str] = None,
37 | start_time: Optional[datetime] = None,
38 | end_time: Optional[datetime] = None,
39 | max_entries: int = 500,
40 | page_size: int = 100,
41 | order_desc: bool = True,
42 | throttle_max_retries: int = 5,
43 | throttle_base_delay: float = 1.0,
44 | ) -> OperationResult:
45 | """
46 | Search GCP logs using simple IR-friendly filters.
47 |
48 | Args:
49 | principal_email: Filter on protoPayload.authenticationInfo.principalEmail.
50 | method_name: Filter on protoPayload.methodName.
51 | resource_name: Filter on protoPayload.resourceName.
52 | source_ip: Filter on protoPayload.requestMetadata.callerIp.
53 | log_id: Log ID; if omitted, uses config.default_log_id.
54 | Examples: "cloudaudit.googleapis.com/activity"
55 | start_time: Earliest event time (UTC).
56 | end_time: Latest event time (UTC).
57 | max_entries: Max number of log entries to return.
58 | page_size: Page size for the API (<=1000 recommended).
59 | order_desc: If True, newest first.
60 | """
61 | if start_time and start_time.tzinfo is None:
62 | start_time = start_time.replace(tzinfo=timezone.utc)
63 | if end_time and end_time.tzinfo is None:
64 | end_time = end_time.replace(tzinfo=timezone.utc)
65 |
66 | log_id_value = log_id or self._config.default_log_id
67 | filter_str = self._build_filter(
68 | principal_email=principal_email,
69 | method_name=method_name,
70 | resource_name=resource_name,
71 | source_ip=source_ip,
72 | log_id=log_id_value,
73 | start_time=start_time,
74 | end_time=end_time,
75 | )
76 |
77 | result = OperationResult(
78 | operation="gcp_search_logs",
79 | target=self._target_string(
80 | principal_email=principal_email,
81 | method_name=method_name,
82 | resource_name=resource_name,
83 | source_ip=source_ip,
84 | log_id=log_id_value,
85 | start_time=start_time,
86 | end_time=end_time,
87 | ),
88 | success=True,
89 | )
90 |
91 | entries: List[Dict[str, Any]] = []
92 | page_token: Optional[str] = None
93 | order_by = logging.DESCENDING if order_desc else logging.ASCENDING
94 |
95 | while len(entries) < max_entries:
96 | try:
97 | iterator = self._call_with_backoff(
98 | filter_=filter_str,
99 | page_size=min(max(page_size, 1), 1000),
100 | order_by=order_by,
101 | page_token=page_token,
102 | throttle_max_retries=throttle_max_retries,
103 | throttle_base_delay=throttle_base_delay,
104 | )
105 | except Exception as exc:
106 | result.add_error(f"GCP Logging API failed: {exc}")
107 | break
108 |
109 | # iterator.pages yields each page; we break after first page
110 | try:
111 | page = next(iterator.pages)
112 | except StopIteration:
113 | break
114 |
115 | for entry in page:
116 | if len(entries) >= max_entries:
117 | break
118 | entries.append(self._normalize_entry(entry))
119 |
120 | page_token = iterator.next_page_token
121 | if not page_token or len(entries) >= max_entries:
122 | break
123 |
124 | result.details["entries"] = entries
125 | result.details["statistics"] = {
126 | "total_entries_returned": len(entries),
127 | "filter": filter_str,
128 | "log_id": log_id_value,
129 | }
130 |
131 | return result
132 |
133 | def search_today(
134 | self,
135 | *,
136 | principal_email: Optional[str] = None,
137 | method_name: Optional[str] = None,
138 | resource_name: Optional[str] = None,
139 | source_ip: Optional[str] = None,
140 | log_id: Optional[str] = None,
141 | max_entries: int = 500,
142 | ) -> OperationResult:
143 | """
144 | Convenience wrapper: fetch *today's* logs (UTC calendar day).
145 |
146 | You can still filter by principal, method, resource name, or source IP.
147 | """
148 | now = datetime.now(timezone.utc)
149 | start = now.replace(hour=0, minute=0, second=0, microsecond=0)
150 | end = now.replace(hour=23, minute=59, second=59, microsecond=999999)
151 |
152 | return self.search_logs(
153 | principal_email=principal_email,
154 | method_name=method_name,
155 | resource_name=resource_name,
156 | source_ip=source_ip,
157 | log_id=log_id,
158 | start_time=start,
159 | end_time=end,
160 | max_entries=max_entries,
161 | )
162 |
163 | # ------------------- internal helpers -------------------
164 |
165 | @staticmethod
166 | def _target_string(
167 | *,
168 | principal_email: Optional[str],
169 | method_name: Optional[str],
170 | resource_name: Optional[str],
171 | source_ip: Optional[str],
172 | log_id: str,
173 | start_time: Optional[datetime],
174 | end_time: Optional[datetime],
175 | ) -> str:
176 | bits = [f"log_id={log_id}"]
177 | if principal_email:
178 | bits.append(f"principal={principal_email}")
179 | if method_name:
180 | bits.append(f"method={method_name}")
181 | if resource_name:
182 | bits.append(f"resource={resource_name}")
183 | if source_ip:
184 | bits.append(f"source_ip={source_ip}")
185 | if start_time or end_time:
186 | bits.append(
187 | f"timestamp={start_time.isoformat() if start_time else ''}"
188 | f"..{end_time.isoformat() if end_time else ''}"
189 | )
190 | return ",".join(bits)
191 |
192 | @staticmethod
193 | def _build_filter(
194 | *,
195 | principal_email: Optional[str],
196 | method_name: Optional[str],
197 | resource_name: Optional[str],
198 | source_ip: Optional[str],
199 | log_id: str,
200 | start_time: Optional[datetime],
201 | end_time: Optional[datetime],
202 | ) -> str:
203 | """
204 | Build a Cloud Logging filter expression using the Logging query language.:contentReference[oaicite:2]{index=2}
205 | """
206 | parts: List[str] = []
207 |
208 | # Restrict to a specific log ID (e.g. cloudaudit.googleapis.com/activity)
209 | # Using log_id() helper makes filters nicer.
210 | parts.append(f'log_id("{log_id}")')
211 |
212 | # Common Cloud Audit Logs fields:
213 | # protoPayload.authenticationInfo.principalEmail
214 | # protoPayload.methodName
215 | # protoPayload.resourceName
216 | # protoPayload.requestMetadata.callerIp
217 | if principal_email:
218 | parts.append(
219 | f'protoPayload.authenticationInfo.principalEmail="{principal_email}"'
220 | )
221 | if method_name:
222 | parts.append(f'protoPayload.methodName="{method_name}"')
223 | if resource_name:
224 | parts.append(f'protoPayload.resourceName="{resource_name}"')
225 | if source_ip:
226 | parts.append(
227 | f'protoPayload.requestMetadata.callerIp="{source_ip}"'
228 | )
229 |
230 | if start_time or end_time:
231 | def ts(dt: datetime) -> str:
232 | # Cloud Logging expects RFC3339 timestamps
233 | return dt.astimezone(timezone.utc).isoformat()
234 |
235 | if start_time and end_time:
236 | # Same calendar day? Use >= and <= with full timestamps for precision.
237 | parts.append(
238 | f'timestamp >= "{ts(start_time)}" AND timestamp <= "{ts(end_time)}"'
239 | )
240 | elif start_time:
241 | parts.append(f'timestamp >= "{ts(start_time)}"')
242 | elif end_time:
243 | parts.append(f'timestamp <= "{ts(end_time)}"')
244 |
245 | return " AND ".join(parts)
246 |
247 | @staticmethod
248 | def _normalize_entry(entry: logging.entries.LogEntry) -> Dict[str, Any]:
249 | """
250 | Normalize a Cloud Logging entry into a stable dict for JSON serialization.
251 | """
252 | return {
253 | "timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
254 | "log_name": entry.log_name,
255 | "severity": entry.severity,
256 | "trace": entry.trace,
257 | "span_id": entry.span_id,
258 | "insert_id": entry.insert_id,
259 | "resource": dict(entry.resource) if entry.resource else None,
260 | "labels": dict(entry.labels) if entry.labels else None,
261 | "payload": entry.payload, # can be dict, str, or proto
262 | }
263 |
264 | def _call_with_backoff(
265 | self,
266 | *,
267 | filter_: str,
268 | page_size: int,
269 | order_by: str,
270 | page_token: Optional[str],
271 | throttle_max_retries: int,
272 | throttle_base_delay: float,
273 | ) -> logging.entries.Iterator:
274 | """
275 | Call Cloud Logging with a simple exponential backoff on resource exhaustion.
276 | """
277 | attempt = 0
278 | while True:
279 | try:
280 | iterator = self._services.list_entries(
281 | filter_=filter_,
282 | page_size=page_size,
283 | order_by=order_by,
284 | page_token=page_token,
285 | )
286 | return iterator
287 | except (g_exceptions.ResourceExhausted, g_exceptions.TooManyRequests) as exc:
288 | if attempt >= throttle_max_retries:
289 | raise RuntimeError(
290 | f"GCP Logging rate limit exceeded and retries exhausted: {exc}"
291 | ) from exc
292 | delay = throttle_base_delay * (2 ** attempt)
293 | time.sleep(delay)
294 | attempt += 1
295 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Mozilla Public License Version 2.0
2 | ==================================
3 |
4 | 1. Definitions
5 | --------------
6 |
7 | 1.1. "Contributor"
8 | means each individual or legal entity that creates, contributes to
9 | the creation of, or owns Covered Software.
10 |
11 | 1.2. "Contributor Version"
12 | means the combination of the Contributions of others (if any) used
13 | by a Contributor and that particular Contributor's Contribution.
14 |
15 | 1.3. "Contribution"
16 | means Covered Software of a particular Contributor.
17 |
18 | 1.4. "Covered Software"
19 | means Source Code Form to which the initial Contributor has attached
20 | the notice in Exhibit A, the Executable Form of such Source Code
21 | Form, and Modifications of such Source Code Form, in each case
22 | including portions thereof.
23 |
24 | 1.5. "Incompatible With Secondary Licenses"
25 | means
26 |
27 | (a) that the initial Contributor has attached the notice described
28 | in Exhibit B to the Covered Software; or
29 |
30 | (b) that the Covered Software was made available under the terms of
31 | version 1.1 or earlier of the License, but not also under the
32 | terms of a Secondary License.
33 |
34 | 1.6. "Executable Form"
35 | means any form of the work other than Source Code Form.
36 |
37 | 1.7. "Larger Work"
38 | means a work that combines Covered Software with other material, in
39 | a separate file or files, that is not Covered Software.
40 |
41 | 1.8. "License"
42 | means this document.
43 |
44 | 1.9. "Licensable"
45 | means having the right to grant, to the maximum extent possible,
46 | whether at the time of the initial grant or subsequently, any and
47 | all of the rights conveyed by this License.
48 |
49 | 1.10. "Modifications"
50 | means any of the following:
51 |
52 | (a) any file in Source Code Form that results from an addition to,
53 | deletion from, or modification of the contents of Covered
54 | Software; or
55 |
56 | (b) any new file in Source Code Form that contains any Covered
57 | Software.
58 |
59 | 1.11. "Patent Claims" of a Contributor
60 | means any patent claim(s), including without limitation, method,
61 | process, and apparatus claims, in any patent Licensable by such
62 | Contributor that would be infringed, but for the grant of the
63 | License, by the making, using, selling, offering for sale, having
64 | made, import, or transfer of either its Contributions or its
65 | Contributor Version.
66 |
67 | 1.12. "Secondary License"
68 | means either the GNU General Public License, Version 2.0, the GNU
69 | Lesser General Public License, Version 2.1, the GNU Affero General
70 | Public License, Version 3.0, or any later versions of those
71 | licenses.
72 |
73 | 1.13. "Source Code Form"
74 | means the form of the work preferred for making modifications.
75 |
76 | 1.14. "You" (or "Your")
77 | means an individual or a legal entity exercising rights under this
78 | License. For legal entities, "You" includes any entity that
79 | controls, is controlled by, or is under common control with You. For
80 | purposes of this definition, "control" means (a) the power, direct
81 | or indirect, to cause the direction or management of such entity,
82 | whether by contract or otherwise, or (b) ownership of more than
83 | fifty percent (50%) of the outstanding shares or beneficial
84 | ownership of such entity.
85 |
86 | 2. License Grants and Conditions
87 | --------------------------------
88 |
89 | 2.1. Grants
90 |
91 | Each Contributor hereby grants You a world-wide, royalty-free,
92 | non-exclusive license:
93 |
94 | (a) under intellectual property rights (other than patent or trademark)
95 | Licensable by such Contributor to use, reproduce, make available,
96 | modify, display, perform, distribute, and otherwise exploit its
97 | Contributions, either on an unmodified basis, with Modifications, or
98 | as part of a Larger Work; and
99 |
100 | (b) under Patent Claims of such Contributor to make, use, sell, offer
101 | for sale, have made, import, and otherwise transfer either its
102 | Contributions or its Contributor Version.
103 |
104 | 2.2. Effective Date
105 |
106 | The licenses granted in Section 2.1 with respect to any Contribution
107 | become effective for each Contribution on the date the Contributor first
108 | distributes such Contribution.
109 |
110 | 2.3. Limitations on Grant Scope
111 |
112 | The licenses granted in this Section 2 are the only rights granted under
113 | this License. No additional rights or licenses will be implied from the
114 | distribution or licensing of Covered Software under this License.
115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a
116 | Contributor:
117 |
118 | (a) for any code that a Contributor has removed from Covered Software;
119 | or
120 |
121 | (b) for infringements caused by: (i) Your and any other third party's
122 | modifications of Covered Software, or (ii) the combination of its
123 | Contributions with other software (except as part of its Contributor
124 | Version); or
125 |
126 | (c) under Patent Claims infringed by Covered Software in the absence of
127 | its Contributions.
128 |
129 | This License does not grant any rights in the trademarks, service marks,
130 | or logos of any Contributor (except as may be necessary to comply with
131 | the notice requirements in Section 3.4).
132 |
133 | 2.4. Subsequent Licenses
134 |
135 | No Contributor makes additional grants as a result of Your choice to
136 | distribute the Covered Software under a subsequent version of this
137 | License (see Section 10.2) or under the terms of a Secondary License (if
138 | permitted under the terms of Section 3.3).
139 |
140 | 2.5. Representation
141 |
142 | Each Contributor represents that the Contributor believes its
143 | Contributions are its original creation(s) or it has sufficient rights
144 | to grant the rights to its Contributions conveyed by this License.
145 |
146 | 2.6. Fair Use
147 |
148 | This License is not intended to limit any rights You have under
149 | applicable copyright doctrines of fair use, fair dealing, or other
150 | equivalents.
151 |
152 | 2.7. Conditions
153 |
154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155 | in Section 2.1.
156 |
157 | 3. Responsibilities
158 | -------------------
159 |
160 | 3.1. Distribution of Source Form
161 |
162 | All distribution of Covered Software in Source Code Form, including any
163 | Modifications that You create or to which You contribute, must be under
164 | the terms of this License. You must inform recipients that the Source
165 | Code Form of the Covered Software is governed by the terms of this
166 | License, and how they can obtain a copy of this License. You may not
167 | attempt to alter or restrict the recipients' rights in the Source Code
168 | Form.
169 |
170 | 3.2. Distribution of Executable Form
171 |
172 | If You distribute Covered Software in Executable Form then:
173 |
174 | (a) such Covered Software must also be made available in Source Code
175 | Form, as described in Section 3.1, and You must inform recipients of
176 | the Executable Form how they can obtain a copy of such Source Code
177 | Form by reasonable means in a timely manner, at a charge no more
178 | than the cost of distribution to the recipient; and
179 |
180 | (b) You may distribute such Executable Form under the terms of this
181 | License, or sublicense it under different terms, provided that the
182 | license for the Executable Form does not attempt to limit or alter
183 | the recipients' rights in the Source Code Form under this License.
184 |
185 | 3.3. Distribution of a Larger Work
186 |
187 | You may create and distribute a Larger Work under terms of Your choice,
188 | provided that You also comply with the requirements of this License for
189 | the Covered Software. If the Larger Work is a combination of Covered
190 | Software with a work governed by one or more Secondary Licenses, and the
191 | Covered Software is not Incompatible With Secondary Licenses, this
192 | License permits You to additionally distribute such Covered Software
193 | under the terms of such Secondary License(s), so that the recipient of
194 | the Larger Work may, at their option, further distribute the Covered
195 | Software under the terms of either this License or such Secondary
196 | License(s).
197 |
198 | 3.4. Notices
199 |
200 | You may not remove or alter the substance of any license notices
201 | (including copyright notices, patent notices, disclaimers of warranty,
202 | or limitations of liability) contained within the Source Code Form of
203 | the Covered Software, except that You may alter any license notices to
204 | the extent required to remedy known factual inaccuracies.
205 |
206 | 3.5. Application of Additional Terms
207 |
208 | You may choose to offer, and to charge a fee for, warranty, support,
209 | indemnity or liability obligations to one or more recipients of Covered
210 | Software. However, You may do so only on Your own behalf, and not on
211 | behalf of any Contributor. You must make it absolutely clear that any
212 | such warranty, support, indemnity, or liability obligation is offered by
213 | You alone, and You hereby agree to indemnify every Contributor for any
214 | liability incurred by such Contributor as a result of warranty, support,
215 | indemnity or liability terms You offer. You may include additional
216 | disclaimers of warranty and limitations of liability specific to any
217 | jurisdiction.
218 |
219 | 4. Inability to Comply Due to Statute or Regulation
220 | ---------------------------------------------------
221 |
222 | If it is impossible for You to comply with any of the terms of this
223 | License with respect to some or all of the Covered Software due to
224 | statute, judicial order, or regulation then You must: (a) comply with
225 | the terms of this License to the maximum extent possible; and (b)
226 | describe the limitations and the code they affect. Such description must
227 | be placed in a text file included with all distributions of the Covered
228 | Software under this License. Except to the extent prohibited by statute
229 | or regulation, such description must be sufficiently detailed for a
230 | recipient of ordinary skill to be able to understand it.
231 |
232 | 5. Termination
233 | --------------
234 |
235 | 5.1. The rights granted under this License will terminate automatically
236 | if You fail to comply with any of its terms. However, if You become
237 | compliant, then the rights granted under this License from a particular
238 | Contributor are reinstated (a) provisionally, unless and until such
239 | Contributor explicitly and finally terminates Your grants, and (b) on an
240 | ongoing basis, if such Contributor fails to notify You of the
241 | non-compliance by some reasonable means prior to 60 days after You have
242 | come back into compliance. Moreover, Your grants from a particular
243 | Contributor are reinstated on an ongoing basis if such Contributor
244 | notifies You of the non-compliance by some reasonable means, this is the
245 | first time You have received notice of non-compliance with this License
246 | from such Contributor, and You become compliant prior to 30 days after
247 | Your receipt of the notice.
248 |
249 | 5.2. If You initiate litigation against any entity by asserting a patent
250 | infringement claim (excluding declaratory judgment actions,
251 | counter-claims, and cross-claims) alleging that a Contributor Version
252 | directly or indirectly infringes any patent, then the rights granted to
253 | You by any and all Contributors for the Covered Software under Section
254 | 2.1 of this License shall terminate.
255 |
256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257 | end user license agreements (excluding distributors and resellers) which
258 | have been validly granted by You or Your distributors under this License
259 | prior to termination shall survive termination.
260 |
261 | ************************************************************************
262 | * *
263 | * 6. Disclaimer of Warranty *
264 | * ------------------------- *
265 | * *
266 | * Covered Software is provided under this License on an "as is" *
267 | * basis, without warranty of any kind, either expressed, implied, or *
268 | * statutory, including, without limitation, warranties that the *
269 | * Covered Software is free of defects, merchantable, fit for a *
270 | * particular purpose or non-infringing. The entire risk as to the *
271 | * quality and performance of the Covered Software is with You. *
272 | * Should any Covered Software prove defective in any respect, You *
273 | * (not any Contributor) assume the cost of any necessary servicing, *
274 | * repair, or correction. This disclaimer of warranty constitutes an *
275 | * essential part of this License. No use of any Covered Software is *
276 | * authorized under this License except under this disclaimer. *
277 | * *
278 | ************************************************************************
279 |
280 | ************************************************************************
281 | * *
282 | * 7. Limitation of Liability *
283 | * -------------------------- *
284 | * *
285 | * Under no circumstances and under no legal theory, whether tort *
286 | * (including negligence), contract, or otherwise, shall any *
287 | * Contributor, or anyone who distributes Covered Software as *
288 | * permitted above, be liable to You for any direct, indirect, *
289 | * special, incidental, or consequential damages of any character *
290 | * including, without limitation, damages for lost profits, loss of *
291 | * goodwill, work stoppage, computer failure or malfunction, or any *
292 | * and all other commercial damages or losses, even if such party *
293 | * shall have been informed of the possibility of such damages. This *
294 | * limitation of liability shall not apply to liability for death or *
295 | * personal injury resulting from such party's negligence to the *
296 | * extent applicable law prohibits such limitation. Some *
297 | * jurisdictions do not allow the exclusion or limitation of *
298 | * incidental or consequential damages, so this exclusion and *
299 | * limitation may not apply to You. *
300 | * *
301 | ************************************************************************
302 |
303 | 8. Litigation
304 | -------------
305 |
306 | Any litigation relating to this License may be brought only in the
307 | courts of a jurisdiction where the defendant maintains its principal
308 | place of business and such litigation shall be governed by laws of that
309 | jurisdiction, without reference to its conflict-of-law provisions.
310 | Nothing in this Section shall prevent a party's ability to bring
311 | cross-claims or counter-claims.
312 |
313 | 9. Miscellaneous
314 | ----------------
315 |
316 | This License represents the complete agreement concerning the subject
317 | matter hereof. If any provision of this License is held to be
318 | unenforceable, such provision shall be reformed only to the extent
319 | necessary to make it enforceable. Any law or regulation which provides
320 | that the language of a contract shall be construed against the drafter
321 | shall not be used to construe this License against a Contributor.
322 |
323 | 10. Versions of the License
324 | ---------------------------
325 |
326 | 10.1. New Versions
327 |
328 | Mozilla Foundation is the license steward. Except as provided in Section
329 | 10.3, no one other than the license steward has the right to modify or
330 | publish new versions of this License. Each version will be given a
331 | distinguishing version number.
332 |
333 | 10.2. Effect of New Versions
334 |
335 | You may distribute the Covered Software under the terms of the version
336 | of the License under which You originally received the Covered Software,
337 | or under the terms of any subsequent version published by the license
338 | steward.
339 |
340 | 10.3. Modified Versions
341 |
342 | If you create software not governed by this License, and you want to
343 | create a new license for such software, you may create and use a
344 | modified version of this License if you rename the license and remove
345 | any references to the name of the license steward (except to note that
346 | such modified license differs from this License).
347 |
348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary
349 | Licenses
350 |
351 | If You choose to distribute Source Code Form that is Incompatible With
352 | Secondary Licenses under the terms of this version of the License, the
353 | notice described in Exhibit B of this License must be attached.
354 |
355 | Exhibit A - Source Code Form License Notice
356 | -------------------------------------------
357 |
358 | This Source Code Form is subject to the terms of the Mozilla Public
359 | License, v. 2.0. If a copy of the MPL was not distributed with this
360 | file, You can obtain one at http://mozilla.org/MPL/2.0/.
361 |
362 | If it is not possible or desirable to put the notice in a particular
363 | file, then You may include the notice in a location (such as a LICENSE
364 | file in a relevant directory) where a recipient would be likely to look
365 | for such a notice.
366 |
367 | You may add additional accurate notices of copyright ownership.
368 |
369 | Exhibit B - "Incompatible With Secondary Licenses" Notice
370 | ---------------------------------------------------------
371 |
372 | This Source Code Form is "Incompatible With Secondary Licenses", as
373 | defined by the Mozilla Public License, v. 2.0.
374 |
--------------------------------------------------------------------------------
/dredge/aws_ir/response.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional, List
4 |
5 | from .. import DredgeConfig
6 | from .services import AwsServiceRegistry
7 | from .models import OperationResult
8 |
9 |
10 | class AwsIRResponse:
11 | """
12 | High-level incident *response* actions.
13 |
14 | These are orchestration methods that can call multiple AWS APIs
15 | and multiple low-level helpers under the hood.
16 | """
17 |
18 | def __init__(self, services: AwsServiceRegistry, config: DredgeConfig) -> None:
19 | self._services = services
20 | self._config = config
21 |
22 | # --------------------
23 | # IAM: Access Keys
24 | # --------------------
25 |
26 | def disable_access_key(self, user_name: str, access_key_id: str) -> OperationResult:
27 | """
28 | Set the given access key to Inactive.
29 | """
30 | result = OperationResult(
31 | operation="disable_access_key",
32 | target=f"user={user_name},access_key_id={access_key_id}",
33 | success=True,
34 | )
35 |
36 | if self._config.dry_run:
37 | result.details["dry_run"] = True
38 | return result
39 |
40 | iam = self._services.iam
41 | try:
42 | iam.update_access_key(
43 | UserName=user_name,
44 | AccessKeyId=access_key_id,
45 | Status="Inactive",
46 | )
47 | result.details["status"] = "Access key disabled"
48 | except Exception as exc: # you might want to narrow this
49 | result.add_error(str(exc))
50 |
51 | return result
52 |
53 | def delete_access_key(self, user_name: str, access_key_id: str) -> OperationResult:
54 | """
55 | Permanently delete the given access key.
56 | """
57 | result = OperationResult(
58 | operation="delete_access_key",
59 | target=f"user={user_name},access_key_id={access_key_id}",
60 | success=True,
61 | )
62 |
63 | if self._config.dry_run:
64 | result.details["dry_run"] = True
65 | return result
66 |
67 | iam = self._services.iam
68 | try:
69 | iam.delete_access_key(UserName=user_name, AccessKeyId=access_key_id)
70 | result.details["status"] = "Access key deleted"
71 | except Exception as exc:
72 | result.add_error(str(exc))
73 |
74 | return result
75 |
76 | # --------------------
77 | # IAM: Users
78 | # --------------------
79 |
80 | def disable_user(self, user_name: str) -> OperationResult:
81 | """
82 | Disable a user by:
83 | - Deactivating all access keys
84 | - Removing from all groups
85 | - Deleting login profile
86 | - Detaching managed policies
87 | - Deleting inline policies
88 | """
89 | result = OperationResult(
90 | operation="disable_user",
91 | target=f"user={user_name}",
92 | success=True,
93 | )
94 |
95 | if self._config.dry_run:
96 | result.details["dry_run"] = True
97 | return result
98 |
99 | iam = self._services.iam
100 |
101 | try:
102 | # 1) Disable all access keys
103 | keys_resp = iam.list_access_keys(UserName=user_name)
104 | key_ids = [k["AccessKeyId"] for k in keys_resp.get("AccessKeyMetadata", [])]
105 | for key_id in key_ids:
106 | try:
107 | iam.update_access_key(
108 | UserName=user_name,
109 | AccessKeyId=key_id,
110 | Status="Inactive",
111 | )
112 | except Exception as exc:
113 | result.add_error(f"Failed to disable key {key_id}: {exc}")
114 |
115 | result.details["access_keys_disabled"] = key_ids
116 |
117 | # 2) Remove from groups
118 | groups_resp = iam.list_groups_for_user(UserName=user_name)
119 | group_names = [g["GroupName"] for g in groups_resp.get("Groups", [])]
120 | for group_name in group_names:
121 | try:
122 | iam.remove_user_from_group(
123 | GroupName=group_name,
124 | UserName=user_name,
125 | )
126 | except Exception as exc:
127 | result.add_error(f"Failed to remove from group {group_name}: {exc}")
128 |
129 | result.details["groups_removed"] = group_names
130 |
131 | # 3) Delete login profile (if exists)
132 | try:
133 | iam.delete_login_profile(UserName=user_name)
134 | result.details["login_profile_deleted"] = True
135 | except iam.exceptions.NoSuchEntityException:
136 | result.details["login_profile_deleted"] = False
137 | except Exception as exc:
138 | result.add_error(f"Failed to delete login profile: {exc}")
139 |
140 | # 4) Detach managed policies
141 | attached_resp = iam.list_attached_user_policies(UserName=user_name)
142 | attached_arns = [
143 | p["PolicyArn"] for p in attached_resp.get("AttachedPolicies", [])
144 | ]
145 | for arn in attached_arns:
146 | try:
147 | iam.detach_user_policy(UserName=user_name, PolicyArn=arn)
148 | except Exception as exc:
149 | result.add_error(f"Failed to detach policy {arn}: {exc}")
150 |
151 | result.details["managed_policies_detached"] = attached_arns
152 |
153 | # 5) Delete inline policies
154 | inline_resp = iam.list_user_policies(UserName=user_name)
155 | inline_names = inline_resp.get("PolicyNames", [])
156 | for policy_name in inline_names:
157 | try:
158 | iam.delete_user_policy(UserName=user_name, PolicyName=policy_name)
159 | except Exception as exc:
160 | result.add_error(f"Failed to delete inline policy {policy_name}: {exc}")
161 |
162 | result.details["inline_policies_deleted"] = inline_names
163 |
164 | except Exception as exc:
165 | result.add_error(f"Fatal error disabling user: {exc}")
166 |
167 | return result
168 |
169 | # --------------------
170 | # IAM: Roles
171 | # --------------------
172 |
173 | def disable_role(self, role_name: str) -> OperationResult:
174 | """
175 | Disable a role by:
176 | - Detaching all managed policies
177 | - Deleting all inline policies
178 | - Clearing trust relationship (set to empty)
179 | """
180 | result = OperationResult(
181 | operation="disable_role",
182 | target=f"role={role_name}",
183 | success=True,
184 | )
185 |
186 | if self._config.dry_run:
187 | result.details["dry_run"] = True
188 | return result
189 |
190 | iam = self._services.iam
191 |
192 | try:
193 | # Detach managed policies
194 | attached_resp = iam.list_attached_role_policies(RoleName=role_name)
195 | attached_arns = [
196 | p["PolicyArn"] for p in attached_resp.get("AttachedPolicies", [])
197 | ]
198 | for arn in attached_arns:
199 | try:
200 | iam.detach_role_policy(RoleName=role_name, PolicyArn=arn)
201 | except Exception as exc:
202 | result.add_error(f"Failed to detach policy {arn}: {exc}")
203 | result.details["managed_policies_detached"] = attached_arns
204 |
205 | # Delete inline policies
206 | inline_resp = iam.list_role_policies(RoleName=role_name)
207 | inline_names = inline_resp.get("PolicyNames", [])
208 | for policy_name in inline_names:
209 | try:
210 | iam.delete_role_policy(RoleName=role_name, PolicyName=policy_name)
211 | except Exception as exc:
212 | result.add_error(f"Failed to delete inline policy {policy_name}: {exc}")
213 | result.details["inline_policies_deleted"] = inline_names
214 |
215 | # Clear trust relationship
216 | iam.update_assume_role_policy(
217 | RoleName=role_name,
218 | PolicyDocument='{"Version":"2012-10-17","Statement":[]}',
219 | )
220 | result.details["trust_relationship_cleared"] = True
221 |
222 | except Exception as exc:
223 | result.add_error(f"Fatal error disabling role {role_name}: {exc}")
224 |
225 | return result
226 |
227 | # --------------------
228 | # S3: Block public access
229 | # --------------------
230 |
231 | def block_s3_public_access(
232 | self,
233 | account_id: str,
234 | *,
235 | block_public_acls: bool = True,
236 | ignore_public_acls: bool = True,
237 | block_public_policy: bool = True,
238 | restrict_public_buckets: bool = True,
239 | ) -> OperationResult:
240 | """
241 | Enable S3 Block Public Access at the account level.
242 |
243 | Uses s3control.PutPublicAccessBlock.
244 | """
245 | result = OperationResult(
246 | operation="block_s3_public_access",
247 | target=f"account={account_id}",
248 | success=True,
249 | )
250 |
251 | if self._config.dry_run:
252 | result.details["dry_run"] = True
253 | return result
254 |
255 | s3control = self._services.s3control
256 |
257 | try:
258 | s3control.put_public_access_block(
259 | AccountId=account_id,
260 | PublicAccessBlockConfiguration={
261 | "BlockPublicAcls": block_public_acls,
262 | "IgnorePublicAcls": ignore_public_acls,
263 | "BlockPublicPolicy": block_public_policy,
264 | "RestrictPublicBuckets": restrict_public_buckets,
265 | },
266 | )
267 | result.details["status"] = "S3 public access blocked at account level"
268 | except Exception as exc:
269 | result.add_error(str(exc))
270 |
271 | return result
272 |
273 | # --------------------
274 | # EC2: Isolate instances
275 | # --------------------
276 |
277 | def isolate_ec2_instances(
278 | self,
279 | instance_ids: list[str],
280 | *,
281 | vpc_id: Optional[str] = None,
282 | sg_name: str = "dredge-forensic-isolation",
283 | description: str = "Dredge forensic isolation group (no inbound/outbound)",
284 | ) -> OperationResult:
285 | """
286 | Isolate one or more EC2 instances by:
287 | - Creating (or reusing) a security group with no ingress/egress
288 | - Assigning that SG to the instances (replacing existing groups)
289 | """
290 | result = OperationResult(
291 | operation="isolate_ec2_instances",
292 | target=",".join(instance_ids),
293 | success=True,
294 | )
295 |
296 | if self._config.dry_run:
297 | result.details["dry_run"] = True
298 | return result
299 |
300 | ec2 = self._services.ec2
301 |
302 | try:
303 | if not vpc_id:
304 | # Infer VPC from first instance
305 | desc = ec2.describe_instances(InstanceIds=[instance_ids[0]])
306 | reservations = desc.get("Reservations", [])
307 | if not reservations or not reservations[0]["Instances"]:
308 | raise RuntimeError("Unable to infer VPC ID from instance")
309 | vpc_id = reservations[0]["Instances"][0]["VpcId"]
310 |
311 | # Try to find existing SG
312 | sg_id = self._find_or_create_isolation_sg(
313 | ec2=ec2,
314 | vpc_id=vpc_id,
315 | sg_name=sg_name,
316 | description=description,
317 | )
318 | result.details["isolation_security_group_id"] = sg_id
319 |
320 | # Replace SGs on each instance
321 | for instance_id in instance_ids:
322 | try:
323 | ec2.modify_instance_attribute(
324 | InstanceId=instance_id,
325 | Groups=[sg_id],
326 | )
327 | except Exception as exc:
328 | result.add_error(f"Failed to isolate {instance_id}: {exc}")
329 |
330 | except Exception as exc:
331 | result.add_error(f"Fatal error isolating instances: {exc}")
332 |
333 | return result
334 |
335 | # ---- internal helpers ----
336 |
337 | @staticmethod
338 | def _find_or_create_isolation_sg(ec2, vpc_id: str, sg_name: str, description: str) -> str:
339 | # Try to find
340 | resp = ec2.describe_security_groups(
341 | Filters=[
342 | {"Name": "group-name", "Values": [sg_name]},
343 | {"Name": "vpc-id", "Values": [vpc_id]},
344 | ]
345 | )
346 | groups = resp.get("SecurityGroups", [])
347 | if groups:
348 | return groups[0]["GroupId"]
349 |
350 | # Create new SG with no rules
351 | create_resp = ec2.create_security_group(
352 | GroupName=sg_name,
353 | Description=description,
354 | VpcId=vpc_id,
355 | )
356 | sg_id = create_resp["GroupId"]
357 |
358 | # Ensure no ingress/egress rules (API might create default egress)
359 | try:
360 | ec2.revoke_security_group_egress(
361 | GroupId=sg_id,
362 | IpPermissions=[
363 | {
364 | "IpProtocol": "-1",
365 | "IpRanges": [{"CidrIp": "0.0.0.0/0"}],
366 | }
367 | ],
368 | )
369 | except Exception:
370 | # If there were no default rules, this might fail. We can ignore.
371 | pass
372 |
373 | return sg_id
374 |
375 | # --------------------
376 | # IAM: Delete user (uses disable_user first)
377 | # --------------------
378 |
379 | def delete_user(self, user_name: str) -> OperationResult:
380 | """
381 | Fully delete an IAM user in a safe-ish way:
382 |
383 | 1) Call disable_user(user_name) to:
384 | - deactivate all access keys
385 | - remove from groups
386 | - delete login profile
387 | - detach managed policies
388 | - delete inline policies
389 | 2) Delete the IAM user object itself.
390 |
391 | NOTE: This is destructive. Prefer disable_user for containment
392 | and only delete when you're sure.
393 | """
394 | # First, reuse disable_user
395 | disable_result = self.disable_user(user_name)
396 |
397 | result = OperationResult(
398 | operation="delete_user",
399 | target=f"user={user_name}",
400 | success=disable_result.success,
401 | details=dict(disable_result.details),
402 | errors=list(disable_result.errors),
403 | )
404 |
405 | if self._config.dry_run:
406 | result.details["dry_run"] = True
407 | return result
408 |
409 | iam = self._services.iam
410 |
411 | # If disable_user already had a fatal error, we still *attempt*
412 | # deletion but keep the errors.
413 | try:
414 | iam.delete_user(UserName=user_name)
415 | result.details["user_deleted"] = True
416 | except Exception as exc:
417 | result.add_error(f"Failed to delete user {user_name}: {exc}")
418 |
419 | return result
420 |
421 | # --------------------
422 | # S3: Bucket / object level block
423 | # --------------------
424 |
425 | def block_s3_bucket_public_access(self, bucket_name: str) -> OperationResult:
426 | """
427 | Make a bucket 'private' in an IR context by:
428 |
429 | - Setting S3 Block Public Access at bucket level
430 | - Setting ACL to 'private'
431 | - Deleting bucket policy (if present)
432 |
433 | This roughly corresponds to your 'Make a bucket private' action.
434 | """
435 | result = OperationResult(
436 | operation="block_s3_bucket_public_access",
437 | target=f"bucket={bucket_name}",
438 | success=True,
439 | )
440 |
441 | if self._config.dry_run:
442 | result.details["dry_run"] = True
443 | return result
444 |
445 | s3 = self._services.s3
446 |
447 | try:
448 | # 1) Block Public Access (bucket-level)
449 | s3.put_public_access_block(
450 | Bucket=bucket_name,
451 | PublicAccessBlockConfiguration={
452 | "BlockPublicAcls": True,
453 | "IgnorePublicAcls": True,
454 | "BlockPublicPolicy": True,
455 | "RestrictPublicBuckets": True,
456 | },
457 | )
458 | result.details["public_access_blocked"] = True
459 | except Exception as exc:
460 | result.add_error(f"Failed to set bucket PublicAccessBlock: {exc}")
461 |
462 | try:
463 | # 2) ACL -> private
464 | s3.put_bucket_acl(Bucket=bucket_name, ACL="private")
465 | result.details["acl_set_private"] = True
466 | except Exception as exc:
467 | result.add_error(f"Failed to set bucket ACL to private: {exc}")
468 |
469 | try:
470 | # 3) Delete bucket policy (if any)
471 | s3.delete_bucket_policy(Bucket=bucket_name)
472 | result.details["bucket_policy_deleted"] = True
473 | except s3.exceptions.from_code("NoSuchBucketPolicy"): # may or may not exist
474 | result.details["bucket_policy_deleted"] = False
475 | except Exception as exc:
476 | # Some SDKs don't expose NoSuchBucketPolicy; we just record error.
477 | result.add_error(f"Failed to delete bucket policy: {exc}")
478 |
479 | return result
480 |
481 | def block_s3_object_public_access(self, bucket_name: str, key: str) -> OperationResult:
482 | """
483 | Make a single object 'private' by:
484 |
485 | - Setting ACL to 'private'
486 |
487 | This corresponds to your 'Make an object private' action.
488 | """
489 | result = OperationResult(
490 | operation="block_s3_object_public_access",
491 | target=f"bucket={bucket_name},key={key}",
492 | success=True,
493 | )
494 |
495 | if self._config.dry_run:
496 | result.details["dry_run"] = True
497 | return result
498 |
499 | s3 = self._services.s3
500 |
501 | try:
502 | s3.put_object_acl(
503 | Bucket=bucket_name,
504 | Key=key,
505 | ACL="private",
506 | )
507 | result.details["acl_set_private"] = True
508 | except Exception as exc:
509 | result.add_error(f"Failed to set object ACL to private: {exc}")
510 |
511 | return result
--------------------------------------------------------------------------------
/dredge/cli.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from __future__ import annotations
3 |
4 | import argparse
5 | import json
6 | import csv
7 | import sys
8 | from dataclasses import asdict
9 | from datetime import datetime, timedelta, timezone
10 | from dateutil.relativedelta import relativedelta
11 | from typing import Optional
12 |
13 | # Library imports – adjust if your package paths differ
14 | from dredge import Dredge, DredgeConfig
15 | from dredge.auth import AwsAuthConfig
16 | from dredge.github_ir.config import GitHubIRConfig
17 |
18 | from importlib.metadata import version, PackageNotFoundError
19 | # ------------- helpers -------------
20 |
21 |
22 | def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
23 | if not value:
24 | return None
25 | v = value.strip()
26 | # Allow 'Z' suffix
27 | if v.endswith("Z"):
28 | v = v[:-1] + "+00:00"
29 | try:
30 | dt = datetime.fromisoformat(v)
31 | if dt.tzinfo is None:
32 | dt = dt.replace(tzinfo=timezone.utc)
33 | return dt
34 | except ValueError:
35 | raise argparse.ArgumentTypeError(
36 | f"Invalid datetime format: {value}. Use ISO 8601, e.g. 2025-01-01T12:00:00+00:00"
37 | )
38 |
39 |
40 | def print_result(result, output: str = "json") -> None:
41 | """
42 | Print an OperationResult in the desired format.
43 |
44 | output: "json" (default) or "csv"
45 | """
46 | # Normalise to a dict first
47 | try:
48 | data = asdict(result)
49 | except TypeError:
50 | data = result
51 |
52 | if output == "json":
53 | print(json.dumps(data, indent=2, default=str))
54 | return
55 |
56 | if output == "csv":
57 | # Try to find a list-like payload to tabularise
58 | details = data.get("details", {}) if isinstance(data, dict) else {}
59 | events = None
60 |
61 | # Common hunt payload keys
62 | for key in ("events", "entries", "results"):
63 | if isinstance(details.get(key), list):
64 | events = details[key]
65 | break
66 |
67 | # If we don't have a sensible list, fall back to JSON
68 | if not events:
69 | print(json.dumps(data, indent=2, default=str))
70 | return
71 |
72 | # Collect all fieldnames across events
73 | fieldnames = set()
74 | for ev in events:
75 | if isinstance(ev, dict):
76 | fieldnames.update(ev.keys())
77 |
78 | fieldnames = sorted(fieldnames)
79 |
80 | writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames)
81 | writer.writeheader()
82 | for ev in events:
83 | if isinstance(ev, dict):
84 | writer.writerow(ev)
85 | else:
86 | # Best effort: dump non-dict as a single 'value' column
87 | writer.writerow({"value": str(ev)})
88 |
89 | return
90 |
91 | # Fallback if an unknown output format sneaks in
92 | print(json.dumps(data, indent=2, default=str))
93 |
94 |
95 |
96 | def build_aws_auth_from_args(args: argparse.Namespace) -> Optional[AwsAuthConfig]:
97 | # If nothing is set, return None (Dredge will use default AWS chain)
98 | if not any(
99 | [
100 | args.aws_profile,
101 | args.aws_access_key_id,
102 | args.aws_secret_access_key,
103 | args.aws_session_token,
104 | args.aws_role_arn,
105 | ]
106 | ):
107 | return None
108 |
109 | return AwsAuthConfig(
110 | access_key_id=args.aws_access_key_id,
111 | secret_access_key=args.aws_secret_access_key,
112 | session_token=args.aws_session_token,
113 | profile_name=args.aws_profile,
114 | role_arn=args.aws_role_arn,
115 | external_id=args.aws_external_id,
116 | region_name=args.aws_region,
117 | )
118 |
119 |
120 | def build_github_config_from_args(args: argparse.Namespace) -> Optional[GitHubIRConfig]:
121 | if not args.github_org and not args.github_enterprise:
122 | return None
123 |
124 | return GitHubIRConfig(
125 | org=args.github_org,
126 | enterprise=args.github_enterprise,
127 | token=args.github_token or None,
128 | )
129 |
130 |
131 | # ------------- AWS command handlers -------------
132 |
133 |
134 | def handle_aws_disable_access_key(args: argparse.Namespace) -> None:
135 | auth = build_aws_auth_from_args(args)
136 | dredge = Dredge(
137 | auth=auth,
138 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
139 | )
140 | res = dredge.aws_ir.response.disable_access_key(
141 | user_name=args.user,
142 | access_key_id=args.access_key_id,
143 | )
144 | print_result(res, output=getattr(args, "output", "json"))
145 |
146 |
147 | def handle_aws_delete_access_key(args: argparse.Namespace) -> None:
148 | auth = build_aws_auth_from_args(args)
149 | dredge = Dredge(
150 | auth=auth,
151 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
152 | )
153 | res = dredge.aws_ir.response.delete_access_key(
154 | user_name=args.user,
155 | access_key_id=args.access_key_id,
156 | )
157 | print_result(res, output=getattr(args, "output", "json"))
158 |
159 |
160 | def handle_aws_disable_user(args: argparse.Namespace) -> None:
161 | auth = build_aws_auth_from_args(args)
162 | dredge = Dredge(
163 | auth=auth,
164 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
165 | )
166 | res = dredge.aws_ir.response.disable_user(args.user)
167 | print_result(res, output=getattr(args, "output", "json"))
168 |
169 |
170 | def handle_aws_delete_user(args: argparse.Namespace) -> None:
171 | auth = build_aws_auth_from_args(args)
172 | dredge = Dredge(
173 | auth=auth,
174 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
175 | )
176 | res = dredge.aws_ir.response.delete_user(args.user)
177 | print_result(res, output=getattr(args, "output", "json"))
178 |
179 |
180 | def handle_aws_disable_role(args: argparse.Namespace) -> None:
181 | auth = build_aws_auth_from_args(args)
182 | dredge = Dredge(
183 | auth=auth,
184 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
185 | )
186 | res = dredge.aws_ir.response.disable_role(args.role)
187 | print_result(res, output=getattr(args, "output", "json"))
188 |
189 |
190 | def handle_aws_block_s3_account(args: argparse.Namespace) -> None:
191 | auth = build_aws_auth_from_args(args)
192 | dredge = Dredge(
193 | auth=auth,
194 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
195 | )
196 | res = dredge.aws_ir.response.block_s3_public_access(
197 | account_id=args.account_id,
198 | )
199 | print_result(res, output=getattr(args, "output", "json"))
200 |
201 |
202 | def handle_aws_block_s3_bucket(args: argparse.Namespace) -> None:
203 | auth = build_aws_auth_from_args(args)
204 | dredge = Dredge(
205 | auth=auth,
206 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
207 | )
208 | res = dredge.aws_ir.response.block_s3_bucket_public_access(
209 | bucket_name=args.bucket,
210 | )
211 | print_result(res, output=getattr(args, "output", "json"))
212 |
213 |
214 | def handle_aws_block_s3_object(args: argparse.Namespace) -> None:
215 | auth = build_aws_auth_from_args(args)
216 | dredge = Dredge(
217 | auth=auth,
218 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
219 | )
220 | res = dredge.aws_ir.response.block_s3_object_public_access(
221 | bucket_name=args.bucket,
222 | key=args.key,
223 | )
224 | print_result(res, output=getattr(args, "output", "json"))
225 |
226 |
227 | def handle_aws_isolate_ec2(args: argparse.Namespace) -> None:
228 | auth = build_aws_auth_from_args(args)
229 | dredge = Dredge(
230 | auth=auth,
231 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
232 | )
233 | res = dredge.aws_ir.response.isolate_ec2_instances(
234 | instance_ids=args.instance_ids,
235 | vpc_id=args.vpc_id,
236 | )
237 | print_result(res, output=getattr(args, "output", "json"))
238 |
239 |
240 | def handle_aws_hunt_cloudtrail(args: argparse.Namespace) -> None:
241 | auth = build_aws_auth_from_args(args)
242 | dredge = Dredge(
243 | auth=auth,
244 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
245 | )
246 | # Relative ranges
247 | if args.week_ago or args.month_ago:
248 | start, end = compute_relative_range(
249 | weeks_ago=args.week_ago,
250 | months_ago=args.month_ago,
251 | )
252 | else:
253 | if args.today:
254 | now = datetime.now(timezone.utc)
255 | start = now.replace(hour=0, minute=0, second=0, microsecond=0)
256 | end = now
257 | else:
258 | start = parse_iso_datetime(args.start_time)
259 | end = parse_iso_datetime(args.end_time)
260 |
261 | res = dredge.aws_ir.hunt.lookup_events(
262 | user_name=args.user,
263 | access_key_id=args.access_key_id,
264 | event_name=args.event_name,
265 | source_ip=args.source_ip,
266 | start_time=start,
267 | end_time=end,
268 | max_events=args.max_events,
269 | )
270 | print_result(res, output=getattr(args, "output", "json"))
271 |
272 |
273 | # ------------- GitHub command handlers -------------
274 |
275 |
276 | def handle_github_hunt_audit(args: argparse.Namespace) -> None:
277 | auth = build_aws_auth_from_args(args) # optional; might be unused
278 | github_cfg = build_github_config_from_args(args)
279 | if github_cfg is None:
280 | raise SystemExit("You must provide --github-org or --github-enterprise")
281 |
282 | dredge = Dredge(
283 | auth=auth,
284 | config=DredgeConfig(region_name=args.aws_region, dry_run=args.dry_run),
285 | github_config=github_cfg,
286 | )
287 |
288 | if dredge.github_ir is None:
289 | raise SystemExit("GitHub IR not configured")
290 |
291 | if args.today:
292 | start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
293 | end = datetime.now(timezone.utc)
294 |
295 | elif args.week_ago or args.month_ago:
296 | start, end = compute_relative_range(
297 | weeks_ago=args.week_ago,
298 | months_ago=args.month_ago,
299 | )
300 |
301 | else:
302 | start = parse_iso_datetime(args.start_time)
303 | end = parse_iso_datetime(args.end_time)
304 |
305 | res = dredge.github_ir.hunt.search_audit_log(
306 | actor=args.actor,
307 | action=args.action,
308 | repo=args.repo,
309 | source_ip=args.source_ip,
310 | start_time=start,
311 | end_time=end,
312 | include=args.include,
313 | max_events=args.max_events,
314 | )
315 |
316 | print_result(res, output=getattr(args, "output", "json"))
317 |
318 |
319 | # ------------- argparse wiring -------------
320 |
321 |
322 | def build_parser() -> argparse.ArgumentParser:
323 | parser = argparse.ArgumentParser(
324 | prog="dredge-cli",
325 | description="Dredge incident response CLI (AWS + GitHub)",
326 | )
327 |
328 | # Global / AWS options
329 | parser.add_argument(
330 | "--aws-region", "--region",
331 | dest="aws_region",
332 | help="AWS region (e.g. us-east-1)",
333 | default=None,
334 | )
335 | parser.add_argument("--aws-profile", help="AWS profile name", default=None)
336 | parser.add_argument("--aws-access-key-id", default=None)
337 | parser.add_argument("--aws-secret-access-key", default=None)
338 | parser.add_argument("--aws-session-token", default=None)
339 | parser.add_argument("--aws-role-arn", default=None)
340 | parser.add_argument("--aws-external-id", default=None)
341 | parser.add_argument(
342 | "--dry-run",
343 | action="store_true",
344 | help="Do not make changes, only simulate (where supported)",
345 | )
346 |
347 | # GitHub-global options (used when github subcommands are run)
348 | parser.add_argument("--github-org", default=None, help="GitHub organization slug")
349 | parser.add_argument("--github-enterprise", default=None, help="GitHub enterprise slug")
350 | parser.add_argument(
351 | "--github-token",
352 | default=None,
353 | help="GitHub token (otherwise uses env var configured in GitHubIRConfig)",
354 | )
355 |
356 | # --version flag
357 | try:
358 | dredge_version = version("dredge")
359 | except PackageNotFoundError:
360 | dredge_version = "development"
361 |
362 | parser.add_argument(
363 | "--version",
364 | action="version",
365 | version=f"dredge {dredge_version}",
366 | )
367 |
368 | # Subcommands
369 | subparsers = parser.add_subparsers(dest="command", required=True)
370 |
371 | # --- AWS response subcommands ---
372 |
373 | p = subparsers.add_parser("aws-disable-access-key", help="Disable an IAM access key")
374 | p.add_argument("--user", required=True, help="IAM username")
375 | p.add_argument("--access-key-id", required=True, help="Access key ID")
376 | p.set_defaults(func=handle_aws_disable_access_key)
377 |
378 | p = subparsers.add_parser("aws-delete-access-key", help="Delete an IAM access key")
379 | p.add_argument("--user", required=True, help="IAM username")
380 | p.add_argument("--access-key-id", required=True, help="Access key ID")
381 | p.set_defaults(func=handle_aws_delete_access_key)
382 |
383 | p = subparsers.add_parser("aws-disable-user", help="Disable an IAM user")
384 | p.add_argument("--user", required=True, help="IAM username")
385 | p.set_defaults(func=handle_aws_disable_user)
386 |
387 | p = subparsers.add_parser("aws-delete-user", help="Delete an IAM user")
388 | p.add_argument("--user", required=True, help="IAM username")
389 | p.set_defaults(func=handle_aws_delete_user)
390 |
391 | p = subparsers.add_parser("aws-disable-role", help="Disable an IAM role")
392 | p.add_argument("--role", required=True, help="IAM role name")
393 | p.set_defaults(func=handle_aws_disable_role)
394 |
395 | p = subparsers.add_parser(
396 | "aws-block-s3-account", help="Block S3 public access at account level"
397 | )
398 | p.add_argument("--account-id", required=True, help="AWS account ID")
399 | p.set_defaults(func=handle_aws_block_s3_account)
400 |
401 | p = subparsers.add_parser(
402 | "aws-block-s3-bucket", help="Make an S3 bucket private / block public access"
403 | )
404 | p.add_argument("--bucket", required=True, help="Bucket name")
405 | p.set_defaults(func=handle_aws_block_s3_bucket)
406 |
407 | p = subparsers.add_parser(
408 | "aws-block-s3-object", help="Make a specific S3 object private"
409 | )
410 | p.add_argument("--bucket", required=True, help="Bucket name")
411 | p.add_argument("--key", required=True, help="Object key")
412 | p.set_defaults(func=handle_aws_block_s3_object)
413 |
414 | p = subparsers.add_parser(
415 | "aws-isolate-ec2", help="Network-isolate EC2 instances (forensic SG)"
416 | )
417 | p.add_argument(
418 | "instance_ids",
419 | nargs="+",
420 | help="One or more EC2 instance IDs",
421 | )
422 | p.add_argument(
423 | "--vpc-id",
424 | default=None,
425 | help="Optional VPC ID (otherwise inferred from first instance)",
426 | )
427 | p.set_defaults(func=handle_aws_isolate_ec2)
428 |
429 | # --- AWS hunt (CloudTrail) ---
430 |
431 | p = subparsers.add_parser(
432 | "aws-hunt-cloudtrail", help="Hunt CloudTrail events with simple filters"
433 | )
434 | p.add_argument("--user", default=None, help="CloudTrail Username")
435 | p.add_argument("--access-key-id", default=None, help="AccessKeyId")
436 | p.add_argument("--event-name", default=None, help="Event name (e.g. ConsoleLogin)")
437 | p.add_argument("--source-ip", default=None, help="Source IP address")
438 | p.add_argument("--start-time", default=None, help="Start time (ISO 8601)")
439 | p.add_argument("--end-time", default=None, help="End time (ISO 8601)")
440 | p.add_argument(
441 | "--max-events",
442 | type=int,
443 | default=500,
444 | help="Maximum number of events to return",
445 | )
446 | p.set_defaults(func=handle_aws_hunt_cloudtrail)
447 | p.add_argument(
448 | "--output",
449 | choices=["json", "csv"],
450 | default="json",
451 | help="Output format (json or csv, default json)",
452 | )
453 | p.add_argument(
454 | "--today",
455 | action="store_true",
456 | help="Search only today's CloudTrail events (UTC)",
457 | )
458 | p.add_argument("--week-ago", type=int, help="Return events from N weeks ago until now")
459 | p.add_argument("--month-ago", type=int, help="Return events from N months ago until now")
460 |
461 | p.set_defaults(func=handle_aws_hunt_cloudtrail)
462 |
463 | # --- GitHub hunt ---
464 |
465 | p = subparsers.add_parser(
466 | "github-hunt-audit", help="Hunt GitHub org/enterprise audit logs"
467 | )
468 | p.add_argument("--actor", default=None, help="GitHub username (actor)")
469 | p.add_argument("--action", default=None, help="Audit action (e.g. repo.create)")
470 | p.add_argument("--repo", default=None, help="Repository (e.g. org/repo)")
471 | p.add_argument("--source-ip", default=None, help="Actor IP address")
472 | p.add_argument(
473 | "--include",
474 | default=None,
475 | help='Include filter: "web", "git", or "all" (default from config)',
476 | )
477 | p.add_argument("--start-time", default=None, help="Start time (ISO 8601)")
478 | p.add_argument("--end-time", default=None, help="End time (ISO 8601)")
479 | p.add_argument(
480 | "--max-events",
481 | type=int,
482 | default=500,
483 | help="Maximum number of events to return",
484 | )
485 | p.add_argument(
486 | "--output",
487 | choices=["json", "csv"],
488 | default="json",
489 | help="Output format (json or csv, default json)",
490 | )
491 | p.add_argument(
492 | "--today",
493 | action="store_true",
494 | help="Search only today's events",
495 | )
496 |
497 | p.add_argument("--week-ago", type=int, help="Return events from N weeks ago until now")
498 | p.add_argument("--month-ago", type=int, help="Return events from N months ago until now")
499 |
500 | p.set_defaults(func=handle_github_hunt_audit)
501 |
502 | return parser
503 |
504 |
505 | def compute_relative_range(weeks_ago: int = None, months_ago: int = None):
506 | """
507 | Returns (start, end) datetimes in UTC based on relative offsets.
508 | - weeks_ago N → from N weeks ago until now
509 | - months_ago N → from N months ago until now
510 | """
511 | now = datetime.now(timezone.utc)
512 |
513 | if weeks_ago is not None:
514 | start = now - timedelta(weeks=weeks_ago)
515 | return start, now
516 |
517 | if months_ago is not None:
518 | start = now - relativedelta(months=months_ago)
519 | return start, now
520 |
521 | return None, None
522 |
523 |
524 | def main():
525 | parser = build_parser()
526 | args = parser.parse_args()
527 | func = getattr(args, "func", None)
528 | if func is None:
529 | parser.print_help()
530 | raise SystemExit(1)
531 | func(args)
532 |
533 | if __name__ == "__main__":
534 | main()
535 |
536 |
--------------------------------------------------------------------------------