From 58430cdfa6e9b71e8cf0654f302b9fa54ab84c16 Mon Sep 17 00:00:00 2001 From: tsmith4014 Date: Sat, 20 Dec 2025 18:36:06 -0600 Subject: [PATCH 1/7] feat(v2): modular scanner + Slack reporting + whitelist + safe teardown controls Summary - Replace v1 script with v2 package architecture (scanner/budget/whitelist/teardown). - Slack reporting: scan summary (per region + totals, including 0 counts for scanned types), budget summary, teardown plan/results, and dedicated whitelisted resources list. - Whitelist: tag-based keep rule (default bloodhound:keep=true) plus optional KEEP_RESOURCE_IDS. - Teardown: dry-run by default; apply-mode gated by APPLY_CHANGES and supports simulate mode (TEARDOWN_SIMULATE) plus safety rails (TEARDOWN_TARGET_IDS, TEARDOWN_ALLOW_ALL). - Budgeting: 7-month cohort spend tracking and month-end projection via Cost Explorer. Operational - Add lambda handler entrypoint (lambda_function.lambda_handler) and local runner (run_local.py). - Add env.example and .env auto-loading for local runs. - Add .gitignore to prevent committing secrets/venvs/build zips. - Update requirements to resolve urllib3/botocore conflict. - Add v2 GitHub Actions workflow (invoke_lambda_v2.yml). - Add v2 plan doc and split Slack setup into SLACK_SETUP.md. Notes - v1 is preserved separately under versions/v1_0/ outside this repo directory; v2 deletes/terminations require explicit env flags. --- .gitignore | 44 ++++ README.md | 315 +++++++------------------ SLACK_SETUP.md | 50 ++++ V2_PLAN.md | 395 ++++++++++++++++++++++++++++++++ bloodhound.py | 94 -------- bloodhound/__init__.py | 5 + bloodhound/app.py | 155 +++++++++++++ bloodhound/aws.py | 29 +++ bloodhound/budget.py | 194 ++++++++++++++++ bloodhound/config.py | 201 ++++++++++++++++ bloodhound/messages.py | 220 ++++++++++++++++++ bloodhound/scanner/__init__.py | 7 + bloodhound/scanner/ec2.py | 157 +++++++++++++ bloodhound/scanner/elbv2.py | 58 +++++ bloodhound/scanner/rds.py | 67 ++++++ bloodhound/scanner/regions.py | 30 +++ bloodhound/scanner/scan_all.py | 40 ++++ bloodhound/slack.py | 28 +++ bloodhound/teardown/__init__.py | 6 + bloodhound/teardown/executor.py | 101 ++++++++ bloodhound/teardown/planner.py | 42 ++++ bloodhound/types.py | 28 +++ bloodhound/whitelist.py | 33 +++ env.example | 63 +++++ lambda_function.py | 16 ++ requirements.txt | 2 +- run_local.py | 20 ++ 27 files changed, 2078 insertions(+), 322 deletions(-) create mode 100644 .gitignore create mode 100644 SLACK_SETUP.md create mode 100644 V2_PLAN.md delete mode 100755 bloodhound.py create mode 100644 bloodhound/__init__.py create mode 100644 bloodhound/app.py create mode 100644 bloodhound/aws.py create mode 100644 bloodhound/budget.py create mode 100644 bloodhound/config.py create mode 100644 bloodhound/messages.py create mode 100644 bloodhound/scanner/__init__.py create mode 100644 bloodhound/scanner/ec2.py create mode 100644 bloodhound/scanner/elbv2.py create mode 100644 bloodhound/scanner/rds.py create mode 100644 bloodhound/scanner/regions.py create mode 100644 bloodhound/scanner/scan_all.py create mode 100644 bloodhound/slack.py create mode 100644 bloodhound/teardown/__init__.py create mode 100644 bloodhound/teardown/executor.py create mode 100644 bloodhound/teardown/planner.py create mode 100644 bloodhound/types.py create mode 100644 bloodhound/whitelist.py create mode 100644 env.example create mode 100644 lambda_function.py create mode 100644 run_local.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad0f32d --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +.eggs/ +dist/ +build/ +pip-wheel-metadata/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Virtualenvs +.venv/ +venv/ +ENV/ +.env/ + +# Environment files / secrets +.env +**/.env + +# Project build artifacts +.build/ +**/.build/ +*.zip + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# Logs +*.log + + +# will add actions folder later but ignore for now we dont want to trigger github actions and destroy aws resources +/.github/workflows/ + +/.build/ diff --git a/README.md b/README.md index 0e09461..e26218e 100644 --- a/README.md +++ b/README.md @@ -1,286 +1,147 @@ -# Bloodhound Lambda Function +# Bloodhound v2 (AWS resource scanner + Slack alerts + optional teardown) -## Table of Contents +Bloodhound v2 scans selected AWS regions for common cost-leak resources, posts results to Slack, and can optionally delete resources that are **not** whitelisted. -- [Overview](#overview) -- [Prerequisites](#prerequisites) -- [Setup Steps](#setup-steps) - - [1. Create a Slack App](#1-create-a-slack-app) - - [2. Add Bot Permissions](#2-add-bot-permissions) - - [3. Get the Slack Channel ID](#3-get-the-slack-channel-id) - - [4. Invite the Bot to the Channel](#4-invite-the-bot-to-the-channel) - - [5. Prepare the Lambda Function Code](#5-prepare-the-lambda-function-code) - - [6. Package and Deploy the Lambda Function](#6-package-and-deploy-the-lambda-function) - - [7. Create an IAM Role for Lambda](#7-create-an-iam-role-for-lambda) - - [8. Set Up Environment Variables in AWS Lambda](#8-set-up-environment-variables-in-aws-lambda) - - [9. Test the Lambda Function](#9-test-the-lambda-function) - - [10. GitHub Actions Setup](#10-github-actions-setup) -- [Conclusion](#conclusion) +This README is intentionally focused on the workflow you asked for: -## Overview +- Clone this repo +- Configure `.env` for local testing +- Rebuild the deployment zip locally (the `.build/` dir is not committed) +- Deploy to a **new** AWS Lambda (do not overwrite v1) +- Configure Lambda env vars to match your `.env` -The Bloodhound Lambda function is designed to scan AWS regions for EC2 and RDS instances and post a summary to a Slack channel. This README provides detailed steps to set up, deploy, and test the Lambda function, including Slack integration. +If you need to create a Slack bot from scratch, see `SLACK_SETUP.md`. ![AWS Architecture Diagram](assets/bloodhound_lambda_architecture.png) -## Prerequisites - -- AWS account with permissions to create IAM roles, Lambda functions, and access to EC2 and RDS. -- Slack workspace with permission to create and install Slack apps. -- Python 3.8 installed locally for testing and packaging the Lambda function. - -## Setup Steps - -### 1. Create a Slack App +--- -1. Go to the Slack API website: [Slack API: Applications](https://api.slack.com/apps). -2. Click on "Create New App". -3. Choose "From scratch". -4. Give your app a name and select the Slack workspace where you have permissions to install the app. -5. Click "Create App". +## Requirements -### 2. Add Bot Permissions +- Python 3.10+ for local dev (or match your Lambda runtime) +- AWS CLI configured (use `AWS_PROFILE=...` as needed) +- Slack bot token and channel IDs -1. In your Slack app settings, go to "OAuth & Permissions". -2. Under "OAuth Tokens & Redirect URLs", scroll down to "Scopes". -3. Add the following bot token scopes: - - `chat:write`: To post messages in channels. - - `channels:read`: To read information about channels. - - `groups:read`: To read information about private channels. -4. At the top of the "OAuth & Permissions" page, click "Install App to Workspace". -5. Review the permissions and click "Allow". -6. Copy the OAuth Access Token; you'll need this as the `SLACK_BOT_TOKEN`. +--- -### 3. Get the Slack Channel ID +## Local setup + testing -1. Open Slack and navigate to the channel where you want the bot to post messages. -2. Click on the channel name to open the channel details. -3. Copy the channel ID from the URL or the channel details pane. The channel ID starts with `C` for public channels or `G` for private channels. +### Create a venv and install dependencies -### 4. Invite the Bot to the Channel +From this directory: -1. In Slack, go to the channel where you want the bot to post messages. -2. Type `/invite @your-bot-name` to invite the bot to the channel. Replace `your-bot-name` with the actual name of your bot. +```bash +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -r requirements.txt +``` -### 5. Prepare the Lambda Function Code +### Configure `.env` -1. Create a directory for your Lambda function code: +Create `.env` from `env.example` and fill it in: -```sh -mkdir bloodhound_lambda -cd bloodhound_lambda +```bash +cp env.example .env ``` -2. Create a file named `lambda_function.py` and add the following code: - -```python -import boto3 -import os -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError - -# Adjust based on the regions you want to scan -STUDENT_REGIONS = [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", -] - -def create_session(region): - return boto3.Session(region_name=region) - -def search_regions_for_rds_resources(session): - rds_instances = [] - print(f"Sniffing out rds resources in {session.region_name}...") - rds = session.client("rds") - response = rds.describe_db_instances() - for dbinstance in response["DBInstances"]: - rds_instances.append(dbinstance["DBInstanceIdentifier"]) - return {"rds": rds_instances} - -def search_regions_for_ec2_resources(session): - ec2_instances = [] - print(f"Sniffing out ec2 resources in {session.region_name}...") - ec2 = session.client("ec2") - response = ec2.describe_instances() - try: - if len(response["Reservations"]) == 1: - for ec2instance in response["Reservations"][0]["Instances"]: - if ec2instance["State"]["Name"] in ("stopped", "terminated"): - continue - ec2_instances.append(ec2instance["InstanceId"]) - else: - for ec2instance in response["Reservations"]: - if ec2instance["Instances"][0]["State"]["Name"] in ("stopped", "terminated"): - continue - ec2_instances.append(ec2instance["Instances"][0]["InstanceId"]) - except IndexError: - return {"ec2": []} - return {"ec2": ec2_instances} - -def format_message(resources): - message = ":dog2: Woof! Woof! :dog2:\n Bloodhound found the following resources in use: \n" - for region in resources.keys(): - if len(resources[region]["ec2"]) == 0 and len(resources[region]["rds"]) == 0: - continue - message += f'- *{region.upper()}*: {len(resources[region]["ec2"])} ec2 instances, {len(resources[region]["rds"])} rds instances\n' - message += f"Please stop or terminate all unneeded resources!" - return message - -def send_slack_message(message): - try: - slack_bot_token = os.environ.get("SLACK_BOT_TOKEN") - channel_id = os.environ.get("CHANNEL_ID") - - client = WebClient(token=slack_bot_token) - response = client.chat_postMessage( - channel=channel_id, - text=message - ) - print(f"Slack message response: {response}") - except SlackApiError as e: - print(f"Error sending message to Slack: {e.response['error']}") - -def lambda_handler(event, context): - resources_in_regions = {} - print(f"Going hunting in regions {STUDENT_REGIONS}") - for region in STUDENT_REGIONS: - session = create_session(region) - resources_in_regions[region] = search_regions_for_ec2_resources(session) - resources_in_regions[region].update(search_regions_for_rds_resources(session)) - message = format_message(resources_in_regions) - print(message) - send_slack_message(message) - return message -``` +Bloodhound v2 automatically loads `.env` for local runs. -3. Create a `requirements.txt` file with the following content: +### Run locally -```text -slack_sdk -boto3 +```bash +# Choose the AWS profile you want to test with: +AWS_PROFILE=geekstar .venv/bin/python run_local.py ``` -### 6. Package and Deploy the Lambda Function +--- -1. Install the dependencies and create a deployment package: +## Whitelisting -```sh -pip install -r requirements.txt -t . -zip -r9 ../bloodhound_lambda.zip . -``` +Resources tagged with: -2. Create the Lambda function using the AWS CLI: +- key: `bloodhound:keep` +- value: `true` -```sh -aws lambda create-function --function-name BloodhoundLambda \ ---zip-file fileb://bloodhound_lambda.zip --handler lambda_function.lambda_handler --runtime python3.8 \ ---role arn:aws:iam:::role/BloodhoundLambdaRole --region us-west-2 -``` +are treated as **kept (whitelisted)** and are excluded from teardown. -Replace `` with your actual AWS account ID. +--- -### 7. Create an IAM Role for Lambda +## Teardown controls (important) -1. Go to the [IAM console](https://console.aws.amazon.com/iam/). -2. Create a new role: - - Choose the "Lambda" service. - - Attach the following policies: - - `AWSLambdaBasicExecutionRole` - - `AmazonEC2ReadOnlyAccess` - - `AmazonRDSReadOnlyAccess` -3. Note the ARN of the created role. +By default, Bloodhound posts a teardown plan only: -### 8. Set Up Environment Variables in AWS Lambda +- `APPLY_CHANGES=false` -1. Navigate to the [AWS Lambda Console](https://console.aws.amazon.com/lambda/). -2. Select your BloodhoundLambda function. -3. Go to the "Configuration" tab and then "Environment variables". -4. Add the following environment variables: - - `SLACK_BOT_TOKEN`: Your Slack bot token. - - `CHANNEL_ID`: Your Slack channel ID. -5. Save the changes. +To delete/terminate non-whitelisted resources: -### 9. Test the Lambda Function +- `APPLY_CHANGES=true` -1. Create a test event named `test_event.json` with the following content: +Safety rails: -```json -{} -``` +- **simulate (no deletes)**: `TEARDOWN_SIMULATE=true` +- **only delete explicit IDs/ARNs**: set `TEARDOWN_TARGET_IDS=...` +- **delete everything not whitelisted**: `TEARDOWN_ALLOW_ALL=true` -2. Invoke the Lambda function: +--- -```sh -aws lambda invoke --function-name BloodhoundLambda --payload file://test_event.json output.txt --region us-west-2 -``` +## Build the Lambda deployment zip (v2) -3. Check the contents of `output.txt` and review the CloudWatch logs to ensure the function executed correctly. +The `.build/` directory is intentionally not committed. Recreate it locally whenever you deploy. -### 10. GitHub Actions Setup +From this directory: -#### 1. Create the Workflow File +```bash +rm -rf .build +mkdir -p .build/lambda_pkg -Create the file `.github/workflows/invoke_lambda.yml` in your repository. +python3 -m pip install -r requirements.txt -t .build/lambda_pkg +rsync -a bloodhound/ .build/lambda_pkg/bloodhound/ +cp lambda_function.py .build/lambda_pkg/ -#### 2. Add the Workflow Configuration +(cd .build/lambda_pkg && zip -qr ../bloodhound_lambda_v2.zip .) +ls -lh .build/bloodhound_lambda_v2.zip +``` -Add the following content to the `invoke_lambda.yml` file: +--- -```yaml -name: Invoke Bloodhound Lambda +## Deploy to AWS Lambda (v2) -on: - schedule: - # Run at 11 AM and 11 PM EST (4 PM and 4 AM UTC) - - cron: "0 16,4 * * *" - workflow_dispatch: +Deploy to a new function name so you do not touch your existing v1 Lambda: -jobs: invoke +- Function name: `BloodhoundLambdaV2` +- Handler: `lambda_function.lambda_handler` --lambda: - runs-on: ubuntu-latest +### Configure Lambda environment variables - steps: - - name: Checkout repository - uses: actions/checkout@v2 +In Lambda Console → **Configuration → Environment variables**, copy the values from your local `.env`. - - name: Set up AWS CLI - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-west-2 +At minimum: - - name: Invoke Bloodhound Lambda function - run: | - echo '{}' > test_event.json - aws lambda invoke --function-name BloodhoundLambda --payload file://test_event.json output.txt --region us-west-2 - cat output.txt -``` +- Slack: `SLACK_BOT_TOKEN`, `SLACK_SCAN_CHANNEL_ID`, `SLACK_ALERT_CHANNEL_ID` +- Regions: `REGION_MODE`, `REGIONS` +- Budget: `COHORT_START_YYYY_MM`, `COHORT_TOTAL_BUDGET_USD`, `COHORT_LENGTH_MONTHS`, `BUDGET_OVER_DAYS` +- Teardown: `APPLY_CHANGES`, `TEARDOWN_SIMULATE`, `TEARDOWN_ALLOW_ALL`, `TEARDOWN_TARGET_IDS` -#### 3. Add AWS Credentials to GitHub Secrets +### Test the Lambda via AWS CLI -1. Go to your repository on GitHub. -2. Click on "Settings". -3. Click on "Secrets and variables" in the left sidebar, then click on "Actions". -4. Click "New repository secret". -5. Add the following secrets: - - `AWS_ACCESS_KEY_ID`: Your AWS access key ID. - - `AWS_SECRET_ACCESS_KEY`: Your AWS secret access key. +```bash +aws lambda invoke \ + --function-name BloodhoundLambdaV2 \ + --payload file://test_event.json \ + output.txt \ + --region us-west-2 -#### 4. Push the Workflow to GitHub +cat output.txt +``` -Commit and push the `.github/workflows/invoke_lambda.yml` file to your repository. +--- -#### 5. Verify the Workflow +## GitHub Actions (invoke v2) -1. Go to the "Actions" tab in your GitHub repository. -2. You should see the new workflow listed. It will run according to the schedule and can also be manually triggered. +This repo includes a separate workflow for v2: -## Conclusion +- `.github/workflows/invoke_lambda_v2.yml` -By following these steps, you have successfully set up, deployed, and tested the Bloodhound Lambda function with Slack integration. The function scans AWS regions for EC2 and RDS instances and posts a summary to the specified Slack channel. Additionally, you have set up a GitHub Action to invoke the Lambda function twice a day at 11 AM and 11 PM EST. Ensure to review the logs and Slack messages to confirm the function's correct behavior. +It invokes: ---- +- `BloodhoundLambdaV2` diff --git a/SLACK_SETUP.md b/SLACK_SETUP.md new file mode 100644 index 0000000..ea917a2 --- /dev/null +++ b/SLACK_SETUP.md @@ -0,0 +1,50 @@ +## Slack setup (from scratch) + +This project’s main `README.md` assumes you already have: + +- a Slack bot token (`SLACK_BOT_TOKEN`) +- Slack channel IDs for `SLACK_SCAN_CHANNEL_ID` and `SLACK_ALERT_CHANNEL_ID` + +Use this guide only if you need to create/configure the Slack app/bot from scratch. + +--- + +### 1) Create a Slack App + +1. Go to [Slack API: Applications](https://api.slack.com/apps). +2. Click **Create New App**. +3. Choose **From scratch**. +4. Name the app and select the workspace. +5. Click **Create App**. + +--- + +### 2) Add bot permissions + +1. In your Slack app settings, go to **OAuth & Permissions**. +2. Under **Scopes**, add bot token scopes: + - `chat:write` + - `channels:read` + - `groups:read` +3. Click **Install App to Workspace** and approve. +4. Copy the **Bot User OAuth Token** (this is your `SLACK_BOT_TOKEN`). + +--- + +### 3) Get the Slack Channel ID + +1. In Slack, open the channel. +2. Copy the channel ID from the channel details (or URL). +3. Set: + - `SLACK_SCAN_CHANNEL_ID=` + - `SLACK_ALERT_CHANNEL_ID=` (can be the same or different) + +--- + +### 4) Invite the bot to the channel + +In the channel, run: + +- `/invite @your-bot-name` + +--- diff --git a/V2_PLAN.md b/V2_PLAN.md new file mode 100644 index 0000000..9a203e8 --- /dev/null +++ b/V2_PLAN.md @@ -0,0 +1,395 @@ +### Bloodhound v2 Plan (tracked implementation checklist) + +This document is the shared plan for evolving Bloodhound from v1.0 to v2.x while keeping the project simple, readable, and student-friendly. + +--- + +### 0) Current v1.0 baseline (what exists today) + +- **What it does** + + - Scans a fixed list of AWS regions. + - Collects **EC2** instance IDs (skipping stopped/terminated). + - Collects **RDS** instance identifiers. + - Formats one Slack message and posts it to a channel. + +- **How it is invoked** + + - A GitHub Actions scheduled workflow calls: + - `aws lambda invoke --function-name BloodhoundLambda ...` + +- **Important constraints carried into v2** + - Keep **GitHub Actions** as the scheduler (do not migrate to EventBridge). + - Keep **Slack** as the only notification surface (no email). + - Single AWS account only (no Organizations / cross-account roles for now). + - Teardown should support **terminate/delete** (default). “Stop-only” is a later enhancement. + +--- + +### 1) v2 guiding principles (to keep it minimal) + +- **Configuration over code edits** + + - Anything that will vary cohort-to-cohort (start month, channels, regions) must be env-configurable. + +- **Safe rollout for destructive actions** + + - Default is **dry-run** (report proposed deletions). + - Apply-mode requires an explicit flag. + +- **Simple whitelist** + + - Primary mechanism is a single tag: `bloodhound:keep=true`. + - Optional escape hatch: env var allowlist for IDs/ARNs. + +- **No unnecessary infrastructure** + - Prefer “compute from AWS APIs each run” when it’s easy (e.g., budget streak calculation). + - Add a database only if it materially simplifies or is required. + +--- + +### 2) v2 configuration (environment variables) + +#### Slack + +- **`SLACK_BOT_TOKEN`**: Slack bot token used to post messages. +- **`SLACK_SCAN_CHANNEL_ID`**: Channel for “scan summary / what exists”. +- **`SLACK_ALERT_CHANNEL_ID`**: Channel for “budget alerts + teardown outcomes”. + - If unset, default to `SLACK_SCAN_CHANNEL_ID`. + +#### Regions + +- **`REGION_MODE`**: `explicit` or `discover` + - `explicit`: use `REGIONS` + - `discover`: discover regions dynamically (then optionally filter) +- **`REGIONS`**: comma-separated region list (only used in `explicit` mode) + +#### Whitelist (minimal) + +- **`KEEP_TAG_KEY`**: default `bloodhound:keep` +- **`KEEP_TAG_VALUE`**: default `true` +- **`KEEP_RESOURCE_IDS`** (optional): comma-separated resource IDs/ARNs always excluded from teardown + +#### Teardown controls + +- **`APPLY_CHANGES`**: `true|false` (default `false`) +- **`TEARDOWN_MODE`**: default `delete` (future: allow `stop`) +- **`RDS_FINAL_SNAPSHOT`**: `true|false` (default `false`) + +#### Budget (dynamic cohort) + +- **`COHORT_START_YYYY_MM`**: e.g. `2026-01` (set per cohort) +- **`COHORT_TOTAL_BUDGET_USD`**: default `3000` +- **`COHORT_LENGTH_MONTHS`**: default `7` +- **`BUDGET_OVER_DAYS`**: default `2` (consecutive days over budget before alerting) + +--- + +### 3) Target v2 architecture (still simple) + +#### Logical flow (every scheduled run) + +- **Scan** + + - Determine regions to scan. + - Collect resources per region/service. + - Apply whitelist. + - Produce: + - Slack scan summary (scan channel) + - Machine-readable report (JSON in logs; optional persisted store later) + +- **Budget** + + - Use Cost Explorer to compute: + - Cohort-to-date spend (from cohort start through today) + - Remaining cohort budget + - Remaining months + - Dynamic monthly allowance (remaining_budget / remaining_months) + - Current month projected month-end spend (simple run-rate) + - Determine “over budget streak” for the last `BUDGET_OVER_DAYS` days using daily costs for the current month. + - Post budget summary + alert (alert channel only when threshold reached). + +- **Teardown** + - Default: post “proposed actions” only (dry-run). + - If `APPLY_CHANGES=true`: execute deletion/termination calls and post results. + +#### Code structure (proposed) + +- `Bloodhound/` + - `bloodhound.py` (entrypoint; keep thin) + - `bloodhound/` + - `config.py` (env parsing, defaults, validation) + - `scanner/` + - `ec2.py` + - `rds.py` + - `eip.py` + - `nat_gateway.py` + - `ebs.py` + - `elbv2.py` + - `regions.py` + - `whitelist.py` (tag filter + optional ID/ARN allowlist) + - `budget.py` (Cost Explorer queries + projection logic) + - `teardown/` + - `planner.py` (dry-run action plan) + - `executor.py` (apply-mode execution) + - `slack.py` (posting + message formatting) + - `types.py` (resource record schema) + - `logging.py` (structured logs, consistent formatting) + +This modularity is intentionally small: one folder, small files, no framework. + +--- + +### 4) Resource record schema (simple + consistent) + +All scanners should output a list of records with the same shape. + +- **Required** + + - `service`: e.g. `ec2`, `rds`, `eip`, `nat`, `ebs`, `elbv2` + - `resource_type`: e.g. `instance`, `db_instance`, `address`, `nat_gateway`, `volume`, `load_balancer` + - `region` + - `id`: service ID (InstanceId, VolumeId, etc.) + - `arn`: include if easily available; else `null` + - `state`: normalized string (e.g. `running`, `available`, `in-use`, `deleting`) + - `tags`: dictionary of tags (best-effort) + +- **Optional (useful for teardown)** + - `delete_supported`: boolean + - `delete_action`: string (e.g. `terminate_instances`, `delete_db_instance`, `release_address`) + - `delete_params`: minimal params needed (ids, flags) + +--- + +### 5) Resources to scan (priority order) + +Goal: cover the most common and expensive student mistakes first. + +#### Phase 1 (v2.1) — high value, low complexity + +- **EC2 Instances** (existing) +- **RDS Instances** (existing) +- **Elastic IPs** (unassociated) +- **NAT Gateways** +- **EBS volumes** (unattached) +- **Load Balancers** (ALB/NLB) + +#### Phase 2 (later; only if needed) + +- EBS snapshots +- AMIs +- ElastiCache +- OpenSearch +- Redshift + +--- + +### 6) Whitelist rules (minimal) + +#### Rule A: tag-based keep (primary) + +- If resource has tag: + - key: `bloodhound:keep` + - value: `true` +- Then: + - Exclude from teardown + - Optionally: + - Exclude from scan counts (or mark as “kept”) + +#### Rule B: env allowlist (optional) + +- If resource `id` or `arn` is listed in `KEEP_RESOURCE_IDS`, exclude from teardown. + +--- + +### 7) Teardown plan (terminate/delete with a safe rollout) + +#### v2.3 (dry-run only) + +- Build a “proposed action plan”: + - For each resource: + - If whitelisted: skip + - Else if deletable: include action + minimal parameters + - Else: include as “manual” (for visibility) +- Post to alert channel: + - Counts by service/region + - List of proposed actions (keep concise; link to logs for full JSON) + +#### v2.4 (apply-mode) + +- If `APPLY_CHANGES=true`: + - Execute actions. + - Post: + - Success counts + - Failure counts + top errors + - Emit structured logs of every action attempted. + +#### Delete policy defaults (as requested) + +- **EC2**: terminate (not stop) +- **RDS**: delete without final snapshot (`RDS_FINAL_SNAPSHOT=false`) +- **EBS**: delete unattached +- **EIP**: release if unassociated +- **NAT Gateway**: delete +- **Load balancer**: delete + +--- + +### 8) Budget/projections plan (7-month cohort, $3000, dynamic) + +#### Definitions + +- Cohort total budget: `COHORT_TOTAL_BUDGET_USD` (default 3000) +- Cohort length months: `COHORT_LENGTH_MONTHS` (default 7) +- Cohort start month: `COHORT_START_YYYY_MM` + +#### Each run computes + +- **Cohort-to-date spend** + - Sum monthly spend from cohort start through current month-to-date. +- **Remaining cohort budget** + - `remaining_budget = total_budget - cohort_to_date_spend` +- **Remaining months** + - Based on cohort start and current date (clamped to at least 1). +- **Dynamic monthly allowance** + - `monthly_allowance = remaining_budget / remaining_months` +- **Month-end projection** + - Simple run-rate: + - `projection = (month_to_date_spend / days_elapsed) * days_in_month` + - (Optional later) use Cost Explorer forecast if desired. + +#### Alerting logic (simple, no DB) + +- For the current month: + - Pull daily costs for the last `BUDGET_OVER_DAYS` days. + - For each of those days, compute what the month-end projection would have been at that point. + - If **all** of those daily projections exceed `monthly_allowance`, alert. + +This yields “consecutive days over budget” behavior without storing state. + +#### Slack message content (alert channel) + +- Cohort start month +- Cohort-to-date spend +- Remaining cohort budget +- Remaining months +- Dynamic monthly allowance +- Current month-to-date spend +- Projected month-end spend +- Whether alert threshold was met (and for how many days) + +--- + +### 9) GitHub Actions plan (keep it, extend it) + +#### Current + +- `invoke_lambda.yml` runs on schedule and calls Lambda. + +#### v2 changes (still simple) + +- Continue invoking the same Lambda. +- Optionally add a second workflow/job later for teardown apply-mode: + - Example: weekly teardown run with `APPLY_CHANGES=true` + - Keep scanning separate from destructive actions to reduce risk. + +--- + +### 10) IAM permissions plan (least privilege) + +#### Scan permissions (read) + +- EC2 read for instances, volumes, addresses, regions, NAT gateways, load balancers. +- RDS read for db instances. +- Cost Explorer read for budget/projection. + +#### Teardown permissions (write) + +- EC2 terminate instances +- Delete volumes +- Release addresses +- Delete NAT gateways +- Delete load balancers +- Delete RDS instances + +Recommendation: split into two Lambda roles later: + +- `BloodhoundScanRole` (read-only) +- `BloodhoundTeardownRole` (write) + +This is optional but strongly recommended once apply-mode is enabled. + +--- + +### 11) Testing plan (minimal but real) + +#### Local dry-run + +- Run scan locally against a dev account/profile. +- Validate: + - Region selection works + - Resource counts look correct + - Slack messages post to the intended channels + +#### Lambda dry-run in AWS + +- Invoke Lambda manually and via GitHub Actions. +- Validate CloudWatch logs and Slack outputs. + +#### Apply-mode staged rollout + +- Start with a “known safe” subset of resources (e.g., EIPs and unattached EBS volumes). +- Then add EC2 termination. +- Then add RDS deletion. + +--- + +### 12) Milestones with checklists + +#### v2.0 (refactor + config) + +- [ ] Add env-driven Slack channels (scan vs alert) +- [ ] Add env-driven region configuration (`REGION_MODE`, `REGIONS`) +- [ ] Remove local-only AWS profile dependency in Lambda context +- [ ] Keep existing scan behavior and Slack scan summary working + +#### v2.1 (more resources) + +- [ ] Add scanners: EIP, NAT GW, EBS unattached, ELBv2 +- [ ] Add pagination everywhere +- [ ] Normalize output into a shared resource schema +- [ ] Slack scan summary includes per-service counts + +#### v2.2 (whitelist) + +- [ ] Implement tag-based whitelist `bloodhound:keep=true` +- [ ] Optional: implement `KEEP_RESOURCE_IDS` +- [ ] Ensure whitelist affects teardown planning and apply-mode + +#### v2.3 (teardown dry-run) + +- [ ] Build “proposed deletions” plan +- [ ] Post proposed plan summary to alert channel +- [ ] Log full plan as JSON + +#### v2.4 (teardown apply-mode) + +- [ ] Guarded by `APPLY_CHANGES=true` +- [ ] Execute delete/terminate actions and post results +- [ ] Structured logs for every attempted action + +#### v2.5 (budget + alerts) + +- [ ] Cost Explorer cohort-to-date + dynamic monthly allowance +- [ ] Month-end run-rate projection +- [ ] Alert when over monthly allowance for `BUDGET_OVER_DAYS` consecutive days +- [ ] Post budget summary (and alert when triggered) to alert channel + +--- + +### 13) Open questions (none required to proceed) + +All previously open decisions are resolved with “simple defaults”, but these can be revisited later: + +- Whether scan summaries should include whitelisted resources (counted vs hidden). +- Whether to split scan vs teardown into separate Lambdas/roles immediately or later. diff --git a/bloodhound.py b/bloodhound.py deleted file mode 100755 index d6288cf..0000000 --- a/bloodhound.py +++ /dev/null @@ -1,94 +0,0 @@ -import boto3 -import os -import json -from dotenv import load_dotenv - -# Import WebClient from Python SDK (github.com/slackapi/python-slack-sdk) -from slack_sdk import WebClient - -load_dotenv() - -STUDENT_REGIONS = [ - "eu-central-1", - "eu-west-1", - "eu-west-2", - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", -] - - -def create_session(region): - return boto3.Session(profile_name="bloodhound", region_name=region) - - -def search_regions_for_rds_resources(session): - rds_instances = [] - print(f"Sniffing out rds resources in {session.region_name}...") - rds = session.client("rds") - response = rds.describe_db_instances() - for dbinstance in response["DBInstances"]: - rds_instances.append(dbinstance["DBInstanceIdentifier"]) - return {"rds": rds_instances} - - -def search_regions_for_ec2_resources(session): - """ - TODO: modify the parsing logic here from the describe_instances() call, - https://serverfault.com/questions/749118/ - Instances spun up at the same time will be in the same reservationId more than likely. - Instances spun up individually will have their own reservationId - """ - ec2_instances = [] - print(f"Sniffing out ec2 resources in {session.region_name}...") - ec2 = session.client("ec2") - response = ec2.describe_instances() - try: - # Instances spun up at the same time will be listed in the same Reservation - if len(response["Reservations"]) == 1: - for ec2instance in response["Reservations"][0]["Instances"]: - if ec2instance["State"]["Name"] in ("stopped", "terminated"): - continue - ec2_instances.append(ec2instance["InstanceId"]) - # Indvidual instances will appear in their own Reservation - else: - for ec2instance in response["Reservations"]: - if ec2instance["Instances"][0]["State"]["Name"] in ("stopped", "terminated"): - continue - ec2_instances.append(ec2instance["Instances"][0]["InstanceId"]) - except IndexError: - return {"ec2": []} - return {"ec2": ec2_instances} - - -def format_message(resources): - message = ":dog2: Woof! Woof! :dog2:\n Bloodhound found the following resources in use: \n" - for region in resources.keys(): - if len(resources[region]["ec2"]) == 0 and len(resources[region]["rds"]) == 0: - continue - message += f'- *{region.upper()}*: {len(resources[region]["ec2"])} ec2 instances, {len(resources[region]["rds"])} rds instances\n' - message += f"Please stop or terminate all unneeded resources!" - return message - - -def send_slack_message(message): - channel_id = os.environ.get("CHANNEL_ID") - client = WebClient(token=os.environ.get("SLACK_BOT_TOKEN")) - client.chat_postMessage(channel=channel_id, text=message) - - -def main(): - resources_in_regions = {} - print(f"Going hunting in regions {STUDENT_REGIONS}") - for region in STUDENT_REGIONS: - session = create_session(region) - resources_in_regions[region] = search_regions_for_ec2_resources(session) - resources_in_regions[region].update(search_regions_for_rds_resources(session)) - message = format_message(resources_in_regions) - print(message) - send_slack_message(message) - - -if __name__ == "__main__": - main() diff --git a/bloodhound/__init__.py b/bloodhound/__init__.py new file mode 100644 index 0000000..e3c7188 --- /dev/null +++ b/bloodhound/__init__.py @@ -0,0 +1,5 @@ +__all__ = ["__version__"] + +__version__ = "2.0.0-dev" + + diff --git a/bloodhound/app.py b/bloodhound/app.py new file mode 100644 index 0000000..0f23e5a --- /dev/null +++ b/bloodhound/app.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +from typing import Any + +from bloodhound.aws import create_clients +from bloodhound.budget import compute_budget_snapshot +from bloodhound.config import load_config, validate_config +from bloodhound.messages import ( + format_budget_message, + format_scan_message, + format_teardown_plan_message, + format_teardown_result_message, + format_whitelisted_resources_message, +) +from bloodhound.scanner.regions import discover_regions, select_regions +from bloodhound.scanner.scan_all import scan_all +from bloodhound.slack import SlackNotifier +from bloodhound.teardown.executor import execute_actions +from bloodhound.teardown.planner import plan_deletions +from bloodhound.whitelist import filter_whitelisted +from bloodhound.types import resource_key + + +def run(event: Any, context: Any) -> dict[str, Any]: + """ + Main orchestration entrypoint for Lambda and local testing. + Returns a small JSON-serializable summary for `aws lambda invoke`. + """ + cfg = load_config() + errors = validate_config(cfg) + if errors: + # Still return a structured error for Lambda invocations. + return {"ok": False, "errors": errors} + + clients = create_clients(profile=cfg.aws.profile) + slack = None + if cfg.slack.enabled: + slack = SlackNotifier.from_token( + bot_token=cfg.slack.bot_token, + scan_channel_id=cfg.slack.scan_channel_id, + alert_channel_id=cfg.slack.alert_channel_id, + ) + + discovered = None + if cfg.regions.mode == "discover": + discovered = discover_regions(clients) + regions = select_regions(cfg.regions.mode, cfg.regions.regions, discovered_regions=discovered) + + # 1) Scan + raw_by_region = scan_all(clients, regions=regions, rds_final_snapshot=cfg.teardown.rds_final_snapshot) + candidates_by_region: dict[str, list] = {} + kept_by_region: dict[str, list] = {} + + for region, records in raw_by_region.items(): + candidates, kept = filter_whitelisted(records, cfg.whitelist) + candidates_by_region[region] = candidates + kept_by_region[region] = kept + + scan_msg = format_scan_message(candidates_by_region, kept_by_region) + if slack: + slack.post_scan(scan_msg) + slack.post_scan(format_whitelisted_resources_message(kept_by_region)) + + # 2) Budget + budget_snapshot = compute_budget_snapshot( + clients, + cohort_start_yyyy_mm=cfg.budget.cohort_start_yyyy_mm, + cohort_total_budget_usd=cfg.budget.cohort_total_budget_usd, + cohort_length_months=cfg.budget.cohort_length_months, + budget_over_days=cfg.budget.budget_over_days, + ) + budget_msg = format_budget_message(budget_snapshot) + # Always post budget summary to alert channel (keeps scan channel quieter). + if slack: + slack.post_alert(budget_msg) + + # 3) Teardown plan + optional apply + all_candidates = [r for region in sorted(candidates_by_region.keys()) for r in candidates_by_region[region]] + actions, _manual = plan_deletions(all_candidates) + if slack: + slack.post_alert( + format_teardown_plan_message( + actions, + apply_changes=cfg.teardown.apply_changes, + simulate=cfg.teardown.simulate, + targets_filter_count=len(cfg.teardown.target_ids), + allow_all=cfg.teardown.allow_all_targets, + ) + ) + + exec_summary = None + if cfg.teardown.apply_changes: + actions_to_execute = actions + if cfg.teardown.target_ids: + targets = cfg.teardown.target_ids + actions_to_execute = [ + a + for a in actions + if (a.id in targets) or (a.arn and a.arn in targets) or (resource_key_from_action(a) in targets) + ] + + exec_result = execute_actions(clients, actions_to_execute, simulate=cfg.teardown.simulate) + exec_summary = { + "attempted": exec_result.attempted, + "succeeded": exec_result.succeeded, + "failed": exec_result.failed, + "simulated": exec_result.simulated, + "failures": exec_result.failures[:20], + "targets_filter": sorted(cfg.teardown.target_ids) if cfg.teardown.target_ids else None, + "planned_actions_total": len(actions), + "executed_actions_total": len(actions_to_execute), + "allow_all_targets": cfg.teardown.allow_all_targets, + } + if slack: + slack.post_alert( + format_teardown_result_message( + attempted=exec_result.attempted, + succeeded=exec_result.succeeded, + failed=exec_result.failed, + simulated=exec_result.simulated, + ) + + "\n" + + json.dumps(exec_summary, indent=2) + ) + + # Return a compact summary to Lambda invoke callers. + return { + "ok": True, + "regions": regions, + "scan": { + "candidates_total": sum(len(v) for v in candidates_by_region.values()), + "kept_total": sum(len(v) for v in kept_by_region.values()), + }, + "budget": { + "projected_month_end_spend_usd": budget_snapshot.projected_month_end_spend_usd, + "dynamic_monthly_allowance_usd": budget_snapshot.dynamic_monthly_allowance_usd, + "over_budget_threshold_met": budget_snapshot.over_budget_threshold_met, + }, + "teardown": { + "apply_changes": cfg.teardown.apply_changes, + "simulate": cfg.teardown.simulate, + "targets_filter": sorted(cfg.teardown.target_ids) if cfg.teardown.target_ids else None, + "planned_actions": len(actions), + "execution": exec_summary, + }, + } + + +def resource_key_from_action(a) -> str: + if a.arn: + return a.arn + return f"{a.service}:{a.region}:{a.id}" + + diff --git a/bloodhound/aws.py b/bloodhound/aws.py new file mode 100644 index 0000000..3339b90 --- /dev/null +++ b/bloodhound/aws.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import boto3 + + +@dataclass(frozen=True) +class AwsClients: + session: boto3.Session + + def client(self, service: str, region: Optional[str] = None): + if region: + return self.session.client(service, region_name=region) + return self.session.client(service) + + +def create_session(profile: Optional[str] = None, region: Optional[str] = None) -> boto3.Session: + # In Lambda, profile should be None and the execution role creds will be used. + if profile: + return boto3.Session(profile_name=profile, region_name=region) + return boto3.Session(region_name=region) + + +def create_clients(profile: Optional[str] = None, region: Optional[str] = None) -> AwsClients: + return AwsClients(session=create_session(profile=profile, region=region)) + + diff --git a/bloodhound/budget.py b/bloodhound/budget.py new file mode 100644 index 0000000..893ad5d --- /dev/null +++ b/bloodhound/budget.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import calendar +from dataclasses import dataclass +from datetime import date, datetime, timedelta + +from bloodhound.aws import AwsClients + + +@dataclass(frozen=True) +class BudgetSnapshot: + cohort_start_yyyy_mm: str + cohort_total_budget_usd: float + cohort_length_months: int + + cohort_to_date_spend_usd: float + remaining_cohort_budget_usd: float + remaining_months: int + dynamic_monthly_allowance_usd: float + + current_month_to_date_spend_usd: float + projected_month_end_spend_usd: float + + budget_over_days: int + over_budget_threshold_met: bool + + +def compute_budget_snapshot( + clients: AwsClients, + cohort_start_yyyy_mm: str, + cohort_total_budget_usd: float, + cohort_length_months: int, + budget_over_days: int, + today: date | None = None, +) -> BudgetSnapshot: + today = today or date.today() + + start_year, start_month = _parse_yyyy_mm(cohort_start_yyyy_mm) + cohort_start = date(start_year, start_month, 1) + + # Cost Explorer is effectively "global"; us-east-1 is the common choice. + ce = clients.client("ce", region="us-east-1") + + # Cohort-to-date monthly spend + cohort_to_date = _get_cost_monthly_total(ce, start=cohort_start, end=today + timedelta(days=1)) + + # Current month daily spend for MTD + "over budget X days" streak computation + month_start = date(today.year, today.month, 1) + daily_costs = _get_cost_daily(ce, start=month_start, end=today + timedelta(days=1)) + mtd_spend = sum(daily_costs.values()) + + remaining_budget = max(0.0, cohort_total_budget_usd - cohort_to_date) + remaining_months = _remaining_months(cohort_start, cohort_length_months, today) + allowance = remaining_budget / remaining_months if remaining_months > 0 else remaining_budget + + projected_month_end = _project_month_end(mtd_spend, today) + over_met = _over_budget_threshold_met( + daily_costs=daily_costs, + days_in_month=calendar.monthrange(today.year, today.month)[1], + monthly_allowance_usd=allowance, + budget_over_days=budget_over_days, + ) + + return BudgetSnapshot( + cohort_start_yyyy_mm=cohort_start_yyyy_mm, + cohort_total_budget_usd=cohort_total_budget_usd, + cohort_length_months=cohort_length_months, + cohort_to_date_spend_usd=cohort_to_date, + remaining_cohort_budget_usd=remaining_budget, + remaining_months=remaining_months, + dynamic_monthly_allowance_usd=allowance, + current_month_to_date_spend_usd=mtd_spend, + projected_month_end_spend_usd=projected_month_end, + budget_over_days=budget_over_days, + over_budget_threshold_met=over_met, + ) + + +def _parse_yyyy_mm(raw: str) -> tuple[int, int]: + try: + dt = datetime.strptime(raw, "%Y-%m") + return dt.year, dt.month + except ValueError: + # Fall back to current month; validation should catch missing/invalid. + today = date.today() + return today.year, today.month + + +def _to_ce_date(d: date) -> str: + return d.strftime("%Y-%m-%d") + + +def _get_cost_monthly_total(ce_client, start: date, end: date) -> float: + """ + Returns the sum of monthly UnblendedCost between [start, end) dates. + Cost Explorer end is exclusive. + """ + resp = ce_client.get_cost_and_usage( + TimePeriod={"Start": _to_ce_date(start), "End": _to_ce_date(end)}, + Granularity="MONTHLY", + Metrics=["UnblendedCost"], + ) + total = 0.0 + for r in resp.get("ResultsByTime", []) or []: + amt = ((r.get("Total") or {}).get("UnblendedCost") or {}).get("Amount") + if not amt: + continue + try: + total += float(amt) + except ValueError: + continue + return total + + +def _get_cost_daily(ce_client, start: date, end: date) -> dict[date, float]: + """ + Returns a mapping of day -> UnblendedCost between [start, end). + """ + resp = ce_client.get_cost_and_usage( + TimePeriod={"Start": _to_ce_date(start), "End": _to_ce_date(end)}, + Granularity="DAILY", + Metrics=["UnblendedCost"], + ) + out: dict[date, float] = {} + for r in resp.get("ResultsByTime", []) or []: + start_str = (r.get("TimePeriod") or {}).get("Start") + if not start_str: + continue + try: + day = datetime.strptime(start_str, "%Y-%m-%d").date() + except ValueError: + continue + amt = ((r.get("Total") or {}).get("UnblendedCost") or {}).get("Amount") + try: + out[day] = float(amt) if amt is not None else 0.0 + except ValueError: + out[day] = 0.0 + return out + + +def _remaining_months(cohort_start: date, cohort_length_months: int, today: date) -> int: + """ + Remaining months INCLUDING current month, clamped to at least 1 while inside cohort. + """ + months_elapsed = (today.year - cohort_start.year) * 12 + (today.month - cohort_start.month) + remaining = cohort_length_months - months_elapsed + return max(1, remaining) + + +def _project_month_end(mtd_spend_usd: float, today: date) -> float: + days_in_month = calendar.monthrange(today.year, today.month)[1] + days_elapsed = max(1, today.day) + return (mtd_spend_usd / days_elapsed) * days_in_month + + +def _over_budget_threshold_met( + daily_costs: dict[date, float], + days_in_month: int, + monthly_allowance_usd: float, + budget_over_days: int, +) -> bool: + """ + Minimal, state-free approach: + - Look at the last N days present in daily_costs. + - For each day, compute what the month-end projection would be at that point. + - If all N projections exceed monthly_allowance_usd, threshold is met. + """ + if budget_over_days <= 0: + return False + if not daily_costs: + return False + + days = sorted(daily_costs.keys()) + last_days = days[-budget_over_days:] + if len(last_days) < budget_over_days: + return False + + # cumulative spend up to each day (inclusive) + cumulative = 0.0 + day_to_cumulative: dict[date, float] = {} + for d in days: + cumulative += daily_costs.get(d, 0.0) + day_to_cumulative[d] = cumulative + + for d in last_days: + days_elapsed = d.day + if days_elapsed <= 0: + return False + projection = (day_to_cumulative[d] / days_elapsed) * days_in_month + if projection <= monthly_allowance_usd: + return False + return True + + diff --git a/bloodhound/config.py b/bloodhound/config.py new file mode 100644 index 0000000..02a6aa0 --- /dev/null +++ b/bloodhound/config.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + +from dotenv import load_dotenv + +def _env(name: str, default: Optional[str] = None) -> Optional[str]: + value = os.environ.get(name) + if value is None: + return default + value = value.strip() + return value if value != "" else default + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = _env(name) + if raw is None: + return default + return raw.lower() in {"1", "true", "t", "yes", "y", "on"} + + +def _env_int(name: str, default: int) -> int: + raw = _env(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + raw = _env(name) + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def _split_csv(raw: Optional[str]) -> list[str]: + if not raw: + return [] + return [x.strip() for x in raw.split(",") if x.strip()] + + +@dataclass(frozen=True) +class SlackConfig: + bot_token: str + scan_channel_id: str + alert_channel_id: str + enabled: bool + + +@dataclass(frozen=True) +class RegionConfig: + mode: str # explicit | discover + regions: list[str] + + +@dataclass(frozen=True) +class WhitelistConfig: + keep_tag_key: str + keep_tag_value: str + keep_resource_ids: set[str] + + +@dataclass(frozen=True) +class TeardownConfig: + apply_changes: bool + simulate: bool + target_ids: set[str] + allow_all_targets: bool + teardown_mode: str # delete (future: stop) + rds_final_snapshot: bool + + +@dataclass(frozen=True) +class BudgetConfig: + cohort_start_yyyy_mm: str + cohort_total_budget_usd: float + cohort_length_months: int + budget_over_days: int + + +@dataclass(frozen=True) +class AwsConfig: + # Optional local-only convenience; Lambda ignores this unless you explicitly set it. + profile: Optional[str] + + +@dataclass(frozen=True) +class AppConfig: + slack: SlackConfig + regions: RegionConfig + whitelist: WhitelistConfig + teardown: TeardownConfig + budget: BudgetConfig + aws: AwsConfig + + +def load_config() -> AppConfig: + # Load local .env for developer convenience. In Lambda, env vars are provided by the runtime. + load_dotenv(override=False) + + slack_enabled = _env_bool("SLACK_ENABLED", True) + slack_bot_token = _env("SLACK_BOT_TOKEN") or "" + # Backwards compat with v1 env var: + v1_channel_id = _env("CHANNEL_ID") + scan_channel_id = _env("SLACK_SCAN_CHANNEL_ID") or v1_channel_id or "" + alert_channel_id = _env("SLACK_ALERT_CHANNEL_ID") or scan_channel_id + + region_mode = (_env("REGION_MODE", "explicit") or "explicit").lower() + regions = _split_csv(_env("REGIONS")) + + keep_tag_key = _env("KEEP_TAG_KEY", "bloodhound:keep") or "bloodhound:keep" + keep_tag_value = _env("KEEP_TAG_VALUE", "true") or "true" + keep_resource_ids = set(_split_csv(_env("KEEP_RESOURCE_IDS"))) + + apply_changes = _env_bool("APPLY_CHANGES", False) + simulate = _env_bool("TEARDOWN_SIMULATE", False) + target_ids = set(_split_csv(_env("TEARDOWN_TARGET_IDS"))) + allow_all_targets = _env_bool("TEARDOWN_ALLOW_ALL", False) + teardown_mode = (_env("TEARDOWN_MODE", "delete") or "delete").lower() + rds_final_snapshot = _env_bool("RDS_FINAL_SNAPSHOT", False) + + cohort_start_yyyy_mm = _env("COHORT_START_YYYY_MM", "") or "" + cohort_total_budget_usd = _env_float("COHORT_TOTAL_BUDGET_USD", 3000.0) + cohort_length_months = _env_int("COHORT_LENGTH_MONTHS", 7) + budget_over_days = max(1, _env_int("BUDGET_OVER_DAYS", 2)) + + profile = _env("AWS_PROFILE") + + return AppConfig( + slack=SlackConfig( + bot_token=slack_bot_token, + scan_channel_id=scan_channel_id, + alert_channel_id=alert_channel_id, + enabled=slack_enabled, + ), + regions=RegionConfig(mode=region_mode, regions=regions), + whitelist=WhitelistConfig( + keep_tag_key=keep_tag_key, + keep_tag_value=keep_tag_value, + keep_resource_ids=keep_resource_ids, + ), + teardown=TeardownConfig( + apply_changes=apply_changes, + simulate=simulate, + target_ids=target_ids, + allow_all_targets=allow_all_targets, + teardown_mode=teardown_mode, + rds_final_snapshot=rds_final_snapshot, + ), + budget=BudgetConfig( + cohort_start_yyyy_mm=cohort_start_yyyy_mm, + cohort_total_budget_usd=cohort_total_budget_usd, + cohort_length_months=cohort_length_months, + budget_over_days=budget_over_days, + ), + aws=AwsConfig(profile=profile), + ) + + +def validate_config(cfg: AppConfig) -> list[str]: + errors: list[str] = [] + + if cfg.slack.enabled: + if not cfg.slack.bot_token: + errors.append("Missing SLACK_BOT_TOKEN") + if not cfg.slack.scan_channel_id: + errors.append("Missing SLACK_SCAN_CHANNEL_ID (or CHANNEL_ID for v1 compatibility)") + + if cfg.regions.mode not in {"explicit", "discover"}: + errors.append("REGION_MODE must be 'explicit' or 'discover'") + if cfg.regions.mode == "explicit" and not cfg.regions.regions: + errors.append("REGION_MODE=explicit requires REGIONS to be set") + + if cfg.teardown.teardown_mode not in {"delete"}: + errors.append("TEARDOWN_MODE currently supports only 'delete' in v2.0") + + if cfg.teardown.apply_changes and not cfg.teardown.simulate and not cfg.teardown.allow_all_targets: + if not cfg.teardown.target_ids: + errors.append( + "APPLY_CHANGES=true requires TEARDOWN_TARGET_IDS to be set (comma-separated IDs/ARNs) " + "unless TEARDOWN_ALLOW_ALL=true" + ) + + if not cfg.budget.cohort_start_yyyy_mm: + errors.append("Missing COHORT_START_YYYY_MM (e.g. 2026-01)") + if cfg.budget.cohort_length_months <= 0: + errors.append("COHORT_LENGTH_MONTHS must be > 0") + if cfg.budget.cohort_total_budget_usd <= 0: + errors.append("COHORT_TOTAL_BUDGET_USD must be > 0") + + return errors + + diff --git a/bloodhound/messages.py b/bloodhound/messages.py new file mode 100644 index 0000000..464580e --- /dev/null +++ b/bloodhound/messages.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +from zoneinfo import ZoneInfo + +from bloodhound.budget import BudgetSnapshot +from bloodhound.teardown.planner import PlannedAction +from bloodhound.types import ResourceRecord + + +SCANNED_RESOURCE_TYPES_ORDER: list[tuple[str, str]] = [ + ("ec2", "instance"), + ("rds", "db_instance"), + ("ec2", "elastic_ip"), + ("ec2", "nat_gateway"), + ("ec2", "ebs_volume"), + ("elbv2", "load_balancer"), +] + + +_ET = ZoneInfo("America/New_York") + + +def _et_now_time_str() -> str: + # Example: 4:24 PM ET + return datetime.now(_ET).strftime("%-I:%M %p ET") + + +def _fmt_kv(key: str, value: str) -> str: + return f"*{key}*: {value}" + + +def _fmt_counts_line(prefix: str, counts: dict[tuple[str, str], int]) -> str: + parts = [f"{svc}.{rtype}={counts[(svc, rtype)]}" for svc, rtype in SCANNED_RESOURCE_TYPES_ORDER] + return f"{prefix} {', '.join(parts)}" + + +def _display_resource(r: ResourceRecord) -> str: + """ + Short, human-friendly one-liner for Slack. + """ + name = r.tags.get("Name") + name_part = f" (Name=`{name}`)" if name else "" + return f"- `{r.region}` {r.service}.{r.resource_type} `{r.id}`{name_part}" + + +def format_scan_message( + resources_by_region: dict[str, list[ResourceRecord]], + whitelisted_by_region: dict[str, list[ResourceRecord]], +) -> str: + lines: list[str] = [] + lines.append("*Bloodhound v2 — Scan Summary*") + lines.append(_fmt_kv("time_et", _et_now_time_str())) + + total_found = 0 + total_whitelisted = 0 + + # Totals by (service, resource_type) across all regions. + totals_candidates: dict[tuple[str, str], int] = defaultdict(int) + totals_kept: dict[tuple[str, str], int] = defaultdict(int) + + for region in sorted(resources_by_region.keys()): + resources = resources_by_region[region] + kept = whitelisted_by_region.get(region, []) + + total_found += len(resources) + total_whitelisted += len(kept) + + type_counts = defaultdict(int) + for r in resources: + type_counts[(r.service, r.resource_type)] += 1 + totals_candidates[(r.service, r.resource_type)] += 1 + + kept_type_counts = defaultdict(int) + for r in kept: + kept_type_counts[(r.service, r.resource_type)] += 1 + totals_kept[(r.service, r.resource_type)] += 1 + + lines.append("") + lines.append(f"*Region*: `{region}`") + lines.append(f"*Counts*: candidates `{len(resources)}` | kept `{len(kept)}`") + lines.append(f"- {_fmt_counts_line('Candidates:', type_counts)}") + lines.append(f"- {_fmt_counts_line('Kept:', kept_type_counts)}") + + lines.append("") + lines.append("*Totals*") + lines.append(f"*Counts*: candidates `{total_found}` | kept `{total_whitelisted}`") + lines.append(f"- {_fmt_counts_line('Candidates:', totals_candidates)}") + lines.append(f"- {_fmt_counts_line('Kept:', totals_kept)}") + return "\n".join(lines) + + +def format_whitelisted_resources_message( + whitelisted_by_region: dict[str, list[ResourceRecord]], + *, + max_items: int = 50, +) -> str: + """ + Separate report listing all whitelisted ("kept") resources. + Capped to max_items to keep Slack readable. + """ + kept_all: list[ResourceRecord] = [] + for region in sorted(whitelisted_by_region.keys()): + kept_all.extend(whitelisted_by_region.get(region, [])) + + lines: list[str] = [] + lines.append("*Bloodhound v2 — Whitelisted Resources (Kept)*") + lines.append(_fmt_kv("time_et", _et_now_time_str())) + lines.append("") + + if not kept_all: + lines.append("No whitelisted resources found.") + return "\n".join(lines) + + lines.append(_fmt_kv("kept_total", f"`{len(kept_all)}`")) + lines.append("") + + shown = 0 + for region in sorted(whitelisted_by_region.keys()): + region_items = whitelisted_by_region.get(region, []) + if not region_items: + continue + lines.append(f"*Region*: `{region}`") + for r in region_items: + if shown >= max_items: + break + lines.append(_display_resource(r)) + shown += 1 + if shown >= max_items: + break + lines.append("") + + if shown < len(kept_all): + lines.append(f"... and `{len(kept_all) - shown}` more (increase max_items if needed)") + + return "\n".join(lines).rstrip() + + +def format_budget_message(b: BudgetSnapshot) -> str: + def usd(x: float) -> str: + return f"${x:,.2f}" + + lines: list[str] = [] + lines.append("*Bloodhound v2 — Budget Summary*") + lines.append(_fmt_kv("time_et", _et_now_time_str())) + lines.append("") + lines.append("*Cohort*") + lines.append(f"- {_fmt_kv('start', f'`{b.cohort_start_yyyy_mm}`')}") + lines.append(f"- {_fmt_kv('total_budget', f'`{usd(b.cohort_total_budget_usd)}` over `{b.cohort_length_months}` months')}") + lines.append(f"- {_fmt_kv('to_date_spend', f'`{usd(b.cohort_to_date_spend_usd)}`')}") + lines.append(f"- {_fmt_kv('remaining_budget', f'`{usd(b.remaining_cohort_budget_usd)}`')}") + lines.append(f"- {_fmt_kv('remaining_months', f'`{b.remaining_months}`')}") + lines.append(f"- {_fmt_kv('monthly_allowance', f'`{usd(b.dynamic_monthly_allowance_usd)}`')}") + lines.append("") + lines.append("*This month*") + lines.append(f"- {_fmt_kv('month_to_date', f'`{usd(b.current_month_to_date_spend_usd)}`')}") + lines.append(f"- {_fmt_kv('projected_month_end', f'`{usd(b.projected_month_end_spend_usd)}`')}") + lines.append(f"- {_fmt_kv('over_budget_days_required', f'`{b.budget_over_days}`')}") + lines.append(f"- {_fmt_kv('over_budget_threshold_met', f'`{str(b.over_budget_threshold_met).lower()}`')}") + return "\n".join(lines) + + +def format_teardown_plan_message( + actions: list[PlannedAction], + apply_changes: bool, + *, + simulate: bool, + targets_filter_count: int, + allow_all: bool, +) -> str: + header = "*Bloodhound v2 — Teardown Plan (APPLY MODE)*" if apply_changes else "*Bloodhound v2 — Teardown Plan (dry-run)*" + lines = [header, _fmt_kv("time_et", _et_now_time_str()), ""] + + targets_filter_active = targets_filter_count > 0 + lines.append(_fmt_kv("simulate", f"`{str(simulate).lower()}`")) + lines.append(_fmt_kv("targets_filter_active", f"`{str(targets_filter_active).lower()}`")) + if targets_filter_active: + lines.append(_fmt_kv("targets_filter_count", f"`{targets_filter_count}`")) + lines.append(_fmt_kv("allow_all_targets", f"`{str(allow_all).lower()}`")) + lines.append("") + + if not actions: + lines.append("No deletions planned.") + return "\n".join(lines) + + lines.append(_fmt_kv("planned_actions", f"`{len(actions)}`")) + + by_action = defaultdict(int) + by_service = defaultdict(int) + for a in actions: + by_action[a.action] += 1 + by_service[a.service] += 1 + lines.append(_fmt_kv("planned_by_service", "`" + ", ".join([f"{k}={by_service[k]}" for k in sorted(by_service.keys())]) + "`")) + lines.append(_fmt_kv("planned_by_action", "`" + ", ".join([f"{k}={by_action[k]}" for k in sorted(by_action.keys())]) + "`")) + + # Keep Slack output short: show a small sample. + sample = actions[:15] + lines.append("") + lines.append("*Sample (first 15)*") + for a in sample: + lines.append(f"- `{a.region}` {a.service}.{a.resource_type} `{a.id}` → `{a.action}`") + if len(actions) > len(sample): + lines.append(f"- ... and `{len(actions) - len(sample)}` more") + + return "\n".join(lines) + + +def format_teardown_result_message(attempted: int, succeeded: int, failed: int, simulated: int) -> str: + lines: list[str] = [] + lines.append("*Bloodhound v2 — Teardown Results*") + lines.append(_fmt_kv("time_et", _et_now_time_str())) + lines.append("") + lines.append(_fmt_kv("attempted", f"`{attempted}`")) + lines.append(_fmt_kv("succeeded", f"`{succeeded}`")) + lines.append(_fmt_kv("failed", f"`{failed}`")) + lines.append(_fmt_kv("simulated", f"`{simulated}`")) + return "\n".join(lines) + + diff --git a/bloodhound/scanner/__init__.py b/bloodhound/scanner/__init__.py new file mode 100644 index 0000000..5e0629b --- /dev/null +++ b/bloodhound/scanner/__init__.py @@ -0,0 +1,7 @@ +__all__ = [ + "scan_all", +] + +from bloodhound.scanner.scan_all import scan_all + + diff --git a/bloodhound/scanner/ec2.py b/bloodhound/scanner/ec2.py new file mode 100644 index 0000000..9d9d69a --- /dev/null +++ b/bloodhound/scanner/ec2.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any + +from bloodhound.aws import AwsClients +from bloodhound.types import ResourceRecord + + +def scan_ec2_instances(clients: AwsClients, region: str) -> list[ResourceRecord]: + ec2 = clients.client("ec2", region=region) + paginator = ec2.get_paginator("describe_instances") + records: list[ResourceRecord] = [] + + for page in paginator.paginate(): + for reservation in page.get("Reservations", []): + for inst in reservation.get("Instances", []): + state = (inst.get("State") or {}).get("Name") or "unknown" + if state in {"stopped", "terminated", "shutting-down"}: + continue + tags = _tags_to_dict(inst.get("Tags")) + instance_id = inst.get("InstanceId") + if not instance_id: + continue + records.append( + ResourceRecord( + service="ec2", + resource_type="instance", + region=region, + id=instance_id, + arn=None, + state=state, + tags=tags, + delete_supported=True, + delete_action="terminate_instances", + delete_params={"InstanceIds": [instance_id]}, + ) + ) + + return records + + +def scan_ebs_unattached_volumes(clients: AwsClients, region: str) -> list[ResourceRecord]: + ec2 = clients.client("ec2", region=region) + paginator = ec2.get_paginator("describe_volumes") + records: list[ResourceRecord] = [] + + for page in paginator.paginate( + Filters=[ + {"Name": "status", "Values": ["available"]}, + ] + ): + for vol in page.get("Volumes", []): + vol_id = vol.get("VolumeId") + if not vol_id: + continue + state = vol.get("State") or "unknown" + tags = _tags_to_dict(vol.get("Tags")) + records.append( + ResourceRecord( + service="ec2", + resource_type="ebs_volume", + region=region, + id=vol_id, + arn=None, + state=state, + tags=tags, + delete_supported=True, + delete_action="delete_volume", + delete_params={"VolumeId": vol_id}, + ) + ) + return records + + +def scan_eips_unassociated(clients: AwsClients, region: str) -> list[ResourceRecord]: + ec2 = clients.client("ec2", region=region) + records: list[ResourceRecord] = [] + + # Note: describe_addresses is not pageable. + resp = ec2.describe_addresses() + for addr in resp.get("Addresses", []): + if addr.get("AssociationId") or addr.get("NetworkInterfaceId") or addr.get("InstanceId"): + continue + alloc_id = addr.get("AllocationId") + public_ip = addr.get("PublicIp") + # AllocationId is required to release in VPC; for classic, PublicIp can be used. + rid = alloc_id or public_ip + if not rid: + continue + tags = _tags_to_dict(addr.get("Tags")) + delete_params: dict[str, Any] + if alloc_id: + delete_params = {"AllocationId": alloc_id} + else: + delete_params = {"PublicIp": public_ip} + records.append( + ResourceRecord( + service="ec2", + resource_type="elastic_ip", + region=region, + id=rid, + arn=None, + state="unassociated", + tags=tags, + delete_supported=True, + delete_action="release_address", + delete_params=delete_params, + ) + ) + + return records + + +def scan_nat_gateways(clients: AwsClients, region: str) -> list[ResourceRecord]: + ec2 = clients.client("ec2", region=region) + paginator = ec2.get_paginator("describe_nat_gateways") + records: list[ResourceRecord] = [] + + for page in paginator.paginate(): + for nat in page.get("NatGateways", []): + nat_id = nat.get("NatGatewayId") + if not nat_id: + continue + state = nat.get("State") or "unknown" + if state in {"deleted", "deleting"}: + continue + tags = _tags_to_dict(nat.get("Tags")) + records.append( + ResourceRecord( + service="ec2", + resource_type="nat_gateway", + region=region, + id=nat_id, + arn=None, + state=state, + tags=tags, + delete_supported=True, + delete_action="delete_nat_gateway", + delete_params={"NatGatewayId": nat_id}, + ) + ) + return records + + +def _tags_to_dict(tags: Any) -> dict[str, str]: + if not tags: + return {} + out: dict[str, str] = {} + for t in tags: + k = t.get("Key") + v = t.get("Value") + if k is None or v is None: + continue + out[str(k)] = str(v) + return out + + diff --git a/bloodhound/scanner/elbv2.py b/bloodhound/scanner/elbv2.py new file mode 100644 index 0000000..e751e3c --- /dev/null +++ b/bloodhound/scanner/elbv2.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from bloodhound.aws import AwsClients +from bloodhound.types import ResourceRecord + + +def scan_elbv2_load_balancers(clients: AwsClients, region: str) -> list[ResourceRecord]: + elb = clients.client("elbv2", region=region) + paginator = elb.get_paginator("describe_load_balancers") + lbs: list[dict] = [] + for page in paginator.paginate(): + lbs.extend(page.get("LoadBalancers", []) or []) + + if not lbs: + return [] + + # Fetch tags in batches of 20 ARNs (API limit). + arn_to_tags: dict[str, dict[str, str]] = {} + arns = [lb.get("LoadBalancerArn") for lb in lbs if lb.get("LoadBalancerArn")] + for i in range(0, len(arns), 20): + batch = arns[i : i + 20] + try: + tag_resp = elb.describe_tags(ResourceArns=batch) + for desc in tag_resp.get("TagDescriptions", []) or []: + arn = desc.get("ResourceArn") + if not arn: + continue + arn_to_tags[arn] = {t.get("Key"): t.get("Value") for t in (desc.get("Tags") or []) if t.get("Key")} + except Exception: + # Best-effort: skip tags on failure. + continue + + records: list[ResourceRecord] = [] + for lb in lbs: + arn = lb.get("LoadBalancerArn") + name = lb.get("LoadBalancerName") + if not arn or not name: + continue + state = (lb.get("State") or {}).get("Code") or "unknown" + if state in {"deleted", "deleting"}: + continue + records.append( + ResourceRecord( + service="elbv2", + resource_type="load_balancer", + region=region, + id=name, + arn=arn, + state=state, + tags=arn_to_tags.get(arn, {}), + delete_supported=True, + delete_action="delete_load_balancer", + delete_params={"LoadBalancerArn": arn}, + ) + ) + return records + + diff --git a/bloodhound/scanner/rds.py b/bloodhound/scanner/rds.py new file mode 100644 index 0000000..aea36b9 --- /dev/null +++ b/bloodhound/scanner/rds.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + +from bloodhound.aws import AwsClients +from bloodhound.types import ResourceRecord + + +def scan_rds_instances(clients: AwsClients, region: str, rds_final_snapshot: bool) -> list[ResourceRecord]: + rds = clients.client("rds", region=region) + paginator = rds.get_paginator("describe_db_instances") + records: list[ResourceRecord] = [] + + for page in paginator.paginate(): + for db in page.get("DBInstances", []): + db_id = db.get("DBInstanceIdentifier") + arn = db.get("DBInstanceArn") + status = db.get("DBInstanceStatus") or "unknown" + if not db_id: + continue + # Tags require a separate call; keep best-effort for now. + tags = _tags_to_dict(_safe_list_tags(rds, arn)) + delete_params: dict[str, Any] = { + "DBInstanceIdentifier": db_id, + "SkipFinalSnapshot": not rds_final_snapshot, + } + records.append( + ResourceRecord( + service="rds", + resource_type="db_instance", + region=region, + id=db_id, + arn=arn, + state=status, + tags=tags, + delete_supported=True, + delete_action="delete_db_instance", + delete_params=delete_params, + ) + ) + + return records + + +def _safe_list_tags(rds_client, arn: str | None) -> list[dict[str, str]]: + if not arn: + return [] + try: + resp = rds_client.list_tags_for_resource(ResourceName=arn) + return resp.get("TagList", []) or [] + except Exception: + return [] + + +def _tags_to_dict(tags: Any) -> dict[str, str]: + if not tags: + return {} + out: dict[str, str] = {} + for t in tags: + k = t.get("Key") + v = t.get("Value") + if k is None or v is None: + continue + out[str(k)] = str(v) + return out + + diff --git a/bloodhound/scanner/regions.py b/bloodhound/scanner/regions.py new file mode 100644 index 0000000..b2cd0c1 --- /dev/null +++ b/bloodhound/scanner/regions.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Optional + +from bloodhound.aws import AwsClients + + +def discover_regions(clients: AwsClients, home_region: str = "us-east-1") -> list[str]: + ec2 = clients.client("ec2", region=home_region) + resp = ec2.describe_regions(AllRegions=True) + regions = [r["RegionName"] for r in resp.get("Regions", []) if "RegionName" in r] + regions.sort() + return regions + + +def select_regions(mode: str, explicit_regions: list[str], discovered_regions: Optional[list[str]]) -> list[str]: + if mode == "explicit": + regions = list(explicit_regions) + regions.sort() + return regions + if mode == "discover": + regions = list(discovered_regions or []) + regions.sort() + return regions + # Fallback: behave like explicit with whatever was provided. + regions = list(explicit_regions) + regions.sort() + return regions + + diff --git a/bloodhound/scanner/scan_all.py b/bloodhound/scanner/scan_all.py new file mode 100644 index 0000000..3ff9f01 --- /dev/null +++ b/bloodhound/scanner/scan_all.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from bloodhound.aws import AwsClients +from bloodhound.scanner.ec2 import ( + scan_ec2_instances, + scan_ebs_unattached_volumes, + scan_eips_unassociated, + scan_nat_gateways, +) +from bloodhound.scanner.elbv2 import scan_elbv2_load_balancers +from bloodhound.scanner.rds import scan_rds_instances +from bloodhound.types import ResourceRecord + + +@dataclass(frozen=True) +class ScanResult: + region: str + resources: list[ResourceRecord] + + +def scan_region(clients: AwsClients, region: str, rds_final_snapshot: bool) -> list[ResourceRecord]: + resources: list[ResourceRecord] = [] + resources.extend(scan_ec2_instances(clients, region)) + resources.extend(scan_rds_instances(clients, region, rds_final_snapshot=rds_final_snapshot)) + resources.extend(scan_eips_unassociated(clients, region)) + resources.extend(scan_nat_gateways(clients, region)) + resources.extend(scan_ebs_unattached_volumes(clients, region)) + resources.extend(scan_elbv2_load_balancers(clients, region)) + return resources + + +def scan_all(clients: AwsClients, regions: list[str], rds_final_snapshot: bool) -> dict[str, list[ResourceRecord]]: + out: dict[str, list[ResourceRecord]] = {} + for region in regions: + out[region] = scan_region(clients, region, rds_final_snapshot=rds_final_snapshot) + return out + + diff --git a/bloodhound/slack.py b/bloodhound/slack.py new file mode 100644 index 0000000..6d2fc99 --- /dev/null +++ b/bloodhound/slack.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from slack_sdk import WebClient + + +@dataclass(frozen=True) +class SlackNotifier: + client: WebClient + scan_channel_id: str + alert_channel_id: str + + @classmethod + def from_token(cls, bot_token: str, scan_channel_id: str, alert_channel_id: str) -> "SlackNotifier": + return cls( + client=WebClient(token=bot_token), + scan_channel_id=scan_channel_id, + alert_channel_id=alert_channel_id, + ) + + def post_scan(self, text: str) -> None: + self.client.chat_postMessage(channel=self.scan_channel_id, text=text) + + def post_alert(self, text: str) -> None: + self.client.chat_postMessage(channel=self.alert_channel_id, text=text) + + diff --git a/bloodhound/teardown/__init__.py b/bloodhound/teardown/__init__.py new file mode 100644 index 0000000..53b7079 --- /dev/null +++ b/bloodhound/teardown/__init__.py @@ -0,0 +1,6 @@ +__all__ = ["plan_deletions", "execute_actions"] + +from bloodhound.teardown.executor import execute_actions +from bloodhound.teardown.planner import plan_deletions + + diff --git a/bloodhound/teardown/executor.py b/bloodhound/teardown/executor.py new file mode 100644 index 0000000..3833f14 --- /dev/null +++ b/bloodhound/teardown/executor.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from bloodhound.aws import AwsClients +from bloodhound.teardown.planner import PlannedAction + + +@dataclass(frozen=True) +class ExecutionResult: + attempted: int + succeeded: int + failed: int + simulated: int + failures: list[str] + + +def execute_actions(clients: AwsClients, actions: list[PlannedAction], *, simulate: bool) -> ExecutionResult: + """ + Executes planned actions. This is intentionally minimal and best-effort. + Caller must gate this behind APPLY_CHANGES=true. + If simulate=True, no destructive calls are performed: + - For EC2 actions that support DryRun, we pass DryRun=True and treat DryRunOperation as success. + - For actions that do not support DryRun (e.g., RDS delete, ELB delete), we no-op and count as simulated success. + """ + attempted = 0 + succeeded = 0 + failed = 0 + simulated_count = 0 + failures: list[str] = [] + + for a in actions: + attempted += 1 + try: + did_simulate = _execute_one(clients, a, simulate=simulate) + if did_simulate: + simulated_count += 1 + succeeded += 1 + except Exception as e: + failed += 1 + failures.append(f"{a.service}/{a.region}/{a.resource_type}/{a.id} -> {a.action}: {e}") + + return ExecutionResult( + attempted=attempted, + succeeded=succeeded, + failed=failed, + simulated=simulated_count, + failures=failures, + ) + + +def _execute_one(clients: AwsClients, a: PlannedAction, *, simulate: bool) -> bool: + # Map actions to boto3 service clients. + if a.action in {"terminate_instances", "delete_volume", "release_address", "delete_nat_gateway"}: + ec2 = clients.client("ec2", region=a.region) + if simulate: + params = dict(a.params) + params["DryRun"] = True + try: + getattr(ec2, a.action)(**params) + except Exception as e: + if _is_dry_run_success(e): + return True + raise + # If the API actually performed the action, something is wrong; treat as error to be safe. + raise RuntimeError("DryRun did not raise; refusing to continue in simulate mode") + getattr(ec2, a.action)(**a.params) + return False + + if a.action == "delete_db_instance": + if simulate: + # RDS delete does not support DryRun. No-op for safety. + return True + rds = clients.client("rds", region=a.region) + getattr(rds, a.action)(**a.params) + return False + + if a.action == "delete_load_balancer": + if simulate: + # ELBv2 delete does not support DryRun. No-op for safety. + return True + elb = clients.client("elbv2", region=a.region) + getattr(elb, a.action)(**a.params) + return False + + raise ValueError(f"Unsupported action: {a.action}") + + +def _is_dry_run_success(exc: Exception) -> bool: + """ + boto3 typically raises botocore.exceptions.ClientError with error code DryRunOperation. + We avoid importing botocore types here; we just pattern-match on known attributes. + """ + resp = getattr(exc, "response", None) + if not isinstance(resp, dict): + return False + err = resp.get("Error") or {} + code = err.get("Code") + return code == "DryRunOperation" + + diff --git a/bloodhound/teardown/planner.py b/bloodhound/teardown/planner.py new file mode 100644 index 0000000..f70c37b --- /dev/null +++ b/bloodhound/teardown/planner.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from bloodhound.types import ResourceRecord + + +@dataclass(frozen=True) +class PlannedAction: + service: str + region: str + resource_type: str + id: str + arn: str | None + action: str + params: dict + + +def plan_deletions(records: list[ResourceRecord]) -> tuple[list[PlannedAction], list[ResourceRecord]]: + """ + Returns (planned_actions, manual_resources). + """ + actions: list[PlannedAction] = [] + manual: list[ResourceRecord] = [] + for r in records: + if r.delete_supported and r.delete_action and r.delete_params: + actions.append( + PlannedAction( + service=r.service, + region=r.region, + resource_type=r.resource_type, + id=r.id, + arn=r.arn, + action=r.delete_action, + params=r.delete_params, + ) + ) + else: + manual.append(r) + return actions, manual + + diff --git a/bloodhound/types.py b/bloodhound/types.py new file mode 100644 index 0000000..b091d2f --- /dev/null +++ b/bloodhound/types.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass(frozen=True) +class ResourceRecord: + service: str + resource_type: str + region: str + id: str + arn: Optional[str] + state: str + tags: dict[str, str] + + delete_supported: bool = False + delete_action: Optional[str] = None + delete_params: Optional[dict[str, Any]] = None + + +def resource_key(r: ResourceRecord) -> str: + # Prefer ARN when present; else fall back to service/region/id. + if r.arn: + return r.arn + return f"{r.service}:{r.region}:{r.id}" + + diff --git a/bloodhound/whitelist.py b/bloodhound/whitelist.py new file mode 100644 index 0000000..1ead029 --- /dev/null +++ b/bloodhound/whitelist.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from bloodhound.config import WhitelistConfig +from bloodhound.types import ResourceRecord, resource_key + + +def is_whitelisted(record: ResourceRecord, cfg: WhitelistConfig) -> bool: + # Explicit allowlist (IDs/ARNs) first. + if record.id in cfg.keep_resource_ids: + return True + if record.arn and record.arn in cfg.keep_resource_ids: + return True + if resource_key(record) in cfg.keep_resource_ids: + return True + + # Tag-based keep (minimal, default). + val = record.tags.get(cfg.keep_tag_key) + if val is None: + return False + return val.strip().lower() == cfg.keep_tag_value.strip().lower() + + +def filter_whitelisted(records: list[ResourceRecord], cfg: WhitelistConfig) -> tuple[list[ResourceRecord], list[ResourceRecord]]: + kept: list[ResourceRecord] = [] + candidates: list[ResourceRecord] = [] + for r in records: + if is_whitelisted(r, cfg): + kept.append(r) + else: + candidates.append(r) + return candidates, kept + + diff --git a/env.example b/env.example new file mode 100644 index 0000000..fea97a0 --- /dev/null +++ b/env.example @@ -0,0 +1,63 @@ +### Bloodhound v2 environment variables (example) +# +# Copy to .env and fill in values: +# cp env.example .env +# +# v2 loads .env automatically for local runs. +# In AWS Lambda, set these in the Lambda "Environment variables" section instead. + +### Slack + +# Enable/disable Slack posting (useful for local testing) +SLACK_ENABLED=true + +# Slack bot token (xoxb-...) +SLACK_BOT_TOKEN=REPLACE_ME + +# Scan summaries channel +SLACK_SCAN_CHANNEL_ID=C0A4YLV0HNY + +# Alerts/teardown channel +SLACK_ALERT_CHANNEL_ID=C0A4YLV0HNY + +### Regions + +# explicit = scan REGIONS list +# discover = auto-discover AWS regions +REGION_MODE=explicit +REGIONS=us-east-1,us-east-2us-west-1,us-west-2 + +### Whitelist + +# If a resource has this tag, it will be considered "kept" (whitelisted) and excluded from teardown. +KEEP_TAG_KEY=bloodhound:keep +KEEP_TAG_VALUE=true + +# Optional: always keep these IDs/ARNs (comma-separated) +KEEP_RESOURCE_IDS= + +### Teardown + +# If false, Bloodhound only posts a plan (dry-run). +APPLY_CHANGES=false + +# If true, never performs destructive calls. EC2-style deletes are validated using DryRun where supported. +TEARDOWN_SIMULATE=true + +# Optional safety rail: only delete resources in this list (comma-separated IDs/ARNs). +TEARDOWN_TARGET_IDS= + +# If true, apply-mode can delete all non-whitelisted candidates without needing TEARDOWN_TARGET_IDS. +TEARDOWN_ALLOW_ALL=false + +# For RDS deletions: if false, skip final snapshot (fast/cheap). +RDS_FINAL_SNAPSHOT=false + +### Budget (dynamic cohort) + +COHORT_START_YYYY_MM=2025-12 +COHORT_TOTAL_BUDGET_USD=3000 +COHORT_LENGTH_MONTHS=7 +BUDGET_OVER_DAYS=2 + + diff --git a/lambda_function.py b/lambda_function.py new file mode 100644 index 0000000..e3c4129 --- /dev/null +++ b/lambda_function.py @@ -0,0 +1,16 @@ +""" +AWS Lambda entrypoint for Bloodhound v2. + +Handler: lambda_function.lambda_handler +""" + +from __future__ import annotations + +from bloodhound.app import run + + +def lambda_handler(event, context): + # Keep the handler extremely thin so the app remains testable locally. + return run(event=event, context=context) + + diff --git a/requirements.txt b/requirements.txt index 155ad8c..ec00a86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ python-dotenv==1.0.0 s3transfer==0.9.0 six==1.16.0 slack-sdk==3.26.1 -urllib3==2.1.0 +urllib3==2.0.7 diff --git a/run_local.py b/run_local.py new file mode 100644 index 0000000..ee49ffa --- /dev/null +++ b/run_local.py @@ -0,0 +1,20 @@ +""" +Local runner for Bloodhound v2. + +Usage: + cd Bloodhound + python run_local.py +""" + +from __future__ import annotations + +import json + +from bloodhound.app import run + + +if __name__ == "__main__": + result = run(event={}, context=None) + print(json.dumps(result, indent=2, sort_keys=True)) + + From c3e644c9fd13b37b1daa908ff1c42bf5c4b5c3f5 Mon Sep 17 00:00:00 2001 From: tsmith4014 Date: Sun, 21 Dec 2025 13:45:28 -0600 Subject: [PATCH 2/7] chore: reorganize repo layout (handlers/docs/tools) - Move Lambda entrypoint into handlers/ and update Terraform handler + build pipeline - Move docs into docs/ and link from README - Move local runner + AWS helper JSON into tools/ - Remove empty scripts directory - Keep functionality unchanged (only paths/organization) --- .gitignore | 7 + README.md | 43 +-- V2_PLAN.md | 395 -------------------------- bloodhound/app.py | 36 +++ bloodhound/aws.py | 9 + bloodhound/budget.py | 20 +- bloodhound/config.py | 10 + bloodhound/messages.py | 9 + bloodhound/scanner/ec2.py | 7 + bloodhound/scanner/elbv2.py | 6 + bloodhound/scanner/rds.py | 6 + bloodhound/scanner/regions.py | 9 + bloodhound/scanner/scan_all.py | 6 + bloodhound/slack.py | 7 + bloodhound/slack_commands.py | 190 +++++++++++++ bloodhound/teardown/executor.py | 13 + bloodhound/teardown/planner.py | 7 + bloodhound/types.py | 7 + bloodhound/whitelist.py | 10 + SLACK_SETUP.md => docs/SLACK_SETUP.md | 2 + docs/V2_PLAN.md | 88 ++++++ env.example | 14 +- handlers/__init__.py | 8 + handlers/lambda_function.py | 28 ++ infra/.terraform.lock.hcl | 45 +++ infra/README.md | 38 +++ infra/build.tf | 57 ++++ infra/function_url.tf | 22 ++ infra/iam.tf | 76 +++++ infra/lambda.tf | 28 ++ infra/locals.tf | 15 + infra/main.tf | 23 ++ infra/outputs.tf | 16 ++ infra/providers.tf | 13 + infra/terraform.tfvars.example | 42 +++ infra/variables.tf | 56 ++++ lambda_function.py | 16 -- test_event.json | 1 - run_local.py => tools/run_local.py | 7 +- tools/test_event.json | 3 + tools/trust-policy.json | 14 + trust-policy.json | 13 - 42 files changed, 970 insertions(+), 452 deletions(-) delete mode 100644 V2_PLAN.md create mode 100644 bloodhound/slack_commands.py rename SLACK_SETUP.md => docs/SLACK_SETUP.md (99%) create mode 100644 docs/V2_PLAN.md create mode 100644 handlers/__init__.py create mode 100644 handlers/lambda_function.py create mode 100644 infra/.terraform.lock.hcl create mode 100644 infra/README.md create mode 100644 infra/build.tf create mode 100644 infra/function_url.tf create mode 100644 infra/iam.tf create mode 100644 infra/lambda.tf create mode 100644 infra/locals.tf create mode 100644 infra/main.tf create mode 100644 infra/outputs.tf create mode 100644 infra/providers.tf create mode 100644 infra/terraform.tfvars.example create mode 100644 infra/variables.tf delete mode 100644 lambda_function.py delete mode 100644 test_event.json rename run_local.py => tools/run_local.py (56%) create mode 100644 tools/test_event.json create mode 100644 tools/trust-policy.json delete mode 100644 trust-policy.json diff --git a/.gitignore b/.gitignore index ad0f32d..251dadb 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,13 @@ Thumbs.db # Logs *.log +# Terraform +**/.terraform/ +**/terraform.tfstate +**/terraform.tfstate.* +**/terraform.tfvars +**/terraform.tfvars.json + # will add actions folder later but ignore for now we dont want to trigger github actions and destroy aws resources /.github/workflows/ diff --git a/README.md b/README.md index e26218e..ae49c28 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,10 @@ This README is intentionally focused on the workflow you asked for: - Deploy to a **new** AWS Lambda (do not overwrite v1) - Configure Lambda env vars to match your `.env` -If you need to create a Slack bot from scratch, see `SLACK_SETUP.md`. +If you need to create a Slack bot from scratch, see `docs/SLACK_SETUP.md`. + +Project docs: +- v2 plan: `docs/V2_PLAN.md` ![AWS Architecture Diagram](assets/bloodhound_lambda_architecture.png) @@ -50,7 +53,7 @@ Bloodhound v2 automatically loads `.env` for local runs. ```bash # Choose the AWS profile you want to test with: -AWS_PROFILE=geekstar .venv/bin/python run_local.py +AWS_PROFILE=geekstar .venv/bin/python tools/run_local.py ``` --- @@ -86,21 +89,7 @@ Safety rails: ## Build the Lambda deployment zip (v2) -The `.build/` directory is intentionally not committed. Recreate it locally whenever you deploy. - -From this directory: - -```bash -rm -rf .build -mkdir -p .build/lambda_pkg - -python3 -m pip install -r requirements.txt -t .build/lambda_pkg -rsync -a bloodhound/ .build/lambda_pkg/bloodhound/ -cp lambda_function.py .build/lambda_pkg/ - -(cd .build/lambda_pkg && zip -qr ../bloodhound_lambda_v2.zip .) -ls -lh .build/bloodhound_lambda_v2.zip -``` +The `.build/` directory is intentionally not committed. Terraform will build the zip automatically (see `infra/README.md`). --- @@ -109,7 +98,7 @@ ls -lh .build/bloodhound_lambda_v2.zip Deploy to a new function name so you do not touch your existing v1 Lambda: - Function name: `BloodhoundLambdaV2` -- Handler: `lambda_function.lambda_handler` +- Handler: `handlers.lambda_function.lambda_handler` ### Configure Lambda environment variables @@ -127,7 +116,7 @@ At minimum: ```bash aws lambda invoke \ --function-name BloodhoundLambdaV2 \ - --payload file://test_event.json \ + --payload file://tools/test_event.json \ output.txt \ --region us-west-2 @@ -145,3 +134,19 @@ This repo includes a separate workflow for v2: It invokes: - `BloodhoundLambdaV2` + +--- + +## Slack slash commands (v2) + +Slash commands require a publicly reachable HTTPS endpoint. For v2 we recommend a **Lambda Function URL** (one endpoint) and route based on the Slack `command` field. + +- `/seek` runs scan + reports (non-destructive) +- `/seek_destroy CONFIRM` runs destructive mode (deletes all non-whitelisted candidates we scan for) + +To enable slash commands you must set these env vars in Lambda: + +- `SLACK_SIGNING_SECRET` +- `SLACK_ALLOWED_USER_IDS` (optional) +- `SLACK_ALLOWED_CHANNEL_IDS` (optional) +- `SLACK_DESTROY_CONFIRM_TOKEN` (default `CONFIRM`) diff --git a/V2_PLAN.md b/V2_PLAN.md deleted file mode 100644 index 9a203e8..0000000 --- a/V2_PLAN.md +++ /dev/null @@ -1,395 +0,0 @@ -### Bloodhound v2 Plan (tracked implementation checklist) - -This document is the shared plan for evolving Bloodhound from v1.0 to v2.x while keeping the project simple, readable, and student-friendly. - ---- - -### 0) Current v1.0 baseline (what exists today) - -- **What it does** - - - Scans a fixed list of AWS regions. - - Collects **EC2** instance IDs (skipping stopped/terminated). - - Collects **RDS** instance identifiers. - - Formats one Slack message and posts it to a channel. - -- **How it is invoked** - - - A GitHub Actions scheduled workflow calls: - - `aws lambda invoke --function-name BloodhoundLambda ...` - -- **Important constraints carried into v2** - - Keep **GitHub Actions** as the scheduler (do not migrate to EventBridge). - - Keep **Slack** as the only notification surface (no email). - - Single AWS account only (no Organizations / cross-account roles for now). - - Teardown should support **terminate/delete** (default). “Stop-only” is a later enhancement. - ---- - -### 1) v2 guiding principles (to keep it minimal) - -- **Configuration over code edits** - - - Anything that will vary cohort-to-cohort (start month, channels, regions) must be env-configurable. - -- **Safe rollout for destructive actions** - - - Default is **dry-run** (report proposed deletions). - - Apply-mode requires an explicit flag. - -- **Simple whitelist** - - - Primary mechanism is a single tag: `bloodhound:keep=true`. - - Optional escape hatch: env var allowlist for IDs/ARNs. - -- **No unnecessary infrastructure** - - Prefer “compute from AWS APIs each run” when it’s easy (e.g., budget streak calculation). - - Add a database only if it materially simplifies or is required. - ---- - -### 2) v2 configuration (environment variables) - -#### Slack - -- **`SLACK_BOT_TOKEN`**: Slack bot token used to post messages. -- **`SLACK_SCAN_CHANNEL_ID`**: Channel for “scan summary / what exists”. -- **`SLACK_ALERT_CHANNEL_ID`**: Channel for “budget alerts + teardown outcomes”. - - If unset, default to `SLACK_SCAN_CHANNEL_ID`. - -#### Regions - -- **`REGION_MODE`**: `explicit` or `discover` - - `explicit`: use `REGIONS` - - `discover`: discover regions dynamically (then optionally filter) -- **`REGIONS`**: comma-separated region list (only used in `explicit` mode) - -#### Whitelist (minimal) - -- **`KEEP_TAG_KEY`**: default `bloodhound:keep` -- **`KEEP_TAG_VALUE`**: default `true` -- **`KEEP_RESOURCE_IDS`** (optional): comma-separated resource IDs/ARNs always excluded from teardown - -#### Teardown controls - -- **`APPLY_CHANGES`**: `true|false` (default `false`) -- **`TEARDOWN_MODE`**: default `delete` (future: allow `stop`) -- **`RDS_FINAL_SNAPSHOT`**: `true|false` (default `false`) - -#### Budget (dynamic cohort) - -- **`COHORT_START_YYYY_MM`**: e.g. `2026-01` (set per cohort) -- **`COHORT_TOTAL_BUDGET_USD`**: default `3000` -- **`COHORT_LENGTH_MONTHS`**: default `7` -- **`BUDGET_OVER_DAYS`**: default `2` (consecutive days over budget before alerting) - ---- - -### 3) Target v2 architecture (still simple) - -#### Logical flow (every scheduled run) - -- **Scan** - - - Determine regions to scan. - - Collect resources per region/service. - - Apply whitelist. - - Produce: - - Slack scan summary (scan channel) - - Machine-readable report (JSON in logs; optional persisted store later) - -- **Budget** - - - Use Cost Explorer to compute: - - Cohort-to-date spend (from cohort start through today) - - Remaining cohort budget - - Remaining months - - Dynamic monthly allowance (remaining_budget / remaining_months) - - Current month projected month-end spend (simple run-rate) - - Determine “over budget streak” for the last `BUDGET_OVER_DAYS` days using daily costs for the current month. - - Post budget summary + alert (alert channel only when threshold reached). - -- **Teardown** - - Default: post “proposed actions” only (dry-run). - - If `APPLY_CHANGES=true`: execute deletion/termination calls and post results. - -#### Code structure (proposed) - -- `Bloodhound/` - - `bloodhound.py` (entrypoint; keep thin) - - `bloodhound/` - - `config.py` (env parsing, defaults, validation) - - `scanner/` - - `ec2.py` - - `rds.py` - - `eip.py` - - `nat_gateway.py` - - `ebs.py` - - `elbv2.py` - - `regions.py` - - `whitelist.py` (tag filter + optional ID/ARN allowlist) - - `budget.py` (Cost Explorer queries + projection logic) - - `teardown/` - - `planner.py` (dry-run action plan) - - `executor.py` (apply-mode execution) - - `slack.py` (posting + message formatting) - - `types.py` (resource record schema) - - `logging.py` (structured logs, consistent formatting) - -This modularity is intentionally small: one folder, small files, no framework. - ---- - -### 4) Resource record schema (simple + consistent) - -All scanners should output a list of records with the same shape. - -- **Required** - - - `service`: e.g. `ec2`, `rds`, `eip`, `nat`, `ebs`, `elbv2` - - `resource_type`: e.g. `instance`, `db_instance`, `address`, `nat_gateway`, `volume`, `load_balancer` - - `region` - - `id`: service ID (InstanceId, VolumeId, etc.) - - `arn`: include if easily available; else `null` - - `state`: normalized string (e.g. `running`, `available`, `in-use`, `deleting`) - - `tags`: dictionary of tags (best-effort) - -- **Optional (useful for teardown)** - - `delete_supported`: boolean - - `delete_action`: string (e.g. `terminate_instances`, `delete_db_instance`, `release_address`) - - `delete_params`: minimal params needed (ids, flags) - ---- - -### 5) Resources to scan (priority order) - -Goal: cover the most common and expensive student mistakes first. - -#### Phase 1 (v2.1) — high value, low complexity - -- **EC2 Instances** (existing) -- **RDS Instances** (existing) -- **Elastic IPs** (unassociated) -- **NAT Gateways** -- **EBS volumes** (unattached) -- **Load Balancers** (ALB/NLB) - -#### Phase 2 (later; only if needed) - -- EBS snapshots -- AMIs -- ElastiCache -- OpenSearch -- Redshift - ---- - -### 6) Whitelist rules (minimal) - -#### Rule A: tag-based keep (primary) - -- If resource has tag: - - key: `bloodhound:keep` - - value: `true` -- Then: - - Exclude from teardown - - Optionally: - - Exclude from scan counts (or mark as “kept”) - -#### Rule B: env allowlist (optional) - -- If resource `id` or `arn` is listed in `KEEP_RESOURCE_IDS`, exclude from teardown. - ---- - -### 7) Teardown plan (terminate/delete with a safe rollout) - -#### v2.3 (dry-run only) - -- Build a “proposed action plan”: - - For each resource: - - If whitelisted: skip - - Else if deletable: include action + minimal parameters - - Else: include as “manual” (for visibility) -- Post to alert channel: - - Counts by service/region - - List of proposed actions (keep concise; link to logs for full JSON) - -#### v2.4 (apply-mode) - -- If `APPLY_CHANGES=true`: - - Execute actions. - - Post: - - Success counts - - Failure counts + top errors - - Emit structured logs of every action attempted. - -#### Delete policy defaults (as requested) - -- **EC2**: terminate (not stop) -- **RDS**: delete without final snapshot (`RDS_FINAL_SNAPSHOT=false`) -- **EBS**: delete unattached -- **EIP**: release if unassociated -- **NAT Gateway**: delete -- **Load balancer**: delete - ---- - -### 8) Budget/projections plan (7-month cohort, $3000, dynamic) - -#### Definitions - -- Cohort total budget: `COHORT_TOTAL_BUDGET_USD` (default 3000) -- Cohort length months: `COHORT_LENGTH_MONTHS` (default 7) -- Cohort start month: `COHORT_START_YYYY_MM` - -#### Each run computes - -- **Cohort-to-date spend** - - Sum monthly spend from cohort start through current month-to-date. -- **Remaining cohort budget** - - `remaining_budget = total_budget - cohort_to_date_spend` -- **Remaining months** - - Based on cohort start and current date (clamped to at least 1). -- **Dynamic monthly allowance** - - `monthly_allowance = remaining_budget / remaining_months` -- **Month-end projection** - - Simple run-rate: - - `projection = (month_to_date_spend / days_elapsed) * days_in_month` - - (Optional later) use Cost Explorer forecast if desired. - -#### Alerting logic (simple, no DB) - -- For the current month: - - Pull daily costs for the last `BUDGET_OVER_DAYS` days. - - For each of those days, compute what the month-end projection would have been at that point. - - If **all** of those daily projections exceed `monthly_allowance`, alert. - -This yields “consecutive days over budget” behavior without storing state. - -#### Slack message content (alert channel) - -- Cohort start month -- Cohort-to-date spend -- Remaining cohort budget -- Remaining months -- Dynamic monthly allowance -- Current month-to-date spend -- Projected month-end spend -- Whether alert threshold was met (and for how many days) - ---- - -### 9) GitHub Actions plan (keep it, extend it) - -#### Current - -- `invoke_lambda.yml` runs on schedule and calls Lambda. - -#### v2 changes (still simple) - -- Continue invoking the same Lambda. -- Optionally add a second workflow/job later for teardown apply-mode: - - Example: weekly teardown run with `APPLY_CHANGES=true` - - Keep scanning separate from destructive actions to reduce risk. - ---- - -### 10) IAM permissions plan (least privilege) - -#### Scan permissions (read) - -- EC2 read for instances, volumes, addresses, regions, NAT gateways, load balancers. -- RDS read for db instances. -- Cost Explorer read for budget/projection. - -#### Teardown permissions (write) - -- EC2 terminate instances -- Delete volumes -- Release addresses -- Delete NAT gateways -- Delete load balancers -- Delete RDS instances - -Recommendation: split into two Lambda roles later: - -- `BloodhoundScanRole` (read-only) -- `BloodhoundTeardownRole` (write) - -This is optional but strongly recommended once apply-mode is enabled. - ---- - -### 11) Testing plan (minimal but real) - -#### Local dry-run - -- Run scan locally against a dev account/profile. -- Validate: - - Region selection works - - Resource counts look correct - - Slack messages post to the intended channels - -#### Lambda dry-run in AWS - -- Invoke Lambda manually and via GitHub Actions. -- Validate CloudWatch logs and Slack outputs. - -#### Apply-mode staged rollout - -- Start with a “known safe” subset of resources (e.g., EIPs and unattached EBS volumes). -- Then add EC2 termination. -- Then add RDS deletion. - ---- - -### 12) Milestones with checklists - -#### v2.0 (refactor + config) - -- [ ] Add env-driven Slack channels (scan vs alert) -- [ ] Add env-driven region configuration (`REGION_MODE`, `REGIONS`) -- [ ] Remove local-only AWS profile dependency in Lambda context -- [ ] Keep existing scan behavior and Slack scan summary working - -#### v2.1 (more resources) - -- [ ] Add scanners: EIP, NAT GW, EBS unattached, ELBv2 -- [ ] Add pagination everywhere -- [ ] Normalize output into a shared resource schema -- [ ] Slack scan summary includes per-service counts - -#### v2.2 (whitelist) - -- [ ] Implement tag-based whitelist `bloodhound:keep=true` -- [ ] Optional: implement `KEEP_RESOURCE_IDS` -- [ ] Ensure whitelist affects teardown planning and apply-mode - -#### v2.3 (teardown dry-run) - -- [ ] Build “proposed deletions” plan -- [ ] Post proposed plan summary to alert channel -- [ ] Log full plan as JSON - -#### v2.4 (teardown apply-mode) - -- [ ] Guarded by `APPLY_CHANGES=true` -- [ ] Execute delete/terminate actions and post results -- [ ] Structured logs for every attempted action - -#### v2.5 (budget + alerts) - -- [ ] Cost Explorer cohort-to-date + dynamic monthly allowance -- [ ] Month-end run-rate projection -- [ ] Alert when over monthly allowance for `BUDGET_OVER_DAYS` consecutive days -- [ ] Post budget summary (and alert when triggered) to alert channel - ---- - -### 13) Open questions (none required to proceed) - -All previously open decisions are resolved with “simple defaults”, but these can be revisited later: - -- Whether scan summaries should include whitelisted resources (counted vs hidden). -- Whether to split scan vs teardown into separate Lambdas/roles immediately or later. diff --git a/bloodhound/app.py b/bloodhound/app.py index 0f23e5a..2f49dbb 100644 --- a/bloodhound/app.py +++ b/bloodhound/app.py @@ -1,6 +1,24 @@ +""" +bloodhound/app.py + +Orchestration entrypoint for Bloodhound v2. + +Primary responsibilities: +- Load env config (via `config.py`) +- Scan resources (via `scanner/*`) +- Split candidates vs whitelisted/kept (via `whitelist.py`) +- Post Slack reports (via `messages.py` + `slack.py`) +- Optionally execute teardown actions (via `teardown/*`) + +Used by: +- `lambda_function.lambda_handler` (AWS Lambda) +- `run_local.py` (local testing) +""" + from __future__ import annotations import json +import os from typing import Any from bloodhound.aws import create_clients @@ -27,12 +45,29 @@ def run(event: Any, context: Any) -> dict[str, Any]: Main orchestration entrypoint for Lambda and local testing. Returns a small JSON-serializable summary for `aws lambda invoke`. """ + # Slack slash command worker mode: + # apply teardown overrides FIRST, then load config so the same invocation uses the intended flags. + if isinstance(event, dict) and event.get("source") == "slack_command": + mode = (event.get("mode") or "").strip() + # /seek = scan + reports only + if mode == "seek": + os.environ["APPLY_CHANGES"] = "false" + os.environ["TEARDOWN_SIMULATE"] = "true" + os.environ["TEARDOWN_ALLOW_ALL"] = "false" + # /seek_destroy = destructive mode (delete all non-whitelisted candidates) + elif mode == "seek_destroy": + os.environ["APPLY_CHANGES"] = "true" + os.environ["TEARDOWN_SIMULATE"] = "false" + os.environ["TEARDOWN_ALLOW_ALL"] = "true" + + # Load configuration from env/.env and validate required fields. cfg = load_config() errors = validate_config(cfg) if errors: # Still return a structured error for Lambda invocations. return {"ok": False, "errors": errors} + # AWS session/clients (Lambda uses its execution role; local can use AWS_PROFILE). clients = create_clients(profile=cfg.aws.profile) slack = None if cfg.slack.enabled: @@ -53,6 +88,7 @@ def run(event: Any, context: Any) -> dict[str, Any]: kept_by_region: dict[str, list] = {} for region, records in raw_by_region.items(): + # Whitelist is applied before teardown planning. candidates, kept = filter_whitelisted(records, cfg.whitelist) candidates_by_region[region] = candidates kept_by_region[region] = kept diff --git a/bloodhound/aws.py b/bloodhound/aws.py index 3339b90..0318846 100644 --- a/bloodhound/aws.py +++ b/bloodhound/aws.py @@ -1,3 +1,12 @@ +""" +bloodhound/aws.py + +Thin AWS session/client helpers for Bloodhound v2. + +In Lambda: credentials come from the execution role. +Locally: you can use AWS_PROFILE via environment variables. +""" + from __future__ import annotations from dataclasses import dataclass diff --git a/bloodhound/budget.py b/bloodhound/budget.py index 893ad5d..a4f8996 100644 --- a/bloodhound/budget.py +++ b/bloodhound/budget.py @@ -1,3 +1,14 @@ +""" +bloodhound/budget.py + +Budget/projection logic for Bloodhound v2. + +Uses Cost Explorer (CE) to compute: +- cohort-to-date spend +- dynamic monthly allowance based on remaining budget/months +- simple month-end projection from daily spend run-rate +""" + from __future__ import annotations import calendar @@ -38,13 +49,15 @@ def compute_budget_snapshot( start_year, start_month = _parse_yyyy_mm(cohort_start_yyyy_mm) cohort_start = date(start_year, start_month, 1) - # Cost Explorer is effectively "global"; us-east-1 is the common choice. + # Cost Explorer is effectively "global"; us-east-1 is the common boto3 convention. ce = clients.client("ce", region="us-east-1") - # Cohort-to-date monthly spend + # Cohort-to-date spend (monthly granularity). cohort_to_date = _get_cost_monthly_total(ce, start=cohort_start, end=today + timedelta(days=1)) - # Current month daily spend for MTD + "over budget X days" streak computation + # Current month daily spend for: + # - month-to-date spend + # - "over budget X days" calculation without needing a database month_start = date(today.year, today.month, 1) daily_costs = _get_cost_daily(ce, start=month_start, end=today + timedelta(days=1)) mtd_spend = sum(daily_costs.values()) @@ -53,6 +66,7 @@ def compute_budget_snapshot( remaining_months = _remaining_months(cohort_start, cohort_length_months, today) allowance = remaining_budget / remaining_months if remaining_months > 0 else remaining_budget + # Run-rate projection: assumes current pace continues to month end. projected_month_end = _project_month_end(mtd_spend, today) over_met = _over_budget_threshold_met( daily_costs=daily_costs, diff --git a/bloodhound/config.py b/bloodhound/config.py index 02a6aa0..da712f4 100644 --- a/bloodhound/config.py +++ b/bloodhound/config.py @@ -1,3 +1,13 @@ +""" +bloodhound/config.py + +Configuration loader for Bloodhound v2. + +Reads configuration from environment variables and (for local runs) a `.env` file. +All non-code “knobs” (channels, regions, whitelist tag, teardown flags, cohort config) +should live here so the rest of the code stays clean. +""" + from __future__ import annotations import os diff --git a/bloodhound/messages.py b/bloodhound/messages.py index 464580e..ec096c9 100644 --- a/bloodhound/messages.py +++ b/bloodhound/messages.py @@ -1,3 +1,12 @@ +""" +bloodhound/messages.py + +Slack message formatting for Bloodhound v2. + +This file contains ONLY formatting/aggregation logic (no AWS calls). +It is used by `app.py` right before posting via `slack.py`. +""" + from __future__ import annotations from collections import defaultdict diff --git a/bloodhound/scanner/ec2.py b/bloodhound/scanner/ec2.py index 9d9d69a..d1bc862 100644 --- a/bloodhound/scanner/ec2.py +++ b/bloodhound/scanner/ec2.py @@ -1,3 +1,10 @@ +""" +bloodhound/scanner/ec2.py + +EC2-family scanners (instances, EBS volumes, EIPs, NAT gateways). +These produce `ResourceRecord` items which are later whitelisted and optionally torn down. +""" + from __future__ import annotations from typing import Any diff --git a/bloodhound/scanner/elbv2.py b/bloodhound/scanner/elbv2.py index e751e3c..8501fc6 100644 --- a/bloodhound/scanner/elbv2.py +++ b/bloodhound/scanner/elbv2.py @@ -1,3 +1,9 @@ +""" +bloodhound/scanner/elbv2.py + +ELBv2 scanner (ALB/NLB). Collects load balancers and tags (in batches). +""" + from __future__ import annotations from bloodhound.aws import AwsClients diff --git a/bloodhound/scanner/rds.py b/bloodhound/scanner/rds.py index aea36b9..80767ea 100644 --- a/bloodhound/scanner/rds.py +++ b/bloodhound/scanner/rds.py @@ -1,3 +1,9 @@ +""" +bloodhound/scanner/rds.py + +RDS scanner. Collects DB instances and best-effort tags (requires list_tags_for_resource). +""" + from __future__ import annotations from typing import Any diff --git a/bloodhound/scanner/regions.py b/bloodhound/scanner/regions.py index b2cd0c1..ae52cba 100644 --- a/bloodhound/scanner/regions.py +++ b/bloodhound/scanner/regions.py @@ -1,3 +1,12 @@ +""" +bloodhound/scanner/regions.py + +Region selection helpers. + +- explicit mode: use REGIONS from config +- discover mode: discover regions via EC2 DescribeRegions +""" + from __future__ import annotations from typing import Optional diff --git a/bloodhound/scanner/scan_all.py b/bloodhound/scanner/scan_all.py index 3ff9f01..81715e2 100644 --- a/bloodhound/scanner/scan_all.py +++ b/bloodhound/scanner/scan_all.py @@ -1,3 +1,9 @@ +""" +bloodhound/scanner/scan_all.py + +Scan coordinator: runs all per-service scanners for each region and returns a region->records map. +""" + from __future__ import annotations from dataclasses import dataclass diff --git a/bloodhound/slack.py b/bloodhound/slack.py index 6d2fc99..0210651 100644 --- a/bloodhound/slack.py +++ b/bloodhound/slack.py @@ -1,3 +1,10 @@ +""" +bloodhound/slack.py + +Small wrapper around the Slack SDK for posting messages. +The formatting is handled in `messages.py`. +""" + from __future__ import annotations from dataclasses import dataclass diff --git a/bloodhound/slack_commands.py b/bloodhound/slack_commands.py new file mode 100644 index 0000000..a309a75 --- /dev/null +++ b/bloodhound/slack_commands.py @@ -0,0 +1,190 @@ +""" +bloodhound/slack_commands.py + +Slack slash command HTTP handler for Bloodhound v2. + +Why this exists: +- Slack needs a public HTTPS endpoint for slash commands (/seek, /seek_destroy) +- Slack requires a fast response; we return immediately and then self-invoke the Lambda + +Deployed via: +- Lambda Function URL (recommended) or API Gateway +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import time +import urllib.parse +from dataclasses import dataclass +from typing import Any + +import boto3 + + +@dataclass(frozen=True) +class SlackCommand: + command: str + text: str + user_id: str + channel_id: str + + +def is_slack_http_event(event: dict[str, Any]) -> bool: + # Lambda Function URL / API Gateway both provide headers + body for HTTP requests. + return isinstance(event, dict) and "headers" in event and "body" in event + + +def handle_slack_command_http(event: dict[str, Any]) -> dict[str, Any]: + """ + Handles Slack slash commands via an HTTP-triggered Lambda (Function URL or API Gateway). + + Responds immediately (Slack requires fast response), then asynchronously invokes the + same Lambda function to run the scan/teardown and post the normal Slack reports. + """ + # Slack request verification (signature + timestamp) is mandatory. + signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "").strip() + if not signing_secret: + return _http_text(500, "Missing SLACK_SIGNING_SECRET") + + headers = _lowercase_headers(event.get("headers") or {}) + raw_body = event.get("body") or "" + if event.get("isBase64Encoded"): + raw_body = base64.b64decode(raw_body).decode("utf-8") + + if not _verify_signature(headers, raw_body, signing_secret): + return _http_text(401, "Invalid Slack signature") + + form = urllib.parse.parse_qs(raw_body, keep_blank_values=True) + cmd = SlackCommand( + command=_first(form, "command"), + text=_first(form, "text"), + user_id=_first(form, "user_id"), + channel_id=_first(form, "channel_id"), + ) + + allowed_channels = _split_csv(os.environ.get("SLACK_ALLOWED_CHANNEL_IDS", "")) + if allowed_channels and cmd.channel_id not in allowed_channels: + # Slack surfaces non-200 responses as "dispatch_failed", so return 200 with a helpful message. + return _http_text(200, "Not allowed in this channel.") + + # Route based on Slack's `command` field. + if cmd.command == "/seek": + _invoke_worker(mode="seek", cmd=cmd) + return _http_text(200, "BloodHound is on the hunt...please stand by.") + + if cmd.command == "/seek_destroy": + if not _destroy_allowed(cmd): + # Slack surfaces non-200 responses as "dispatch_failed", so return 200 with a helpful message. + return _http_text(200, "Not allowed. Use `/seek_destroy CONFIRM` (and ensure you are allowlisted).") + _invoke_worker(mode="seek_destroy", cmd=cmd) + return _http_text(200, "Uh oh someone let the dog out ---> Seek & Destroy Underway friendly assets whitelisted...please stand by.") + + # Unknown command (still return 200 so Slack doesn't show dispatch_failed). + return _http_text(200, f"Unknown command: {cmd.command}") + + +def _invoke_worker(mode: str, cmd: SlackCommand) -> None: + """ + Asynchronously invoke THIS lambda so the HTTP response can return immediately. + The worker invocation will run the normal scan/teardown and post to Slack. + """ + # We invoke THIS lambda asynchronously so Slack isn't waiting on a long scan. + function_name = os.environ.get("AWS_LAMBDA_FUNCTION_NAME") + if not function_name: + # Local runs won't have this; just no-op. + return + + payload = { + "source": "slack_command", + "mode": mode, + "slack": { + "command": cmd.command, + "text": cmd.text, + "user_id": cmd.user_id, + "channel_id": cmd.channel_id, + }, + } + + boto3.client("lambda").invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=str.encode(json_dumps(payload)), + ) + + +def _destroy_allowed(cmd: SlackCommand) -> bool: + """ + Super-safe by default: + - Require CONFIRM token + - Require user allowlist if configured + """ + # Minimal "are you sure?" gate. + confirm_token = os.environ.get("SLACK_DESTROY_CONFIRM_TOKEN", "CONFIRM").strip() + if cmd.text.strip() != confirm_token: + return False + + allowed_users = _split_csv(os.environ.get("SLACK_ALLOWED_USER_IDS", "")) + if allowed_users and cmd.user_id not in allowed_users: + return False + + return True + + +def _verify_signature(headers: dict[str, str], body: str, signing_secret: str) -> bool: + ts = headers.get("x-slack-request-timestamp", "") + sig = headers.get("x-slack-signature", "") + if not ts or not sig: + return False + + try: + ts_int = int(ts) + except ValueError: + return False + + # Reject old requests (replay protection). + if abs(int(time.time()) - ts_int) > 60 * 5: + return False + + base = f"v0:{ts}:{body}".encode("utf-8") + digest = hmac.new(signing_secret.encode("utf-8"), base, hashlib.sha256).hexdigest() + expected = f"v0={digest}" + return hmac.compare_digest(expected, sig) + + +def _http_text(status_code: int, text: str) -> dict[str, Any]: + return { + "statusCode": status_code, + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + "body": text, + } + + +def _lowercase_headers(headers: dict[str, Any]) -> dict[str, str]: + out: dict[str, str] = {} + for k, v in headers.items(): + if k is None or v is None: + continue + out[str(k).lower()] = str(v) + return out + + +def _first(form: dict[str, list[str]], key: str) -> str: + vals = form.get(key) or [] + return vals[0] if vals else "" + + +def _split_csv(raw: str) -> list[str]: + return [x.strip() for x in raw.split(",") if x.strip()] + + +def json_dumps(obj: Any) -> str: + # Tiny local wrapper to avoid importing json at module import time. + import json + + return json.dumps(obj) + + diff --git a/bloodhound/teardown/executor.py b/bloodhound/teardown/executor.py index 3833f14..b26e18a 100644 --- a/bloodhound/teardown/executor.py +++ b/bloodhound/teardown/executor.py @@ -1,3 +1,13 @@ +""" +bloodhound/teardown/executor.py + +Executes teardown actions produced by `planner.py`. + +Supports simulate mode (TEARDOWN_SIMULATE): +- EC2-family actions use DryRun=True where supported +- non-DryRun services are treated as no-op in simulate mode (safest) +""" + from __future__ import annotations from dataclasses import dataclass @@ -32,6 +42,9 @@ def execute_actions(clients: AwsClients, actions: list[PlannedAction], *, simula for a in actions: attempted += 1 try: + # simulate=True guarantees we do NOT destroy anything: + # - EC2-family: validate permissions/shape with DryRun=True + # - RDS/ELB deletes: no-op (safest) did_simulate = _execute_one(clients, a, simulate=simulate) if did_simulate: simulated_count += 1 diff --git a/bloodhound/teardown/planner.py b/bloodhound/teardown/planner.py index f70c37b..84e675f 100644 --- a/bloodhound/teardown/planner.py +++ b/bloodhound/teardown/planner.py @@ -1,3 +1,10 @@ +""" +bloodhound/teardown/planner.py + +Builds a teardown action plan from candidate ResourceRecords. +This is used for both dry-run reporting and apply-mode execution. +""" + from __future__ import annotations from dataclasses import dataclass diff --git a/bloodhound/types.py b/bloodhound/types.py index b091d2f..e734eaf 100644 --- a/bloodhound/types.py +++ b/bloodhound/types.py @@ -1,3 +1,10 @@ +""" +bloodhound/types.py + +Shared data structures used across scanners/whitelist/teardown. +Keeping these centralized avoids “dict soup” across the codebase. +""" + from __future__ import annotations from dataclasses import dataclass diff --git a/bloodhound/whitelist.py b/bloodhound/whitelist.py index 1ead029..4bc8b99 100644 --- a/bloodhound/whitelist.py +++ b/bloodhound/whitelist.py @@ -1,3 +1,12 @@ +""" +bloodhound/whitelist.py + +Whitelist (keep) logic for Bloodhound v2. + +Kept resources are excluded from teardown planning/execution. +Primary rule is a single tag (configurable via KEEP_TAG_KEY/KEEP_TAG_VALUE). +""" + from __future__ import annotations from bloodhound.config import WhitelistConfig @@ -17,6 +26,7 @@ def is_whitelisted(record: ResourceRecord, cfg: WhitelistConfig) -> bool: val = record.tags.get(cfg.keep_tag_key) if val is None: return False + # Normalize for "TRUE", "true", etc. return val.strip().lower() == cfg.keep_tag_value.strip().lower() diff --git a/SLACK_SETUP.md b/docs/SLACK_SETUP.md similarity index 99% rename from SLACK_SETUP.md rename to docs/SLACK_SETUP.md index ea917a2..e781164 100644 --- a/SLACK_SETUP.md +++ b/docs/SLACK_SETUP.md @@ -48,3 +48,5 @@ In the channel, run: - `/invite @your-bot-name` --- + + diff --git a/docs/V2_PLAN.md b/docs/V2_PLAN.md new file mode 100644 index 0000000..7fe49a3 --- /dev/null +++ b/docs/V2_PLAN.md @@ -0,0 +1,88 @@ +### Bloodhound v2 Plan (tracked implementation checklist) + +This document is the shared plan for evolving Bloodhound from v1.0 to v2.x while keeping the project simple, readable, and student-friendly. + +--- + +### 0) Current v1.0 baseline (what exists today) + +- **What it does** + + - Scans a fixed list of AWS regions. + - Collects **EC2** instance IDs (skipping stopped/terminated). + - Collects **RDS** instance identifiers. + - Formats one Slack message and posts it to a channel. + +- **How it is invoked** + + - A GitHub Actions scheduled workflow calls: + - `aws lambda invoke --function-name BloodhoundLambda ...` + +- **Important constraints carried into v2** + - Keep **GitHub Actions** as the scheduler (do not migrate to EventBridge). + - Keep **Slack** as the only notification surface (no email). + - Single AWS account only (no Organizations / cross-account roles for now). + - Teardown should support **terminate/delete** (default). “Stop-only” is a later enhancement. + +--- + +### 1) v2 guiding principles (to keep it minimal) + +- **Configuration over code edits** + + - Anything that will vary cohort-to-cohort (start month, channels, regions) must be env-configurable. + +- **Safe rollout for destructive actions** + + - Default is **dry-run** (report proposed deletions). + - Apply-mode requires an explicit flag. + +- **Simple whitelist** + + - Primary mechanism is a single tag: `bloodhound:keep=true`. + - Optional escape hatch: env var allowlist for IDs/ARNs. + +- **No unnecessary infrastructure** + - Prefer “compute from AWS APIs each run” when it’s easy (e.g., budget streak calculation). + - Add a database only if it materially simplifies or is required. + +--- + +### 2) v2 configuration (environment variables) + +#### Slack + +- **`SLACK_BOT_TOKEN`**: Slack bot token used to post messages. +- **`SLACK_SCAN_CHANNEL_ID`**: Channel for “scan summary / what exists”. +- **`SLACK_ALERT_CHANNEL_ID`**: Channel for “budget alerts + teardown outcomes”. + - If unset, default to `SLACK_SCAN_CHANNEL_ID`. + +#### Regions + +- **`REGION_MODE`**: `explicit` or `discover` + - `explicit`: use `REGIONS` + - `discover`: discover regions dynamically (then optionally filter) +- **`REGIONS`**: comma-separated region list (only used in `explicit` mode) + +#### Whitelist (minimal) + +- **`KEEP_TAG_KEY`**: default `bloodhound:keep` +- **`KEEP_TAG_VALUE`**: default `true` +- **`KEEP_RESOURCE_IDS`** (optional): comma-separated resource IDs/ARNs always excluded from teardown + +#### Teardown controls + +- **`APPLY_CHANGES`**: `true|false` (default `false`) +- **`TEARDOWN_SIMULATE`**: `true|false` (default `true` for safe testing) +- **`TEARDOWN_TARGET_IDS`**: optional safety rail, comma-separated IDs/ARNs +- **`TEARDOWN_ALLOW_ALL`**: if true, delete all non-whitelisted candidates +- **`RDS_FINAL_SNAPSHOT`**: `true|false` (default `false`) + +#### Budget (dynamic cohort) + +- **`COHORT_START_YYYY_MM`**: e.g. `2025-12` (set per cohort) +- **`COHORT_TOTAL_BUDGET_USD`**: default `3000` +- **`COHORT_LENGTH_MONTHS`**: default `7` +- **`BUDGET_OVER_DAYS`**: default `2` + + diff --git a/env.example b/env.example index fea97a0..e1a70ba 100644 --- a/env.example +++ b/env.example @@ -20,12 +20,24 @@ SLACK_SCAN_CHANNEL_ID=C0A4YLV0HNY # Alerts/teardown channel SLACK_ALERT_CHANNEL_ID=C0A4YLV0HNY +### Slack slash commands (Function URL) + +# Required to validate /seek and /seek_destroy requests from Slack +SLACK_SIGNING_SECRET=REPLACE_ME + +# Optional allowlists (comma-separated). If unset, allow all. +SLACK_ALLOWED_USER_IDS= +SLACK_ALLOWED_CHANNEL_IDS= + +# Safety: /seek_destroy requires exact text match to this token +SLACK_DESTROY_CONFIRM_TOKEN=CONFIRM + ### Regions # explicit = scan REGIONS list # discover = auto-discover AWS regions REGION_MODE=explicit -REGIONS=us-east-1,us-east-2us-west-1,us-west-2 +REGIONS=us-east-1,us-east-2,us-west-1,us-west-2 ### Whitelist diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..3c64b66 --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1,8 @@ +""" +handlers/ + +Deployment entrypoints. +These modules are referenced by AWS Lambda handler strings. +""" + + diff --git a/handlers/lambda_function.py b/handlers/lambda_function.py new file mode 100644 index 0000000..cc0e779 --- /dev/null +++ b/handlers/lambda_function.py @@ -0,0 +1,28 @@ +""" +handlers/lambda_function.py + +AWS Lambda entrypoint for Bloodhound v2. + +This handler supports: +- Scheduled/manual invocations (runs full scan/report/optional teardown) +- Slack slash commands via Function URL (HTTP events) + +Terraform config points at: +- handlers.lambda_function.lambda_handler +""" + +from __future__ import annotations + +from bloodhound.app import run +from bloodhound.slack_commands import handle_slack_command_http, is_slack_http_event + + +def lambda_handler(event, context): + # 1) Slack slash commands (HTTP events) -> immediate response + async self-invoke. + if isinstance(event, dict) and is_slack_http_event(event): + return handle_slack_command_http(event) + + # 2) Normal invocation (CLI, schedule, async worker invocation). + return run(event=event, context=context) + + diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl new file mode 100644 index 0000000..6c39e9c --- /dev/null +++ b/infra/.terraform.lock.hcl @@ -0,0 +1,45 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.7.1" + constraints = "~> 2.0" + hashes = [ + "h1:A7EnRBVm4h9ryO9LwxYnKr4fy7ExPMwD5a1DsY7m1Y0=", + "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", + "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", + "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", + "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", + "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", + "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", + "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", + "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", + "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", + "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.27.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:emgTfB1LXSFYh9uAwgsRMoMIN5Wz7jNNKq3rqC0EHWk=", + "zh:177a24b806c72e8484b5cabc93b2b38e3d770ae6f745a998b54d6619fd0e8129", + "zh:4ac4a85c14fb868a3306b542e6a56c10bd6c6d5a67bc0c9b8f6a9060cf5f3be7", + "zh:552652185bc85c8ba1da1d65dea47c454728a5c6839c458b6dcd3ce71c19ccfc", + "zh:60284b8172d09aee91eae0856f09855eaf040ce3a58d6933602ae17c53f8ed04", + "zh:6be38d156756ca61fb8e7c752cc5d769cd709686700ac4b230f40a6e95b5dbc9", + "zh:7a409138fae4ef42e3a637e37cb9efedf96459e28a3c764fc4e855e8db9a7485", + "zh:8070cf5224ed1ed3a3e9a59f7c30ff88bf071c7567165275d477c1738a56c064", + "zh:894439ef340a9a79f69cd759e27ad11c7826adeca27be1b1ca82b3c9702fa300", + "zh:89d035eebf08a97c89374ff06040955ddc09f275ecca609d0c9d58d149bef5cf", + "zh:985b1145d724fc1f38369099e4a5087141885740fd6c0b1dbc492171e73c2e49", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a80b47ae8d1475201c86bd94a5dcb9dd4da5e8b73102a90820b68b66b76d50fd", + "zh:d3395be1556210f82199b9166a6b2e677cee9c4b67e96e63f6c3a98325ad7ab0", + "zh:db0b869d09657f6f1e4110b56093c5fcdf9dbdd97c020db1e577b239c0adcbce", + "zh:ffc72e680370ae7c21f9bd3082c6317730df805c6797427839a6b6b7e9a26a01", + ] +} diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..02338d5 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,38 @@ +## Bloodhound v2 Infrastructure (Terraform) + +This directory provisions the AWS infrastructure for running Bloodhound v2 with Slack slash commands. + +We use a **Lambda Function URL** (single endpoint) for `/seek` and `/seek_destroy`. + +### What Terraform creates + +- Lambda function: `BloodhoundLambdaV2` +- Lambda Function URL (public, `authorization_type = NONE`) +- IAM role + policies for: + - CloudWatch logs + - scanning resources (EC2/RDS/ELBv2) + - cost explorer (CE) + - teardown actions (terminate/delete) + - async self-invocation (so slash commands can return immediately) + +### Deploy flow + +1. Apply Terraform (from `Bloodhound/infra/`): + +```bash +terraform init +terraform apply +``` + +Terraform will automatically prepare `../.build/lambda_pkg/` (dependencies + source) and build `../.build/bloodhound_lambda_v2.zip` as part of `terraform apply` (via `terraform_data` + the `archive_file` data source). + +2. Configure Slack slash commands + +In your Slack App settings, set the Request URL for `/seek` and `/seek_destroy` to the Terraform output: + +- `lambda_function_url` + +### Notes + +- Terraform runs `python3 -m pip install ...` locally to build the zip, so you need `python3`, `pip`, `zip`, and `rsync` installed. +- Putting secrets in `var.lambda_env` stores them in Terraform state. Prefer setting secrets in the Lambda console (or a secrets manager). diff --git a/infra/build.tf b/infra/build.tf new file mode 100644 index 0000000..8bc49f0 --- /dev/null +++ b/infra/build.tf @@ -0,0 +1,57 @@ +/* +infra/build.tf + +Build pipeline for the Lambda zip (Terraform-managed). + +How it works: +1) terraform_data.build_lambda_pkg prepares ../.build/lambda_pkg by: + - pip installing dependencies into the package directory + - copying our source code into the package directory +2) archive_file.lambda_zip zips that directory into ../.build/bloodhound_lambda_v2.zip + +Notes: +- This runs locally on the machine executing `terraform apply`. +- Requires python3 + pip + rsync installed locally. +*/ + +resource "terraform_data" "build_lambda_pkg" { + # Rebuild when requirements or source changes. + triggers_replace = { + requirements_hash = filesha256("${path.module}/../requirements.txt") + lambda_handler_hash = filesha256("${path.module}/../handlers/lambda_function.py") + # Small codebase: hash all python files for rebuild trigger. + source_tree_hash = sha256(join("", [ + for f in fileset("${path.module}/../bloodhound", "**/*.py") : + filesha256("${path.module}/../bloodhound/${f}") + ])) + handlers_tree_hash = sha256(join("", [ + for f in fileset("${path.module}/../handlers", "**/*.py") : + filesha256("${path.module}/../handlers/${f}") + ])) + } + + provisioner "local-exec" { + working_dir = "${path.module}/.." + command = < Slack slash command Request URL +*/ + +output "lambda_function_name" { + value = aws_lambda_function.bloodhound_v2.function_name +} + +output "lambda_function_url" { + value = aws_lambda_function_url.bloodhound_url.function_url +} + + diff --git a/infra/providers.tf b/infra/providers.tf new file mode 100644 index 0000000..e8171bc --- /dev/null +++ b/infra/providers.tf @@ -0,0 +1,13 @@ +/* +infra/providers.tf + +AWS provider configuration for this module. +We keep provider config separate so main.tf can stay provider-requirements-only. +*/ + +provider "aws" { + region = var.aws_region + profile = var.aws_profile +} + + diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..c5d83dc --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,42 @@ +# Example Terraform inputs for Bloodhound v2. +# +# Copy to terraform.tfvars (do not commit terraform.tfvars; it's gitignored). +# +# NOTE: Any values you put in lambda_env will end up in Terraform state. +# Avoid putting Slack tokens or secrets here. Prefer setting secrets in the Lambda console +# or via your preferred secret management approach. + +aws_region = "us-east-1" +aws_profile = "geekstar" + +name_prefix = "bloodhound-v2" +lambda_function_name = "BloodhoundLambdaV2" +lambda_runtime = "python3.10" + +# Non-secret env vars can live here safely. +lambda_env = { + SLACK_ENABLED = "true" + # Secrets (OK to set here if you accept they will be stored in Terraform state) + SLACK_BOT_TOKEN = "REPLACE_ME" + SLACK_SIGNING_SECRET = "REPLACE_ME" + + SLACK_SCAN_CHANNEL_ID = "C0A4YLV0HNY" + SLACK_ALERT_CHANNEL_ID = "C0A4YLV0HNY" + REGION_MODE = "explicit" + REGIONS = "us-east-1,us-east-2,us-west-1,us-west-2" + KEEP_TAG_KEY = "bloodhound:keep" + KEEP_TAG_VALUE = "true" + COHORT_START_YYYY_MM = "2025-12" + COHORT_TOTAL_BUDGET_USD = "3000" + COHORT_LENGTH_MONTHS = "7" + BUDGET_OVER_DAYS = "2" + + # Slash commands (non-secret) + SLACK_DESTROY_CONFIRM_TOKEN = "CONFIRM" + + # Optional allowlists (comma-separated) + SLACK_ALLOWED_USER_IDS = "" + SLACK_ALLOWED_CHANNEL_IDS = "" +} + + diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..f16ec51 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,56 @@ +/* +infra/variables.tf + +Input variables for the Terraform module. +Keep these minimal; most per-environment config is passed via lambda_env. +*/ + +variable "aws_region" { + type = string + description = "AWS region to deploy the Lambda into." + default = "us-east-1" +} + +variable "aws_profile" { + type = string + description = "Optional AWS CLI profile name for Terraform." + default = null +} + +variable "name_prefix" { + type = string + description = "Prefix for IAM resources." + default = "bloodhound-v2" +} + +variable "lambda_function_name" { + type = string + description = "Name of the Bloodhound v2 Lambda function." + default = "BloodhoundLambdaV2" +} + +variable "lambda_runtime" { + type = string + description = "Lambda runtime." + default = "python3.10" +} + +variable "lambda_timeout_seconds" { + type = number + description = "Lambda timeout in seconds." + default = 120 +} + +variable "lambda_memory_mb" { + type = number + description = "Lambda memory size." + default = 256 +} + +variable "lambda_env" { + type = map(string) + description = "Lambda environment variables (copy from your .env, minus secrets you don't want in TF state)." + default = {} +} + + diff --git a/lambda_function.py b/lambda_function.py deleted file mode 100644 index e3c4129..0000000 --- a/lambda_function.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -AWS Lambda entrypoint for Bloodhound v2. - -Handler: lambda_function.lambda_handler -""" - -from __future__ import annotations - -from bloodhound.app import run - - -def lambda_handler(event, context): - # Keep the handler extremely thin so the app remains testable locally. - return run(event=event, context=context) - - diff --git a/test_event.json b/test_event.json deleted file mode 100644 index 9e26dfe..0000000 --- a/test_event.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/run_local.py b/tools/run_local.py similarity index 56% rename from run_local.py rename to tools/run_local.py index ee49ffa..5c2f415 100644 --- a/run_local.py +++ b/tools/run_local.py @@ -1,9 +1,8 @@ """ -Local runner for Bloodhound v2. +tools/run_local.py -Usage: - cd Bloodhound - python run_local.py +Local runner for Bloodhound v2 (uses `.env` + your AWS_PROFILE). +This is the fastest way to validate config + Slack output before deploying to Lambda. """ from __future__ import annotations diff --git a/tools/test_event.json b/tools/test_event.json new file mode 100644 index 0000000..5cf32ec --- /dev/null +++ b/tools/test_event.json @@ -0,0 +1,3 @@ +{} + + diff --git a/tools/trust-policy.json b/tools/trust-policy.json new file mode 100644 index 0000000..27875d5 --- /dev/null +++ b/tools/trust-policy.json @@ -0,0 +1,14 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} + + diff --git a/trust-policy.json b/trust-policy.json deleted file mode 100644 index 942abb3..0000000 --- a/trust-policy.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com" - }, - "Action": "sts:AssumeRole" - } - ] -} - From 05babb7b6fa43af19ebecb3b5a76b81f4bdd7c4a Mon Sep 17 00:00:00 2001 From: tsmith4014 Date: Sun, 21 Dec 2025 13:47:18 -0600 Subject: [PATCH 3/7] updated main readme post root reorg --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ae49c28..4300224 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This README is intentionally focused on the workflow you asked for: If you need to create a Slack bot from scratch, see `docs/SLACK_SETUP.md`. Project docs: + - v2 plan: `docs/V2_PLAN.md` ![AWS Architecture Diagram](assets/bloodhound_lambda_architecture.png) From 53446beb84c9f1993c0b7584411e89f7f552e98a Mon Sep 17 00:00:00 2001 From: tsmith4014 Date: Sat, 17 Jan 2026 16:38:31 -0600 Subject: [PATCH 4/7] updated diagram, added .sh resource script for building resources for the demo, feel free to use this just update with your aws profile, it builds 8 ec2, 2 rds and half get whitelisted, cleaned up readme --- README.md | 7 +- assets/bloodhound_lambda_architecture_v2.svg | 211 +++++++++++++++++++ docs/demo_build.sh | 175 +++++++++++++++ 3 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 assets/bloodhound_lambda_architecture_v2.svg create mode 100644 docs/demo_build.sh diff --git a/README.md b/README.md index 4300224..9b91e74 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,9 @@ Bloodhound v2 scans selected AWS regions for common cost-leak resources, posts results to Slack, and can optionally delete resources that are **not** whitelisted. -This README is intentionally focused on the workflow you asked for: - - Clone this repo - Configure `.env` for local testing - Rebuild the deployment zip locally (the `.build/` dir is not committed) -- Deploy to a **new** AWS Lambda (do not overwrite v1) - Configure Lambda env vars to match your `.env` If you need to create a Slack bot from scratch, see `docs/SLACK_SETUP.md`. @@ -16,14 +13,14 @@ Project docs: - v2 plan: `docs/V2_PLAN.md` -![AWS Architecture Diagram](assets/bloodhound_lambda_architecture.png) +![AWS Architecture Diagram (v2)](assets/bloodhound_lambda_architecture_v2.svg) --- ## Requirements - Python 3.10+ for local dev (or match your Lambda runtime) -- AWS CLI configured (use `AWS_PROFILE=...` as needed) +- AWS CLI configured (use `AWS_PROFILE=...` as needed). To set it up the first time: `aws configure --profile ` (or `aws configure` for the default profile). To see what profiles you have: `aws configure list-profiles`; your local config/creds live in `~/.aws/config` and `~/.aws/credentials` (view with `cat ~/.aws/config` and `cat ~/.aws/credentials`). - Slack bot token and channel IDs --- diff --git a/assets/bloodhound_lambda_architecture_v2.svg b/assets/bloodhound_lambda_architecture_v2.svg new file mode 100644 index 0000000..515c5d7 --- /dev/null +++ b/assets/bloodhound_lambda_architecture_v2.svg @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + Bloodhound v2 — Architecture and Logic Flow + AWS resource scan + whitelist filter + budget projection + Slack alerts + optional teardown (with safety rails) + + + + Triggers + + + Scheduled (GitHub Actions or scheduler) + Invokes Lambda for scan + budget report + Recommended for regular visibility + + + On-demand (Slack slash commands) + Slack → Lambda Function URL (HTTPS) + Signature verification + replay protection + Responds quickly; runs worker async + + + + AWS Account (single account) — BloodhoundLambdaV2 + Runtime: python3.10 • Handler: handlers.lambda_function.lambda_handler • Deployed via Terraform + + + + HTTP Entry (Function URL) + + bloodhound.slack_commands: verify + route (/seek, /seek_destroy) + + Immediately returns 200 to Slack + Worker invoked async for full run + + + Scheduled / Direct Invoke + + aws lambda invoke (CLI) or scheduler + + Runs full scan + budget + teardown plan + Optionally executes teardown (apply mode) + + + + Core Orchestrator + + bloodhound.app: load config → scan_all → whitelist → budget → messages → Slack posts + + Config + safety rails + APPLY_CHANGES (default false) + TEARDOWN_SIMULATE, TEARDOWN_TARGET_IDS + TEARDOWN_ALLOW_ALL (dangerous) + + Slack destinations + Scan channel: SLACK_SCAN_CHANNEL_ID + Alert channel: SLACK_ALERT_CHANNEL_ID + Posting via chat.postMessage + + + + Multi-region Scanning + bloodhound.scanner.scan_all + per-service scanners + Regions: explicit list or discovery mode + Resources scanned (examples): + - EC2 instances, EBS volumes, Elastic IPs + - RDS instances + - ELBv2 load balancers + + + + Budget Projection + bloodhound.budget → AWS Cost Explorer + Cohort config: start YYYY-MM, total USD, length months + Dynamic monthly allowance using cohort-to-date spend + Over-budget threshold based on projected days over + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Whitelist Filter + bloodhound.whitelist: KEEP_TAG_KEY / KEEP_TAG_VALUE + Kept resources are excluded from teardown + + Teardown (optional) + bloodhound.teardown: planner + executor + Dry-run by default; apply requires explicit flags + + + + Slack Workspace + + Channels + Scan summaries + whitelisted list + Budget summaries + overage alerts + Teardown plan + results (when enabled) + + + + invoke + + + HTTPS request + + + + /seek, /seek_destroy + + + + async invoke worker + + + + direct run + + + + scan candidates + + + + + + + + fetch costs + + + + chat.postMessage + + + + Safety rails for destructive actions + Default behavior posts a teardown plan only (APPLY_CHANGES=false). + Apply-mode supports simulate, explicit targets, or allow-all (dangerous). + Slash destroy requires a confirm token and optional allowlists. + diff --git a/docs/demo_build.sh b/docs/demo_build.sh new file mode 100644 index 0000000..a135702 --- /dev/null +++ b/docs/demo_build.sh @@ -0,0 +1,175 @@ +env AWS_PROFILE=changeme bash -lc ' +set -euo pipefail + +KEEP_TAG_KEY="bloodhound:keep" +KEEP_TAG_VALUE="true" + +REGIONS=(us-east-1 us-east-2 us-west-1 us-west-2) +TS="$(date +%Y%m%d%H%M%S)" + +say() { echo "[$(date +%H:%M:%S)] $*"; } + +get_default_vpc_id() { + local region="$1" + aws --region "$region" ec2 describe-vpcs \ + --filters Name=isDefault,Values=true \ + --query "Vpcs[0].VpcId" --output text +} + +get_default_sg_id() { + local region="$1" vpc_id="$2" + aws --region "$region" ec2 describe-security-groups \ + --filters Name=vpc-id,Values="$vpc_id" Name=group-name,Values=default \ + --query "SecurityGroups[0].GroupId" --output text +} + +get_default_subnets() { + local region="$1" vpc_id="$2" + aws --region "$region" ec2 describe-subnets \ + --filters Name=vpc-id,Values="$vpc_id" Name=default-for-az,Values=true \ + --query "Subnets[].SubnetId" --output text +} + +get_al2023_ami() { + local region="$1" + aws --region "$region" ssm get-parameter \ + --name "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64" \ + --query "Parameter.Value" --output text +} + +create_ec2() { + local region="$1" subnet_id="$2" sg_id="$3" ami_id="$4" keep="$5" name="$6" + local tag_spec + + if [[ "$keep" == "true" ]]; then + tag_spec="ResourceType=instance,Tags=[{Key=Name,Value=${name}},{Key=${KEEP_TAG_KEY},Value=${KEEP_TAG_VALUE}},{Key=bloodhound:demo,Value=true},{Key=bloodhound:created_by,Value=cursor}]" + else + tag_spec="ResourceType=instance,Tags=[{Key=Name,Value=${name}},{Key=bloodhound:demo,Value=true},{Key=bloodhound:created_by,Value=cursor}]" + fi + + local vol_tag_spec="ResourceType=volume,Tags=[{Key=Name,Value=${name}-root},{Key=bloodhound:demo,Value=true},{Key=bloodhound:created_by,Value=cursor}]" + + aws --region "$region" ec2 run-instances \ + --image-id "$ami_id" \ + --instance-type "t3.micro" \ + --subnet-id "$subnet_id" \ + --security-group-ids "$sg_id" \ + --count 1 \ + --tag-specifications "$tag_spec" "$vol_tag_spec" \ + --query "Instances[0].InstanceId" --output text +} + +ensure_rds_subnet_group() { + local region="$1" name="$2" subnet_ids_csv="$3" + + if aws --region "$region" rds describe-db-subnet-groups --db-subnet-group-name "$name" >/dev/null 2>&1; then + return 0 + fi + + # shellcheck disable=SC2206 + local subnet_ids=(${subnet_ids_csv}) + if [[ ${#subnet_ids[@]} -lt 2 ]]; then + echo "ERROR: Need at least 2 default subnets for RDS subnet group in region=${region}, got: ${subnet_ids_csv}" >&2 + return 1 + fi + + aws --region "$region" rds create-db-subnet-group \ + --db-subnet-group-name "$name" \ + --db-subnet-group-description "Bloodhound demo subnet group (${region})" \ + --subnet-ids "${subnet_ids[0]}" "${subnet_ids[1]}" \ + --tags Key=bloodhound:demo,Value=true Key=bloodhound:created_by,Value=cursor >/dev/null +} + +create_rds() { + local region="$1" subnet_group="$2" sg_id="$3" keep="$4" identifier="$5" + + local username="bloodhound" + local password + password="$(openssl rand -base64 24 | tr -d '\''/@" '\'' | cut -c1-20)" + + local tags=(Key=bloodhound:demo,Value=true Key=bloodhound:created_by,Value=cursor) + if [[ "$keep" == "true" ]]; then + tags+=(Key="${KEEP_TAG_KEY}",Value="${KEEP_TAG_VALUE}") + tags+=(Key=Name,Value="${identifier}") + else + tags+=(Key=Name,Value="${identifier}") + fi + + aws --region "$region" rds create-db-instance \ + --db-instance-identifier "$identifier" \ + --engine postgres \ + --db-instance-class db.t3.micro \ + --allocated-storage 20 \ + --master-username "$username" \ + --master-user-password "$password" \ + --no-publicly-accessible \ + --backup-retention-period 1 \ + --storage-type gp2 \ + --db-subnet-group-name "$subnet_group" \ + --vpc-security-group-ids "$sg_id" \ + --tags "${tags[@]}" \ + --query "DBInstance.DBInstanceIdentifier" --output text +} + +say "Confirming caller identity (profile=geekstar)" +aws sts get-caller-identity --output json + +say "Creating EC2 instances (2 per region: 1 whitelisted, 1 not)" +for region in "${REGIONS[@]}"; do + say "Region: ${region}" + + vpc_id="$(get_default_vpc_id "$region")" + if [[ -z "$vpc_id" || "$vpc_id" == "None" ]]; then + echo "ERROR: No default VPC found in region=${region}" >&2 + exit 1 + fi + + sg_id="$(get_default_sg_id "$region" "$vpc_id")" + subnets="$(get_default_subnets "$region" "$vpc_id")" + # pick the first subnet for EC2 placement + subnet_id="$(awk "{print \$1}" <<<"$subnets")" + + ami_id="$(get_al2023_ami "$region")" + + keep_name="bloodhound-demo-ec2-keep-${region}-${TS}" + nokeep_name="bloodhound-demo-ec2-nokeep-${region}-${TS}" + + keep_id="$(create_ec2 "$region" "$subnet_id" "$sg_id" "$ami_id" true "$keep_name")" + say " EC2 (kept) ${keep_id} name=${keep_name}" + + nokeep_id="$(create_ec2 "$region" "$subnet_id" "$sg_id" "$ami_id" false "$nokeep_name")" + say " EC2 (not kept) ${nokeep_id} name=${nokeep_name}" +done + +say "Creating 2 RDS instances (one kept, one not)" +RDS_KEEP_REGION="us-east-1" +RDS_NOKEEP_REGION="us-west-2" + +for region in "$RDS_KEEP_REGION" "$RDS_NOKEEP_REGION"; do + say "RDS Region: ${region}" + vpc_id="$(get_default_vpc_id "$region")" + sg_id="$(get_default_sg_id "$region" "$vpc_id")" + subnets="$(get_default_subnets "$region" "$vpc_id")" + + subnet_group="bloodhound-demo-subnetgrp-${region}" + ensure_rds_subnet_group "$region" "$subnet_group" "$subnets" + +done + +keep_rds_id="bloodhound-demo-rds-keep-${TS}" +nokeep_rds_id="bloodhound-demo-rds-nokeep-${TS}" + +vpc_id="$(get_default_vpc_id "$RDS_KEEP_REGION")" +sg_id="$(get_default_sg_id "$RDS_KEEP_REGION" "$vpc_id")" +subnet_group="bloodhound-demo-subnetgrp-${RDS_KEEP_REGION}" +created_keep_rds="$(create_rds "$RDS_KEEP_REGION" "$subnet_group" "$sg_id" true "$keep_rds_id")" +say " RDS (kept) ${created_keep_rds} region=${RDS_KEEP_REGION}" + +vpc_id="$(get_default_vpc_id "$RDS_NOKEEP_REGION")" +sg_id="$(get_default_sg_id "$RDS_NOKEEP_REGION" "$vpc_id")" +subnet_group="bloodhound-demo-subnetgrp-${RDS_NOKEEP_REGION}" +created_nokeep_rds="$(create_rds "$RDS_NOKEEP_REGION" "$subnet_group" "$sg_id" false "$nokeep_rds_id")" +say " RDS (not kept) ${created_nokeep_rds} region=${RDS_NOKEEP_REGION}" + +say "Done. (RDS will take several minutes to become available.)" +' \ No newline at end of file From df713fdc2c311340f0421d60dd7ed06aa7a5eb4c Mon Sep 17 00:00:00 2001 From: tsmith4014 Date: Sat, 17 Jan 2026 17:00:22 -0600 Subject: [PATCH 5/7] plan update --- docs/V2_PLAN.md | 255 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 211 insertions(+), 44 deletions(-) diff --git a/docs/V2_PLAN.md b/docs/V2_PLAN.md index 7fe49a3..3ad5227 100644 --- a/docs/V2_PLAN.md +++ b/docs/V2_PLAN.md @@ -1,88 +1,255 @@ -### Bloodhound v2 Plan (tracked implementation checklist) +## Bloodhound v2 Plan (tracked implementation checklist) -This document is the shared plan for evolving Bloodhound from v1.0 to v2.x while keeping the project simple, readable, and student-friendly. +This document is the shared plan for evolving Bloodhound from v1.0 → v2.x while keeping the project simple and readable. +It’s intentionally pragmatic: what’s **done** is marked with strikethrough, and what’s **next** stays short and actionable. + +Repo: `https://github.com/codeplatoon-devops/Bloodhound.git` --- -### 0) Current v1.0 baseline (what exists today) +## 0) Current reality (what v2 does today) -- **What it does** +Bloodhound v2 is already deployed as a **new** Lambda (`BloodhoundLambdaV2`) so it does not touch v1. - - Scans a fixed list of AWS regions. - - Collects **EC2** instance IDs (skipping stopped/terminated). - - Collects **RDS** instance identifiers. - - Formats one Slack message and posts it to a channel. +### Invocation paths -- **How it is invoked** +- **Scheduled**: GitHub Actions can invoke `BloodhoundLambdaV2` on a cadence. +- **On-demand**: Slack slash commands (`/seek`, `/seek_destroy`) hit a **Lambda Function URL**. - - A GitHub Actions scheduled workflow calls: - - `aws lambda invoke --function-name BloodhoundLambda ...` +### What it scans (per region) -- **Important constraints carried into v2** - - Keep **GitHub Actions** as the scheduler (do not migrate to EventBridge). - - Keep **Slack** as the only notification surface (no email). - - Single AWS account only (no Organizations / cross-account roles for now). - - Teardown should support **terminate/delete** (default). “Stop-only” is a later enhancement. +- **EC2 instances** (skips stopped/terminated) +- **EBS volumes** (unattached/`available`) +- **Elastic IPs** (unassociated) +- **NAT Gateways** (active states) +- **RDS DB instances** +- **ELBv2 load balancers** ---- +### What it produces (Slack) -### 1) v2 guiding principles (to keep it minimal) +- **Scan summary** (includes zero counts) +- **Whitelisted resources list** (separate message) +- **Budget summary** (7-month cohort, dynamic monthly allowance) +- **Teardown plan** (always produced) +- **Teardown results** (only when apply-mode executes) -- **Configuration over code edits** +### Teardown safety rails (v2) - - Anything that will vary cohort-to-cohort (start month, channels, regions) must be env-configurable. +- Default is **dry-run** (`APPLY_CHANGES=false`) +- Safe testing of apply-mode: **simulate** (`TEARDOWN_SIMULATE=true`) +- Strong safety rails: + - Explicit allowlist: `TEARDOWN_TARGET_IDS=...` + - Allow-all mode: `TEARDOWN_ALLOW_ALL=true` (dangerous; relies on whitelisting) +- Slash destroy requires: + - Confirm token: `/seek_destroy CONFIRM` (configurable token) + - Optional allowlists: user/channel IDs -- **Safe rollout for destructive actions** +--- - - Default is **dry-run** (report proposed deletions). - - Apply-mode requires an explicit flag. +## 1) v2 guiding principles (keep it minimal) +- **Configuration over code edits** + - Anything that varies cohort-to-cohort (start month, channels, regions) must be env-configurable. +- **Safe rollout for destructive actions** + - Default is dry-run. + - Apply-mode is explicit and layered with safety rails. - **Simple whitelist** - - Primary mechanism is a single tag: `bloodhound:keep=true`. - - Optional escape hatch: env var allowlist for IDs/ARNs. - + - Optional escape hatch: env allowlist for IDs/ARNs (keep list) and explicit teardown targets. - **No unnecessary infrastructure** - - Prefer “compute from AWS APIs each run” when it’s easy (e.g., budget streak calculation). - - Add a database only if it materially simplifies or is required. + - Compute from AWS APIs each run when possible. + - Avoid DB/state unless it materially simplifies the system. --- -### 2) v2 configuration (environment variables) +## 2) v2 configuration (environment variables) + +This is the canonical list; `env.example` should be treated as the “source of truth” for local runs. -#### Slack +### Slack -- **`SLACK_BOT_TOKEN`**: Slack bot token used to post messages. -- **`SLACK_SCAN_CHANNEL_ID`**: Channel for “scan summary / what exists”. -- **`SLACK_ALERT_CHANNEL_ID`**: Channel for “budget alerts + teardown outcomes”. - - If unset, default to `SLACK_SCAN_CHANNEL_ID`. +- **`SLACK_ENABLED`**: `true|false` +- **`SLACK_BOT_TOKEN`**: bot token used to post messages +- **`SLACK_SCAN_CHANNEL_ID`**: channel for scan summaries + whitelisted list +- **`SLACK_ALERT_CHANNEL_ID`**: channel for budget alerts + teardown plan/results + - If unset, defaults to `SLACK_SCAN_CHANNEL_ID` -#### Regions +### Slack slash commands (Function URL) + +- **`SLACK_SIGNING_SECRET`**: required for Slack signature verification +- **`SLACK_DESTROY_CONFIRM_TOKEN`**: required argument text for `/seek_destroy` (default `CONFIRM`) +- **`SLACK_ALLOWED_USER_IDS`** (optional): comma-separated list of Slack user IDs allowed to run destroy +- **`SLACK_ALLOWED_CHANNEL_IDS`** (optional): comma-separated list of Slack channel IDs allowed to run destroy + +### Regions - **`REGION_MODE`**: `explicit` or `discover` - `explicit`: use `REGIONS` - - `discover`: discover regions dynamically (then optionally filter) + - `discover`: discover regions dynamically - **`REGIONS`**: comma-separated region list (only used in `explicit` mode) -#### Whitelist (minimal) +### Whitelist - **`KEEP_TAG_KEY`**: default `bloodhound:keep` - **`KEEP_TAG_VALUE`**: default `true` -- **`KEEP_RESOURCE_IDS`** (optional): comma-separated resource IDs/ARNs always excluded from teardown +- **`KEEP_RESOURCE_IDS`** (optional): comma-separated IDs/ARNs always treated as kept -#### Teardown controls +### Teardown controls - **`APPLY_CHANGES`**: `true|false` (default `false`) -- **`TEARDOWN_SIMULATE`**: `true|false` (default `true` for safe testing) -- **`TEARDOWN_TARGET_IDS`**: optional safety rail, comma-separated IDs/ARNs -- **`TEARDOWN_ALLOW_ALL`**: if true, delete all non-whitelisted candidates +- **`TEARDOWN_SIMULATE`**: `true|false` (default `false` in config; we often use `true` for safe testing) +- **`TEARDOWN_TARGET_IDS`** (optional): comma-separated IDs/ARNs that are the only allowed targets +- **`TEARDOWN_ALLOW_ALL`**: `true|false` (dangerous; delete everything not whitelisted) - **`RDS_FINAL_SNAPSHOT`**: `true|false` (default `false`) -#### Budget (dynamic cohort) +### Budget (dynamic cohort) -- **`COHORT_START_YYYY_MM`**: e.g. `2025-12` (set per cohort) +- **`COHORT_START_YYYY_MM`**: e.g. `2026-01` - **`COHORT_TOTAL_BUDGET_USD`**: default `3000` - **`COHORT_LENGTH_MONTHS`**: default `7` - **`BUDGET_OVER_DAYS`**: default `2` +--- + +## 3) Target architecture (what’s implemented) + +### Code structure (current) + +- `bloodhound/` + - `app.py` (orchestrator) + - `config.py` (env parsing + defaults + validation) + - `scanner/` (`regions.py`, `scan_all.py`, `ec2.py`, `rds.py`, `elbv2.py`) + - `whitelist.py` + - `budget.py` + - `teardown/` (`planner.py`, `executor.py`) + - `messages.py` (Slack message formatting) + - `slack.py` (Slack API) + - `slack_commands.py` (Function URL entrypoint: verify + route) + - `types.py` (resource record schema) +- `handlers/lambda_function.py` (Lambda handler) +- `tools/run_local.py` (local runner) +- `infra/` (Terraform: build zip, IAM, Lambda, Function URL) + +### Logic flow (every run) + +- **Scan** + - Determine regions + - Collect resources per region/service + - Apply whitelist + - Post scan summary + whitelisted list +- **Budget** + - Use Cost Explorer to compute cohort-to-date spend + dynamic monthly allowance + - Compute run-rate month-end projection and “over budget streak” + - Post budget summary; post alert only when threshold is met +- **Teardown** + - Build a plan (always) + - If apply-mode: execute (or simulate) and post results + +--- + +## 4) Resource record schema (current) + +All scanners produce records with a shared shape (`bloodhound/types.py`): + +- **Required** + - `service`, `resource_type`, `region`, `id`, `state`, `tags` +- **Optional** + - `delete_supported`, `delete_action`, `delete_params` + +--- + +## 5) Resources to scan (status) + +Phase 1 (high value, low complexity) + +- ~~EC2 instances~~ +- ~~RDS instances~~ +- ~~Elastic IPs (unassociated)~~ +- ~~NAT gateways~~ +- ~~EBS volumes (unattached)~~ +- ~~ELBv2 load balancers~~ + +Phase 2 (later; only if needed) + +- EBS snapshots +- AMIs +- ElastiCache +- OpenSearch +- Redshift + +--- + +## 6) Teardown delete policy (status) + +Defaults (as implemented) + +- ~~EC2: terminate~~ +- ~~EBS: delete unattached volumes~~ +- ~~EIP: release if unassociated~~ +- ~~NAT gateway: delete~~ +- ~~Load balancer: delete~~ +- ~~RDS: delete without final snapshot by default (`RDS_FINAL_SNAPSHOT=false`)~~ + +--- + +## 7) Milestones (tracked checklist) + +### v2.0 (refactor + config + deploy) + +- ~~Archive v1.0 as runnable snapshot under `versions/v1_0/`~~ +- ~~Modularize into package structure (`bloodhound/`, `handlers/`, `tools/`, `docs/`)~~ +- ~~Env-driven Slack channels (scan vs alert)~~ +- ~~Env-driven region configuration (explicit + discover mode)~~ +- ~~Terraform deploy of a new Lambda (`BloodhoundLambdaV2`)~~ +- ~~Slack message formatting improvements (human-readable, consistent, includes zeros)~~ + +### v2.1 (resource coverage) + +- ~~EC2 instances~~ +- ~~EBS unattached volumes~~ +- ~~EIPs unassociated~~ +- ~~NAT gateways~~ +- ~~RDS instances~~ +- ~~ELBv2~~ + +### v2.2 (whitelist) + +- ~~Tag-based whitelist: `bloodhound:keep=true`~~ +- ~~Optional keep list: `KEEP_RESOURCE_IDS`~~ +- ~~Whitelisted resources posted as a separate list~~ + +### v2.3 (teardown dry-run) + +- ~~Planner produces proposed actions~~ +- ~~Plan posted to Slack~~ +- ~~Full plan logged as JSON~~ + +### v2.4 (teardown apply-mode + safety rails) + +- ~~Apply-mode gated by `APPLY_CHANGES=true`~~ +- ~~Simulate mode available (`TEARDOWN_SIMULATE=true`)~~ +- ~~Target filter safety rail (`TEARDOWN_TARGET_IDS`)~~ +- ~~Allow-all mode (`TEARDOWN_ALLOW_ALL=true`)~~ +- ~~Executor posts results to Slack~~ + +### v2.5 (budget + alerts) + +- ~~Cost Explorer cohort-to-date + dynamic monthly allowance~~ +- ~~Month-end run-rate projection~~ +- ~~Alert on `BUDGET_OVER_DAYS` consecutive days over allowance~~ + +### v2.6 (slash commands) + +- ~~Function URL entrypoint~~ +- ~~Signature verification + replay protection~~ +- ~~`/seek` (non-destructive)~~ +- ~~`/seek_destroy CONFIRM` (destructive, guarded)~~ +- ~~Optional allowlists for destroy (user/channel IDs)~~ + +--- + +## 8) Open questions / next improvements (optional) +- Separate scan vs teardown IAM roles (read-only vs write) +- Add more resource types if needed (snapshots/AMIs/etc.) +- Optional: separate scheduled scan runs from destructive runs via dedicated workflow/job \ No newline at end of file From 6a1030ec21abbcc8926965ec9fb0dc7376d994ae Mon Sep 17 00:00:00 2001 From: jbautista Date: Fri, 10 Jul 2026 14:32:16 -0500 Subject: [PATCH 6/7] 071026 --- .gitignore | 3 + README.md | 15 +- bloodhound/app.py | 279 ++++++++++++------ bloodhound/attribution.py | 103 +++++++ bloodhound/config.py | 33 ++- bloodhound/controls.py | 329 +++++++++++++++++++++ bloodhound/costs.py | 240 +++++++++++++++ bloodhound/guard.py | 536 +++++++++++++++++++++++++++++++++ bloodhound/messages.py | 556 +++++++++++++++++++++++++++-------- bloodhound/report_format.py | 108 +++++++ bloodhound/scanner/ec2.py | 78 ++++- bloodhound/scanner/elbv2.py | 5 + bloodhound/scanner/rds.py | 7 + bloodhound/slack.py | 48 ++- bloodhound/slack_commands.py | 22 ++ bloodhound/tables.py | 23 ++ bloodhound/types.py | 7 +- bloodhound/whitelist.py | 69 +++-- docs/V2_PLAN.md | 16 + env | 75 +++++ env.example | 15 +- infra/iam.tf | 46 ++- output.txt | 1 + tools/run_local.py | 30 +- 24 files changed, 2378 insertions(+), 266 deletions(-) create mode 100644 bloodhound/attribution.py create mode 100644 bloodhound/controls.py create mode 100644 bloodhound/costs.py create mode 100644 bloodhound/guard.py create mode 100644 bloodhound/report_format.py create mode 100644 bloodhound/tables.py create mode 100644 env create mode 100644 output.txt diff --git a/.gitignore b/.gitignore index 251dadb..2f70aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ Thumbs.db # Logs *.log +# Local dry-run reports +reports/ + # Terraform **/.terraform/ **/terraform.tfstate diff --git a/README.md b/README.md index 9b91e74..46ac4b5 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,17 @@ AWS_PROFILE=geekstar .venv/bin/python tools/run_local.py ## Whitelisting -Resources tagged with: +Resources are **kept (whitelisted)** when they match any rule: -- key: `bloodhound:keep` -- value: `true` +| Rule type | Env var | Example | +|---|---|---| +| Tag | `KEEP_TAG_RULES` | `bloodhound:keep=true` | +| Name/tag regex | `KEEP_NAME_PATTERNS` | `buffalo,fullstack,vetlaunch,dont-touch` | +| Explicit ID/ARN | `KEEP_RESOURCE_IDS` | `arn:aws:rds:...:db:vetlaunch-dev-db` | -are treated as **kept (whitelisted)** and are excluded from teardown. +Legacy single-tag config still works via `KEEP_TAG_KEY` + `KEEP_TAG_VALUE`. + +Slack command `/seek_whitelist` posts active whitelist rules plus all currently protected resources. --- @@ -140,6 +145,8 @@ It invokes: Slash commands require a publicly reachable HTTPS endpoint. For v2 we recommend a **Lambda Function URL** (one endpoint) and route based on the Slack `command` field. - `/seek` runs scan + reports (non-destructive) +- `/seek_cost` posts AWS MTD cost breakdown + budget summary (non-destructive) +- `/seek_whitelist` posts whitelist rules + protected resources (non-destructive) - `/seek_destroy CONFIRM` runs destructive mode (deletes all non-whitelisted candidates we scan for) To enable slash commands you must set these env vars in Lambda: diff --git a/bloodhound/app.py b/bloodhound/app.py index 2f49dbb..d2f6ca8 100644 --- a/bloodhound/app.py +++ b/bloodhound/app.py @@ -2,17 +2,6 @@ bloodhound/app.py Orchestration entrypoint for Bloodhound v2. - -Primary responsibilities: -- Load env config (via `config.py`) -- Scan resources (via `scanner/*`) -- Split candidates vs whitelisted/kept (via `whitelist.py`) -- Post Slack reports (via `messages.py` + `slack.py`) -- Optionally execute teardown actions (via `teardown/*`) - -Used by: -- `lambda_function.lambda_handler` (AWS Lambda) -- `run_local.py` (local testing) """ from __future__ import annotations @@ -21,53 +10,78 @@ import os from typing import Any +from bloodhound.attribution import enrich_creators from bloodhound.aws import create_clients from bloodhound.budget import compute_budget_snapshot from bloodhound.config import load_config, validate_config +from bloodhound.controls import get_spending_controls_snapshot +from bloodhound.costs import enrich_records, get_mtd_cost_snapshot from bloodhound.messages import ( format_budget_message, + format_cost_breakdown_message, + format_resource_table_message, format_scan_message, + format_spending_controls_message, format_teardown_plan_message, format_teardown_result_message, - format_whitelisted_resources_message, + format_whitelist_config_message, ) from bloodhound.scanner.regions import discover_regions, select_regions from bloodhound.scanner.scan_all import scan_all from bloodhound.slack import SlackNotifier from bloodhound.teardown.executor import execute_actions from bloodhound.teardown.planner import plan_deletions +from bloodhound.report_format import LocalReportWriter, markdown_to_slack from bloodhound.whitelist import filter_whitelisted -from bloodhound.types import resource_key def run(event: Any, context: Any) -> dict[str, Any]: - """ - Main orchestration entrypoint for Lambda and local testing. - Returns a small JSON-serializable summary for `aws lambda invoke`. - """ - # Slack slash command worker mode: - # apply teardown overrides FIRST, then load config so the same invocation uses the intended flags. if isinstance(event, dict) and event.get("source") == "slack_command": mode = (event.get("mode") or "").strip() - # /seek = scan + reports only + # Guardrail management is its own flow — it does not scan/teardown. + if mode == "guard": + return _run_guard(event) + os.environ["BLOODHOUND_RUN_MODE"] = mode if mode == "seek": os.environ["APPLY_CHANGES"] = "false" os.environ["TEARDOWN_SIMULATE"] = "true" os.environ["TEARDOWN_ALLOW_ALL"] = "false" - # /seek_destroy = destructive mode (delete all non-whitelisted candidates) elif mode == "seek_destroy": os.environ["APPLY_CHANGES"] = "true" os.environ["TEARDOWN_SIMULATE"] = "false" os.environ["TEARDOWN_ALLOW_ALL"] = "true" + elif mode in {"whitelist", "seek_cost"}: + os.environ["APPLY_CHANGES"] = "false" + os.environ["TEARDOWN_SIMULATE"] = "true" + os.environ["TEARDOWN_ALLOW_ALL"] = "false" - # Load configuration from env/.env and validate required fields. cfg = load_config() errors = validate_config(cfg) if errors: - # Still return a structured error for Lambda invocations. return {"ok": False, "errors": errors} - # AWS session/clients (Lambda uses its execution role; local can use AWS_PROFILE). + run_mode = os.environ.get("BLOODHOUND_RUN_MODE", "seek") + whitelist_only = run_mode == "whitelist" + cost_only = run_mode == "seek_cost" + print_reports = _env_bool("BLOODHOUND_PRINT_REPORTS", False) + report_format = os.environ.get("BLOODHOUND_REPORT_FORMAT", "markdown").strip().lower() + report_dir = os.environ.get("BLOODHOUND_REPORT_DIR", "reports") + attribution_enabled = _env_bool("ATTRIBUTION_ENABLED", True) + + local_report = LocalReportWriter( + enabled=print_reports, + output_format=report_format, + report_dir=report_dir, + mode=run_mode, + ) + + def _done(result: dict[str, Any]) -> dict[str, Any]: + report_path = local_report.flush() + if report_path: + result["report_path"] = str(report_path) + print(f"\nReport written to: {report_path}") + return result + clients = create_clients(profile=cfg.aws.profile) slack = None if cfg.slack.enabled: @@ -77,28 +91,113 @@ def run(event: Any, context: Any) -> dict[str, Any]: alert_channel_id=cfg.slack.alert_channel_id, ) + def emit(title: str, body: str, *, channel: str = "scan") -> None: + if print_reports: + print(local_report.add(title, body)) + if not slack: + return + slack_body = markdown_to_slack(body) + if channel == "alert": + slack.post_alert(slack_body) + else: + slack.post_scan(slack_body) + discovered = None if cfg.regions.mode == "discover": discovered = discover_regions(clients) regions = select_regions(cfg.regions.mode, cfg.regions.regions, discovered_regions=discovered) - # 1) Scan raw_by_region = scan_all(clients, regions=regions, rds_final_snapshot=cfg.teardown.rds_final_snapshot) candidates_by_region: dict[str, list] = {} kept_by_region: dict[str, list] = {} + kept_reasons = {} for region, records in raw_by_region.items(): - # Whitelist is applied before teardown planning. - candidates, kept = filter_whitelisted(records, cfg.whitelist) + enriched = enrich_records(records) + if attribution_enabled: + enriched = enrich_creators(clients, enriched) + candidates, kept, reasons = filter_whitelisted(enriched, cfg.whitelist) candidates_by_region[region] = candidates kept_by_region[region] = kept + kept_reasons.update(reasons) + + all_candidates = [r for region in sorted(candidates_by_region) for r in candidates_by_region[region]] + all_kept = [r for region in sorted(kept_by_region) for r in kept_by_region[region]] + all_active = all_candidates + all_kept + mtd_snapshot = get_mtd_cost_snapshot(clients) - scan_msg = format_scan_message(candidates_by_region, kept_by_region) - if slack: - slack.post_scan(scan_msg) - slack.post_scan(format_whitelisted_resources_message(kept_by_region)) + emit("Whitelist config", format_whitelist_config_message(cfg.whitelist)) + emit( + "Cost breakdown", + format_cost_breakdown_message( + mtd_snapshot, + active_records=all_active, + candidate_records=all_candidates, + kept_records=all_kept, + ), + ) + controls_snapshot = get_spending_controls_snapshot(clients) + emit("Spending controls", format_spending_controls_message(controls_snapshot)) + + if cost_only: + budget_snapshot = compute_budget_snapshot( + clients, + cohort_start_yyyy_mm=cfg.budget.cohort_start_yyyy_mm, + cohort_total_budget_usd=cfg.budget.cohort_total_budget_usd, + cohort_length_months=cfg.budget.cohort_length_months, + budget_over_days=cfg.budget.budget_over_days, + ) + emit("Budget summary", format_budget_message(budget_snapshot), channel="alert") + return _done( + { + "ok": True, + "mode": "seek_cost", + "scan": { + "candidates_total": len(all_candidates), + "kept_total": sum(len(v) for v in kept_by_region.values()), + }, + "budget": { + "projected_month_end_spend_usd": budget_snapshot.projected_month_end_spend_usd, + "dynamic_monthly_allowance_usd": budget_snapshot.dynamic_monthly_allowance_usd, + }, + } + ) + + emit("Scan summary", format_scan_message(candidates_by_region, kept_by_region)) + + actions, _manual = plan_deletions(all_candidates) + emit( + "Teardown plan", + format_teardown_plan_message( + candidates_by_region, + apply_changes=cfg.teardown.apply_changes, + simulate=cfg.teardown.simulate, + targets_filter_count=len(cfg.teardown.target_ids), + allow_all=cfg.teardown.allow_all_targets, + ), + channel="alert", + ) + emit( + "Whitelisted resources", + format_resource_table_message( + "Bloodhound v2 — Whitelisted Resources (Kept)", + kept_by_region, + match_reasons=kept_reasons, + ), + ) + + if whitelist_only: + return _done( + { + "ok": True, + "mode": "whitelist", + "scan": { + "candidates_total": len(all_candidates), + "kept_total": sum(len(v) for v in kept_by_region.values()), + }, + } + ) - # 2) Budget budget_snapshot = compute_budget_snapshot( clients, cohort_start_yyyy_mm=cfg.budget.cohort_start_yyyy_mm, @@ -106,24 +205,7 @@ def run(event: Any, context: Any) -> dict[str, Any]: cohort_length_months=cfg.budget.cohort_length_months, budget_over_days=cfg.budget.budget_over_days, ) - budget_msg = format_budget_message(budget_snapshot) - # Always post budget summary to alert channel (keeps scan channel quieter). - if slack: - slack.post_alert(budget_msg) - - # 3) Teardown plan + optional apply - all_candidates = [r for region in sorted(candidates_by_region.keys()) for r in candidates_by_region[region]] - actions, _manual = plan_deletions(all_candidates) - if slack: - slack.post_alert( - format_teardown_plan_message( - actions, - apply_changes=cfg.teardown.apply_changes, - simulate=cfg.teardown.simulate, - targets_filter_count=len(cfg.teardown.target_ids), - allow_all=cfg.teardown.allow_all_targets, - ) - ) + emit("Budget summary", format_budget_message(budget_snapshot), channel="alert") exec_summary = None if cfg.teardown.apply_changes: @@ -133,7 +215,7 @@ def run(event: Any, context: Any) -> dict[str, Any]: actions_to_execute = [ a for a in actions - if (a.id in targets) or (a.arn and a.arn in targets) or (resource_key_from_action(a) in targets) + if (a.id in targets) or (a.arn and a.arn in targets) or (_action_key(a) in targets) ] exec_result = execute_actions(clients, actions_to_execute, simulate=cfg.teardown.simulate) @@ -148,44 +230,75 @@ def run(event: Any, context: Any) -> dict[str, Any]: "executed_actions_total": len(actions_to_execute), "allow_all_targets": cfg.teardown.allow_all_targets, } - if slack: - slack.post_alert( - format_teardown_result_message( - attempted=exec_result.attempted, - succeeded=exec_result.succeeded, - failed=exec_result.failed, - simulated=exec_result.simulated, - ) - + "\n" - + json.dumps(exec_summary, indent=2) + emit( + "Teardown results", + format_teardown_result_message( + attempted=exec_result.attempted, + succeeded=exec_result.succeeded, + failed=exec_result.failed, + simulated=exec_result.simulated, ) + + "\n" + + json.dumps(exec_summary, indent=2), + channel="alert", + ) - # Return a compact summary to Lambda invoke callers. - return { - "ok": True, - "regions": regions, - "scan": { - "candidates_total": sum(len(v) for v in candidates_by_region.values()), - "kept_total": sum(len(v) for v in kept_by_region.values()), - }, - "budget": { - "projected_month_end_spend_usd": budget_snapshot.projected_month_end_spend_usd, - "dynamic_monthly_allowance_usd": budget_snapshot.dynamic_monthly_allowance_usd, - "over_budget_threshold_met": budget_snapshot.over_budget_threshold_met, - }, - "teardown": { - "apply_changes": cfg.teardown.apply_changes, - "simulate": cfg.teardown.simulate, - "targets_filter": sorted(cfg.teardown.target_ids) if cfg.teardown.target_ids else None, - "planned_actions": len(actions), - "execution": exec_summary, - }, - } + return _done( + { + "ok": True, + "mode": run_mode, + "regions": regions, + "scan": { + "candidates_total": sum(len(v) for v in candidates_by_region.values()), + "kept_total": sum(len(v) for v in kept_by_region.values()), + }, + "budget": { + "projected_month_end_spend_usd": budget_snapshot.projected_month_end_spend_usd, + "dynamic_monthly_allowance_usd": budget_snapshot.dynamic_monthly_allowance_usd, + "over_budget_threshold_met": budget_snapshot.over_budget_threshold_met, + }, + "teardown": { + "apply_changes": cfg.teardown.apply_changes, + "simulate": cfg.teardown.simulate, + "targets_filter": sorted(cfg.teardown.target_ids) if cfg.teardown.target_ids else None, + "planned_actions": len(actions), + "execution": exec_summary, + }, + } + ) + + +def _run_guard(event: Any) -> dict[str, Any]: + """Handle a `/guard` slash command: parse, execute against AWS, post result to Slack.""" + from bloodhound.guard import GuardEngine, parse_command + + cfg = load_config() + clients = create_clients(profile=cfg.aws.profile) + + text = ((event.get("slack") or {}).get("text") or "").strip() + sub, args = parse_command(text) + result_md = GuardEngine(clients).run(sub, args) + + print(result_md) + if cfg.slack.enabled and cfg.slack.bot_token: + slack = SlackNotifier.from_token( + bot_token=cfg.slack.bot_token, + scan_channel_id=cfg.slack.scan_channel_id, + alert_channel_id=cfg.slack.alert_channel_id, + ) + slack.post_alert(markdown_to_slack(result_md)) + + return {"ok": True, "mode": "guard", "subcommand": sub} -def resource_key_from_action(a) -> str: +def _action_key(a) -> str: if a.arn: return a.arn return f"{a.service}:{a.region}:{a.id}" +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "t", "yes", "y", "on"} diff --git a/bloodhound/attribution.py b/bloodhound/attribution.py new file mode 100644 index 0000000..f4e41ae --- /dev/null +++ b/bloodhound/attribution.py @@ -0,0 +1,103 @@ +""" +bloodhound/attribution.py + +Best-effort creator attribution via CloudTrail lookup_events. +Requires CloudTrail enabled in the account/region (management events). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from bloodhound.aws import AwsClients +from bloodhound.types import ResourceRecord + +CREATE_EVENTS: dict[tuple[str, str], list[str]] = { + ("ec2", "instance"): ["RunInstances"], + ("ec2", "ebs_volume"): ["CreateVolume"], + ("ec2", "elastic_ip"): ["AllocateAddress"], + ("ec2", "nat_gateway"): ["CreateNatGateway"], + ("rds", "db_instance"): ["CreateDBInstance"], + ("elbv2", "load_balancer"): ["CreateLoadBalancer"], +} + + +def _identity_from_user_identity(ui: dict) -> str: + if ui.get("userName"): + return str(ui["userName"]) + if ui.get("arn"): + arn = str(ui["arn"]) + if "/" in arn: + return arn.rsplit("/", 1)[-1] + return arn + if ui.get("type") == "AssumedRole" and ui.get("sessionContext"): + session = ui["sessionContext"] or {} + issuer = (session.get("sessionIssuer") or {}).get("userName") + if issuer: + return f"role:{issuer}" + return "unknown" + + +def lookup_creator(clients: AwsClients, record: ResourceRecord, *, lookback_days: int = 90) -> str | None: + ct = clients.client("cloudtrail", region=record.region) + start = datetime.now(timezone.utc) - timedelta(days=lookback_days) + event_names = CREATE_EVENTS.get((record.service, record.resource_type), []) + + lookups: list[tuple[str, str]] = [("ResourceName", record.id)] + if record.arn: + lookups.append(("ResourceName", record.arn)) + + for attr_key, attr_value in lookups: + try: + kwargs: dict = { + "LookupAttributes": [{"AttributeKey": attr_key, "AttributeValue": attr_value}], + "StartTime": start, + "MaxResults": 10, + } + if event_names: + # CloudTrail only supports one lookup attribute per call; filter client-side. + resp = ct.lookup_events(**kwargs) + else: + resp = ct.lookup_events(**kwargs) + + events = resp.get("Events") or [] + if event_names: + events = [e for e in events if e.get("EventName") in event_names] or events + + if not events: + continue + + # Oldest matching create event ≈ creator. + oldest = sorted(events, key=lambda e: e.get("EventTime") or datetime.now(timezone.utc))[0] + import json + + detail = json.loads(oldest.get("CloudTrailEvent") or "{}") + return _identity_from_user_identity(detail.get("userIdentity") or {}) + except Exception: + continue + return None + + +def enrich_creator(clients: AwsClients, record: ResourceRecord) -> ResourceRecord: + creator = lookup_creator(clients, record) + if not creator: + return record + meta = dict(record.metadata) + meta["created_by"] = creator + return ResourceRecord( + service=record.service, + resource_type=record.resource_type, + region=record.region, + id=record.id, + arn=record.arn, + state=record.state, + tags=record.tags, + metadata=meta, + delete_supported=record.delete_supported, + delete_action=record.delete_action, + delete_params=record.delete_params, + ) + + +def enrich_creators(clients: AwsClients, records: list[ResourceRecord]) -> list[ResourceRecord]: + return [enrich_creator(clients, r) for r in records] diff --git a/bloodhound/config.py b/bloodhound/config.py index da712f4..0e190fa 100644 --- a/bloodhound/config.py +++ b/bloodhound/config.py @@ -57,6 +57,24 @@ def _split_csv(raw: Optional[str]) -> list[str]: return [x.strip() for x in raw.split(",") if x.strip()] +def _parse_tag_rules(raw: Optional[str], legacy_key: str, legacy_value: str) -> list[tuple[str, str]]: + rules: list[tuple[str, str]] = [] + for item in _split_csv(raw): + if "=" not in item: + continue + key, value = item.split("=", 1) + key, value = key.strip(), value.strip() + if key and value: + rules.append((key, value)) + if not rules: + rules.append((legacy_key, legacy_value)) + return rules + + +def _split_patterns(raw: Optional[str]) -> list[str]: + return [x.strip() for x in _split_csv(raw) if x.strip()] + + @dataclass(frozen=True) class SlackConfig: bot_token: str @@ -73,8 +91,8 @@ class RegionConfig: @dataclass(frozen=True) class WhitelistConfig: - keep_tag_key: str - keep_tag_value: str + keep_tag_rules: list[tuple[str, str]] + keep_name_patterns: list[str] keep_resource_ids: set[str] @@ -128,6 +146,13 @@ def load_config() -> AppConfig: keep_tag_key = _env("KEEP_TAG_KEY", "bloodhound:keep") or "bloodhound:keep" keep_tag_value = _env("KEEP_TAG_VALUE", "true") or "true" + keep_tag_rules = _parse_tag_rules(_env("KEEP_TAG_RULES"), keep_tag_key, keep_tag_value) + keep_name_patterns = _split_patterns( + _env( + "KEEP_NAME_PATTERNS", + "buffalo,fullstack,vetlaunch,dont-touch,do-not-touch,dont_touch", + ) + ) keep_resource_ids = set(_split_csv(_env("KEEP_RESOURCE_IDS"))) apply_changes = _env_bool("APPLY_CHANGES", False) @@ -153,8 +178,8 @@ def load_config() -> AppConfig: ), regions=RegionConfig(mode=region_mode, regions=regions), whitelist=WhitelistConfig( - keep_tag_key=keep_tag_key, - keep_tag_value=keep_tag_value, + keep_tag_rules=keep_tag_rules, + keep_name_patterns=keep_name_patterns, keep_resource_ids=keep_resource_ids, ), teardown=TeardownConfig( diff --git a/bloodhound/controls.py b/bloodhound/controls.py new file mode 100644 index 0000000..a0f33b3 --- /dev/null +++ b/bloodhound/controls.py @@ -0,0 +1,329 @@ +""" +bloodhound/controls.py + +Spending-controls / guardrails inventory. + +Answers: "are spending limits actually locked in place, and for which OUs, +accounts, Groups, and Users?" Pulls: + +- AWS Budgets (the spending caps) +- AWS Budgets Actions (the enforcement that *blocks* spend, and whether it is + AUTOMATIC = locked, or MANUAL = needs a human to approve) +- For SCP/IAM enforcement: what API actions are denied, and which OUs / accounts + / IAM Groups / Users / Roles the guardrail applies to + +Everything is best-effort: each AWS call is wrapped so a missing permission +degrades to a note instead of failing the whole run. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from bloodhound.aws import AwsClients + +# Budget action statuses that mean the guardrail has actually fired / is enforcing. +ACTIVE_ACTION_STATUSES = { + "PENDING", + "EXECUTION_IN_PROGRESS", + "EXECUTION_SUCCESS", +} + + +@dataclass(frozen=True) +class BudgetCap: + name: str + budget_type: str + limit_usd: float + actual_usd: float + forecast_usd: float | None + time_unit: str + + @property + def pct_used(self) -> float: + if self.limit_usd <= 0: + return 0.0 + return round(self.actual_usd / self.limit_usd * 100, 1) + + @property + def over(self) -> bool: + return self.limit_usd > 0 and self.actual_usd > self.limit_usd + + +@dataclass(frozen=True) +class EnforcementAction: + budget_name: str + action_id: str + action_type: str # APPLY_SCP_POLICY | APPLY_IAM_POLICY | RUN_SSM_DOCUMENTS + approval_model: str # AUTOMATIC | MANUAL + status: str + threshold_value: float + threshold_type: str # PERCENTAGE | ABSOLUTE_VALUE + policy_id: str | None = None + policy_name: str | None = None + policy_desc: str | None = None + denied_actions: list[str] = field(default_factory=list) + ec2_allowed_instance_types: list[str] = field(default_factory=list) + target_ids: list[str] = field(default_factory=list) + target_names: list[str] = field(default_factory=list) + iam_groups: list[str] = field(default_factory=list) + iam_users: list[str] = field(default_factory=list) + iam_roles: list[str] = field(default_factory=list) + + @property + def locked(self) -> bool: + """AUTOMATIC enforcement fires with no human approval — i.e. locked in place.""" + return self.approval_model.upper() == "AUTOMATIC" + + @property + def active(self) -> bool: + """The guardrail has tripped and is currently enforcing.""" + return self.status.upper() in ACTIVE_ACTION_STATUSES + + +@dataclass(frozen=True) +class SpendingControlsSnapshot: + account_id: str + caps: list[BudgetCap] + actions: list[EnforcementAction] + iam_group_names: list[str] + iam_user_count: int + notes: list[str] = field(default_factory=list) + + @property + def locked_actions(self) -> list[EnforcementAction]: + return [a for a in self.actions if a.locked] + + @property + def all_locked(self) -> bool: + return bool(self.actions) and all(a.locked for a in self.actions) + + +def get_spending_controls_snapshot(clients: AwsClients, account_id: str | None = None) -> SpendingControlsSnapshot: + notes: list[str] = [] + + if not account_id: + try: + account_id = clients.client("sts").get_caller_identity()["Account"] + except Exception as exc: # noqa: BLE001 + account_id = "unknown" + notes.append(f"Could not resolve account id: {exc}") + + # Budgets + budget actions live in the global (us-east-1) endpoint. + budgets = clients.client("budgets", region="us-east-1") + org = clients.client("organizations", region="us-east-1") + iam = clients.client("iam") + + caps = _fetch_caps(budgets, account_id, notes) + actions = _fetch_actions(budgets, org, account_id, notes) + iam_group_names, iam_user_count = _fetch_iam_principals(iam, notes) + + return SpendingControlsSnapshot( + account_id=account_id, + caps=caps, + actions=actions, + iam_group_names=iam_group_names, + iam_user_count=iam_user_count, + notes=notes, + ) + + +def _fetch_caps(budgets, account_id: str, notes: list[str]) -> list[BudgetCap]: + caps: list[BudgetCap] = [] + try: + paginator = budgets.get_paginator("describe_budgets") + pages = paginator.paginate(AccountId=account_id) + except Exception as exc: # noqa: BLE001 + notes.append(f"describe_budgets unavailable: {exc}") + return caps + + try: + for page in pages: + for b in page.get("Budgets", []) or []: + calc = b.get("CalculatedSpend") or {} + caps.append( + BudgetCap( + name=b.get("BudgetName") or "unnamed", + budget_type=b.get("BudgetType") or "unknown", + limit_usd=_amt((b.get("BudgetLimit") or {}).get("Amount")), + actual_usd=_amt((calc.get("ActualSpend") or {}).get("Amount")), + forecast_usd=_amt_opt((calc.get("ForecastedSpend") or {}).get("Amount")), + time_unit=b.get("TimeUnit") or "unknown", + ) + ) + except Exception as exc: # noqa: BLE001 + notes.append(f"describe_budgets error: {exc}") + caps.sort(key=lambda c: -c.limit_usd) + return caps + + +def _fetch_actions(budgets, org, account_id: str, notes: list[str]) -> list[EnforcementAction]: + raw_actions: list[dict] = [] + try: + paginator = budgets.get_paginator("describe_budget_actions_for_account") + for page in paginator.paginate(AccountId=account_id): + raw_actions.extend(page.get("Actions", []) or []) + except Exception as exc: # noqa: BLE001 + notes.append(f"describe_budget_actions_for_account unavailable: {exc}") + return [] + + actions: list[EnforcementAction] = [] + for a in raw_actions: + threshold = a.get("ActionThreshold") or {} + definition = a.get("Definition") or {} + action_type = a.get("ActionType") or "unknown" + + policy_id = policy_name = policy_desc = None + denied: list[str] = [] + ec2_allowed: list[str] = [] + target_ids: list[str] = [] + target_names: list[str] = [] + iam_groups: list[str] = [] + iam_users: list[str] = [] + iam_roles: list[str] = [] + + scp = definition.get("ScpActionDefinition") + iam_def = definition.get("IamActionDefinition") + ssm = definition.get("SsmActionDefinition") + + if scp: + policy_id = scp.get("PolicyId") + target_ids = list(scp.get("TargetIds") or []) + policy_name, policy_desc, denied, ec2_allowed = _describe_scp(org, policy_id, notes) + target_names = _resolve_targets(org, target_ids, notes) + elif iam_def: + policy_id = iam_def.get("PolicyArn") + policy_name = (policy_id or "").split("/")[-1] or policy_id + iam_groups = list(iam_def.get("Groups") or []) + iam_users = list(iam_def.get("Users") or []) + iam_roles = list(iam_def.get("Roles") or []) + elif ssm: + policy_name = f"SSM {ssm.get('ActionSubType') or ''}".strip() + target_ids = list(ssm.get("InstanceIds") or []) + + actions.append( + EnforcementAction( + budget_name=a.get("BudgetName") or "unknown", + action_id=a.get("ActionId") or "unknown", + action_type=action_type, + approval_model=a.get("ApprovalModel") or "unknown", + status=a.get("Status") or "unknown", + threshold_value=_amt(threshold.get("ActionThresholdValue")), + threshold_type=threshold.get("ActionThresholdType") or "unknown", + policy_id=policy_id, + policy_name=policy_name, + policy_desc=policy_desc, + denied_actions=denied, + ec2_allowed_instance_types=ec2_allowed, + target_ids=target_ids, + target_names=target_names, + iam_groups=iam_groups, + iam_users=iam_users, + iam_roles=iam_roles, + ) + ) + return actions + + +def _describe_scp(org, policy_id: str | None, notes: list[str]) -> tuple[str | None, str | None, list[str], list[str]]: + if not policy_id: + return None, None, [], [] + try: + resp = org.describe_policy(PolicyId=policy_id) + except Exception as exc: # noqa: BLE001 + notes.append(f"describe_policy({policy_id}) unavailable: {exc}") + return policy_id, None, [], [] + + policy = resp.get("Policy") or {} + summary = policy.get("PolicySummary") or {} + name = summary.get("Name") or policy_id + desc = summary.get("Description") + denied, ec2_allowed = _parse_scp_content(policy.get("Content")) + return name, desc, denied, ec2_allowed + + +def _parse_scp_content(content: str | None) -> tuple[list[str], list[str]]: + """Pull denied actions and any EC2 instance-type allowlist out of an SCP document.""" + if not content: + return [], [] + try: + doc = json.loads(content) + except (ValueError, TypeError): + return [], [] + + denied: list[str] = [] + ec2_allowed: list[str] = [] + statements = doc.get("Statement") + if isinstance(statements, dict): + statements = [statements] + for stmt in statements or []: + if (stmt.get("Effect") or "").lower() != "deny": + continue + actions = stmt.get("Action") + if isinstance(actions, str): + actions = [actions] + for act in actions or []: + if act not in denied: + denied.append(act) + cond = stmt.get("Condition") or {} + for op_vals in cond.values(): + if not isinstance(op_vals, dict): + continue + for key, vals in op_vals.items(): + if "instancetype" in key.lower(): + if isinstance(vals, str): + vals = [vals] + for v in vals: + if v not in ec2_allowed: + ec2_allowed.append(v) + return denied, ec2_allowed + + +def _resolve_targets(org, target_ids: list[str], notes: list[str]) -> list[str]: + names: list[str] = [] + for tid in target_ids: + label = tid + try: + if tid.startswith("ou-"): + resp = org.describe_organizational_unit(OrganizationalUnitId=tid) + label = (resp.get("OrganizationalUnit") or {}).get("Name") or tid + elif tid.isdigit(): + resp = org.describe_account(AccountId=tid) + label = (resp.get("Account") or {}).get("Name") or tid + except Exception: # noqa: BLE001 — best effort; fall back to raw id + label = tid + names.append(label) + return names + + +def _fetch_iam_principals(iam, notes: list[str]) -> tuple[list[str], int]: + groups: list[str] = [] + user_count = 0 + try: + for page in iam.get_paginator("list_groups").paginate(): + groups.extend(g.get("GroupName") for g in page.get("Groups", []) or [] if g.get("GroupName")) + except Exception as exc: # noqa: BLE001 + notes.append(f"list_groups unavailable: {exc}") + try: + for page in iam.get_paginator("list_users").paginate(): + user_count += len(page.get("Users", []) or []) + except Exception as exc: # noqa: BLE001 + notes.append(f"list_users unavailable: {exc}") + return groups, user_count + + +def _amt(raw) -> float: + try: + return float(raw) if raw is not None else 0.0 + except (ValueError, TypeError): + return 0.0 + + +def _amt_opt(raw) -> float | None: + if raw is None: + return None + try: + return float(raw) + except (ValueError, TypeError): + return None diff --git a/bloodhound/costs.py b/bloodhound/costs.py new file mode 100644 index 0000000..96a578d --- /dev/null +++ b/bloodhound/costs.py @@ -0,0 +1,240 @@ +""" +bloodhound/costs.py + +Cost estimation for scanned resources and MTD service breakdown from Cost Explorer. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone + +from bloodhound.aws import AwsClients +from bloodhound.types import ResourceRecord + + +@dataclass(frozen=True) +class MtdCostSnapshot: + """Actual account billing from Cost Explorer for the current calendar month.""" + + period_start: date + period_end_inclusive: date + by_service: dict[str, float] + + @property + def total_usd(self) -> float: + return round(sum(self.by_service.values()), 2) + + +# Rough on-demand us-east-1 monthly estimates (USD). Intentionally conservative. +EC2_HOURLY: dict[str, float] = { + "t2.micro": 0.0116, + "t2.small": 0.023, + "t3.micro": 0.0104, + "t3.small": 0.0208, + "t3.medium": 0.0416, + "t3.large": 0.0832, + "t3.xlarge": 0.1664, + "m5.large": 0.096, + "m5.xlarge": 0.192, +} + +RDS_HOURLY: dict[str, float] = { + "db.t3.micro": 0.018, + "db.t3.small": 0.036, + "db.t4g.micro": 0.016, + "db.t4g.small": 0.032, + "db.t3.medium": 0.072, + "db.m5.large": 0.171, +} + +# EBS $/GB-month by volume type (us-east-1 base). Provisioned IOPS/throughput excluded. +EBS_GB_RATE: dict[str, float] = { + "gp3": 0.08, + "gp2": 0.10, + "io1": 0.125, + "io2": 0.125, + "st1": 0.045, + "sc1": 0.015, + "standard": 0.05, +} + +# ELB base $/hour by type (us-east-1). LCU/data charges are excluded (no metrics here). +ELB_HOURLY: dict[str, float] = { + "application": 0.0225, + "network": 0.0225, + "gateway": 0.0125, +} + +# Per-region multiplier applied to us-east-1 base rates. Approximate — replace with the +# AWS Price List API for exact figures. US/standard regions ~1.0; others scaled up. +REGION_MULTIPLIER: dict[str, float] = { + "us-east-1": 1.00, + "us-east-2": 1.00, + "us-west-2": 1.00, + "us-west-1": 1.08, + "ca-central-1": 1.05, + "eu-west-1": 1.06, + "eu-west-2": 1.07, + "eu-central-1": 1.08, + "ap-south-1": 0.95, + "ap-southeast-1": 1.10, + "ap-southeast-2": 1.12, + "ap-northeast-1": 1.12, + "sa-east-1": 1.40, +} +DEFAULT_REGION_MULTIPLIER = 1.10 + +HOURS_PER_MONTH = 730 + +# Forward-looking rolling window used for projections. +WINDOW_DAYS = 30 +DAYS_PER_MONTH_AVG = 30.4375 # 365.25 / 12 + + +def region_multiplier(region: str | None) -> float: + return REGION_MULTIPLIER.get(region or "", DEFAULT_REGION_MULTIPLIER) + + +def _ebs_gb_rate(vol_type: str | None) -> float: + return EBS_GB_RATE.get((vol_type or "gp3").lower(), 0.10) + + +def get_mtd_cost_snapshot(clients: AwsClients, today: date | None = None) -> MtdCostSnapshot: + today = today or date.today() + month_start = date(today.year, today.month, 1) + ce = clients.client("ce", region="us-east-1") + resp = ce.get_cost_and_usage( + TimePeriod={ + "Start": month_start.strftime("%Y-%m-%d"), + "End": (today + timedelta(days=1)).strftime("%Y-%m-%d"), + }, + Granularity="MONTHLY", + Metrics=["UnblendedCost"], + GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}], + ) + out: dict[str, float] = {} + for period in resp.get("ResultsByTime", []) or []: + for group in period.get("Groups", []) or []: + service = (group.get("Keys") or ["Unknown"])[0] + amt = ((group.get("Metrics") or {}).get("UnblendedCost") or {}).get("Amount") + try: + out[service] = float(amt) if amt else 0.0 + except ValueError: + out[service] = 0.0 + return MtdCostSnapshot( + period_start=month_start, + period_end_inclusive=today, + by_service=dict(sorted(out.items(), key=lambda x: -x[1])), + ) + + +def get_mtd_cost_by_service(clients: AwsClients, today: date | None = None) -> dict[str, float]: + return get_mtd_cost_snapshot(clients, today=today).by_service + + +def estimate_monthly_cost_usd(record: ResourceRecord) -> float: + meta = record.metadata + svc = record.service + rtype = record.resource_type + + mult = region_multiplier(record.region) + + if svc == "ec2" and rtype == "instance": + itype = str(meta.get("instance_type") or "t3.micro") + hourly = EC2_HOURLY.get(itype, 0.05) + compute = hourly * HOURS_PER_MONTH * mult + # Include attached EBS — it is billed (and deleted) with the instance. + ebs = 0.0 + for vol in meta.get("attached_ebs") or []: + ebs += float(vol.get("size_gb") or 0) * _ebs_gb_rate(vol.get("volume_type")) * mult + return round(compute + ebs, 2) + + if svc == "ec2" and rtype == "ebs_volume": + size = float(meta.get("size_gb") or 8) + return round(size * _ebs_gb_rate(meta.get("volume_type")) * mult, 2) + + if svc == "ec2" and rtype == "elastic_ip": + return round(3.65 * mult, 2) + + if svc == "ec2" and rtype == "nat_gateway": + # Base hourly only ($0.045/hr); per-GB data processing excluded. + return round(0.045 * HOURS_PER_MONTH * mult, 2) + + if svc == "rds" and rtype == "db_instance": + cls = str(meta.get("instance_class") or "db.t3.micro") + storage = float(meta.get("allocated_storage_gb") or 20) + hourly = RDS_HOURLY.get(cls, 0.02) + az_factor = 2.0 if meta.get("multi_az") else 1.0 + compute = hourly * HOURS_PER_MONTH * az_factor * mult + storage_cost = storage * 0.115 * az_factor * mult + return round(compute + storage_cost, 2) + + if svc == "elbv2" and rtype == "load_balancer": + lb_type = str(meta.get("type") or "application").lower() + hourly = ELB_HOURLY.get(lb_type, 0.0225) + return round(hourly * HOURS_PER_MONTH * mult, 2) + + return 0.0 + + +def estimate_forward_window_usd(record: ResourceRecord, days: int = WINDOW_DAYS) -> float: + """Projected cost over a rolling forward window, assuming the resource stays as-is. + + Built from the monthly run-rate and prorated to the window length, so a 30-day + window answers: "if this stays live and unchanged, what does it cost over the next + 30 days?" + """ + monthly = estimate_monthly_cost_usd(record) + return round(monthly * days / DAYS_PER_MONTH_AVG, 2) + + +def enrich_record_cost(record: ResourceRecord) -> ResourceRecord: + est = estimate_monthly_cost_usd(record) + meta = dict(record.metadata) + meta["est_monthly_usd"] = est + meta["est_forward_30d_usd"] = estimate_forward_window_usd(record) + return ResourceRecord( + service=record.service, + resource_type=record.resource_type, + region=record.region, + id=record.id, + arn=record.arn, + state=record.state, + tags=record.tags, + metadata=meta, + delete_supported=record.delete_supported, + delete_action=record.delete_action, + delete_params=record.delete_params, + ) + + +def enrich_records(records: list[ResourceRecord]) -> list[ResourceRecord]: + return [enrich_record_cost(r) for r in records] + + +def format_age(meta: dict) -> str: + raw = meta.get("created_at") or meta.get("launch_time") + if not raw: + return "unknown" + try: + if isinstance(raw, datetime): + created = raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc) + else: + created = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + days = max(0, (datetime.now(timezone.utc) - created).days) + if days == 0: + return "<1d" + if days < 30: + return f"{days}d" + return f"{days // 30}mo {days % 30}d" + except (ValueError, TypeError): + return "unknown" + + +def sum_estimated_monthly(records: list[ResourceRecord]) -> float: + return round(sum(float(r.metadata.get("est_monthly_usd") or 0) for r in records), 2) + + +def sum_forward_30d(records: list[ResourceRecord]) -> float: + return round(sum(float(r.metadata.get("est_forward_30d_usd") or 0) for r in records), 2) diff --git a/bloodhound/guard.py b/bloodhound/guard.py new file mode 100644 index 0000000..909119c --- /dev/null +++ b/bloodhound/guard.py @@ -0,0 +1,536 @@ +""" +bloodhound/guard.py + +Cost-guard management — the "/guard" Slack command. + +A *guardrail* is a named, two-layer spend control: + - an Organizations **SCP** (e.g. `cp-org-spend-stop`) -> restricts member accounts + under its target OUs/accounts + - an IAM **managed policy attached to a group** (e.g. `cp-aico-echo-cost-guard` on + `aico-echo-class`) -> restricts IAM users in the management account + +Both layers historically carry the SAME deny list. `/guard` edits BOTH together so they +can never drift apart — drift is exactly what once left RDS blocked for IAM-user students +even after the SCP was loosened. + +Subcommands +----------- + list all guardrails: layers, attachments, denied services + show full detail for one guardrail + members list users in the guardrail's group + allow remove a service from the deny lists (unblock) [mutation] + deny add a service to the deny lists (block) [mutation] + enable (re)attach the IAM policy to its group [mutation] + disable detach the IAM policy from its group [mutation] + add-student add a user to the group [mutation] + remove-student remove a user from the group [mutation] + +Safety +------ +- Mutations require the caller to be in GUARD_ALLOWED_USER_IDS (falls back to + SLACK_ALLOWED_USER_IDS). If neither is set, mutations are refused (fail-safe) — the + command is reachable from a public Lambda Function URL. +- `allow` / `deny` only touch services in GUARD_TOGGLEABLE_SERVICES, so a Slack message + can never toggle iam:* / organizations:* / ec2 instance-type caps, etc. + +Tiers (phase 2) +--------------- +Each guardrail carries a `tier` field. Today everything is one tier ("core"); adding +2-3 tiers later is config (GUARD_REGISTRY), not a rewrite. +""" + +from __future__ import annotations + +import json +import os +import urllib.parse +from dataclasses import dataclass, field + +from bloodhound.aws import AwsClients +from bloodhound.tables import md_table + +# --- configuration (env-driven; self-contained like slack_commands.py) ---------------- + +_DEFAULT_REGISTRY = [ + { + "name": "cost-guard", + "tier": "core", + "scp": "cp-org-spend-stop", + "iam_policy": "cp-aico-echo-cost-guard", + "group": "aico-echo-class", + } +] + +# Services that allow/deny may toggle. Deliberately excludes iam, organizations, ec2, etc. +_DEFAULT_TOGGLEABLE = [ + "rds", "redshift", "neptune-db", "dax", "memorydb", + "sagemaker", "bedrock", "bedrock-runtime", "bedrock-agent-runtime", + "eks", "elasticmapreduce", "es", "aoss", "q", "deepracer", "forecast", + "frauddetector", "kendra", "comprehendmedical", "healthlake", "omics", "braket", +] + +MUTATION_SUBCOMMANDS = { + "allow", "deny", "enable", "disable", "add-student", "remove-student", +} + +_ALIASES = { + "": "help", "help": "help", + "list": "list", "ls": "list", + "show": "show", + "members": "members", "who": "members", + "allow": "allow", "unblock": "allow", + "deny": "deny", "block": "deny", + "enable": "enable", + "disable": "disable", + "add-student": "add-student", "add": "add-student", + "remove-student": "remove-student", "remove": "remove-student", "rm": "remove-student", +} + + +def _split_csv(raw: str | None) -> list[str]: + return [x.strip() for x in (raw or "").split(",") if x.strip()] + + +@dataclass(frozen=True) +class GuardrailDef: + name: str + tier: str + scp: str | None + iam_policy: str | None + group: str | None + + +def load_registry() -> list[GuardrailDef]: + raw = os.environ.get("GUARD_REGISTRY", "").strip() + entries = _DEFAULT_REGISTRY + if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, list) and parsed: + entries = parsed + except ValueError: + pass + out: list[GuardrailDef] = [] + for e in entries: + out.append( + GuardrailDef( + name=e.get("name") or "guardrail", + tier=e.get("tier") or "core", + scp=e.get("scp"), + iam_policy=e.get("iam_policy"), + group=e.get("group"), + ) + ) + return out + + +def toggleable_services() -> list[str]: + override = _split_csv(os.environ.get("GUARD_TOGGLEABLE_SERVICES")) + return override or list(_DEFAULT_TOGGLEABLE) + + +def parse_command(text: str) -> tuple[str, list[str]]: + tokens = (text or "").strip().split() + if not tokens: + return "help", [] + sub = _ALIASES.get(tokens[0].lower(), tokens[0].lower()) + return sub, tokens[1:] + + +def user_allowed_to_mutate(user_id: str) -> bool: + allowed = _split_csv( + os.environ.get("GUARD_ALLOWED_USER_IDS") or os.environ.get("SLACK_ALLOWED_USER_IDS") + ) + return bool(allowed) and user_id in allowed # fail-safe: empty allowlist => no mutations + + +# --- low-level AWS helpers (best-effort; surface notes instead of crashing) ------------ + + +def _org(clients: AwsClients): + return clients.client("organizations", region="us-east-1") + + +def _iam(clients: AwsClients): + return clients.client("iam") + + +def _find_scp_id(org, name: str | None) -> str | None: + if not name: + return None + try: + paginator = org.get_paginator("list_policies") + for page in paginator.paginate(Filter="SERVICE_CONTROL_POLICY"): + for p in page.get("Policies", []) or []: + if p.get("Name") == name: + return p.get("Id") + except Exception: # noqa: BLE001 + return None + return None + + +def _find_iam_policy_arn(iam, name: str | None) -> str | None: + if not name: + return None + try: + paginator = iam.get_paginator("list_policies") + for page in paginator.paginate(Scope="Local"): + for p in page.get("Policies", []) or []: + if p.get("PolicyName") == name: + return p.get("Arn") + except Exception: # noqa: BLE001 + return None + return None + + +def _scp_document(org, policy_id: str) -> dict: + content = (org.describe_policy(PolicyId=policy_id).get("Policy") or {}).get("Content") + return json.loads(content) if content else {"Version": "2012-10-17", "Statement": []} + + +def _iam_document(iam, arn: str) -> dict: + ver = iam.get_policy(PolicyArn=arn)["Policy"]["DefaultVersionId"] + doc = iam.get_policy_version(PolicyArn=arn, VersionId=ver)["PolicyVersion"]["Document"] + if isinstance(doc, str): + doc = json.loads(urllib.parse.unquote(doc)) + return doc + + +def _statements(doc: dict) -> list[dict]: + stmts = doc.get("Statement") + if isinstance(stmts, dict): + return [stmts] + return stmts or [] + + +def _actions(stmt: dict) -> list[str]: + acts = stmt.get("Action") + if isinstance(acts, list): + return acts + return [acts] if acts else [] + + +def _denied_services(doc: dict) -> list[str]: + svcs: set[str] = set() + for s in _statements(doc): + if (s.get("Effect") or "").lower() != "deny": + continue + for a in _actions(s): + if a: + svcs.add(a.split(":")[0]) + return sorted(svcs) + + +def _remove_service(doc: dict, service: str) -> list[str]: + """Strip every Deny action whose service prefix == `service`. Returns removed actions.""" + removed: list[str] = [] + for s in _statements(doc): + if (s.get("Effect") or "").lower() != "deny": + continue + original = _actions(s) + kept = [] + for a in original: + if a and a.split(":")[0] == service: + removed.append(a) + else: + kept.append(a) + if len(kept) != len(original): + s["Action"] = kept + # Drop now-empty deny statements (keep everything else). + doc["Statement"] = [ + s for s in _statements(doc) + if (s.get("Effect") or "").lower() != "deny" or _actions(s) + ] + return removed + + +def _add_service(doc: dict, service: str) -> str | None: + """Add `:*` to a wildcard-resource Deny statement. Returns added action or None.""" + action = f"{service}:*" + target = None + for s in _statements(doc): + if (s.get("Effect") or "").lower() == "deny" and s.get("Resource") in ("*", ["*"]): + target = s + break + if target is None: + target = {"Sid": "GuardManagedDenies", "Effect": "Deny", "Action": [], "Resource": "*"} + doc.setdefault("Statement", []).append(target) + acts = _actions(target) + if any(a and a.split(":")[0] == service for a in acts): + return None # already covered + acts.append(action) + target["Action"] = acts + return action + + +def _put_scp(org, policy_id: str, doc: dict) -> None: + org.update_policy(PolicyId=policy_id, Content=json.dumps(doc)) + + +def _put_iam(iam, arn: str, doc: dict) -> str: + """Create + set-as-default a new policy version, pruning the oldest if at the 5-version cap.""" + versions = iam.list_policy_versions(PolicyArn=arn).get("Versions", []) + if len(versions) >= 5: + nondefault = sorted( + (v for v in versions if not v.get("IsDefaultVersion")), + key=lambda v: v.get("CreateDate"), + ) + if nondefault: + iam.delete_policy_version(PolicyArn=arn, VersionId=nondefault[0]["VersionId"]) + resp = iam.create_policy_version( + PolicyArn=arn, PolicyDocument=json.dumps(doc), SetAsDefault=True + ) + return resp["PolicyVersion"]["VersionId"] + + +def _scp_targets(org, policy_id: str) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + paginator = org.get_paginator("list_targets_for_policy") + for page in paginator.paginate(PolicyId=policy_id): + for t in page.get("Targets", []) or []: + out.append((t.get("Type", ""), t.get("Name", t.get("TargetId", "")))) + return out + + +def _group_has_policy(iam, group: str, arn: str) -> bool: + paginator = iam.get_paginator("list_attached_group_policies") + for page in paginator.paginate(GroupName=group): + for p in page.get("AttachedPolicies", []) or []: + if p.get("PolicyArn") == arn: + return True + return False + + +def _group_members(iam, group: str) -> list[str]: + out: list[str] = [] + paginator = iam.get_paginator("get_group") + for page in paginator.paginate(GroupName=group): + out.extend(u.get("UserName") for u in page.get("Users", []) or [] if u.get("UserName")) + return out + + +# --- engine ---------------------------------------------------------------------------- + + +@dataclass +class GuardEngine: + clients: AwsClients + registry: list[GuardrailDef] = field(default_factory=load_registry) + + def run(self, sub: str, args: list[str]) -> str: + try: + if sub == "help": + return _help() + if sub == "list": + return self.list_all() + if sub == "show": + return self.show(self._need_name(args)) + if sub == "members": + return self.members(self._need_name(args)) + if sub == "allow": + return self.toggle(self._need_name(args), self._need_arg(args, 1, "service"), block=False) + if sub == "deny": + return self.toggle(self._need_name(args), self._need_arg(args, 1, "service"), block=True) + if sub == "enable": + return self.set_enabled(self._need_name(args), enabled=True) + if sub == "disable": + return self.set_enabled(self._need_name(args), enabled=False) + if sub == "add-student": + return self.student(self._need_name(args), self._need_arg(args, 1, "user"), add=True) + if sub == "remove-student": + return self.student(self._need_name(args), self._need_arg(args, 1, "user"), add=False) + return f"Unknown subcommand `{sub}`.\n\n" + _help() + except _GuardError as exc: + return f"⚠️ {exc}" + + # -- resolution -- + + def _get_def(self, name: str) -> GuardrailDef: + for d in self.registry: + if d.name == name: + return d + names = ", ".join(f"`{d.name}`" for d in self.registry) or "(none configured)" + raise _GuardError(f"No guardrail named `{name}`. Known: {names}") + + def _need_name(self, args: list[str]) -> str: + if args: + return args[0] + if len(self.registry) == 1: + return self.registry[0].name # convenience: single guardrail is implied + raise _GuardError("Specify a guardrail name. Try `/guard list`.") + + @staticmethod + def _need_arg(args: list[str], idx: int, label: str) -> str: + if len(args) > idx: + return args[idx] + raise _GuardError(f"Missing `<{label}>`.") + + # -- read ops -- + + def list_all(self) -> str: + org, iam = _org(self.clients), _iam(self.clients) + rows: list[list[object]] = [] + for d in self.registry: + scp_id = _find_scp_id(org, d.scp) + iam_arn = _find_iam_policy_arn(iam, d.iam_policy) + scp_denied = iam_denied = "—" + attaches: list[str] = [] + if scp_id: + try: + scp_denied = ", ".join(_denied_services(_scp_document(org, scp_id))) or "none" + n = len(_scp_targets(org, scp_id)) + attaches.append(f"SCP→{n} OU/acct") + except Exception: # noqa: BLE001 + scp_denied = "?" + if iam_arn and d.group: + try: + iam_denied = ", ".join(_denied_services(_iam_document(iam, iam_arn))) or "none" + on = "on" if _group_has_policy(iam, d.group, iam_arn) else "OFF" + attaches.append(f"IAM→{d.group} ({on})") + except Exception: # noqa: BLE001 + iam_denied = "?" + rows.append([d.name, d.tier, " ; ".join(attaches) or "—", scp_denied, iam_denied]) + body = md_table( + ["Guardrail", "Tier", "Attached to", "SCP denies", "IAM denies"], rows + ) + return _header("Guardrails") + body + ( + "\n\n_Two layers per guardrail: **SCP** gates member accounts, " + "**IAM** policy gates IAM-user students in the mgmt account. " + "`/guard show ` for detail._" + ) + + def show(self, name: str) -> str: + d = self._get_def(name) + org, iam = _org(self.clients), _iam(self.clients) + out = [_header(f"Guardrail: {d.name} (tier: {d.tier})")] + + scp_id = _find_scp_id(org, d.scp) + if scp_id: + doc = _scp_document(org, scp_id) + targets = _scp_targets(org, scp_id) + out.append(f"**SCP `{d.scp}`** (`{scp_id}`)") + out.append("Applies to: " + (", ".join(f"{t}:{n}" for t, n in targets) or "—")) + out.append("Denied: " + (", ".join(_denied_services(doc)) or "none")) + out.append("") + elif d.scp: + out.append(f"**SCP `{d.scp}`** — not found.\n") + + iam_arn = _find_iam_policy_arn(iam, d.iam_policy) + if iam_arn: + doc = _iam_document(iam, iam_arn) + on = _group_has_policy(iam, d.group, iam_arn) if d.group else False + out.append(f"**IAM policy `{d.iam_policy}`** → group `{d.group}` " + f"({'attached/ON' if on else 'detached/OFF'})") + out.append("Denied: " + (", ".join(_denied_services(doc)) or "none")) + if d.group: + members = _group_members(iam, d.group) + out.append(f"Group members ({len(members)}): " + (", ".join(members) or "—")) + elif d.iam_policy: + out.append(f"**IAM policy `{d.iam_policy}`** — not found.") + return "\n".join(out) + + def members(self, name: str) -> str: + d = self._get_def(name) + if not d.group: + raise _GuardError(f"`{name}` has no group configured.") + members = _group_members(_iam(self.clients), d.group) + return _header(f"Members of `{d.group}` ({len(members)})") + ( + "\n".join(f"- {m}" for m in members) or "_No members._" + ) + + # -- mutations -- + + def toggle(self, name: str, service: str, *, block: bool) -> str: + d = self._get_def(name) + service = service.lower().rstrip(":*") + allowed = toggleable_services() + if service not in allowed: + raise _GuardError( + f"`{service}` is not a toggleable service. Allowed: {', '.join(allowed)}" + ) + org, iam = _org(self.clients), _iam(self.clients) + verb = "deny" if block else "allow" + lines = [_header(f"/guard {verb} {d.name} {service}")] + + scp_id = _find_scp_id(org, d.scp) + if scp_id: + doc = _scp_document(org, scp_id) + changed = _add_service(doc, service) if block else _remove_service(doc, service) + if changed: + _put_scp(org, scp_id, doc) + detail = changed if isinstance(changed, str) else ", ".join(changed) + lines.append(f"- SCP `{d.scp}`: {'added' if block else 'removed'} {detail}") + else: + lines.append(f"- SCP `{d.scp}`: no change") + + iam_arn = _find_iam_policy_arn(iam, d.iam_policy) + if iam_arn: + doc = _iam_document(iam, iam_arn) + changed = _add_service(doc, service) if block else _remove_service(doc, service) + if changed: + ver = _put_iam(iam, iam_arn, doc) + detail = changed if isinstance(changed, str) else ", ".join(changed) + lines.append(f"- IAM `{d.iam_policy}`: {'added' if block else 'removed'} {detail} (now {ver})") + else: + lines.append(f"- IAM `{d.iam_policy}`: no change") + + lines.append("") + lines.append( + f"{'🔒' if block else '✅'} `{service}` is now {'blocked' if block else 'allowed'} " + f"across both layers of `{d.name}`." + ) + return "\n".join(lines) + + def set_enabled(self, name: str, *, enabled: bool) -> str: + d = self._get_def(name) + if not (d.iam_policy and d.group): + raise _GuardError(f"`{name}` has no IAM policy/group to enable/disable.") + iam = _iam(self.clients) + arn = _find_iam_policy_arn(iam, d.iam_policy) + if not arn: + raise _GuardError(f"IAM policy `{d.iam_policy}` not found.") + if enabled: + iam.attach_group_policy(GroupName=d.group, PolicyArn=arn) + state = "attached to" + else: + iam.detach_group_policy(GroupName=d.group, PolicyArn=arn) + state = "detached from" + return _header(f"/guard {'enable' if enabled else 'disable'} {d.name}") + ( + f"IAM policy `{d.iam_policy}` {state} group `{d.group}`.\n\n" + f"_Note: the SCP layer is governed by its AWS Budgets action, not toggled here._" + ) + + def student(self, name: str, user: str, *, add: bool) -> str: + d = self._get_def(name) + if not d.group: + raise _GuardError(f"`{name}` has no group configured.") + iam = _iam(self.clients) + if add: + iam.add_user_to_group(GroupName=d.group, UserName=user) + msg = f"Added `{user}` to `{d.group}`." + else: + iam.remove_user_from_group(GroupName=d.group, UserName=user) + msg = f"Removed `{user}` from `{d.group}`." + return _header(f"/guard {'add-student' if add else 'remove-student'} {d.name} {user}") + msg + + +class _GuardError(Exception): + pass + + +def _header(title: str) -> str: + return f"**Bloodhound — {title}**\n\n" + + +def _help() -> str: + return ( + "*`/guard` — cost-guard management*\n" + "• `/guard list` — all guardrails + what they block\n" + "• `/guard show ` — full detail\n" + "• `/guard members ` — users in the group\n" + "• `/guard allow ` — unblock a service _(admin)_\n" + "• `/guard deny ` — block a service _(admin)_\n" + "• `/guard enable|disable ` — attach/detach the IAM policy _(admin)_\n" + "• `/guard add-student|remove-student ` _(admin)_\n" + "\n_With one guardrail configured, `` can be omitted._" + ) diff --git a/bloodhound/messages.py b/bloodhound/messages.py index ec096c9..023af2a 100644 --- a/bloodhound/messages.py +++ b/bloodhound/messages.py @@ -1,10 +1,7 @@ """ bloodhound/messages.py -Slack message formatting for Bloodhound v2. - -This file contains ONLY formatting/aggregation logic (no AWS calls). -It is used by `app.py` right before posting via `slack.py`. +Report formatting for Bloodhound v2 (markdown-native; converted for Slack on post). """ from __future__ import annotations @@ -14,8 +11,12 @@ from zoneinfo import ZoneInfo from bloodhound.budget import BudgetSnapshot -from bloodhound.teardown.planner import PlannedAction -from bloodhound.types import ResourceRecord +from bloodhound.config import WhitelistConfig +from bloodhound.controls import SpendingControlsSnapshot +from bloodhound.costs import MtdCostSnapshot, format_age, sum_forward_30d +from bloodhound.tables import md_table +from bloodhound.types import ResourceRecord, resource_display_name, resource_key +from bloodhound.whitelist import WhitelistMatch, format_whitelist_rules SCANNED_RESOURCE_TYPES_ORDER: list[tuple[str, str]] = [ @@ -27,203 +28,502 @@ ("elbv2", "load_balancer"), ] - _ET = ZoneInfo("America/New_York") def _et_now_time_str() -> str: - # Example: 4:24 PM ET return datetime.now(_ET).strftime("%-I:%M %p ET") -def _fmt_kv(key: str, value: str) -> str: - return f"*{key}*: {value}" +def _header(title: str) -> list[str]: + return [f"**{title}**", f"**time_et**: {_et_now_time_str()}", ""] + + +def _resource_type_label(r: ResourceRecord) -> str: + return f"{r.service}.{r.resource_type}" + +def _resource_detail(r: ResourceRecord) -> str: + return str( + r.metadata.get("instance_type") + or r.metadata.get("instance_class") + or r.metadata.get("size_gb") + or "" + ) + + +def _resource_table_rows( + records: list[ResourceRecord], + *, + extra_column: str | None = None, + extra_value, +) -> list[list[object]]: + rows: list[list[object]] = [] + for r in sorted(records, key=lambda x: (x.region, x.service, x.resource_type, x.id)): + row = [ + r.region, + _resource_type_label(r), + r.id, + resource_display_name(r), + r.state or "", + format_age(r.metadata), + r.metadata.get("created_by") or "", + _resource_detail(r), + ] + if extra_column: + if callable(extra_value): + row.append(extra_value(r)) + else: + row.append(extra_value or "") + rows.append(row) + return rows + + +def format_cost_breakdown_message( + mtd: MtdCostSnapshot, + *, + active_records: list[ResourceRecord] | None = None, + candidate_records: list[ResourceRecord] | None = None, + kept_records: list[ResourceRecord] | None = None, +) -> str: + lines = _header("Bloodhound v2 — Cost Breakdown") + + lines.append( + md_table( + ["Field", "Value"], + [ + ["Current month start", mtd.period_start.strftime("%Y-%m-%d")], + ["Current month end", mtd.period_end_inclusive.strftime("%Y-%m-%d")], + ["Period note", "MTD end date is today (inclusive); sourced from AWS Cost Explorer"], + ], + ) + ) + lines.append("") + lines.append("**Current month — actual charges (MTD)**") + lines.append("") + lines.append( + "Account-wide charges for the current calendar month to date. Includes deleted " + "resources, VPC, data transfer, tax, and services Bloodhound does not scan." + ) + lines.append("") -def _fmt_counts_line(prefix: str, counts: dict[tuple[str, str], int]) -> str: - parts = [f"{svc}.{rtype}={counts[(svc, rtype)]}" for svc, rtype in SCANNED_RESOURCE_TYPES_ORDER] - return f"{prefix} {', '.join(parts)}" + if not mtd.by_service: + lines.append("No Cost Explorer data returned.") + else: + total = mtd.total_usd + rows: list[list[object]] = [] + for service, cost in mtd.by_service.items(): + if cost < 0.01: + continue + pct = (cost / total * 100) if total else 0 + rows.append([service, f"${cost:,.2f}", f"{pct:.0f}%"]) + rows.append(["**Total billed MTD**", f"**${total:,.2f}**", "**100%**"]) + lines.append(md_table(["Service", "MTD", "% of total"], rows)) + + if active_records is not None: + candidates = candidate_records or [] + kept = kept_records or [] + lines.append("") + lines.append("**Forward rolling 30-day window (projected run-rate)**") + lines.append("") + lines.append( + "Projected spend over the **next 30 days** if every resource Bloodhound found " + "**live right now** stays running unchanged. Region-aware on-demand list prices " + "(includes attached EBS) — excludes VPC, data transfer, tax, ELB LCUs, " + "RIs/Savings Plans, and anything Bloodhound does not scan. **Not actual billing.**" + ) + lines.append("") + summary_rows: list[list[object]] = [ + [ + "All active scanned", + len(active_records), + f"~${sum_forward_30d(active_records):,.2f}/30d", + ], + [ + "Teardown candidates", + len(candidates), + f"~${sum_forward_30d(candidates):,.2f}/30d", + ], + [ + "Kept (whitelisted)", + len(kept), + f"~${sum_forward_30d(kept):,.2f}/30d", + ], + ] + lines.append(md_table(["Group", "Resources", "Est. next 30d"], summary_rows)) + + by_type: dict[tuple[str, str], list[ResourceRecord]] = defaultdict(list) + for r in active_records: + by_type[(r.service, r.resource_type)].append(r) + + type_rows: list[list[object]] = [] + for svc, rtype in SCANNED_RESOURCE_TYPES_ORDER: + items = by_type.get((svc, rtype), []) + if not items: + continue + type_rows.append([f"{svc}.{rtype}", len(items), f"~${sum_forward_30d(items):,.2f}/30d"]) + if type_rows: + lines.append("") + lines.append("**By resource type**") + lines.append("") + lines.append(md_table(["Type", "Resources", "Est. next 30d"], type_rows)) -def _display_resource(r: ResourceRecord) -> str: - """ - Short, human-friendly one-liner for Slack. - """ - name = r.tags.get("Name") - name_part = f" (Name=`{name}`)" if name else "" - return f"- `{r.region}` {r.service}.{r.resource_type} `{r.id}`{name_part}" + return "\n".join(lines) def format_scan_message( resources_by_region: dict[str, list[ResourceRecord]], whitelisted_by_region: dict[str, list[ResourceRecord]], ) -> str: - lines: list[str] = [] - lines.append("*Bloodhound v2 — Scan Summary*") - lines.append(_fmt_kv("time_et", _et_now_time_str())) + lines = _header("Bloodhound v2 — Scan Summary") total_found = 0 total_whitelisted = 0 - - # Totals by (service, resource_type) across all regions. totals_candidates: dict[tuple[str, str], int] = defaultdict(int) totals_kept: dict[tuple[str, str], int] = defaultdict(int) - for region in sorted(resources_by_region.keys()): - resources = resources_by_region[region] + region_rows: list[list[object]] = [] + for region in sorted(set(resources_by_region.keys()) | set(whitelisted_by_region.keys())): + resources = resources_by_region.get(region, []) kept = whitelisted_by_region.get(region, []) - total_found += len(resources) total_whitelisted += len(kept) - - type_counts = defaultdict(int) for r in resources: - type_counts[(r.service, r.resource_type)] += 1 totals_candidates[(r.service, r.resource_type)] += 1 - - kept_type_counts = defaultdict(int) for r in kept: - kept_type_counts[(r.service, r.resource_type)] += 1 totals_kept[(r.service, r.resource_type)] += 1 + region_rows.append([region, len(resources), len(kept)]) + + region_rows.append(["**Total**", f"**{total_found}**", f"**{total_whitelisted}**"]) + lines.append(md_table(["Region", "Teardown candidates", "Kept (whitelisted)"], region_rows)) + lines.append("") + type_rows: list[list[object]] = [] + for svc, rtype in SCANNED_RESOURCE_TYPES_ORDER: + c = totals_candidates.get((svc, rtype), 0) + k = totals_kept.get((svc, rtype), 0) + if c or k: + type_rows.append([f"{svc}.{rtype}", c, k]) + if type_rows: + lines.append("**By resource type**") lines.append("") - lines.append(f"*Region*: `{region}`") - lines.append(f"*Counts*: candidates `{len(resources)}` | kept `{len(kept)}`") - lines.append(f"- {_fmt_counts_line('Candidates:', type_counts)}") - lines.append(f"- {_fmt_counts_line('Kept:', kept_type_counts)}") + lines.append(md_table(["Type", "Candidates", "Kept"], type_rows)) - lines.append("") - lines.append("*Totals*") - lines.append(f"*Counts*: candidates `{total_found}` | kept `{total_whitelisted}`") - lines.append(f"- {_fmt_counts_line('Candidates:', totals_candidates)}") - lines.append(f"- {_fmt_counts_line('Kept:', totals_kept)}") return "\n".join(lines) -def format_whitelisted_resources_message( - whitelisted_by_region: dict[str, list[ResourceRecord]], +def format_resource_table_message( + title: str, + resources_by_region: dict[str, list[ResourceRecord]], *, - max_items: int = 50, + extra_column: str | None = None, + extra_value=None, + match_reasons: dict[str, WhitelistMatch] | None = None, ) -> str: - """ - Separate report listing all whitelisted ("kept") resources. - Capped to max_items to keep Slack readable. - """ - kept_all: list[ResourceRecord] = [] - for region in sorted(whitelisted_by_region.keys()): - kept_all.extend(whitelisted_by_region.get(region, [])) + lines = _header(title) - lines: list[str] = [] - lines.append("*Bloodhound v2 — Whitelisted Resources (Kept)*") - lines.append(_fmt_kv("time_et", _et_now_time_str())) + all_records = [r for region in sorted(resources_by_region) for r in resources_by_region[region]] + if not all_records: + lines.append("None.") + return "\n".join(lines) + + if match_reasons and extra_column is None: + extra_column = "Kept because" + extra_value = lambda r: (match_reasons.get(resource_key(r)) or WhitelistMatch(True, "")).reason + + headers = ["Region", "Type", "ID", "Name", "State", "Age", "Created by", "Spec"] + if extra_column: + headers.append(extra_column) + + rows = _resource_table_rows(all_records, extra_column=extra_column, extra_value=extra_value) + lines.append(f"**Count:** {len(all_records)}") lines.append("") + lines.append(md_table(headers, rows)) + return "\n".join(lines) - if not kept_all: - lines.append("No whitelisted resources found.") - return "\n".join(lines) - lines.append(_fmt_kv("kept_total", f"`{len(kept_all)}`")) +def format_whitelist_config_message(cfg: WhitelistConfig) -> str: + lines = _header("Bloodhound v2 — Whitelist Configuration") + lines.append("Resources matching any rule below are **kept** and excluded from teardown.") lines.append("") - shown = 0 - for region in sorted(whitelisted_by_region.keys()): - region_items = whitelisted_by_region.get(region, []) - if not region_items: + rules = format_whitelist_rules(cfg) + if rules: + rule_rows = [] + for rule in rules: + text = rule.lstrip("- ").strip() + rule_rows.append([text]) + lines.append(md_table(["Rule"], rule_rows)) + else: + lines.append("No whitelist rules configured.") + + lines.append("") + lines.append("**Manage via Lambda env vars:** `KEEP_TAG_RULES`, `KEEP_NAME_PATTERNS`, `KEEP_RESOURCE_IDS`") + return "\n".join(lines) + + +def format_spending_controls_message(snap: SpendingControlsSnapshot) -> str: + """Prove spending limits are locked in place, and show for whom + what is blocked.""" + + def usd(x: float) -> str: + return f"${x:,.2f}" + + lines = _header("Bloodhound v2 — Spending Controls (proof of guardrails)") + lines.append(f"**Account:** `{snap.account_id}`") + lines.append("") + + # --- Verdict line: are limits actually locked? --- + total_actions = len(snap.actions) + locked = len(snap.locked_actions) + active = sum(1 for a in snap.actions if a.active) + if total_actions == 0: + verdict = "⚠️ **No enforcement actions found — spending caps are alert-only, not locked.**" + elif snap.all_locked: + verdict = ( + f"✅ **All {total_actions} enforcement action(s) are AUTOMATIC (locked in place).** " + f"{active} currently enforcing." + ) + else: + verdict = ( + f"⚠️ **{locked}/{total_actions} enforcement action(s) are AUTOMATIC (locked).** " + f"The rest require manual approval to fire." + ) + lines.append(verdict) + lines.append("") + + # --- Spending caps --- + lines.append("**Budget caps (spending limits)**") + lines.append("") + if not snap.caps: + lines.append("No AWS Budgets configured.") + else: + rows: list[list[object]] = [] + for c in snap.caps: + status = "🔴 OVER" if c.over else "🟢 within" + rows.append( + [ + c.name, + f"{usd(c.limit_usd)}/{c.time_unit.lower()}", + usd(c.actual_usd), + f"{c.pct_used:.0f}%", + status, + ] + ) + lines.append(md_table(["Budget", "Limit", "Actual", "% used", "Status"], rows)) + lines.append("") + + # --- Enforcement (the lock) --- + lines.append("**Enforcement — what is locked in place**") + lines.append("") + if not snap.actions: + lines.append("No budget actions configured. Caps will alert but cannot block spend.") + else: + rows = [] + for a in snap.actions: + lock = "🔒 AUTOMATIC" if a.locked else "🔓 manual" + fired = "ENFORCING" if a.active else a.status + thresh = ( + f"{a.threshold_value:.0f}%" + if a.threshold_type.upper() == "PERCENTAGE" + else usd(a.threshold_value) + ) + rows.append( + [ + a.budget_name, + _action_type_label(a.action_type), + a.policy_name or a.policy_id or "—", + f"@ {thresh}", + lock, + fired, + ] + ) + lines.append( + md_table( + ["Budget", "Mechanism", "Policy", "Trips", "Approval", "State"], + rows, + ) + ) + + # --- For what Groups / Users / OUs / Accounts --- + lines.append("") + lines.append("**Applies to (Groups, Users, OUs & Accounts)**") + lines.append("") + scope_rows: list[list[object]] = [] + for a in snap.actions: + principals: list[str] = [] + if a.iam_groups: + principals.append("groups: " + ", ".join(a.iam_groups)) + if a.iam_users: + principals.append("users: " + ", ".join(a.iam_users)) + if a.iam_roles: + principals.append("roles: " + ", ".join(a.iam_roles)) + if a.target_names: + principals.append("scope: " + ", ".join(a.target_names)) + scope_rows.append([a.budget_name, a.policy_name or a.action_id, "; ".join(principals) or "—"]) + if scope_rows: + lines.append(md_table(["Budget", "Policy", "Applies to"], scope_rows)) + lines.append("") + if snap.iam_group_names: + lines.append( + f"_IAM principals subject to account/OU-scoped guardrails: " + f"**{len(snap.iam_group_names)} groups** ({', '.join(snap.iam_group_names)}), " + f"**{snap.iam_user_count} users**._" + ) + lines.append("") + + # --- What is blocked --- + lines.append("**What is blocked when a cap trips**") + lines.append("") + any_blocked = False + for a in snap.actions: + if not (a.denied_actions or a.ec2_allowed_instance_types): continue - lines.append(f"*Region*: `{region}`") - for r in region_items: - if shown >= max_items: - break - lines.append(_display_resource(r)) - shown += 1 - if shown >= max_items: - break + any_blocked = True + header = f"_{a.policy_name or a.action_id}_" + if a.policy_desc: + header += f" — {a.policy_desc}" + lines.append(header) + if a.denied_actions: + shown = a.denied_actions[:30] + lines.append("- **Denied:** " + ", ".join(f"`{x}`" for x in shown)) + if len(a.denied_actions) > len(shown): + lines.append(f"- _(+{len(a.denied_actions) - len(shown)} more denied actions)_") + if a.ec2_allowed_instance_types: + lines.append( + "- **EC2 launches capped to:** " + + ", ".join(f"`{x}`" for x in a.ec2_allowed_instance_types) + ) + lines.append("") + if not any_blocked: + lines.append("No SCP/IAM deny rules resolved (insufficient Organizations/IAM read access, or none defined).") lines.append("") - if shown < len(kept_all): - lines.append(f"... and `{len(kept_all) - shown}` more (increase max_items if needed)") + if snap.notes: + lines.append("**Notes (degraded data)**") + for n in snap.notes: + lines.append(f"- {n}") return "\n".join(lines).rstrip() +def _action_type_label(action_type: str) -> str: + return { + "APPLY_SCP_POLICY": "SCP (org-wide)", + "APPLY_IAM_POLICY": "IAM policy", + "RUN_SSM_DOCUMENTS": "SSM (stop resources)", + }.get(action_type, action_type) + + def format_budget_message(b: BudgetSnapshot) -> str: def usd(x: float) -> str: return f"${x:,.2f}" - lines: list[str] = [] - lines.append("*Bloodhound v2 — Budget Summary*") - lines.append(_fmt_kv("time_et", _et_now_time_str())) - lines.append("") - lines.append("*Cohort*") - lines.append(f"- {_fmt_kv('start', f'`{b.cohort_start_yyyy_mm}`')}") - lines.append(f"- {_fmt_kv('total_budget', f'`{usd(b.cohort_total_budget_usd)}` over `{b.cohort_length_months}` months')}") - lines.append(f"- {_fmt_kv('to_date_spend', f'`{usd(b.cohort_to_date_spend_usd)}`')}") - lines.append(f"- {_fmt_kv('remaining_budget', f'`{usd(b.remaining_cohort_budget_usd)}`')}") - lines.append(f"- {_fmt_kv('remaining_months', f'`{b.remaining_months}`')}") - lines.append(f"- {_fmt_kv('monthly_allowance', f'`{usd(b.dynamic_monthly_allowance_usd)}`')}") - lines.append("") - lines.append("*This month*") - lines.append(f"- {_fmt_kv('month_to_date', f'`{usd(b.current_month_to_date_spend_usd)}`')}") - lines.append(f"- {_fmt_kv('projected_month_end', f'`{usd(b.projected_month_end_spend_usd)}`')}") - lines.append(f"- {_fmt_kv('over_budget_days_required', f'`{b.budget_over_days}`')}") - lines.append(f"- {_fmt_kv('over_budget_threshold_met', f'`{str(b.over_budget_threshold_met).lower()}`')}") + lines = _header("Bloodhound v2 — Budget Summary") + lines.append( + md_table( + ["Metric", "Value"], + [ + ["Cohort start", b.cohort_start_yyyy_mm], + ["Cohort budget", f"{usd(b.cohort_total_budget_usd)} over {b.cohort_length_months} months"], + ["Remaining cohort budget", usd(b.remaining_cohort_budget_usd)], + ["Dynamic monthly allowance", usd(b.dynamic_monthly_allowance_usd)], + ["This month MTD", usd(b.current_month_to_date_spend_usd)], + ["Projected month end", usd(b.projected_month_end_spend_usd)], + ["Over budget threshold", str(b.over_budget_threshold_met).lower()], + ], + ) + ) return "\n".join(lines) def format_teardown_plan_message( - actions: list[PlannedAction], + candidates_by_region: dict[str, list[ResourceRecord]], apply_changes: bool, *, simulate: bool, targets_filter_count: int, allow_all: bool, ) -> str: - header = "*Bloodhound v2 — Teardown Plan (APPLY MODE)*" if apply_changes else "*Bloodhound v2 — Teardown Plan (dry-run)*" - lines = [header, _fmt_kv("time_et", _et_now_time_str()), ""] - + header_title = ( + "Bloodhound v2 — Teardown Plan (APPLY MODE)" + if apply_changes + else "Bloodhound v2 — Teardown Plan (dry-run)" + ) + all_candidates = [r for region in sorted(candidates_by_region) for r in candidates_by_region[region]] + + lines = _header(header_title) targets_filter_active = targets_filter_count > 0 - lines.append(_fmt_kv("simulate", f"`{str(simulate).lower()}`")) - lines.append(_fmt_kv("targets_filter_active", f"`{str(targets_filter_active).lower()}`")) + + meta_rows = [ + ["Mode", "apply" if apply_changes else "dry-run"], + ["Simulate deletes", str(simulate).lower()], + ["Targets filter active", str(targets_filter_active).lower()], + ["Allow all targets", str(allow_all).lower()], + ] if targets_filter_active: - lines.append(_fmt_kv("targets_filter_count", f"`{targets_filter_count}`")) - lines.append(_fmt_kv("allow_all_targets", f"`{str(allow_all).lower()}`")) - lines.append("") + meta_rows.append(["Targets filter count", str(targets_filter_count)]) - if not actions: + if not all_candidates: + lines.extend(meta_rows[0:1]) + lines.append("") lines.append("No deletions planned.") return "\n".join(lines) - lines.append(_fmt_kv("planned_actions", f"`{len(actions)}`")) + by_action: dict[str, int] = defaultdict(int) + by_service: dict[str, int] = defaultdict(int) + for r in all_candidates: + if r.delete_action: + by_action[r.delete_action] += 1 + by_service[r.service] += 1 + + meta_rows.extend( + [ + ["Planned actions", str(len(all_candidates))], + ["By service", ", ".join(f"{k}={by_service[k]}" for k in sorted(by_service))], + ["By action", ", ".join(f"{k}={by_action[k]}" for k in sorted(by_action))], + ] + ) + lines.append(md_table(["Setting", "Value"], meta_rows)) + lines.append("") - by_action = defaultdict(int) - by_service = defaultdict(int) - for a in actions: - by_action[a.action] += 1 - by_service[a.service] += 1 - lines.append(_fmt_kv("planned_by_service", "`" + ", ".join([f"{k}={by_service[k]}" for k in sorted(by_service.keys())]) + "`")) - lines.append(_fmt_kv("planned_by_action", "`" + ", ".join([f"{k}={by_action[k]}" for k in sorted(by_action.keys())]) + "`")) + deletable = [r for r in all_candidates if r.delete_supported] + manual = [r for r in all_candidates if not r.delete_supported] - # Keep Slack output short: show a small sample. - sample = actions[:15] - lines.append("") - lines.append("*Sample (first 15)*") - for a in sample: - lines.append(f"- `{a.region}` {a.service}.{a.resource_type} `{a.id}` → `{a.action}`") - if len(actions) > len(sample): - lines.append(f"- ... and `{len(actions) - len(sample)}` more") + if deletable: + by_region = { + region: [r for r in candidates_by_region[region] if r.delete_supported] + for region in sorted(candidates_by_region) + } + deletable_records = [r for region in sorted(by_region) for r in by_region[region]] + lines.append("**Resources scheduled for teardown**") + lines.append("") + headers = ["Region", "Type", "ID", "Name", "State", "Age", "Created by", "Spec", "Action"] + rows = _resource_table_rows( + deletable_records, + extra_column="Action", + extra_value=lambda r: r.delete_action or "manual", + ) + lines.append(md_table(headers, rows)) + + if manual: + lines.append("") + lines.append(f"**Manual review required:** {len(manual)} resources without automated delete support") - return "\n".join(lines) + return "\n".join(lines).replace("\n\n\n", "\n\n").strip() def format_teardown_result_message(attempted: int, succeeded: int, failed: int, simulated: int) -> str: - lines: list[str] = [] - lines.append("*Bloodhound v2 — Teardown Results*") - lines.append(_fmt_kv("time_et", _et_now_time_str())) - lines.append("") - lines.append(_fmt_kv("attempted", f"`{attempted}`")) - lines.append(_fmt_kv("succeeded", f"`{succeeded}`")) - lines.append(_fmt_kv("failed", f"`{failed}`")) - lines.append(_fmt_kv("simulated", f"`{simulated}`")) + lines = _header("Bloodhound v2 — Teardown Results") + lines.append( + md_table( + ["Metric", "Count"], + [ + ["Attempted", attempted], + ["Succeeded", succeeded], + ["Failed", failed], + ["Simulated", simulated], + ], + ) + ) return "\n".join(lines) - - diff --git a/bloodhound/report_format.py b/bloodhound/report_format.py new file mode 100644 index 0000000..3355e77 --- /dev/null +++ b/bloodhound/report_format.py @@ -0,0 +1,108 @@ +""" +bloodhound/report_format.py + +Local markdown reports and Slack mrkdwn conversion. +""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +_ET = ZoneInfo("America/New_York") + +_MD_BOLD = re.compile(r"\*\*([^*\n]+)\*\*") +_MD_ITALIC = re.compile(r"(? str: + """Convert markdown report bodies to Slack mrkdwn.""" + text = _wrap_tables_for_slack(text) + placeholders: dict[str, str] = {} + + def _stash_fence(match: re.Match[str]) -> str: + key = f"@@FENCE{len(placeholders)}@@" + placeholders[key] = match.group(0) + return key + + protected = _FENCE.sub(_stash_fence, text) + converted = _MD_BOLD.sub(r"*\1*", protected) + converted = _MD_ITALIC.sub(r"_\1_", converted) + for key, value in placeholders.items(): + converted = converted.replace(key, value) + return converted + + +def _wrap_tables_for_slack(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + i = 0 + while i < len(lines): + if lines[i].lstrip().startswith("|"): + block: list[str] = [] + while i < len(lines) and lines[i].lstrip().startswith("|"): + block.append(lines[i]) + i += 1 + if out and out[-1] != "": + out.append("") + out.append("```") + out.extend(block) + out.append("```") + out.append("") + continue + out.append(lines[i]) + i += 1 + return "\n".join(out).strip() + + +def format_report_section(title: str, body: str, *, output_format: str) -> str: + if output_format == "markdown": + return f"## {title}\n\n{body}\n" + return f"\n{'=' * 72}\n{title}\n{'=' * 72}\n{body}\n" + + +class LocalReportWriter: + def __init__( + self, + *, + enabled: bool, + output_format: str = "markdown", + report_dir: str | Path | None = None, + mode: str = "seek", + ) -> None: + self.enabled = enabled + self.output_format = output_format if output_format in {"markdown", "plain"} else "markdown" + self.report_dir = Path(report_dir or "reports") + self.mode = mode + self._sections: list[str] = [] + + def add(self, title: str, body: str) -> str: + section = format_report_section(title, body, output_format=self.output_format) + if self.enabled: + self._sections.append(section) + return section + + def flush(self) -> Path | None: + if not self.enabled or not self._sections: + return None + + self.report_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now(_ET).strftime("%Y-%m-%d_%H%M%S") + suffix = "md" if self.output_format == "markdown" else "txt" + path = self.report_dir / f"bloodhound-{self.mode}-{ts}.{suffix}" + + if self.output_format == "markdown": + header = ( + f"# Bloodhound v2 — local dry run (`{self.mode}`)\n\n" + f"- Generated: {datetime.now(_ET).strftime('%Y-%m-%d %I:%M %p ET')}\n\n" + "---\n\n" + ) + content = header + "\n---\n\n".join(self._sections) + else: + content = "\n".join(self._sections) + + path.write_text(content, encoding="utf-8") + return path diff --git a/bloodhound/scanner/ec2.py b/bloodhound/scanner/ec2.py index d1bc862..c382353 100644 --- a/bloodhound/scanner/ec2.py +++ b/bloodhound/scanner/ec2.py @@ -16,36 +16,76 @@ def scan_ec2_instances(clients: AwsClients, region: str) -> list[ResourceRecord]: ec2 = clients.client("ec2", region=region) paginator = ec2.get_paginator("describe_instances") - records: list[ResourceRecord] = [] + # First pass: collect live instances and the EBS volume ids attached to each. + pending: list[tuple[dict, str, dict[str, str], list[str]]] = [] + all_vol_ids: set[str] = set() for page in paginator.paginate(): for reservation in page.get("Reservations", []): for inst in reservation.get("Instances", []): state = (inst.get("State") or {}).get("Name") or "unknown" if state in {"stopped", "terminated", "shutting-down"}: continue - tags = _tags_to_dict(inst.get("Tags")) instance_id = inst.get("InstanceId") if not instance_id: continue - records.append( - ResourceRecord( - service="ec2", - resource_type="instance", - region=region, - id=instance_id, - arn=None, - state=state, - tags=tags, - delete_supported=True, - delete_action="terminate_instances", - delete_params={"InstanceIds": [instance_id]}, - ) - ) + vol_ids = [ + (m.get("Ebs") or {}).get("VolumeId") + for m in inst.get("BlockDeviceMappings", []) or [] + if (m.get("Ebs") or {}).get("VolumeId") + ] + all_vol_ids.update(vol_ids) + pending.append((inst, instance_id, _tags_to_dict(inst.get("Tags")), vol_ids)) + + vol_specs = _describe_volume_specs(ec2, sorted(all_vol_ids)) + + records: list[ResourceRecord] = [] + for inst, instance_id, tags, vol_ids in pending: + attached_ebs = [vol_specs[v] for v in vol_ids if v in vol_specs] + records.append( + ResourceRecord( + service="ec2", + resource_type="instance", + region=region, + id=instance_id, + arn=None, + state=(inst.get("State") or {}).get("Name") or "unknown", + tags=tags, + metadata={ + "instance_type": inst.get("InstanceType"), + "launch_time": (inst.get("LaunchTime").isoformat() if inst.get("LaunchTime") else None), + "attached_ebs": attached_ebs, + "attached_ebs_gb": sum(v["size_gb"] for v in attached_ebs), + }, + delete_supported=True, + delete_action="terminate_instances", + delete_params={"InstanceIds": [instance_id]}, + ) + ) return records +def _describe_volume_specs(ec2, vol_ids: list[str]) -> dict[str, dict[str, Any]]: + """Map volume id -> {size_gb, volume_type} for attached-EBS cost attribution.""" + specs: dict[str, dict[str, Any]] = {} + if not vol_ids: + return specs + # Batch to stay well under request-size limits. + for i in range(0, len(vol_ids), 200): + batch = vol_ids[i : i + 200] + try: + resp = ec2.describe_volumes(VolumeIds=batch) + except Exception: # noqa: BLE001 — best effort; cost just omits attached EBS + continue + for vol in resp.get("Volumes", []) or []: + vid = vol.get("VolumeId") + if not vid: + continue + specs[vid] = {"size_gb": float(vol.get("Size") or 0), "volume_type": vol.get("VolumeType")} + return specs + + def scan_ebs_unattached_volumes(clients: AwsClients, region: str) -> list[ResourceRecord]: ec2 = clients.client("ec2", region=region) paginator = ec2.get_paginator("describe_volumes") @@ -71,6 +111,11 @@ def scan_ebs_unattached_volumes(clients: AwsClients, region: str) -> list[Resour arn=None, state=state, tags=tags, + metadata={ + "size_gb": vol.get("Size"), + "volume_type": vol.get("VolumeType"), + "created_at": vol.get("CreateTime").isoformat() if vol.get("CreateTime") else None, + }, delete_supported=True, delete_action="delete_volume", delete_params={"VolumeId": vol_id}, @@ -109,6 +154,7 @@ def scan_eips_unassociated(clients: AwsClients, region: str) -> list[ResourceRec arn=None, state="unassociated", tags=tags, + metadata={"public_ip": public_ip}, delete_supported=True, delete_action="release_address", delete_params=delete_params, diff --git a/bloodhound/scanner/elbv2.py b/bloodhound/scanner/elbv2.py index 8501fc6..0a6deb3 100644 --- a/bloodhound/scanner/elbv2.py +++ b/bloodhound/scanner/elbv2.py @@ -54,6 +54,11 @@ def scan_elbv2_load_balancers(clients: AwsClients, region: str) -> list[Resource arn=arn, state=state, tags=arn_to_tags.get(arn, {}), + metadata={ + "scheme": lb.get("Scheme"), + "type": lb.get("Type"), + "created_at": lb.get("CreatedTime").isoformat() if lb.get("CreatedTime") else None, + }, delete_supported=True, delete_action="delete_load_balancer", delete_params={"LoadBalancerArn": arn}, diff --git a/bloodhound/scanner/rds.py b/bloodhound/scanner/rds.py index 80767ea..40cb450 100644 --- a/bloodhound/scanner/rds.py +++ b/bloodhound/scanner/rds.py @@ -39,6 +39,13 @@ def scan_rds_instances(clients: AwsClients, region: str, rds_final_snapshot: boo arn=arn, state=status, tags=tags, + metadata={ + "instance_class": db.get("DBInstanceClass"), + "allocated_storage_gb": db.get("AllocatedStorage"), + "engine": db.get("Engine"), + "multi_az": bool(db.get("MultiAZ")), + "created_at": db.get("InstanceCreateTime").isoformat() if db.get("InstanceCreateTime") else None, + }, delete_supported=True, delete_action="delete_db_instance", delete_params=delete_params, diff --git a/bloodhound/slack.py b/bloodhound/slack.py index 0210651..82b3269 100644 --- a/bloodhound/slack.py +++ b/bloodhound/slack.py @@ -1,8 +1,7 @@ """ bloodhound/slack.py -Small wrapper around the Slack SDK for posting messages. -The formatting is handled in `messages.py`. +Slack API wrapper with chunked posting for long reports. """ from __future__ import annotations @@ -11,6 +10,8 @@ from slack_sdk import WebClient +SLACK_TEXT_LIMIT = 3900 + @dataclass(frozen=True) class SlackNotifier: @@ -27,9 +28,48 @@ def from_token(cls, bot_token: str, scan_channel_id: str, alert_channel_id: str) ) def post_scan(self, text: str) -> None: - self.client.chat_postMessage(channel=self.scan_channel_id, text=text) + self._post_chunked(self.scan_channel_id, text) def post_alert(self, text: str) -> None: - self.client.chat_postMessage(channel=self.alert_channel_id, text=text) + self._post_chunked(self.alert_channel_id, text) + + def _post_chunked(self, channel_id: str, text: str) -> None: + for chunk in split_slack_text(text): + self.client.chat_postMessage(channel=channel_id, text=chunk) + + +def split_slack_text(text: str, limit: int = SLACK_TEXT_LIMIT) -> list[str]: + if len(text) <= limit: + return [text] + + chunks: list[str] = [] + current: list[str] = [] + current_len = 0 + + for line in text.splitlines(keepends=True): + if len(line) > limit: + if current: + chunks.append("".join(current).rstrip()) + current, current_len = [], 0 + for i in range(0, len(line), limit): + chunks.append(line[i : i + limit].rstrip()) + continue + + if current_len + len(line) > limit: + chunks.append("".join(current).rstrip()) + current, current_len = [line], len(line) + else: + current.append(line) + current_len += len(line) + + if current: + chunks.append("".join(current).rstrip()) + total = len(chunks) + if total <= 1: + return chunks + out: list[str] = [] + for idx, chunk in enumerate(chunks, start=1): + out.append(f"[{idx}/{total}]\n{chunk}") + return out diff --git a/bloodhound/slack_commands.py b/bloodhound/slack_commands.py index a309a75..005016b 100644 --- a/bloodhound/slack_commands.py +++ b/bloodhound/slack_commands.py @@ -76,6 +76,14 @@ def handle_slack_command_http(event: dict[str, Any]) -> dict[str, Any]: _invoke_worker(mode="seek", cmd=cmd) return _http_text(200, "BloodHound is on the hunt...please stand by.") + if cmd.command == "/seek_whitelist": + _invoke_worker(mode="whitelist", cmd=cmd) + return _http_text(200, "Fetching whitelist rules and protected resources...") + + if cmd.command == "/seek_cost": + _invoke_worker(mode="seek_cost", cmd=cmd) + return _http_text(200, "Pulling cost breakdown and budget summary...") + if cmd.command == "/seek_destroy": if not _destroy_allowed(cmd): # Slack surfaces non-200 responses as "dispatch_failed", so return 200 with a helpful message. @@ -83,6 +91,20 @@ def handle_slack_command_http(event: dict[str, Any]) -> dict[str, Any]: _invoke_worker(mode="seek_destroy", cmd=cmd) return _http_text(200, "Uh oh someone let the dog out ---> Seek & Destroy Underway friendly assets whitelisted...please stand by.") + if cmd.command == "/guard": + # Local import keeps the cold-start path light and avoids import cycles. + from bloodhound.guard import MUTATION_SUBCOMMANDS, parse_command, user_allowed_to_mutate + + sub, _args = parse_command(cmd.text) + if sub in MUTATION_SUBCOMMANDS and not user_allowed_to_mutate(cmd.user_id): + return _http_text( + 200, + f"`/guard {sub}` is restricted to allowlisted admins " + "(set GUARD_ALLOWED_USER_IDS / SLACK_ALLOWED_USER_IDS).", + ) + _invoke_worker(mode="guard", cmd=cmd) + return _http_text(200, f"Working the guardrails: `/guard {cmd.text.strip() or 'list'}`...") + # Unknown command (still return 200 so Slack doesn't show dispatch_failed). return _http_text(200, f"Unknown command: {cmd.command}") diff --git a/bloodhound/tables.py b/bloodhound/tables.py new file mode 100644 index 0000000..b54c3be --- /dev/null +++ b/bloodhound/tables.py @@ -0,0 +1,23 @@ +""" +bloodhound/tables.py + +Markdown table formatting for reports. +""" + +from __future__ import annotations + + +def _cell(value: object) -> str: + return str(value).replace("|", "\\|").replace("\n", " ") + + +def md_table(headers: list[str], rows: list[list[object]]) -> str: + if not rows: + return "_No rows._" + lines = [ + "| " + " | ".join(_cell(h) for h in headers) + " |", + "| " + " | ".join("---" for _ in headers) + " |", + ] + for row in rows: + lines.append("| " + " | ".join(_cell(c) for c in row) + " |") + return "\n".join(lines) diff --git a/bloodhound/types.py b/bloodhound/types.py index e734eaf..e8ebbf3 100644 --- a/bloodhound/types.py +++ b/bloodhound/types.py @@ -2,12 +2,11 @@ bloodhound/types.py Shared data structures used across scanners/whitelist/teardown. -Keeping these centralized avoids “dict soup” across the codebase. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Optional @@ -20,6 +19,7 @@ class ResourceRecord: arn: Optional[str] state: str tags: dict[str, str] + metadata: dict[str, Any] = field(default_factory=dict) delete_supported: bool = False delete_action: Optional[str] = None @@ -27,9 +27,10 @@ class ResourceRecord: def resource_key(r: ResourceRecord) -> str: - # Prefer ARN when present; else fall back to service/region/id. if r.arn: return r.arn return f"{r.service}:{r.region}:{r.id}" +def resource_display_name(r: ResourceRecord) -> str: + return r.tags.get("Name") or r.id diff --git a/bloodhound/whitelist.py b/bloodhound/whitelist.py index 4bc8b99..62b3b48 100644 --- a/bloodhound/whitelist.py +++ b/bloodhound/whitelist.py @@ -2,42 +2,75 @@ bloodhound/whitelist.py Whitelist (keep) logic for Bloodhound v2. - -Kept resources are excluded from teardown planning/execution. -Primary rule is a single tag (configurable via KEEP_TAG_KEY/KEEP_TAG_VALUE). """ from __future__ import annotations +import re +from dataclasses import dataclass + from bloodhound.config import WhitelistConfig -from bloodhound.types import ResourceRecord, resource_key +from bloodhound.types import ResourceRecord, resource_display_name, resource_key -def is_whitelisted(record: ResourceRecord, cfg: WhitelistConfig) -> bool: - # Explicit allowlist (IDs/ARNs) first. +@dataclass(frozen=True) +class WhitelistMatch: + reason: str + + +def _name_blob(record: ResourceRecord) -> str: + parts = [record.id, resource_display_name(record)] + parts.extend(record.tags.values()) + return " ".join(parts) + + +def match_whitelist(record: ResourceRecord, cfg: WhitelistConfig) -> WhitelistMatch | None: if record.id in cfg.keep_resource_ids: - return True + return WhitelistMatch(reason="KEEP_RESOURCE_IDS") if record.arn and record.arn in cfg.keep_resource_ids: - return True + return WhitelistMatch(reason="KEEP_RESOURCE_IDS") if resource_key(record) in cfg.keep_resource_ids: - return True + return WhitelistMatch(reason="KEEP_RESOURCE_IDS") + + for key, value in cfg.keep_tag_rules: + tag_val = record.tags.get(key) + if tag_val is not None and tag_val.strip().lower() == value.strip().lower(): + return WhitelistMatch(reason=f"tag `{key}={tag_val}`") + + blob = _name_blob(record) + for pattern in cfg.keep_name_patterns: + if re.search(pattern, blob, re.IGNORECASE): + return WhitelistMatch(reason=f"name/tag matches `/{pattern}/i`") - # Tag-based keep (minimal, default). - val = record.tags.get(cfg.keep_tag_key) - if val is None: - return False - # Normalize for "TRUE", "true", etc. - return val.strip().lower() == cfg.keep_tag_value.strip().lower() + return None + + +def is_whitelisted(record: ResourceRecord, cfg: WhitelistConfig) -> bool: + return match_whitelist(record, cfg) is not None -def filter_whitelisted(records: list[ResourceRecord], cfg: WhitelistConfig) -> tuple[list[ResourceRecord], list[ResourceRecord]]: +def filter_whitelisted( + records: list[ResourceRecord], cfg: WhitelistConfig +) -> tuple[list[ResourceRecord], list[ResourceRecord], dict[str, WhitelistMatch]]: kept: list[ResourceRecord] = [] candidates: list[ResourceRecord] = [] + reasons: dict[str, WhitelistMatch] = {} for r in records: - if is_whitelisted(r, cfg): + match = match_whitelist(r, cfg) + if match: kept.append(r) + reasons[resource_key(r)] = match else: candidates.append(r) - return candidates, kept + return candidates, kept, reasons +def format_whitelist_rules(cfg: WhitelistConfig) -> list[str]: + lines: list[str] = [] + for key, value in cfg.keep_tag_rules: + lines.append(f"- tag `{key}={value}`") + for pattern in cfg.keep_name_patterns: + lines.append(f"- name/tag regex `/{pattern}/i`") + if cfg.keep_resource_ids: + lines.append(f"- explicit IDs/ARNs: `{len(cfg.keep_resource_ids)}` configured") + return lines diff --git a/docs/V2_PLAN.md b/docs/V2_PLAN.md index 3ad5227..9a66f98 100644 --- a/docs/V2_PLAN.md +++ b/docs/V2_PLAN.md @@ -246,6 +246,22 @@ Defaults (as implemented) - ~~`/seek_destroy CONFIRM` (destructive, guarded)~~ - ~~Optional allowlists for destroy (user/channel IDs)~~ +### v2.7 (guardrail management — `/guard`) + +Bloodhound owns the cost guards instead of CLI scripts. A *guardrail* spans two layers +edited in sync: the org **SCP** (member accounts) and the **IAM group policy** (IAM-user +students in the mgmt account) — so they can never drift (the drift once left RDS blocked +for students after the SCP was loosened). + +- ~~`/guard list` / `show ` / `members ` (read)~~ +- ~~`/guard allow|deny ` — edits BOTH layers, toggleable-service safelist~~ +- ~~`/guard enable|disable ` — attach/detach the IAM policy to its group~~ +- ~~`/guard add-student|remove-student `~~ +- ~~Mutation gate: GUARD_ALLOWED_USER_IDS (falls back to SLACK_ALLOWED_USER_IDS)~~ +- ~~Additive IAM perms for the Lambda role (org UpdatePolicy/Attach, iam policy-version + group mgmt)~~ + +Phase 2 (next): 2-3 tiers (Core Lab / Extended / Locked) via the `tier` field + GUARD_REGISTRY. + --- ## 8) Open questions / next improvements (optional) diff --git a/env b/env new file mode 100644 index 0000000..e1a70ba --- /dev/null +++ b/env @@ -0,0 +1,75 @@ +### Bloodhound v2 environment variables (example) +# +# Copy to .env and fill in values: +# cp env.example .env +# +# v2 loads .env automatically for local runs. +# In AWS Lambda, set these in the Lambda "Environment variables" section instead. + +### Slack + +# Enable/disable Slack posting (useful for local testing) +SLACK_ENABLED=true + +# Slack bot token (xoxb-...) +SLACK_BOT_TOKEN=REPLACE_ME + +# Scan summaries channel +SLACK_SCAN_CHANNEL_ID=C0A4YLV0HNY + +# Alerts/teardown channel +SLACK_ALERT_CHANNEL_ID=C0A4YLV0HNY + +### Slack slash commands (Function URL) + +# Required to validate /seek and /seek_destroy requests from Slack +SLACK_SIGNING_SECRET=REPLACE_ME + +# Optional allowlists (comma-separated). If unset, allow all. +SLACK_ALLOWED_USER_IDS= +SLACK_ALLOWED_CHANNEL_IDS= + +# Safety: /seek_destroy requires exact text match to this token +SLACK_DESTROY_CONFIRM_TOKEN=CONFIRM + +### Regions + +# explicit = scan REGIONS list +# discover = auto-discover AWS regions +REGION_MODE=explicit +REGIONS=us-east-1,us-east-2,us-west-1,us-west-2 + +### Whitelist + +# If a resource has this tag, it will be considered "kept" (whitelisted) and excluded from teardown. +KEEP_TAG_KEY=bloodhound:keep +KEEP_TAG_VALUE=true + +# Optional: always keep these IDs/ARNs (comma-separated) +KEEP_RESOURCE_IDS= + +### Teardown + +# If false, Bloodhound only posts a plan (dry-run). +APPLY_CHANGES=false + +# If true, never performs destructive calls. EC2-style deletes are validated using DryRun where supported. +TEARDOWN_SIMULATE=true + +# Optional safety rail: only delete resources in this list (comma-separated IDs/ARNs). +TEARDOWN_TARGET_IDS= + +# If true, apply-mode can delete all non-whitelisted candidates without needing TEARDOWN_TARGET_IDS. +TEARDOWN_ALLOW_ALL=false + +# For RDS deletions: if false, skip final snapshot (fast/cheap). +RDS_FINAL_SNAPSHOT=false + +### Budget (dynamic cohort) + +COHORT_START_YYYY_MM=2025-12 +COHORT_TOTAL_BUDGET_USD=3000 +COHORT_LENGTH_MONTHS=7 +BUDGET_OVER_DAYS=2 + + diff --git a/env.example b/env.example index e1a70ba..c64685f 100644 --- a/env.example +++ b/env.example @@ -41,10 +41,15 @@ REGIONS=us-east-1,us-east-2,us-west-1,us-west-2 ### Whitelist -# If a resource has this tag, it will be considered "kept" (whitelisted) and excluded from teardown. +# Tag rules: comma-separated key=value pairs. Any matching tag keeps the resource. +# Legacy KEEP_TAG_KEY + KEEP_TAG_VALUE are used when KEEP_TAG_RULES is unset. +KEEP_TAG_RULES=bloodhound:keep=true KEEP_TAG_KEY=bloodhound:keep KEEP_TAG_VALUE=true +# Name/tag regex patterns (comma-separated). Matched case-insensitively against Name, id, and tag values. +KEEP_NAME_PATTERNS=buffalo,fullstack,vetlaunch,dont-touch,do-not-touch,dont_touch + # Optional: always keep these IDs/ARNs (comma-separated) KEEP_RESOURCE_IDS= @@ -65,6 +70,9 @@ TEARDOWN_ALLOW_ALL=false # For RDS deletions: if false, skip final snapshot (fast/cheap). RDS_FINAL_SNAPSHOT=false +# Optional: lookup CloudTrail creator per resource (best-effort; requires CloudTrail) +ATTRIBUTION_ENABLED=true + ### Budget (dynamic cohort) COHORT_START_YYYY_MM=2025-12 @@ -72,4 +80,9 @@ COHORT_TOTAL_BUDGET_USD=3000 COHORT_LENGTH_MONTHS=7 BUDGET_OVER_DAYS=2 +### Local dry-run output +# Print reports to stdout and write a file under reports/ +BLOODHOUND_PRINT_REPORTS=true +BLOODHOUND_REPORT_FORMAT=markdown +BLOODHOUND_REPORT_DIR=reports diff --git a/infra/iam.tf b/infra/iam.tf index 4501c87..f3a6c03 100644 --- a/infra/iam.tf +++ b/infra/iam.tf @@ -25,7 +25,7 @@ resource "aws_iam_role" "lambda_role" { } resource "aws_iam_role_policy_attachment" "basic_logs" { - role = aws_iam_role.lambda_role.name + role = aws_iam_role.lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } @@ -43,6 +43,50 @@ data "aws_iam_policy_document" "bloodhound" { resources = ["*"] } + # Spending-controls / guardrails proof (budgets, SCPs, IAM principals). + statement { + actions = [ + "sts:GetCallerIdentity", + "budgets:DescribeBudgets", + "budgets:ViewBudget", + "budgets:DescribeBudgetActionsForAccount", + "budgets:DescribeBudgetActionsForBudget", + "budgets:DescribeBudgetAction", + "organizations:DescribePolicy", + "organizations:DescribeOrganizationalUnit", + "organizations:DescribeAccount", + "iam:ListGroups", + "iam:ListUsers" + ] + resources = ["*"] + } + + # Cost-guard management ("/guard" command): edit the SCP + the IAM group policy in + # sync, and manage group membership. Powerful — paired with Slack allowlist gating + # (GUARD_ALLOWED_USER_IDS) and a toggleable-service safelist in the app layer. + statement { + actions = [ + "organizations:ListPolicies", + "organizations:UpdatePolicy", + "organizations:ListTargetsForPolicy", + "organizations:AttachPolicy", + "organizations:DetachPolicy", + "iam:ListPolicies", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyVersions", + "iam:CreatePolicyVersion", + "iam:DeletePolicyVersion", + "iam:ListAttachedGroupPolicies", + "iam:AttachGroupPolicy", + "iam:DetachGroupPolicy", + "iam:GetGroup", + "iam:AddUserToGroup", + "iam:RemoveUserFromGroup" + ] + resources = ["*"] + } + # Teardown permissions (delete/terminate) statement { actions = [ diff --git a/output.txt b/output.txt new file mode 100644 index 0000000..bc6f823 --- /dev/null +++ b/output.txt @@ -0,0 +1 @@ +{"errorMessage": "The request to the Slack API failed. (url: https://www.slack.com/api/chat.postMessage)\nThe server responded with: {'ok': False, 'error': 'invalid_auth'}", "errorType": "SlackApiError", "requestId": "8c5f6d13-e264-4dc2-8ba2-ff4ce7c0d6b5", "stackTrace": [" File \"/var/task/handlers/lambda_function.py\", line 26, in lambda_handler\n return run(event=event, context=context)\n", " File \"/var/task/bloodhound/app.py\", line 98, in run\n slack.post_scan(scan_msg)\n", " File \"/var/task/bloodhound/slack.py\", line 30, in post_scan\n self.client.chat_postMessage(channel=self.scan_channel_id, text=text)\n", " File \"/var/task/slack_sdk/web/client.py\", line 2564, in chat_postMessage\n return self.api_call(\"chat.postMessage\", json=kwargs)\n", " File \"/var/task/slack_sdk/web/base_client.py\", line 155, in api_call\n return self._sync_send(api_url=api_url, req_args=req_args)\n", " File \"/var/task/slack_sdk/web/base_client.py\", line 186, in _sync_send\n return self._urllib_api_call(\n", " File \"/var/task/slack_sdk/web/base_client.py\", line 317, in _urllib_api_call\n ).validate()\n", " File \"/var/task/slack_sdk/web/slack_response.py\", line 199, in validate\n raise e.SlackApiError(message=msg, response=self)\n"]} \ No newline at end of file diff --git a/tools/run_local.py b/tools/run_local.py index 5c2f415..92153ff 100644 --- a/tools/run_local.py +++ b/tools/run_local.py @@ -1,19 +1,41 @@ """ tools/run_local.py -Local runner for Bloodhound v2 (uses `.env` + your AWS_PROFILE). -This is the fastest way to validate config + Slack output before deploying to Lambda. +Local runner for Bloodhound v2. + +Writes a markdown report under `reports/` and prints sections to stdout. +Set SLACK_ENABLED=true in .env to also post to Slack. """ from __future__ import annotations import json +import os +import sys from bloodhound.app import run -if __name__ == "__main__": - result = run(event={}, context=None) +def main() -> int: + # Default local run: dry-run, stdout only. + os.environ.setdefault("APPLY_CHANGES", "false") + os.environ.setdefault("TEARDOWN_SIMULATE", "true") + os.environ.setdefault("TEARDOWN_ALLOW_ALL", "false") + os.environ.setdefault("SLACK_ENABLED", "false") + os.environ.setdefault("BLOODHOUND_PRINT_REPORTS", "true") + os.environ.setdefault("BLOODHOUND_REPORT_FORMAT", "markdown") + os.environ.setdefault("BLOODHOUND_REPORT_DIR", "reports") + + mode = (sys.argv[1] if len(sys.argv) > 1 else "seek").strip() + event: dict = {} + if mode in {"seek", "seek_cost", "whitelist", "seek_destroy"}: + event = {"source": "slack_command", "mode": mode} + + result = run(event=event, context=None) + print("\n=== JSON summary ===") print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result.get("ok") else 1 +if __name__ == "__main__": + raise SystemExit(main()) From b5c067c44bdf4e9d456068ff66dbf80518276c30 Mon Sep 17 00:00:00 2001 From: jbautista Date: Mon, 3 Aug 2026 12:46:45 -0500 Subject: [PATCH 7/7] docs: lead README with slash-command reference (incl /guard) and deploy; document GUARD_* env vars Co-Authored-By: Claude Opus 4.8 --- README.md | 264 ++++++++++++++++++++++++++++++++++------------------ env.example | 14 +++ 2 files changed, 189 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 46ac4b5..27a335c 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,243 @@ -# Bloodhound v2 (AWS resource scanner + Slack alerts + optional teardown) +# Bloodhound v2 — AWS cost-guard scanner, Slack control plane, and safe teardown -Bloodhound v2 scans selected AWS regions for common cost-leak resources, posts results to Slack, and can optionally delete resources that are **not** whitelisted. +Bloodhound scans selected AWS regions for common cost-leak resources (EC2 / RDS / ELBv2), +posts results to Slack, manages spend **guardrails** (which services student groups may use), +and can optionally tear down resources that are **not** whitelisted. -- Clone this repo -- Configure `.env` for local testing -- Rebuild the deployment zip locally (the `.build/` dir is not committed) -- Configure Lambda env vars to match your `.env` +You drive it entirely from **Slack slash commands** — start there. -If you need to create a Slack bot from scratch, see `docs/SLACK_SETUP.md`. - -Project docs: - -- v2 plan: `docs/V2_PLAN.md` +- [Slack slash commands](#slack-slash-commands) ← the operator interface +- [Managing service access with `/guard`](#managing-service-access-with-guard) ← e.g. Bedrock for a student group +- [Deploy](#deploy) +- [Environment variables](#environment-variables) +- [Local setup + testing](#local-setup--testing) +- [Whitelisting](#whitelisting) · [Teardown controls](#teardown-controls) ![AWS Architecture Diagram (v2)](assets/bloodhound_lambda_architecture_v2.svg) --- -## Requirements +## Slack slash commands -- Python 3.10+ for local dev (or match your Lambda runtime) -- AWS CLI configured (use `AWS_PROFILE=...` as needed). To set it up the first time: `aws configure --profile ` (or `aws configure` for the default profile). To see what profiles you have: `aws configure list-profiles`; your local config/creds live in `~/.aws/config` and `~/.aws/credentials` (view with `cat ~/.aws/config` and `cat ~/.aws/credentials`). -- Slack bot token and channel IDs +All commands hit a single **Lambda Function URL**; routing is by Slack's `command` field. +Read-only commands anyone in an allowed channel can run; **mutating** commands require the +caller to be allowlisted (see [Permissions & safety](#permissions--safety)). + +| Command | What it does | Mutates? | +|---|---|---| +| `/seek` | Run a scan and post the report to Slack | no | +| `/seek_cost` | Post AWS month-to-date cost breakdown + budget summary | no | +| `/seek_whitelist` | Post active whitelist rules + all currently protected resources | no | +| `/seek_destroy CONFIRM` | Destructive teardown of all non-whitelisted candidates (requires the confirm token) | **yes** | +| `/guard ` | View and manage spend guardrails (service allow/deny, group membership) | some subcommands | + +### `/guard` subcommands + +A **guardrail** is a named, two-layer spend control that `/guard` edits as a unit so the two +layers can never drift apart: + +- an Organizations **SCP** (e.g. `cp-org-spend-stop`) — restricts member accounts under its target OUs/accounts +- an IAM **managed policy attached to a group** (e.g. `cp-aico-echo-cost-guard` on group `aico-echo-class`) — restricts IAM users in the management account + +| Subcommand | Alias | Effect | Mutates? | +|---|---|---|---| +| `/guard list` | `ls` | List all guardrails: layers, attachments, denied services | no | +| `/guard show ` | | Full detail for one guardrail | no | +| `/guard members ` | `who` | List users in the guardrail's IAM group | no | +| `/guard allow ` | `unblock` | **Remove** a service from the deny lists (grant access) | **yes** | +| `/guard deny ` | `block` | **Add** a service to the deny lists (block access) | **yes** | +| `/guard enable ` | | (Re)attach the IAM policy to its group | **yes** | +| `/guard disable ` | | Detach the IAM policy from its group | **yes** | +| `/guard add-student ` | `add` | Add a user to the guardrail's group | **yes** | +| `/guard remove-student ` | `remove`, `rm` | Remove a user from the guardrail's group | **yes** | + +The default guardrail is named **`cost-guard`** (override the registry via `GUARD_REGISTRY`). --- -## Local setup + testing +## Managing service access with `/guard` -### Create a venv and install dependencies +This is the common case: a student group needs a normally-blocked service turned on (or off). -From this directory: +**Example — give a student group access to Amazon Bedrock:** -```bash -python3 -m venv .venv -.venv/bin/python -m pip install --upgrade pip -.venv/bin/python -m pip install -r requirements.txt +``` +/guard allow cost-guard bedrock ``` -### Configure `.env` +That removes `bedrock` from the deny list on **both** the SCP and the IAM group policy at once. +Bedrock exposes three service prefixes — grant all three for the full runtime: -Create `.env` from `env.example` and fill it in: +``` +/guard allow cost-guard bedrock +/guard allow cost-guard bedrock-runtime +/guard allow cost-guard bedrock-agent-runtime +``` + +To block it again, swap `allow` for `deny`: -```bash -cp env.example .env +``` +/guard deny cost-guard bedrock ``` -Bloodhound v2 automatically loads `.env` for local runs. +### Which services can be toggled -### Run locally +`allow` / `deny` may only touch services in `GUARD_TOGGLEABLE_SERVICES`. This is deliberately +the expensive-compute / ML set and **excludes** `iam`, `organizations`, EC2 instance-type caps, +etc., so a Slack message can never loosen the core guardrails themselves. Defaults: -```bash -# Choose the AWS profile you want to test with: -AWS_PROFILE=geekstar .venv/bin/python tools/run_local.py +``` +rds, redshift, neptune-db, dax, memorydb, +sagemaker, bedrock, bedrock-runtime, bedrock-agent-runtime, +eks, elasticmapreduce, es, aoss, q, deepracer, forecast, +frauddetector, kendra, comprehendmedical, healthlake, omics, braket ``` ---- +### Permissions & safety -## Whitelisting +- **Mutating** commands (`/guard allow|deny|enable|disable|add-student|remove-student`, and + `/seek_destroy`) require the caller's Slack user ID to be in `GUARD_ALLOWED_USER_IDS` + (falls back to `SLACK_ALLOWED_USER_IDS`). If neither is set, **all mutations are refused** + (fail-safe) — the command endpoint is a public Lambda Function URL. +- `/seek_destroy` also requires the confirm token (`SLACK_DESTROY_CONFIRM_TOKEN`, default `CONFIRM`). +- Set `SLACK_SIGNING_SECRET` so the Lambda can verify requests genuinely come from Slack. -Resources are **kept (whitelisted)** when they match any rule: +--- -| Rule type | Env var | Example | -|---|---|---| -| Tag | `KEEP_TAG_RULES` | `bloodhound:keep=true` | -| Name/tag regex | `KEEP_NAME_PATTERNS` | `buffalo,fullstack,vetlaunch,dont-touch` | -| Explicit ID/ARN | `KEEP_RESOURCE_IDS` | `arn:aws:rds:...:db:vetlaunch-dev-db` | +## Deploy -Legacy single-tag config still works via `KEEP_TAG_KEY` + `KEEP_TAG_VALUE`. +Bloodhound v2 deploys as its own Lambda (`BloodhoundLambdaV2`) so it never touches any v1 function. -Slack command `/seek_whitelist` posts active whitelist rules plus all currently protected resources. +### Option A — Terraform (recommended) ---- +Terraform builds the deployment zip and provisions the Lambda, its Function URL, and IAM. -## Teardown controls (important) +```bash +cd infra +terraform init +terraform apply +``` -By default, Bloodhound posts a teardown plan only: +Terraform builds `../.build/bloodhound_lambda_v2.zip` automatically (via `terraform_data` + +`archive_file`), so you need `python3`, `pip`, `zip`, and `rsync` installed locally. +After apply, set the `lambda_function_url` output as the Request URL for your slash commands +in Slack. See [`infra/README.md`](infra/README.md) for details. -- `APPLY_CHANGES=false` +> Prefer setting secrets (Slack token, signing secret) in the Lambda console or a secrets +> manager rather than `var.lambda_env` — the latter stores them in Terraform state. -To delete/terminate non-whitelisted resources: +### Option B — manual Lambda console -- `APPLY_CHANGES=true` +- **Function name:** `BloodhoundLambdaV2` +- **Handler:** `handlers.lambda_function.lambda_handler` +- Build the zip (the `.build/` dir is not committed — Terraform builds it, or build it yourself), + upload it, then set the [environment variables](#environment-variables) below. -Safety rails: +Smoke-test the deployed function: -- **simulate (no deletes)**: `TEARDOWN_SIMULATE=true` -- **only delete explicit IDs/ARNs**: set `TEARDOWN_TARGET_IDS=...` -- **delete everything not whitelisted**: `TEARDOWN_ALLOW_ALL=true` +```bash +aws lambda invoke \ + --function-name BloodhoundLambdaV2 \ + --payload file://tools/test_event.json \ + output.txt \ + --region us-west-2 ---- +cat output.txt +``` -## Build the Lambda deployment zip (v2) +### Wire up Slack -The `.build/` directory is intentionally not committed. Terraform will build the zip automatically (see `infra/README.md`). +In your Slack app settings, register each slash command (`/seek`, `/seek_cost`, +`/seek_whitelist`, `/seek_destroy`, `/guard`) with the **Request URL** set to the Lambda +Function URL. Creating the Slack app from scratch is covered in [`docs/SLACK_SETUP.md`](docs/SLACK_SETUP.md). --- -## Deploy to AWS Lambda (v2) +## Environment variables -Deploy to a new function name so you do not touch your existing v1 Lambda: +Set these on the Lambda (copy from your local `.env` — see [`env.example`](env.example)). -- Function name: `BloodhoundLambdaV2` -- Handler: `handlers.lambda_function.lambda_handler` +**Slack (required for slash commands)** -### Configure Lambda environment variables +| Var | Purpose | +|---|---| +| `SLACK_BOT_TOKEN` | Bot token used to post reports | +| `SLACK_SCAN_CHANNEL_ID` / `SLACK_ALERT_CHANNEL_ID` | Where scan reports / alerts go | +| `SLACK_SIGNING_SECRET` | Verify inbound requests came from Slack | +| `SLACK_ALLOWED_USER_IDS` | (optional) allowlist for mutating commands (fallback for `/guard`) | +| `SLACK_ALLOWED_CHANNEL_IDS` | (optional) restrict which channels may invoke commands | +| `SLACK_DESTROY_CONFIRM_TOKEN` | Confirm token for `/seek_destroy` (default `CONFIRM`) | -In Lambda Console → **Configuration → Environment variables**, copy the values from your local `.env`. +**Guardrails (`/guard`)** -At minimum: +| Var | Purpose | +|---|---| +| `GUARD_ALLOWED_USER_IDS` | Allowlist of Slack user IDs permitted to run `/guard` mutations (falls back to `SLACK_ALLOWED_USER_IDS`) | +| `GUARD_TOGGLEABLE_SERVICES` | (optional) override the CSV of services `allow`/`deny` may touch | +| `GUARD_REGISTRY` | (optional) JSON overriding the guardrail registry (name, scp, iam_policy, group) | -- Slack: `SLACK_BOT_TOKEN`, `SLACK_SCAN_CHANNEL_ID`, `SLACK_ALERT_CHANNEL_ID` -- Regions: `REGION_MODE`, `REGIONS` -- Budget: `COHORT_START_YYYY_MM`, `COHORT_TOTAL_BUDGET_USD`, `COHORT_LENGTH_MONTHS`, `BUDGET_OVER_DAYS` -- Teardown: `APPLY_CHANGES`, `TEARDOWN_SIMULATE`, `TEARDOWN_ALLOW_ALL`, `TEARDOWN_TARGET_IDS` +**Scan / budget / teardown** -### Test the Lambda via AWS CLI +| Var | Purpose | +|---|---| +| `REGION_MODE`, `REGIONS` | Which regions to scan | +| `COHORT_START_YYYY_MM`, `COHORT_TOTAL_BUDGET_USD`, `COHORT_LENGTH_MONTHS`, `BUDGET_OVER_DAYS` | Budget summary math | +| `APPLY_CHANGES`, `TEARDOWN_SIMULATE`, `TEARDOWN_ALLOW_ALL`, `TEARDOWN_TARGET_IDS` | Teardown behavior (see below) | + +--- + +## Local setup + testing ```bash -aws lambda invoke \ - --function-name BloodhoundLambdaV2 \ - --payload file://tools/test_event.json \ - output.txt \ - --region us-west-2 +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -r requirements.txt -cat output.txt +cp env.example .env # then fill it in — Bloodhound auto-loads .env for local runs + +# Run against a chosen AWS profile: +AWS_PROFILE=geekstar .venv/bin/python tools/run_local.py ``` ---- +Requirements: Python 3.10+ (or match your Lambda runtime), AWS CLI configured +(`aws configure --profile `; profiles live in `~/.aws/config` / `~/.aws/credentials`), +and a Slack bot token + channel IDs. -## GitHub Actions (invoke v2) +--- -This repo includes a separate workflow for v2: +## Whitelisting -- `.github/workflows/invoke_lambda_v2.yml` +Resources are **kept (whitelisted)** when they match any rule: -It invokes: +| Rule type | Env var | Example | +|---|---|---| +| Tag | `KEEP_TAG_RULES` | `bloodhound:keep=true` | +| Name/tag regex | `KEEP_NAME_PATTERNS` | `buffalo,fullstack,vetlaunch,dont-touch` | +| Explicit ID/ARN | `KEEP_RESOURCE_IDS` | `arn:aws:rds:...:db:vetlaunch-dev-db` | -- `BloodhoundLambdaV2` +Legacy single-tag config still works via `KEEP_TAG_KEY` + `KEEP_TAG_VALUE`. +`/seek_whitelist` posts the active rules plus every currently protected resource. --- -## Slack slash commands (v2) +## Teardown controls + +By default Bloodhound posts a teardown **plan only** (`APPLY_CHANGES=false`). To actually +delete/terminate non-whitelisted resources, set `APPLY_CHANGES=true`. Safety rails: + +- **simulate (no deletes):** `TEARDOWN_SIMULATE=true` +- **only delete explicit IDs/ARNs:** `TEARDOWN_TARGET_IDS=...` +- **delete everything not whitelisted:** `TEARDOWN_ALLOW_ALL=true` + +--- -Slash commands require a publicly reachable HTTPS endpoint. For v2 we recommend a **Lambda Function URL** (one endpoint) and route based on the Slack `command` field. +## GitHub Actions -- `/seek` runs scan + reports (non-destructive) -- `/seek_cost` posts AWS MTD cost breakdown + budget summary (non-destructive) -- `/seek_whitelist` posts whitelist rules + protected resources (non-destructive) -- `/seek_destroy CONFIRM` runs destructive mode (deletes all non-whitelisted candidates we scan for) +`.github/workflows/invoke_lambda.yml` invokes `BloodhoundLambdaV2` on a schedule (currently +disabled — enable it in the Actions tab when you want scheduled scans). -To enable slash commands you must set these env vars in Lambda: +## Project docs -- `SLACK_SIGNING_SECRET` -- `SLACK_ALLOWED_USER_IDS` (optional) -- `SLACK_ALLOWED_CHANNEL_IDS` (optional) -- `SLACK_DESTROY_CONFIRM_TOKEN` (default `CONFIRM`) +- v2 plan: [`docs/V2_PLAN.md`](docs/V2_PLAN.md) +- Slack app setup: [`docs/SLACK_SETUP.md`](docs/SLACK_SETUP.md) +- Infrastructure: [`infra/README.md`](infra/README.md) diff --git a/env.example b/env.example index c64685f..62147b2 100644 --- a/env.example +++ b/env.example @@ -32,6 +32,20 @@ SLACK_ALLOWED_CHANNEL_IDS= # Safety: /seek_destroy requires exact text match to this token SLACK_DESTROY_CONFIRM_TOKEN=CONFIRM +### Guardrails (/guard) + +# Slack user IDs allowed to run /guard mutations (allow/deny/enable/disable/add-student/remove-student). +# Falls back to SLACK_ALLOWED_USER_IDS. If neither is set, ALL mutations are refused (fail-safe). +GUARD_ALLOWED_USER_IDS= + +# Optional: override the services /guard allow|deny may toggle (comma-separated). +# Default is the expensive-compute/ML set (rds, bedrock, sagemaker, eks, ...); iam/organizations/ec2 are never toggleable. +GUARD_TOGGLEABLE_SERVICES= + +# Optional: JSON array overriding the guardrail registry. +# Each entry: {"name","tier","scp","iam_policy","group"}. Default is a single "cost-guard" guardrail. +GUARD_REGISTRY= + ### Regions # explicit = scan REGIONS list