diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0cd22c7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[{Dockerfile,Dockerfile.*}] +indent_size = 4 +tab_width = 4 + +[{Makefile,makefile,GNUmakefile}] +indent_style = tab +indent_size = 4 + +[Makefile.*] +indent_style = tab +indent_size = 4 + +[**/*.{go,mod,sum}] +indent_style = tab +indent_size = unset + +[**/*.py] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..161c538 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +APP_DEBUG_ENABLED=true +APP_AWS_CONSOLE_URL=https://console.aws.amazon.com +APP_AWS_ACCESS_PORTAL_URL= +APP_AWS_ACCESS_ROLE_NAME= +APP_AWS_SECURITYHUBV2_REGION= + +# Auto-close rules (JSON array) - optional +# APP_AUTO_CLOSE_RULES='[{"name":"auto-close-runs-on-container-mounts","enabled":true,"filters":{"finding_types":["PrivilegeEscalation:Runtime/ContainerMountsHostDirectory"],"resource_tags":[{"name":"provider","value":"runs-on.com"}]},"action":{"status_id":5,"comment":"Auto-closed: Expected behavior for runs-on.com ephemeral runners"},"skip_notification":true}]' + +# Auto-close rules from S3 (recommended for large rule sets) - optional +# APP_AUTO_CLOSE_RULES_S3_BUCKET=my-securityhub-rules-bucket +# APP_AUTO_CLOSE_RULES_S3_PREFIX=rules/ + +# Slack integration (optional - both required to enable Slack notifications) +APP_SLACK_TOKEN= +APP_SLACK_CHANNEL= diff --git a/.github/.dependabot.yaml b/.github/.dependabot.yaml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/.github/.dependabot.yaml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..d1a79d1 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,67 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: '1.24' + + - name: Run go vet + run: go vet ./... + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "following files are not formatted:" + echo "$unformatted" + exit 1 + fi + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: '1.24' + + - name: Run Tests + run: make test + + - name: Run Verify Tests + run: make test-verify-verbose + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: '1.24' + + - name: Build Lambda + run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -C cmd/lambda -o ../../dist/bootstrap diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..59c9553 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,29 @@ +name: release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - name: Bump Version + id: tag_version + uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: minor + custom_release_rules: bug:patch:Fixes,chore:patch:Chores,docs:patch:Documentation,feat:minor:Features,refactor:minor:Refactors,test:patch:Tests,ci:patch:Development,dev:patch:Development + - name: Create Release + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} diff --git a/.github/workflows/semantic-check.yaml b/.github/workflows/semantic-check.yaml new file mode 100644 index 0000000..6dfaeca --- /dev/null +++ b/.github/workflows/semantic-check.yaml @@ -0,0 +1,26 @@ +name: semantic-check +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + contents: read + pull-requests: read + +jobs: + main: + name: Semantic Commit Message Check + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + name: Check PR for Semantic Commit Message + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + requireScope: false + validateSingleCommit: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efabf14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +!**/.gitkeep + +tmp/ +dist/ +.DS_Store + +.local/ +.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d8e98a3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Agent Guidelines + +## Build & Test +- **Build Lambda**: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -C cmd/lambda -o ../../dist/bootstrap` +- **Test all**: `make test` (runs with `-race -count=1`) +- **Test single package**: `go test -race -count=1 ./internal/filters` +- **Test single function**: `go test -race -count=1 ./internal/filters -run TestFilterEngine_FindMatchingRule_RunsOnExample` +- **Run sample locally**: `go run -C cmd/sample .` (requires `.env` file and AWS credentials for auto-close testing) +- **Lint**: `go vet ./...` and `gofmt -l .` + +## Code Style +- **Imports**: stdlib, then blank line, then third-party, then local (e.g., `internal/`) +- **Naming**: Go standard - `PascalCase` exports, `camelCase` private, `ALL_CAPS` for env vars prefixed with `APP_` +- **Error handling**: return errors up the stack; use `fmt.Errorf` for wrapping +- **Structs**: define types in package, constructors as `New()` or `NewTypeName()`; all methods must be public (PascalCase) +- **Interfaces**: keep minimal (e.g., `SecurityHubEvent` has 2 methods) +- **Formatting**: use `gofmt` (tabs for indentation) +- **Comments**: rare, lowercase, short, concise; code should be self-documenting +- **Code smells**: keep to minimum; prefer clear naming over comments + +## Architecture +- `cmd/lambda/main.go` - Lambda handler entry point +- `cmd/sample/main.go` - Local development runner using fixtures +- `internal/app/` - Core application logic and configuration +- `internal/events/` - OCSF event parsing and Slack message formatting +- `internal/filters/` - Auto-close rule engine and filter matching logic +- `internal/actions/` - Finding update actions (auto-close via BatchUpdateFindingsV2) +- `internal/notifiers/` - Optional notification integrations (Slack) +- `fixtures/samples.json` - Sample Security Hub v2 OCSF findings for testing + +## Important Notes +- This project is specifically for **AWS Security Hub v2** which uses OCSF (Open Cybersecurity Schema Framework) format +- It is NOT compatible with the original AWS Security Hub (now called Security Hub CSPM) ASFF format +- Security Hub v2 centralizes findings from GuardDuty, Inspector, Macie, IAM Access Analyzer, and Security Hub CSPM +- Events use OCSF fields like `finding_info`, `metadata`, `severity`, `class_name`, etc. +- Auto-close rules use **BatchUpdateFindingsV2** API (not BatchUpdateFindings) +- Slack integration is **optional** - bot works without Slack if only auto-close is needed +- Rules are evaluated in order; first match wins +- Filter matching uses AND logic - all specified filters must match diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..30ed2f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Crux Stack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2ea257c --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# set mac-only linker flags only for go test (not global) +UNAME_S := $(shell uname -s) +TEST_ENV := +ifeq ($(UNAME_S),Darwin) + TEST_ENV = CGO_LDFLAGS=-w +endif + +TEST_FLAGS := -race -count=1 +.PHONY: build-debug +build-debug: + GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags "-s -w" -o dist/sample ./cmd/sample + +.PHONY: debug +debug: + go run ./cmd/sample + +.PHONY: test +test: + $(TEST_ENV) go test $(TEST_FLAGS) $$(go list ./... | grep -v tmp/) + +.PHONY: test-unit +test-unit: + $(TEST_ENV) go test $(TEST_FLAGS) ./internal/... + +.PHONY: test-verify +test-verify: + go run ./cmd/verify + +.PHONY: test-verify-verbose +test-verify-verbose: + go run ./cmd/verify -verbose + diff --git a/README.md b/README.md index 8591900..2894a5f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,256 @@ -# aws-securityhubv2-bot-go +# aws-securityhubv2-bot +AWS Lambda bot that processes **AWS Security Hub v2** findings with configurable auto-close rules and optional Slack notifications. + +> **Important:** Security Hub v2 only (OCSF format). Not directly compatible with original Security Hub CSPM (ASFF format). + +## Features + +* **auto-close rules** - suppress/resolve findings via JSON filters (type, severity, tags, accounts, regions) +* **optional slack** - rich notifications with context and remediation links +* **flexible config** - environment variables or S3 for rule storage +* **multi-service** - GuardDuty, Inspector, Macie, IAM Access Analyzer, Security Hub CSPM + +--- + +## Quick Start + +### Build + +```bash +mkdir -p dist +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -C cmd/lambda -o ../../dist/bootstrap +cd dist && zip deployment.zip bootstrap && cd .. +``` + +### Deploy Lambda + +1. **IAM role** with `AWSLambdaBasicExecutionRole` + `securityhub:BatchUpdateFindingsV2` +2. **Create function** using `deployment.zip` (runtime: `provided.al2023`, handler: `bootstrap`) +3. **EventBridge rule** targeting the Lambda: + ```json + { + "source": ["aws.securityhub"], + "detail-type": ["Findings Imported V2"] + } + ``` +4. **Configure** using environment variables below + +--- + +## Configuration + +### Auto-Close Rules + +| Name | Description | +| ---------------------------------- | -------------------------------------------------- | +| `APP_AUTO_CLOSE_RULES` | JSON array of auto-close rules (see examples) | +| `APP_AUTO_CLOSE_RULES_S3_BUCKET` | S3 bucket for rules (for large rule sets) | +| `APP_AUTO_CLOSE_RULES_S3_PREFIX` | S3 prefix for rules (default: `rules/`) | + +Use environment variables, S3, or both. Environment rules evaluated first. + +### Slack (Optional) + +| Name | Description | +| -------------------- | ----------------------------------------- | +| `APP_SLACK_TOKEN` | Bot token with `chat:write` scope | +| `APP_SLACK_CHANNEL` | Channel ID (e.g., `C000XXXXXXX`) | + +### Additional + +| Name | Description | +| --------------------------------- | ---------------------------------------- | +| `APP_DEBUG_ENABLED` | Verbose logging (default: `false`) | +| `APP_AWS_CONSOLE_URL` | Base console URL | +| `APP_AWS_ACCESS_PORTAL_URL` | Federated access portal URL | +| `APP_AWS_ACCESS_ROLE_NAME` | IAM role for portal | +| `APP_AWS_SECURITYHUBV2_REGION` | Centralized SecurityHub region | + +--- + +## Examples + +### Basic Rule: Suppress GitHub Runner Findings + +```json +[ + { + "name": "auto-close-github-runners", + "enabled": true, + "filters": { + "finding_types": ["Execution:Runtime/NewBinaryExecuted"], + "resource_tags": [{"name": "component-type", "value": "github-action-runners"}] + }, + "action": { + "status_id": 5, + "comment": "Auto-archived: Expected CI/CD behavior" + }, + "skip_notification": true + } +] +``` + +See [examples/github-actions-runner-example.md](examples/github-actions-runner-example.md) for detailed walkthrough. + +### Multiple Rules + +```json +[ + { + "name": "suppress-inspector-low-dev", + "filters": { + "product_name": ["Inspector"], + "severity": ["Low"], + "accounts": ["123456789012"] + }, + "action": {"status_id": 3, "comment": "Auto-suppressed: Low severity in dev"}, + "skip_notification": false + }, + { + "name": "resolve-approved-scans", + "filters": { + "finding_types": ["Recon:EC2/PortProbeUnprotectedPort"], + "resource_tags": [{"name": "ScannerApproved", "value": "true"}] + }, + "action": {"status_id": 4, "comment": "Auto-resolved: Approved scanner"}, + "skip_notification": true + } +] +``` + +### Filter Reference + +All filters use AND logic. First matching rule wins. + +| Field | Type | Example | +| ----------------- | ------------ | --------------------------------------------- | +| `finding_types` | `[]string` | `["Execution:Runtime/NewBinaryExecuted"]` | +| `severity` | `[]string` | `["Critical", "High"]` | +| `product_name` | `[]string` | `["GuardDuty", "Inspector"]` | +| `resource_types` | `[]string` | `["AWS::EC2::Instance"]` | +| `resource_tags` | `[]object` | `[{"name": "Environment", "value": "dev"}]` | +| `accounts` | `[]string` | `["123456789012"]` | +| `regions` | `[]string` | `["us-east-1"]` | + + +### Status IDs + +Based on [OCSF 1.6.0 specification](https://schema.ocsf.io/1.6.0/classes/detection_finding): + +| ID | Status | Description | +| --- | ------------- | -------------------------------------------------------------------------------- | +| 0 | Unknown | The status is unknown | +| 1 | New | The finding is new and yet to be reviewed | +| 2 | In Progress | The finding is under review | +| 3 | Suppressed | The finding was reviewed, determined to be benign or false positive, suppressed | +| 4 | Resolved | The finding was reviewed, remediated and is now considered resolved | +| 5 | Archived | The finding was archived | +| 6 | Deleted | The finding was deleted (e.g., created in error) | +| 99 | Other | The status is not mapped (see status attribute for source-specific value) | + +Common usage: `status_id: 5` (Archived) for accepted behavior, `status_id: 4` (Resolved) for remediated issues, `status_id: 3` (Suppressed) for false positives. + +### S3 Rule Storage + +For large rule sets (>4KB), store rules in S3. Supports single rule per file, arrays of rules, or mixed approach: + +``` +s3://my-rules-bucket/rules/ +├── guardduty/ +│ └── suppress-dev.json +├── inspector/ +│ └── all-rules.json +└── auto-close-runners.json +``` + +Requirements: Lambda needs `s3:GetObject` and `s3:ListBucket` on the bucket. Only `.json` files processed. + +--- + +## EventBridge Filters (Optional) + +Filter by severity for high-volume environments: + +```json +{ + "source": ["aws.securityhub"], + "detail-type": ["Findings Imported V2"], + "detail": { + "findings": { + "severity": ["Critical", "High"] + } + } +} +``` + +Or by source service: + +```json +{ + "detail": { + "findings": { + "metadata": { + "product": { + "name": ["GuardDuty", "Inspector"] + } + } + } + } +} +``` + +--- + +## IAM Permissions + +Lambda role needs `AWSLambdaBasicExecutionRole` plus: + +```json +{ + "Effect": "Allow", + "Action": ["securityhub:BatchUpdateFindingsV2"], + "Resource": "*" +} +``` + +If using S3 rules, add: + +```json +{ + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:ListBucket"], + "Resource": [ + "arn:aws:s3:::my-rules-bucket", + "arn:aws:s3:::my-rules-bucket/*" + ] +} +``` + +--- + +## How It Works + +1. EventBridge triggers Lambda on "Findings Imported V2" +2. Parse OCSF finding from event +3. Evaluate auto-close rules in order (first match wins) +4. If matched: call `BatchUpdateFindingsV2` with status + comment +5. Send Slack notification (unless `skip_notification: true`) +6. If no match: send to Slack if finding is alertable + +--- + +## Local Development + +```bash +cp .env.example .env # edit values +go run -C cmd/sample . +``` + +Uses OCSF findings from `fixtures/samples.json`. Requires AWS credentials for auto-close testing. + +--- + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/assets/slack/avatar.png b/assets/slack/avatar.png new file mode 100644 index 0000000..76aa584 Binary files /dev/null and b/assets/slack/avatar.png differ diff --git a/assets/slack/manifest.json b/assets/slack/manifest.json new file mode 100644 index 0000000..262346b --- /dev/null +++ b/assets/slack/manifest.json @@ -0,0 +1,28 @@ +{ + "display_information": { + "name": "AWS Security Hub v2", + "description": "AWS Security Hub v2 Findings Bot", + "background_color": "#dd344c" + }, + "features": { + "bot_user": { + "display_name": "AWS Security Hub v2", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "channels:join", + "channels:read", + "chat:write.public", + "chat:write" + ] + } + }, + "settings": { + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/cmd/lambda/main.go b/cmd/lambda/main.go new file mode 100644 index 0000000..b3e1bd0 --- /dev/null +++ b/cmd/lambda/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "sync" + + awsevents "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/cruxstack/aws-securityhubv2-bot/internal/app" + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +var ( + once sync.Once + a *app.App + logger *slog.Logger + initErr error +) + +func LambdaHandler(ctx context.Context, evt awsevents.CloudWatchEvent) error { + once.Do(func() { + logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + cfg, err := app.NewConfig() + if err != nil { + initErr = err + return + } + + if cfg.DebugEnabled { + logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + } + + a, initErr = app.New(ctx, cfg, logger) + }) + + if initErr != nil { + return initErr + } + + if a.Config.DebugEnabled { + j, _ := json.Marshal(evt) + logger.Debug("received event", "event_id", evt.ID, "detail_type", evt.DetailType, "event", string(j)) + } + + // convert Lambda CloudWatch event to runtime-agnostic event input + input := events.SecurityHubEventInput{ + EventID: evt.ID, + DetailType: evt.DetailType, + Detail: evt.Detail, + } + + return a.Process(ctx, input) +} + +func main() { + lambda.Start(LambdaHandler) +} diff --git a/cmd/sample/main.go b/cmd/sample/main.go new file mode 100644 index 0000000..c9b4832 --- /dev/null +++ b/cmd/sample/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/joho/godotenv" + + "github.com/cruxstack/aws-securityhubv2-bot/internal/app" + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +func main() { + ctx := context.Background() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + + envpath := filepath.Join(".env") + logger.Info("loading environment", "path", envpath) + if _, err := os.Stat(envpath); err == nil { + _ = godotenv.Load(envpath) + } + + cfg, err := app.NewConfig() + if err != nil { + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + a, err := app.New(ctx, cfg, logger) + if err != nil { + logger.Error("failed to create app", "error", err) + os.Exit(1) + } + + path := filepath.Join("fixtures", "samples.json") + raw, err := os.ReadFile(path) + if err != nil { + logger.Error("failed to read fixtures", "error", err, "path", path) + os.Exit(1) + } + + var findings []json.RawMessage + if err := json.Unmarshal(raw, &findings); err != nil { + logger.Error("failed to unmarshal fixtures", "error", err) + os.Exit(1) + } + + logger.Info("processing samples", "count", len(findings)) + + for i, finding := range findings { + detail := map[string]any{ + "findings": []json.RawMessage{finding}, + } + detailBytes, err := json.Marshal(detail) + if err != nil { + logger.Error("failed to marshal detail", "error", err, "sample", i) + os.Exit(1) + } + + evt := events.SecurityHubEventInput{ + EventID: fmt.Sprintf("sample-%d", i), + DetailType: "Findings Imported V2", + Detail: detailBytes, + } + + if err := a.Process(ctx, evt); err != nil { + logger.Error("failed to process sample", "error", err, "sample", i) + os.Exit(1) + } + logger.Info("processed sample successfully", "sample", i) + } +} diff --git a/cmd/verify/.env.test b/cmd/verify/.env.test new file mode 100644 index 0000000..1fb5c5a --- /dev/null +++ b/cmd/verify/.env.test @@ -0,0 +1,12 @@ +# dummy values for offline testing - never sent to real apis + +# aws configuration +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# app configuration +APP_DEBUG_ENABLED=false +APP_AWS_CONSOLE_URL=https://console.aws.amazon.com +APP_AWS_SECURITYHUBV2_REGION=us-east-1 + diff --git a/cmd/verify/README.md b/cmd/verify/README.md new file mode 100644 index 0000000..4bbd99e --- /dev/null +++ b/cmd/verify/README.md @@ -0,0 +1,90 @@ +# Integration Tests + +Offline integration tests that validate bot behavior with Security Hub v2 events +and auto-close rules using local mock servers. Tests use real SDK clients +against production code paths, so no mocking of `internal/` packages. + +## Quick Start + +```bash +# run all tests +make test-verify + +# debug with verbose output +make test-verify-verbose + +# run specific scenario +go run ./cmd/verify -filter="auto_close_github_runner" +``` + +No setup required—uses `.env.test` automatically (dummy credentials, never sent to real APIs). + +## How It Works + +Tests run production code against local HTTPS mock servers (ports 9001-9003): + +1. Mock servers start with self-signed TLS certificates +2. AWS/Slack SDKs are redirected to mocks via environment variables +3. Requests are captured and validated against expected API calls +4. Mock responses return predefined JSON from scenario definitions + +## Scenarios + +Current test suite in `fixtures/scenarios.json`: + +- **auto_close_github_runner_new_binary** - Auto-close by resource tags +- **auto_close_inspector_low_severity_in_dev** - Close by product, severity, account with notification +- **no_match_sends_notification** - Unmatched findings trigger Slack +- **multiple_rules_first_match_wins** - Rule precedence validation +- **disabled_rule_not_applied** - Disabled rules are skipped +- **suppress_macie_findings_in_sandbox** - Multi-value filter matching + +Each scenario defines: + +- CloudWatch Event payload (OCSF finding) +- Config overrides (auto-close rules, Slack settings) +- Expected API calls (method, path patterns with wildcard support) +- Mock responses (status code, body) + +See `fixtures/scenarios.json` for complete examples. + +## Adding Tests + +1. Add scenario to `fixtures/scenarios.json` +2. Define event payload and config overrides +3. Specify expected API calls and mock responses +4. Run `make test-verify` + +## Debugging + +Use `-verbose` flag for detailed output showing: +- Real-time HTTP request/response logging +- Application logs during execution +- All captured requests on test failure +- Missing or unexpected API calls + +## Architecture + +``` +CloudWatch Event → App → AWS/Slack SDK → Mock HTTPS Server (localhost) + ↓ + Capture & Validate +``` + +Mock servers: +- SecurityHub: `https://localhost:9001` (via `AWS_ENDPOINT_URL`) +- Slack: `https://localhost:9002/api` (via `APP_SLACK_API_URL`) +- S3: `https://localhost:9003` (if needed) + +Implementation: +- `mock.go`: http server +- `tls.go`: certs +- `match.go`: path matching +- `scenario.go`: orchestration +- `logger.go`: log capture + +## Limitations + +- fixed ports prevent parallel execution +- tests run serially +- requires dummy aws credentials in environment diff --git a/cmd/verify/logger.go b/cmd/verify/logger.go new file mode 100644 index 0000000..1780d7d --- /dev/null +++ b/cmd/verify/logger.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "strings" +) + +// testHandler implements slog.Handler for capturing application logs during +// tests. only displays logs in verbose mode. +type testHandler struct { + prefix string + verbose bool + w io.Writer +} + +// Enabled returns true for all log levels when verbose mode is enabled. +func (h *testHandler) Enabled(_ context.Context, _ slog.Level) bool { + return true +} + +// Handle formats and writes log records to output with test-appropriate +// formatting. +func (h *testHandler) Handle(_ context.Context, r slog.Record) error { + if !h.verbose { + return nil + } + + prefix := h.prefix + "› " + msg := r.Message + + var attrs []string + r.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, fmt.Sprintf("%s=%v", a.Key, a.Value)) + return true + }) + + if len(attrs) > 0 { + msg = fmt.Sprintf("%s %s", msg, strings.Join(attrs, " ")) + } + + fmt.Fprintf(h.w, "%s%s\n", prefix, msg) + return nil +} + +// WithAttrs returns the handler unchanged. +func (h *testHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h +} + +// WithGroup returns the handler unchanged. +func (h *testHandler) WithGroup(name string) slog.Handler { + return h +} diff --git a/cmd/verify/main.go b/cmd/verify/main.go new file mode 100644 index 0000000..f4c0407 --- /dev/null +++ b/cmd/verify/main.go @@ -0,0 +1,89 @@ +// Package main provides offline integration testing using HTTP mock servers. +// tests AWS Security Hub v2 event processing and auto-close rules without +// requiring live AWS credentials or Slack API access. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" +) + +func main() { + scenarioFile := flag.String("scenarios", "fixtures/scenarios.json", "path to test scenarios file") + verbose := flag.Bool("verbose", false, "enable verbose output") + scenarioFilter := flag.String("filter", "", "run only scenarios matching this name") + flag.Parse() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + envPath := filepath.Join("cmd", "verify", ".env") + envExamplePath := filepath.Join("cmd", "verify", ".env.test") + + if _, err := os.Stat(envPath); err == nil { + if err := godotenv.Load(envPath); err != nil { + logger.Warn("failed to load .env file", slog.String("error", err.Error())) + } + } else if _, err := os.Stat(envExamplePath); err == nil { + fmt.Printf("Using .env.test (no .env file found)\n") + if err := godotenv.Load(envExamplePath); err != nil { + logger.Warn("failed to load .env.test file", slog.String("error", err.Error())) + } + } + + ctx := context.Background() + + path := filepath.Join(*scenarioFile) + raw, err := os.ReadFile(path) + if err != nil { + logger.Error("failed to read scenarios file", slog.String("error", err.Error())) + os.Exit(1) + } + + var scenarios []TestScenario + if err := json.Unmarshal(raw, &scenarios); err != nil { + logger.Error("failed to parse scenarios", slog.String("error", err.Error())) + os.Exit(1) + } + + passed := 0 + failed := 0 + skipped := 0 + + for _, scenario := range scenarios { + if *scenarioFilter != "" && !strings.Contains(scenario.Name, *scenarioFilter) { + skipped++ + continue + } + + if err := runScenario(ctx, scenario, *verbose, logger); err != nil { + fmt.Printf("✗ FAILED: %v\n\n", err) + failed++ + } else { + passed++ + } + } + + fmt.Printf("\n") + separator := strings.Repeat("═", 60) + fmt.Printf("%s\n", separator) + if failed > 0 { + fmt.Printf(" Test Results: %d passed, %d failed, %d skipped\n", passed, failed, skipped) + } else { + fmt.Printf(" Test Results: ✓ All %d tests passed, %d skipped\n", passed, skipped) + } + fmt.Printf("%s\n", separator) + + if failed > 0 { + os.Exit(1) + } +} diff --git a/cmd/verify/match.go b/cmd/verify/match.go new file mode 100644 index 0000000..7596143 --- /dev/null +++ b/cmd/verify/match.go @@ -0,0 +1,49 @@ +package main + +import "strings" + +// matchPath checks if an actual HTTP path matches an expected pattern. +// supports wildcards (*) in the expected pattern. +func matchPath(actual, expected string) bool { + if expected == "*" { + return true + } + + if actual == expected { + return true + } + + if strings.Contains(expected, "*") { + parts := strings.Split(expected, "*") + + if len(parts) == 2 { + return strings.HasPrefix(actual, parts[0]) && strings.HasSuffix(actual, parts[1]) + } + + pos := 0 + for i, part := range parts { + if part == "" { + continue + } + + idx := strings.Index(actual[pos:], part) + if idx == -1 { + return false + } + + if i == 0 && idx != 0 { + return false + } + + pos += idx + len(part) + } + + if parts[len(parts)-1] != "" { + return strings.HasSuffix(actual, parts[len(parts)-1]) + } + + return true + } + + return false +} diff --git a/cmd/verify/mock.go b/cmd/verify/mock.go new file mode 100644 index 0000000..811dc8f --- /dev/null +++ b/cmd/verify/mock.go @@ -0,0 +1,143 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// RequestRecord captures details of an HTTP request made during testing. +// used to verify expected API calls were made correctly. +type RequestRecord struct { + Timestamp time.Time `json:"timestamp"` + Method string `json:"method"` + Host string `json:"host"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + Headers map[string][]string `json:"headers"` + Body string `json:"body,omitempty"` +} + +// MockResponse defines a canned HTTP response returned by the mock server +// for matching requests. +type MockResponse struct { + Service string `json:"service"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body"` + Description string `json:"description,omitempty"` +} + +// MockServer simulates an HTTP API service for integration testing. +// records all requests and returns predefined responses. +type MockServer struct { + name string + mu sync.Mutex + requests []RequestRecord + responses map[string]MockResponse + verbose bool +} + +// NewMockServer creates a new mock HTTP server with canned responses. +// matches requests by HTTP method and path pattern. +func NewMockServer(name string, responses []MockResponse, verbose bool) *MockServer { + respMap := make(map[string]MockResponse) + for _, r := range responses { + key := fmt.Sprintf("%s:%s", r.Method, r.Path) + respMap[key] = r + } + return &MockServer{ + name: name, + requests: make([]RequestRecord, 0), + responses: respMap, + verbose: verbose, + } +} + +// ServeHTTP records the request and returns a matching mock response. +// implements http.Handler interface. +func (ms *MockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + r.Body.Close() + + rec := RequestRecord{ + Timestamp: time.Now(), + Method: r.Method, + Host: r.Host, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Headers: r.Header, + Body: string(body), + } + + ms.mu.Lock() + ms.requests = append(ms.requests, rec) + ms.mu.Unlock() + + if ms.verbose { + serviceName := fmt.Sprintf("%-8s", ms.name) + fmt.Printf(" → %s %-6s %s\n", serviceName, r.Method, r.URL.Path) + } + + key := fmt.Sprintf("%s:%s", r.Method, r.URL.Path) + if resp, ok := ms.responses[key]; ok { + for k, v := range resp.Headers { + w.Header().Set(k, v) + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(resp.StatusCode) + w.Write([]byte(resp.Body)) + return + } + + for key, resp := range ms.responses { + parts := strings.Split(key, ":") + if len(parts) == 2 { + method, pattern := parts[0], parts[1] + if method == r.Method && matchPath(r.URL.Path, pattern) { + for k, v := range resp.Headers { + w.Header().Set(k, v) + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(resp.StatusCode) + w.Write([]byte(resp.Body)) + return + } + } + } + + if ms.verbose { + serviceName := fmt.Sprintf("%-8s", ms.name) + fmt.Printf(" ✗ %s No mock response for: %s %s\n", serviceName, r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"not found in mock"}`)) +} + +// GetRequests returns all HTTP requests captured by the mock server. +// safe for concurrent use. +func (ms *MockServer) GetRequests() []RequestRecord { + ms.mu.Lock() + defer ms.mu.Unlock() + reqs := make([]RequestRecord, len(ms.requests)) + copy(reqs, ms.requests) + return reqs +} + +// Reset clears all recorded requests from the mock server. +// safe for concurrent use. +func (ms *MockServer) Reset() { + ms.mu.Lock() + defer ms.mu.Unlock() + ms.requests = make([]RequestRecord, 0) +} diff --git a/cmd/verify/scenario.go b/cmd/verify/scenario.go new file mode 100644 index 0000000..db403c1 --- /dev/null +++ b/cmd/verify/scenario.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + awsEvent "github.com/aws/aws-lambda-go/events" + "github.com/cruxstack/aws-securityhubv2-bot/internal/app" + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +// TestScenario defines a test case with input events and expected outcomes. +type TestScenario struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + EventPayload json.RawMessage `json:"event_payload"` + ConfigOverrides map[string]string `json:"config_overrides,omitempty"` + ExpectedCalls []ExpectedCall `json:"expected_calls"` + MockResponses []MockResponse `json:"mock_responses"` + ExpectError bool `json:"expect_error,omitempty"` +} + +// ExpectedCall defines an HTTP API call the test expects the application to +// make. +type ExpectedCall struct { + Service string `json:"service"` + Method string `json:"method"` + Path string `json:"path"` +} + +// runScenario executes a single test scenario with mock HTTP servers and +// validates that expected API calls were made. +func runScenario(ctx context.Context, scenario TestScenario, verbose bool, logger *slog.Logger) error { + startTime := time.Now() + + fmt.Printf("\n▶ Running: %s\n", scenario.Name) + if scenario.Description != "" { + fmt.Printf(" %s\n", scenario.Description) + } + + securityhubResponses := []MockResponse{} + slackResponses := []MockResponse{} + s3Responses := []MockResponse{} + + for _, resp := range scenario.MockResponses { + switch resp.Service { + case "securityhub": + securityhubResponses = append(securityhubResponses, resp) + case "slack": + slackResponses = append(slackResponses, resp) + case "s3": + s3Responses = append(s3Responses, resp) + } + } + + securityhubMock := NewMockServer("SecurityHub", securityhubResponses, verbose) + slackMock := NewMockServer("Slack", slackResponses, verbose) + s3Mock := NewMockServer("S3", s3Responses, verbose) + + tlsCert, certPool, err := generateSelfSignedCert() + if err != nil { + return fmt.Errorf("generate cert: %w", err) + } + + securityhubServer := &http.Server{ + Addr: "localhost:9001", + Handler: securityhubMock, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }, + } + slackServer := &http.Server{ + Addr: "localhost:9002", + Handler: slackMock, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }, + } + s3Server := &http.Server{ + Addr: "localhost:9003", + Handler: s3Mock, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }, + } + + securityhubReady := make(chan bool) + slackReady := make(chan bool) + s3Ready := make(chan bool) + + go func() { + securityhubReady <- true + if err := securityhubServer.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + logger.Error("securityhub mock server error", slog.String("error", err.Error())) + } + }() + + go func() { + slackReady <- true + if err := slackServer.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + logger.Error("slack mock server error", slog.String("error", err.Error())) + } + }() + + go func() { + s3Ready <- true + if err := s3Server.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + logger.Error("s3 mock server error", slog.String("error", err.Error())) + } + }() + + <-securityhubReady + <-slackReady + <-s3Ready + time.Sleep(100 * time.Millisecond) + + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + securityhubServer.Shutdown(shutdownCtx) + slackServer.Shutdown(shutdownCtx) + s3Server.Shutdown(shutdownCtx) + }() + + // create HTTP client with custom TLS config + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + }, + }, + } + + http.DefaultTransport = httpClient.Transport + + // pass HTTP client through context for AWS SDK + ctx = context.WithValue(ctx, "aws_http_client", httpClient) + + // configure AWS SDK to use mock endpoints + os.Setenv("AWS_ENDPOINT_URL", "https://localhost:9001") + os.Setenv("AWS_ENDPOINT_URL_SECURITYHUB", "https://localhost:9001") + os.Setenv("AWS_ENDPOINT_URL_S3", "https://localhost:9003") + + // configure Slack API URL for mock server + os.Setenv("SLACK_API_URL", "https://localhost:9002/api") + + // enable debug mode for verbose scenarios + if verbose { + os.Setenv("APP_DEBUG_ENABLED", "true") + } + + // apply config overrides + for key, value := range scenario.ConfigOverrides { + os.Setenv(key, value) + } + + cfg, err := app.NewConfig() + if err != nil { + return fmt.Errorf("config creation failed: %w", err) + } + + if verbose { + fmt.Printf(" Auto-close rules configured: %d\n", len(cfg.AutoCloseRules)) + } + + appLogger := slog.New(&testHandler{prefix: " ", verbose: verbose, w: os.Stdout}) + + a, err := app.New(ctx, cfg, appLogger) + if err != nil { + return fmt.Errorf("app creation failed: %w", err) + } + + if verbose { + fmt.Printf("\n Application Output:\n") + } + + var evt awsEvent.CloudWatchEvent + if err := json.Unmarshal(scenario.EventPayload, &evt); err != nil { + return fmt.Errorf("unmarshal event payload failed: %w", err) + } + + if verbose { + fmt.Printf(" Processing event...\n") + } + + // convert Lambda CloudWatch event to runtime-agnostic event input + input := events.SecurityHubEventInput{ + EventID: evt.ID, + DetailType: evt.DetailType, + Detail: evt.Detail, + } + + processErr := a.Process(ctx, input) + + if scenario.ExpectError { + if processErr == nil { + return fmt.Errorf("expected error but processing succeeded") + } + if verbose { + fmt.Printf(" ✓ Expected error occurred: %v\n", processErr) + } + } else { + if processErr != nil { + return fmt.Errorf("process event failed: %w", processErr) + } + if verbose { + fmt.Printf(" ✓ Event processed successfully\n") + } + } + + time.Sleep(500 * time.Millisecond) + + securityhubReqs := securityhubMock.GetRequests() + slackReqs := slackMock.GetRequests() + s3Reqs := s3Mock.GetRequests() + + allReqs := make(map[string][]RequestRecord) + allReqs["securityhub"] = securityhubReqs + allReqs["slack"] = slackReqs + allReqs["s3"] = s3Reqs + + totalCalls := len(securityhubReqs) + len(slackReqs) + len(s3Reqs) + + if verbose { + fmt.Printf("\n") + } + + if err := validateExpectedCalls(scenario.ExpectedCalls, allReqs); err != nil { + fmt.Printf("\n Validation:\n") + fmt.Printf(" ✗ FAILED: %v\n", err) + fmt.Printf("\n All captured requests:\n") + if len(securityhubReqs) > 0 { + fmt.Printf(" SecurityHub (%d):\n", len(securityhubReqs)) + for i, req := range securityhubReqs { + fmt.Printf(" [%d] %s %s\n", i+1, req.Method, req.Path) + } + } + if len(slackReqs) > 0 { + fmt.Printf(" Slack (%d):\n", len(slackReqs)) + for i, req := range slackReqs { + fmt.Printf(" [%d] %s %s\n", i+1, req.Method, req.Path) + } + } + if len(s3Reqs) > 0 { + fmt.Printf(" S3 (%d):\n", len(s3Reqs)) + for i, req := range s3Reqs { + fmt.Printf(" [%d] %s %s\n", i+1, req.Method, req.Path) + } + } + return err + } + + duration := time.Since(startTime) + + if verbose { + fmt.Printf(" Validation:\n") + fmt.Printf(" ✓ All expected calls verified (%d total)\n", totalCalls) + fmt.Printf("\n") + } + + fmt.Printf("✓ PASSED (Duration: %.2fs)\n", duration.Seconds()) + return nil +} + +// validateExpectedCalls verifies that all expected HTTP calls were captured +// by the mock servers. +func validateExpectedCalls(expected []ExpectedCall, allReqs map[string][]RequestRecord) error { + for _, exp := range expected { + reqs := allReqs[exp.Service] + found := false + for _, req := range reqs { + if req.Method == exp.Method && matchPath(req.Path, exp.Path) { + found = true + break + } + } + if !found { + return fmt.Errorf("expected call not found: %s %s %s", exp.Service, exp.Method, exp.Path) + } + } + return nil +} diff --git a/cmd/verify/tls.go b/cmd/verify/tls.go new file mode 100644 index 0000000..64405a1 --- /dev/null +++ b/cmd/verify/tls.go @@ -0,0 +1,67 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "time" +) + +// generateSelfSignedCert creates a self-signed TLS certificate for testing. +// returns the certificate, certificate pool, and any error encountered. +func generateSelfSignedCert() (tls.Certificate, *x509.CertPool, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("generate key: %w", err) + } + + notBefore := time.Now() + notAfter := notBefore.Add(24 * time.Hour) + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("generate serial: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"E2E Test"}, + CommonName: "localhost", + }, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("create cert: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + + tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("create keypair: %w", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("parse cert: %w", err) + } + + certPool := x509.NewCertPool() + certPool.AddCert(cert) + + return tlsCert, certPool, nil +} diff --git a/examples/auto-close-rules.json b/examples/auto-close-rules.json new file mode 100644 index 0000000..4b0fcb5 --- /dev/null +++ b/examples/auto-close-rules.json @@ -0,0 +1,109 @@ +[ + { + "name": "auto-close-github-runner-new-binaries", + "enabled": true, + "filters": { + "finding_types": [ + "Execution:Runtime/NewBinaryExecuted" + ], + "resource_tags": [ + { + "name": "component-type", + "value": "github-action-runners" + } + ] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed: Expected behavior for GitHub Actions CI/CD runners" + }, + "skip_notification": true + }, + { + "name": "suppress-inspector-low-in-dev", + "enabled": true, + "filters": { + "product_name": [ + "Inspector" + ], + "severity": [ + "Low" + ], + "accounts": [ + "123456789012" + ] + }, + "action": { + "status_id": 3, + "comment": "Auto-suppressed: Low severity Inspector findings in dev account" + }, + "skip_notification": false + }, + { + "name": "resolve-approved-scanner-activity", + "enabled": true, + "filters": { + "finding_types": [ + "Recon:EC2/PortProbeUnprotectedPort" + ], + "resource_types": [ + "AWS::EC2::Instance" + ], + "resource_tags": [ + { + "name": "ScannerApproved", + "value": "true" + } + ] + }, + "action": { + "status_id": 4, + "comment": "Auto-resolved: Approved security scanner activity" + }, + "skip_notification": true + }, + { + "name": "suppress-guardduty-cryptocurrency-mining-test-env", + "enabled": true, + "filters": { + "finding_types": [ + "CryptoCurrency:EC2/BitcoinTool.B!DNS" + ], + "product_name": [ + "GuardDuty" + ], + "resource_tags": [ + { + "name": "Environment", + "value": "test" + } + ] + }, + "action": { + "status_id": 3, + "comment": "Auto-suppressed: Cryptocurrency testing in isolated test environment" + }, + "skip_notification": true + }, + { + "name": "suppress-macie-findings-in-sandbox", + "enabled": true, + "filters": { + "product_name": [ + "Macie" + ], + "accounts": [ + "999888777666" + ], + "severity": [ + "Low", + "Medium" + ] + }, + "action": { + "status_id": 3, + "comment": "Auto-suppressed: Macie findings in sandbox account" + }, + "skip_notification": true + } +] diff --git a/examples/github-actions-runner-example.md b/examples/github-actions-runner-example.md new file mode 100644 index 0000000..fa442f4 --- /dev/null +++ b/examples/github-actions-runner-example.md @@ -0,0 +1,322 @@ +# Example: Auto-Close GitHub Actions Runner Findings + +This example demonstrates how to automatically close GuardDuty `Execution:Runtime/NewBinaryExecuted` findings for GitHub Actions runners. This is a common pattern for CI/CD environments where runners regularly execute newly created binaries as part of the build process. + +## The Problem + +When using GitHub Actions runners (self-hosted or ephemeral), GuardDuty Runtime Monitoring generates `NewBinaryExecuted` findings when containers or EC2 instances execute newly created binaries. This is expected behavior for CI/CD workflows but creates noise in Security Hub. + +For example: +- Build tools creating and executing compiled binaries +- Package managers downloading and running install scripts +- Test runners executing newly built test binaries +- Deployment tools creating temporary executables + +## The Solution + +Use an auto-close rule that: +1. Matches the specific finding type (`Execution:Runtime/NewBinaryExecuted`) +2. Filters by resource tags (e.g., `component-type=github-action-runners`) +3. Automatically suppresses the finding +4. Skips Slack notifications (since it's not a security issue) + +## Configuration + +### Environment Variable + +```bash +export APP_AUTO_CLOSE_RULES='[ + { + "name": "auto-close-github-runner-new-binaries", + "enabled": true, + "filters": { + "finding_types": [ + "Execution:Runtime/NewBinaryExecuted" + ], + "resource_tags": [ + { + "name": "component-type", + "value": "github-action-runners" + } + ] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed: Expected behavior for GitHub Actions CI/CD runners" + }, + "skip_notification": true + } +]' +``` + +### Lambda Environment Variables (AWS Console) + +For better readability in the AWS Console, you can format it on a single line: + +``` +{"name":"auto-close-github-runner-new-binaries","enabled":true,"filters":{"finding_types":["Execution:Runtime/NewBinaryExecuted"],"resource_tags":[{"name":"component-type","value":"github-action-runners"}]},"action":{"status_id":5,"comment":"Auto-closed: Expected behavior for GitHub Actions CI/CD runners"},"skip_notification":true} +``` + +Then wrap it in an array: +``` +[{...}] +``` + +## IAM Policy + +Add this inline policy to your Lambda execution role: + +```jsonc +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAutoCloseFindings", + "Effect": "Allow", + "Action": [ + "securityhub:BatchUpdateFindingsV2" + ], + "Resource": "*" + } + ] +} +``` + +## How It Works + +### 1. EventBridge Event Arrives + +When GuardDuty Runtime Monitoring detects a newly created binary being executed, Security Hub receives the finding and EventBridge triggers your Lambda: + +```jsonc +{ + "source": "aws.securityhub", + "detail-type": "Findings Imported V2", + "detail": { + "findings": [{ + "finding_info": { + "title": "A container has executed a newly created binary file.", + "types": [ + "Threats", + "Execution:Runtime/NewBinaryExecuted" + ] + }, + "resources": [{ + "tags": [ + {"name": "component-type", "value": "github-action-runners"}, + {"name": "ghr:Application", "value": "github-action-runner"}, + // ... + ] + }], + "evidences": [{ + "actor": { + "process": { + "name": "kubectl", + "path": "/opt/actions-runner/_work/_tool/kind/v0.22.0/amd64/kubectl/bin/kubectl" + } + } + }] + }] + } +} +``` + +### 2. Lambda Evaluates Rules + +The bot: +1. Parses the OCSF finding +2. Checks if finding type matches: `Execution:Runtime/NewBinaryExecuted` ✓ +3. Searches resources for tag: `component-type=github-action-runners` ✓ +4. Rule matches! + +### 3. Auto-Close Action Executes + +The bot calls BatchUpdateFindingsV2: + +```go +input := &securityhub.BatchUpdateFindingsV2Input{ + MetadataUids: []string{"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}, + StatusId: aws.Int32(5), // SUPPRESSED + Comment: aws.String("Auto-closed: Expected behavior for GitHub Actions CI/CD runners"), +} +``` + +### 4. Notification Skipped + +Since `skip_notification: true`, no Slack message is sent. + +### 5. Result + +- Finding status: `New` → `Suppressed` +- Finding comment: "Auto-closed: Expected behavior for GitHub Actions CI/CD runners" +- Slack: No notification +- CloudWatch Logs: `auto-closed finding ... using rule 'auto-close-github-runner-new-binaries'` + +## Sample Finding Data + +See `fixtures/samples.json` (1st finding) for the exact OCSF structure that matches this rule. The finding includes: +- Process ancestry showing GitHub Actions runner chain +- File hash of executed binary (SHA-256) +- EC2 instance details with runner tags +- Network interface information + +## Variations + +### Also notify (audit trail) + +Keep Slack notifications enabled for visibility: + +```jsonc +{ + "name": "auto-close-github-runner-new-binaries", + "enabled": true, + "filters": { + // ... + }, + "action": { + // ... + }, + "skip_notification": false // changed from true +} +``` + +### Match multiple environments or teams + +Use broader tag patterns to match different runner deployments: + +```jsonc +{ + "name": "auto-close-all-ci-runner-binaries", + "enabled": true, + "filters": { + "finding_types": [ + "Execution:Runtime/NewBinaryExecuted" + ], + "resource_tags": [ + { + "name": "environment", + "value": "ci" + } + ] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed: Expected behavior for CI/CD environment" + }, + "skip_notification": true +} +``` + +### Match by account instead of tags + +Auto-close findings in dedicated CI/CD accounts: + +```jsonc +{ + "name": "auto-close-ci-account-new-binaries", + "enabled": true, + "filters": { + "finding_types": [ + "Execution:Runtime/NewBinaryExecuted" + ], + "accounts": [ + "123456789012" + ] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed: Expected behavior in CI/CD account" + }, + "skip_notification": true +} +``` + +### Resolve instead of suppress + +```jsonc +{ + "action": { + "status_id": 3, // RESOLVED instead of SUPPRESSED + "comment": "Auto-resolved: Known safe behavior for GitHub Actions runners" + } +} +``` + +## Monitoring + +### CloudWatch Logs + +Look for these log messages: + +**Rule matched:** +``` +processing finding: eeeeeeee... (status: New, severity: Medium) +finding matched rule: auto-close-github-runner-new-binaries +auto-closed finding eeeeeeee... using rule 'auto-close-github-runner-new-binaries' +``` + +**Rule not matched:** +``` +processing finding: abc123... (status: New, severity: High) +``` +(No "matched rule" message - finding proceeds to normal notification) + +### Security Hub Console + +1. Navigate to Security Hub v2 → Threats +2. Search for finding type: `Execution:Runtime/NewBinaryExecuted` +3. Find the specific instance finding and check "Finding history" tab +4. Should see update with: + - Status: New → Suppressed + - Comment: "Auto-closed: Expected behavior for GitHub Actions CI/CD runners" + - Updated by: aws-securityhubv2-bot + +## Multiple Rules Example + +Combine multiple CI/CD-related auto-close rules: + +```jsonc +[ + { + "name": "auto-close-github-runner-new-binaries", + "enabled": true, + "filters": { + "finding_types": ["Execution:Runtime/NewBinaryExecuted"], + "resource_tags": [{"name": "component-type", "value": "github-action-runners"}] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed: Expected behavior for GitHub Actions CI/CD runners" + }, + "skip_notification": true + }, + { + "name": "auto-close-github-runner-docker-commands", + "enabled": true, + "filters": { + "finding_types": ["Execution:Runtime/DockerCommandWithUnknownArgs"], + "resource_tags": [{"name": "component-type", "value": "github-action-runners"}] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed: Expected Docker behavior in CI/CD runners" + }, + "skip_notification": true + }, + { + "name": "auto-close-build-environment-network-activity", + "enabled": true, + "filters": { + "finding_types": ["UnauthorizedAccess:EC2/MaliciousIPCaller"], + "resource_tags": [{"name": "environment", "value": "ci"}] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed: Known build dependency sources in CI environment" + }, + "skip_notification": false + } +] +``` + +**Note:** Rules are evaluated in order. The first matching rule wins. diff --git a/fixtures/samples.json b/fixtures/samples.json new file mode 100644 index 0000000..77e06fb --- /dev/null +++ b/fixtures/samples.json @@ -0,0 +1,699 @@ +[ + { + "activity_id": 1, + "activity_name": "Create", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + }, + "cloud_partition": "aws", + "provider": "AWS", + "region": "us-east-1" + }, + "count": 1, + "evidences": [ + { + "actor": { + "process": { + "ancestry": [ + { + "created_time": 1759523833119, + "created_time_dt": "2025-10-03T20:37:13.119Z", + "name": "bash", + "path": "/usr/bin/bash", + "pid": 29856, + "uid": "0" + }, + { + "created_time": 1759523833114, + "created_time_dt": "2025-10-03T20:37:13.114Z", + "name": "bash", + "path": "/usr/bin/bash", + "pid": 29852, + "uid": "0" + }, + { + "created_time": 1759523833089, + "created_time_dt": "2025-10-03T20:37:13.089Z", + "name": "node", + "path": "/opt/actions-runner/externals/node20/bin/node", + "pid": 29845, + "uid": "0" + }, + { + "created_time": 1759523818257, + "created_time_dt": "2025-10-03T20:36:58.257Z", + "name": "Runner.Worker", + "path": "/opt/actions-runner/bin/Runner.Worker", + "pid": 29660, + "uid": "0" + }, + { + "created_time": 1759523219917, + "created_time_dt": "2025-10-03T20:26:59.917Z", + "name": "Runner.Listener", + "path": "/opt/actions-runner/bin/Runner.Listener", + "pid": 29200, + "uid": "0" + }, + { + "created_time": 1759523219917, + "created_time_dt": "2025-10-03T20:26:59.917Z", + "name": "run-helper.sh", + "path": "/usr/bin/bash", + "pid": 29196, + "uid": "0" + }, + { + "created_time": 1759523219907, + "created_time_dt": "2025-10-03T20:26:59.907Z", + "name": "run.sh", + "path": "/usr/bin/bash", + "pid": 29192, + "uid": "0" + }, + { + "created_time": 1759523219427, + "created_time_dt": "2025-10-03T20:26:59.427Z", + "name": "sudo", + "path": "/usr/bin/sudo", + "pid": 29157, + "uid": "0" + }, + { + "created_time": 1759523219417, + "created_time_dt": "2025-10-03T20:26:59.417Z", + "name": "sh", + "path": "/usr/bin/bash", + "pid": 29156, + "uid": "0" + }, + { + "created_time": 1759523137407, + "created_time_dt": "2025-10-03T20:25:37.407Z", + "name": "systemd", + "path": "/usr/lib/systemd/systemd", + "pid": 1, + "uid": "0" + } + ], + "created_time": 1759523833716, + "created_time_dt": "2025-10-03T20:37:13.716Z", + "file": { + "hashes": [ + { + "algorithm": "SHA-256", + "algorithm_id": 3, + "value": "89c0435cec75278f84b62b848b8c0d3e15897d6947b6c59a49ddccd93d7312bf" + } + ], + "name": "kubectl", + "path": "/opt/actions-runner/_work/_tool/kind/v0.22.0/amd64/kubectl/bin/kubectl", + "type": "Unknown", + "type_id": 0 + }, + "name": "kubectl", + "parent_process": { + "created_time": 1759523833119, + "created_time_dt": "2025-10-03T20:37:13.119Z", + "file": { + "name": "bash", + "path": "/usr/bin/bash", + "type": "Unknown", + "type_id": 0 + }, + "name": "bash", + "parent_process": { + "uid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + }, + "pid": 29856, + "uid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "user": { + "uid": "0", + "uid_alt": "0" + } + }, + "pid": 29877, + "uid": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "working_directory": "/opt/actions-runner/_work/platform/platform" + }, + "user": { + "name": "", + "uid": "0", + "uid_alt": "0" + } + } + }, + { + "file": { + "modified_time": 1759523833513, + "modified_time_dt": "2025-10-03T20:37:13.513Z", + "name": "UNKNOWN", + "type": "Unknown", + "type_id": 0 + }, + "process": { + "created_time": 1759523833458, + "created_time_dt": "2025-10-03T20:37:13.458Z", + "file": { + "hashes": [ + { + "algorithm": "SHA-256", + "algorithm_id": 3, + "value": "76e9f863aaedea6159503cb01f074b9f0694a2eb3896ebec9f223dd47ac72595" + } + ], + "name": "UNKNOWN", + "path": "/usr/bin/curl", + "type": "Unknown", + "type_id": 0 + }, + "name": "curl", + "parent_process": { + "uid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + }, + "pid": 29868, + "uid": "dddddddd-dddd-dddd-dddd-dddddddddddd", + "user": { + "name": "root", + "uid": "0", + "uid_alt": "0" + }, + "working_directory": "/opt/actions-runner/_work/platform/platform" + } + } + ], + "finding_info": { + "analytic": { + "type": "Rule", + "type_id": 1, + "uid": "00000000000000000000000000000000" + }, + "created_time": 1759524561464, + "created_time_dt": "2025-10-03T20:49:21.464Z", + "desc": "A process has executed a newly created binary.", + "first_seen_time": 1759523833911, + "first_seen_time_dt": "2025-10-03T20:37:13.911Z", + "last_seen_time": 1759523833911, + "last_seen_time_dt": "2025-10-03T20:37:13.911Z", + "modified_time": 1759524561464, + "modified_time_dt": "2025-10-03T20:49:21.464Z", + "product": { + "uid": "00000000000000000000000000000000" + }, + "title": "A container has executed a newly created binary file.", + "types": [ + "Threats", + "Execution:Runtime/NewBinaryExecuted" + ], + "uid": "arn:aws:guardduty:us-east-1:123456789012:detector/00000000000000000000000000000000/finding/ffffffffffffffffffffffffffffffff", + "uid_alt": "ffffffffffffffffffffffffffffffff" + }, + "metadata": { + "product": { + "feature": { + "name": "RuntimeMonitoring" + }, + "name": "GuardDuty", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/guardduty", + "vendor_name": "AWS" + }, + "profiles": [ + "cloud", + "datetime" + ], + "uid": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "version": "1.6.0" + }, + "remediation": { + "desc": "Please review the remediation guidance provided in the referenced documentation", + "references": [ + "https://docs.aws.amazon.com/guardduty/latest/ug/findings-runtime-monitoring.html#execution-runtime-newbinaryexecuted" + ] + }, + "resources": [ + { + "cloud_partition": "aws", + "data": { + "availability_zone": "us-east-1b", + "iam_instance_profile": { + "arn": "arn:aws:iam::123456789012:instance-profile/example-instance-profile/example-runner-profile", + "id": "AIDACKCEVSQ6C2EXAMPLE" + }, + "image_description": "Amazon Linux 2023 AMI 2023.7.20250414.0 x86_64 HVM kernel-6.1", + "image_id": "ami-0e449927258d45bc4", + "instance_id": "i-0123456789abcdef0", + "instance_state": "running", + "instance_type": "c5d.2xlarge", + "launch_time": 1759523129000, + "network_interfaces": [ + { + "network_interface_id": "eni-0123456789abcdef0", + "private_dns_name": "ip-10-0-0-100.ec2.internal", + "private_ip_address": "10.0.0.100", + "private_ip_addresses": [ + { + "private_dns_name": "ip-10-0-0-100.ec2.internal", + "private_ip_address": "10.0.0.100" + } + ], + "public_dns_name": "ec2-1-2-3-4.compute-1.amazonaws.com", + "public_ip": "1.2.3.4", + "security_groups": [ + { + "group_id": "sg-0123456789abcdef0", + "group_name": "example-security-group" + } + ], + "subnet_id": "subnet-0123456789abcdef0", + "vpc_id": "vpc-0123456789abcdef0" + } + ], + "tags": [ + { + "key": "Name", + "value": "example-action-runner" + }, + { + "key": "component-type", + "value": "github-action-runners" + }, + { + "key": "environment", + "value": "production" + } + ] + }, + "name": "i-0123456789abcdef0", + "owner": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + } + }, + "region": "us-east-1", + "tags": [ + { + "name": "Name", + "value": "example-action-runner" + }, + { + "name": "component-type", + "value": "github-action-runners" + }, + { + "name": "environment", + "value": "production" + } + ], + "type": "AWS::EC2::Instance", + "uid": "i-0123456789abcdef0" + } + ], + "severity": "Medium", + "severity_id": 3, + "status": "New", + "status_id": 1, + "time": 1759524561464, + "time_dt": "2025-10-03T20:49:21.464Z", + "type_name": "Detection Finding: Create", + "type_uid": 200401, + "vendor_attributes": { + "severity": "Medium", + "severity_id": 3 + } + }, + { + "activity_id": 2, + "activity_name": "Update", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Compliance Finding", + "class_uid": 2003, + "cloud": { + "account": { + "uid": "123456789012" + }, + "provider": "AWS", + "region": "us-east-1" + }, + "compliance": { + "assessments": [ + { + "desc": "AWS Config isn't enabled with the configuration recorder turned on.", + "meets_criteria": false, + "name": "CONFIG_RECORDER_DISABLED" + } + ], + "control": "Config.1", + "control_parameters": [ + { + "name": "includeConfigServiceLinkedRoleCheck", + "values": [ + "true" + ] + } + ], + "requirements": [ + "CIS AWS Foundations 2.5" + ], + "standards": [ + "ruleset/cis-aws-foundations-benchmark/v/1.2.0" + ], + "status": "Fail", + "status_id": 3 + }, + "finding_info": { + "created_time": 1750730000501, + "created_time_dt": "2025-06-24T01:53:20.501Z", + "desc": "AWS Config is a web service that performs configuration management of supported AWS resources within your account and delivers log files to you. The recorded information includes the configuration item (AWS resource), relationships between configuration items (AWS resources), and any configuration changes between resources. It is recommended to enable AWS Config in all regions.", + "first_seen_time": 1750730000501, + "first_seen_time_dt": "2025-06-24T01:53:20.501Z", + "last_seen_time": 1762155117235, + "last_seen_time_dt": "2025-11-03T07:31:57.235Z", + "modified_time": 1762155117235, + "modified_time_dt": "2025-11-03T07:31:57.235Z", + "title": "2.5 AWS Config should be enabled and use the service-linked role for resource recording", + "types": [ + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "Posture Management" + ], + "uid": "arn:aws:securityhub:us-east-1:123456789012:subscription/cis-aws-foundations-benchmark/v/1.2.0/2.5/finding/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + }, + "metadata": { + "product": { + "name": "Security Hub", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/securityhub", + "vendor_name": "AWS" + }, + "profiles": [ + "cloud", + "datetime" + ], + "uid": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "version": "1.6.0" + }, + "remediation": { + "desc": "For information on how to correct this issue, consult the AWS Security Hub controls documentation.", + "references": [ + "https://docs.aws.amazon.com/console/securityhub/Config.1/remediation" + ] + }, + "resources": [ + { + "cloud_partition": "aws", + "owner": { + "account": { + "uid": "123456789012" + } + }, + "region": "us-east-1", + "type": "AWS::::Account", + "uid": "123456789012" + } + ], + "severity": "Critical", + "severity_id": 5, + "status": "New", + "status_id": 1, + "time": 1762155117235, + "time_dt": "2025-11-03T07:31:57.235Z", + "type_name": "Compliance Finding: Update", + "type_uid": 200302, + "vendor_attributes": { + "severity": "Critical", + "severity_id": 5 + } + }, + { + "activity_id": 3, + "activity_name": "Close", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "core-tools", + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + }, + "cloud_partition": "aws", + "provider": "AWS", + "region": "us-east-1" + }, + "count": 1, + "evidences": [ + { + "actor": { + "process": { + "ancestry": [ + { + "created_time": 1763766647283, + "created_time_dt": "2025-11-21T23:10:47.283Z", + "name": "systemd", + "path": "/usr/lib/systemd/systemd", + "pid": 2561, + "uid": "0" + }, + { + "created_time": 1763766647018, + "created_time_dt": "2025-11-21T23:10:47.018Z", + "name": "init", + "path": "/usr/lib/systemd/systemd", + "pid": 2264, + "uid": "0" + } + ], + "created_time": 1763766647284, + "created_time_dt": "2025-11-21T23:10:47.284Z", + "file": { + "name": "(sd-mkdcreds)", + "path": "/usr/lib/systemd/systemd", + "type": "Unknown", + "type_id": 0 + }, + "name": "(sd-mkdcreds)", + "parent_process": { + "created_time": 1763766647283, + "created_time_dt": "2025-11-21T23:10:47.283Z", + "file": { + "name": "systemd", + "path": "/usr/lib/systemd/systemd", + "type": "Unknown", + "type_id": 0 + }, + "name": "systemd", + "parent_process": { + "uid": "e9714b77-3b27-8d9b-d9b5-a34c955e547b" + }, + "pid": 2561, + "uid": "f52d23e5-d48b-79e1-b35b-798e312d2de4", + "user": { + "uid": "0", + "uid_alt": "0" + } + }, + "pid": 2563, + "uid": "6d225b47-c1cb-7fa9-967e-8e79d89616ef", + "working_directory": "/" + }, + "user": { + "name": "", + "uid": "0", + "uid_alt": "0" + } + } + } + ], + "finding_info": { + "analytic": { + "type": "Rule", + "type_id": 1, + "uid": "12cbda91026ae3393af947729d385166" + }, + "created_time": 1763766928515, + "created_time_dt": "2025-11-21T23:15:28.515Z", + "desc": "A container has mounted a host directory.", + "first_seen_time": 1763766647284, + "first_seen_time_dt": "2025-11-21T23:10:47.284Z", + "last_seen_time": 1763766647284, + "last_seen_time_dt": "2025-11-21T23:10:47.284Z", + "modified_time": 1763766928515, + "modified_time_dt": "2025-11-21T23:15:28.515Z", + "product": { + "uid": "12cbda91026ae3393af947729d385166" + }, + "title": "A container has mounted a host directory.", + "types": [ + "Threats", + "PrivilegeEscalation:Runtime/ContainerMountsHostDirectory" + ], + "uid": "arn:aws:guardduty:us-east-1:205014383633:detector/12cbda91026ae3393af947729d385166/finding/d6cd5459c241b902f2aaff9db607c941", + "uid_alt": "d6cd5459c241b902f2aaff9db607c941" + }, + "metadata": { + "extensions": [ + { + "name": "aws", + "uid": "998", + "version": "1.0.0" + } + ], + "product": { + "feature": { + "name": "RuntimeMonitoring" + }, + "name": "GuardDuty", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/guardduty", + "vendor_name": "AWS" + }, + "profiles": [ + "cloud", + "container", + "datetime", + "aws/cloud_resources" + ], + "uid": "5121a512c39d657e0dd78133fc41ad6bdd145b7cf5a306f52bda76f5883fa500", + "version": "1.6.0" + }, + "observables": [ + { + "name": "fileSystemType", + "type": "Other", + "type_id": 99, + "value": "" + }, + { + "name": "flags", + "type": "Other", + "type_id": 99, + "value": "MS_MOVE" + }, + { + "name": "mountSource", + "type": "File", + "type_id": 24, + "value": "/dev/shm" + }, + { + "name": "mountTarget", + "type": "File", + "type_id": 24, + "value": "/run/credentials/systemd-sysctl.service" + } + ], + "remediation": { + "desc": "Please review the remediation guidance provided in the referenced documentation", + "references": [ + "https://docs.aws.amazon.com/guardduty/latest/ug/findings-runtime-monitoring.html#privilegeescalation-runtime-containermountshostdirectory" + ] + }, + "resources": [ + { + "cloud_partition": "aws", + "device": { + "container": { + "image": { + "name": "kindest/node:v1.30.6@sha256:b6d08db72079ba5ae1f4a88a09025c0a904af3b52387643c285442afb05ab994", + "uid": "sha256:02b360c27dd065ba9fc70553ef8511f3713c5de3189c21cd15c218c4102e122e" + }, + "name": "/default-control-plane", + "runtime": "docker", + "uid": "287ae865fc4257511514453a78a03743c5c63a40ab2a2a2f81579745b1febf41" + }, + "name": "/default-control-plane", + "type": "Virtual", + "type_id": 6, + "uid": "287ae865fc4257511514453a78a03743c5c63a40ab2a2a2f81579745b1febf41" + }, + "name": "/default-control-plane", + "owner": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + } + }, + "region": "us-east-1", + "type": "AWS::Container::Container", + "uid": "287ae865fc4257511514453a78a03743c5c63a40ab2a2a2f81579745b1febf41" + }, + { + "cloud_partition": "aws", + "device": { + "image": { + "uid": "ami-05c5514f9b7e67f21" + }, + "instance_profile": { + "uid": "AIPAS7O6PHQIX3MFNMBF3", + "uid_alt": "arn:aws:iam::205014383633:instance-profile/mpz-core-use1-tools-runs-on-EC2InstanceProfile-6TBpBHJ2Hwrr" + }, + "launch_time": 1763766604000, + "launch_time_dt": "2025-11-21T23:10:04Z", + "model": "m8a.2xlarge", + "network_interfaces": [ + { + "ip": "3.84.206.192", + "security_groups": [ + { + "name": "mpz-core-use1-tools-runs-on--7Z0s-20251118113053677800000001", + "uid": "sg-0c9f4484176f9ea9c" + } + ], + "uid": "eni-09b95b87c322afee4" + } + ], + "type": "Virtual", + "type_id": 6, + "uid": "i-0d7663df710a0f17a" + }, + "owner": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + } + }, + "region": "us-east-1", + "tags": [ + { + "name": "Name", + "value": "runs-on-ci-runner" + }, + { + "name": "provider", + "value": "runs-on.com" + }, + { + "name": "environment", + "value": "production" + } + ], + "type": "AWS::EC2::Instance", + "uid": "i-0d7663df710a0f17a", + "zone": "us-east-1b" + } + ], + "severity": "Medium", + "severity_id": 3, + "status": "New", + "status_id": 1, + "time": 1763766928515, + "time_dt": "2025-11-21T23:15:28.515Z", + "type_name": "Detection Finding: Close", + "type_uid": 200403, + "vendor_attributes": { + "severity": "Medium", + "severity_id": 3 + } + } +] diff --git a/fixtures/scenarios.json b/fixtures/scenarios.json new file mode 100644 index 0000000..44a4d6d --- /dev/null +++ b/fixtures/scenarios.json @@ -0,0 +1,591 @@ +[ + { + "name": "auto_close_github_runner_new_binary", + "description": "Auto-close new binary execution finding on GitHub Actions runner", + "event_payload": { + "version": "0", + "id": "test-event-1", + "detail-type": "Findings Imported V2", + "source": "aws.securityhub", + "account": "123456789012", + "time": "2025-10-03T20:49:21Z", + "region": "us-east-1", + "detail": { + "findings": [ + { + "activity_id": 1, + "activity_name": "Create", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + }, + "provider": "AWS", + "region": "us-east-1" + }, + "finding_info": { + "created_time": 1759524561464, + "created_time_dt": "2025-10-03T20:49:21.464Z", + "title": "A container has executed a newly created binary file.", + "types": [ + "Threats", + "Execution:Runtime/NewBinaryExecuted" + ], + "uid": "arn:aws:guardduty:us-east-1:123456789012:detector/abc123/finding/finding-001" + }, + "metadata": { + "product": { + "name": "GuardDuty", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/guardduty", + "vendor_name": "AWS" + }, + "uid": "test-finding-001", + "version": "1.6.0" + }, + "resources": [ + { + "data": { + "instance_id": "i-0123456789abcdef0", + "tags": [ + { + "key": "component-type", + "value": "github-action-runners" + }, + { + "key": "environment", + "value": "production" + } + ] + }, + "region": "us-east-1", + "tags": [ + { + "name": "component-type", + "value": "github-action-runners" + }, + { + "name": "environment", + "value": "production" + } + ], + "type": "AwsEc2Instance", + "uid": "arn:aws:ec2:us-east-1:123456789012:instance/i-0123456789abcdef0" + } + ], + "severity": "Medium", + "severity_id": 3, + "status": "New", + "status_id": 1, + "type_name": "Detection Finding: Create", + "type_uid": 200401 + } + ] + } + }, + "config_overrides": { + "APP_AUTO_CLOSE_RULES": "[{\"name\":\"auto-close-github-runner-new-binaries\",\"enabled\":true,\"filters\":{\"finding_types\":[\"Execution:Runtime/NewBinaryExecuted\"],\"resource_tags\":[{\"name\":\"component-type\",\"value\":\"github-action-runners\"}]},\"action\":{\"status_id\":5,\"comment\":\"Auto-closed: Expected behavior for GitHub Actions CI/CD runners\"},\"skip_notification\":true}]" + }, + "expected_calls": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2" + } + ], + "mock_responses": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2", + "status_code": 200, + "body": "{\"ProcessedFindings\":[{\"Id\":\"arn:aws:guardduty:us-east-1:123456789012:detector/abc123/finding/finding-001\",\"ProductArn\":\"arn:aws:securityhub:us-east-1::productv2/aws/guardduty\"}],\"UnprocessedFindings\":[]}", + "description": "securityhub batch update findings v2 success" + } + ] + }, + { + "name": "auto_close_inspector_low_severity_in_dev", + "description": "Auto-close low severity Inspector findings in dev account", + "event_payload": { + "version": "0", + "id": "test-event-2", + "detail-type": "Findings Imported V2", + "source": "aws.securityhub", + "account": "123456789012", + "time": "2025-10-03T21:00:00Z", + "region": "us-east-1", + "detail": { + "findings": [ + { + "activity_id": 1, + "activity_name": "Create", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Vulnerability Finding", + "class_uid": 2002, + "cloud": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + }, + "provider": "AWS", + "region": "us-east-1" + }, + "finding_info": { + "created_time": 1759525200000, + "created_time_dt": "2025-10-03T21:00:00.000Z", + "title": "CVE-2024-12345 found in package example-package", + "types": [ + "Software and Configuration Checks/Vulnerabilities/CVE" + ], + "uid": "arn:aws:inspector2:us-east-1:123456789012:finding/finding-002" + }, + "metadata": { + "product": { + "name": "Inspector", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/inspector", + "vendor_name": "AWS" + }, + "uid": "test-finding-002", + "version": "1.0.0" + }, + "resources": [ + { + "region": "us-east-1", + "type": "AwsEcrContainerImage", + "uid": "arn:aws:ecr:us-east-1:123456789012:repository/example-repo" + } + ], + "severity": "Low", + "severity_id": 2, + "status": "New", + "status_id": 1, + "type_name": "Vulnerability Finding: Create", + "type_uid": 200201 + } + ] + } + }, + "config_overrides": { + "APP_AUTO_CLOSE_RULES": "[{\"name\":\"suppress-inspector-low-in-dev\",\"enabled\":true,\"filters\":{\"product_name\":[\"Inspector\"],\"severity\":[\"Low\"],\"accounts\":[\"123456789012\"]},\"action\":{\"status_id\":3,\"comment\":\"Auto-suppressed: Low severity Inspector findings in dev account\"},\"skip_notification\":false}]", + "APP_SLACK_TOKEN": "xoxb-test-token", + "APP_SLACK_CHANNEL": "C01234TEST" + }, + "expected_calls": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2" + }, + { + "service": "slack", + "method": "POST", + "path": "/api/chat.postMessage" + } + ], + "mock_responses": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2", + "status_code": 200, + "body": "{\"ProcessedFindings\":[{\"Id\":\"arn:aws:inspector2:us-east-1:123456789012:finding/finding-002\",\"ProductArn\":\"arn:aws:securityhub:us-east-1::productv2/aws/inspector\"}],\"UnprocessedFindings\":[]}", + "description": "securityhub batch update findings v2 success" + }, + { + "service": "slack", + "method": "POST", + "path": "/api/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", + "description": "slack notification sent successfully" + } + ] + }, + { + "name": "no_match_sends_notification", + "description": "Finding that doesn't match any rule sends Slack notification", + "event_payload": { + "version": "0", + "id": "test-event-3", + "detail-type": "Findings Imported V2", + "source": "aws.securityhub", + "account": "999888777666", + "time": "2025-10-03T21:15:00Z", + "region": "us-west-2", + "detail": { + "findings": [ + { + "activity_id": 1, + "activity_name": "Create", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "999888777666" + }, + "provider": "AWS", + "region": "us-west-2" + }, + "finding_info": { + "created_time": 1759526100000, + "created_time_dt": "2025-10-03T21:15:00.000Z", + "title": "Unrecognized malicious activity detected", + "types": [ + "Threats", + "Trojan:EC2/DNSDataExfiltration" + ], + "uid": "arn:aws:guardduty:us-west-2:999888777666:detector/xyz789/finding/finding-003" + }, + "metadata": { + "product": { + "name": "GuardDuty", + "uid": "arn:aws:securityhub:us-west-2::productv2/aws/guardduty", + "vendor_name": "AWS" + }, + "uid": "test-finding-003", + "version": "1.6.0" + }, + "resources": [ + { + "data": { + "instance_id": "i-9876543210fedcba0" + }, + "region": "us-west-2", + "type": "AwsEc2Instance", + "uid": "arn:aws:ec2:us-west-2:999888777666:instance/i-9876543210fedcba0" + } + ], + "severity": "High", + "severity_id": 4, + "status": "New", + "status_id": 1, + "type_name": "Detection Finding: Create", + "type_uid": 200401 + } + ] + } + }, + "config_overrides": { + "APP_AUTO_CLOSE_RULES": "[{\"name\":\"auto-close-github-runner-new-binaries\",\"enabled\":true,\"filters\":{\"finding_types\":[\"Execution:Runtime/NewBinaryExecuted\"],\"resource_tags\":[{\"name\":\"component-type\",\"value\":\"github-action-runners\"}]},\"action\":{\"status_id\":5,\"comment\":\"Auto-closed: Expected behavior for GitHub Actions CI/CD runners\"},\"skip_notification\":true}]", + "APP_SLACK_TOKEN": "xoxb-test-token", + "APP_SLACK_CHANNEL": "C01234TEST" + }, + "expected_calls": [ + { + "service": "slack", + "method": "POST", + "path": "/api/chat.postMessage" + } + ], + "mock_responses": [ + { + "service": "slack", + "method": "POST", + "path": "/api/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", + "description": "slack notification sent for unmatched high severity finding" + } + ] + }, + { + "name": "multiple_rules_first_match_wins", + "description": "Test that first matching rule is applied when multiple rules could match", + "event_payload": { + "version": "0", + "id": "test-event-4", + "detail-type": "Findings Imported V2", + "source": "aws.securityhub", + "account": "123456789012", + "time": "2025-10-03T21:30:00Z", + "region": "us-east-1", + "detail": { + "findings": [ + { + "activity_id": 1, + "activity_name": "Create", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + }, + "provider": "AWS", + "region": "us-east-1" + }, + "finding_info": { + "created_time": 1759527000000, + "created_time_dt": "2025-10-03T21:30:00.000Z", + "title": "Port probe on unprotected port", + "types": [ + "Threats", + "Recon:EC2/PortProbeUnprotectedPort" + ], + "uid": "arn:aws:guardduty:us-east-1:123456789012:detector/abc123/finding/finding-004" + }, + "metadata": { + "product": { + "name": "GuardDuty", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/guardduty", + "vendor_name": "AWS" + }, + "uid": "test-finding-004", + "version": "1.6.0" + }, + "resources": [ + { + "data": { + "instance_id": "i-scanner-approved", + "tags": [ + { + "key": "ScannerApproved", + "value": "true" + } + ] + }, + "region": "us-east-1", + "tags": [ + { + "name": "ScannerApproved", + "value": "true" + } + ], + "type": "AwsEc2Instance", + "uid": "arn:aws:ec2:us-east-1:123456789012:instance/i-scanner-approved" + } + ], + "severity": "Low", + "severity_id": 2, + "status": "New", + "status_id": 1, + "type_name": "Detection Finding: Create", + "type_uid": 200401 + } + ] + } + }, + "config_overrides": { + "APP_AUTO_CLOSE_RULES": "[{\"name\":\"resolve-approved-scanner-activity\",\"enabled\":true,\"filters\":{\"finding_types\":[\"Recon:EC2/PortProbeUnprotectedPort\"],\"resource_types\":[\"AwsEc2Instance\"],\"resource_tags\":[{\"name\":\"ScannerApproved\",\"value\":\"true\"}]},\"action\":{\"status_id\":4,\"comment\":\"Auto-resolved: Approved security scanner activity\"},\"skip_notification\":true},{\"name\":\"suppress-all-low-severity\",\"enabled\":true,\"filters\":{\"severity\":[\"Low\"]},\"action\":{\"status_id\":3,\"comment\":\"Auto-suppressed: Low severity finding\"},\"skip_notification\":true}]" + }, + "expected_calls": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2" + } + ], + "mock_responses": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2", + "status_code": 200, + "body": "{\"ProcessedFindings\":[{\"Id\":\"arn:aws:guardduty:us-east-1:123456789012:detector/abc123/finding/finding-004\",\"ProductArn\":\"arn:aws:securityhub:us-east-1::productv2/aws/guardduty\"}],\"UnprocessedFindings\":[]}", + "description": "securityhub batch update findings v2 - status_id 3 (resolved)" + } + ] + }, + { + "name": "disabled_rule_not_applied", + "description": "Test that disabled rules are not applied", + "event_payload": { + "version": "0", + "id": "test-event-5", + "detail-type": "Findings Imported V2", + "source": "aws.securityhub", + "account": "123456789012", + "time": "2025-10-03T21:45:00Z", + "region": "us-east-1", + "detail": { + "findings": [ + { + "activity_id": 1, + "activity_name": "Create", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "123456789012" + }, + "provider": "AWS", + "region": "us-east-1" + }, + "finding_info": { + "created_time": 1759527900000, + "created_time_dt": "2025-10-03T21:45:00.000Z", + "title": "Cryptocurrency mining detected", + "types": [ + "Threats", + "CryptoCurrency:EC2/BitcoinTool.B!DNS" + ], + "uid": "arn:aws:guardduty:us-east-1:123456789012:detector/abc123/finding/finding-005" + }, + "metadata": { + "product": { + "name": "GuardDuty", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/guardduty", + "vendor_name": "AWS" + }, + "uid": "test-finding-005", + "version": "1.6.0" + }, + "resources": [ + { + "data": { + "instance_id": "i-test-instance", + "tags": [ + { + "key": "Environment", + "value": "test" + } + ] + }, + "region": "us-east-1", + "tags": [ + { + "name": "Environment", + "value": "test" + } + ], + "type": "AwsEc2Instance", + "uid": "arn:aws:ec2:us-east-1:123456789012:instance/i-test-instance" + } + ], + "severity": "High", + "severity_id": 4, + "status": "New", + "status_id": 1, + "type_name": "Detection Finding: Create", + "type_uid": 200401 + } + ] + } + }, + "config_overrides": { + "APP_AUTO_CLOSE_RULES": "[{\"name\":\"suppress-guardduty-cryptocurrency-mining-test-env\",\"enabled\":false,\"filters\":{\"finding_types\":[\"CryptoCurrency:EC2/BitcoinTool.B!DNS\"],\"product_name\":[\"GuardDuty\"],\"resource_tags\":[{\"name\":\"Environment\",\"value\":\"test\"}]},\"action\":{\"status_id\":3,\"comment\":\"Auto-suppressed: Cryptocurrency testing in isolated test environment\"},\"skip_notification\":true}]", + "APP_SLACK_TOKEN": "xoxb-test-token", + "APP_SLACK_CHANNEL": "C01234TEST" + }, + "expected_calls": [ + { + "service": "slack", + "method": "POST", + "path": "/api/chat.postMessage" + } + ], + "mock_responses": [ + { + "service": "slack", + "method": "POST", + "path": "/api/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", + "description": "slack notification sent because rule was disabled" + } + ] + }, + { + "name": "suppress_macie_findings_in_sandbox", + "description": "Auto-suppress Macie findings in sandbox account", + "event_payload": { + "version": "0", + "id": "test-event-6", + "detail-type": "Findings Imported V2", + "source": "aws.securityhub", + "account": "999888777666", + "time": "2025-10-03T22:00:00Z", + "region": "us-east-1", + "detail": { + "findings": [ + { + "activity_id": 1, + "activity_name": "Create", + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "type": "AWS Account", + "type_id": 10, + "uid": "999888777666" + }, + "provider": "AWS", + "region": "us-east-1" + }, + "finding_info": { + "created_time": 1759528800000, + "created_time_dt": "2025-10-03T22:00:00.000Z", + "title": "Sensitive data discovered in S3 bucket", + "types": [ + "Sensitive Data Identifications/PII" + ], + "uid": "arn:aws:macie2:us-east-1:999888777666:finding/finding-006" + }, + "metadata": { + "product": { + "name": "Macie", + "uid": "arn:aws:securityhub:us-east-1::productv2/aws/macie", + "vendor_name": "AWS" + }, + "uid": "test-finding-006", + "version": "1.0.0" + }, + "resources": [ + { + "region": "us-east-1", + "type": "AwsS3Bucket", + "uid": "arn:aws:s3:::sandbox-test-bucket" + } + ], + "severity": "Medium", + "severity_id": 3, + "status": "New", + "status_id": 1, + "type_name": "Detection Finding: Create", + "type_uid": 200401 + } + ] + } + }, + "config_overrides": { + "APP_AUTO_CLOSE_RULES": "[{\"name\":\"suppress-macie-findings-in-sandbox\",\"enabled\":true,\"filters\":{\"product_name\":[\"Macie\"],\"accounts\":[\"999888777666\"],\"severity\":[\"Low\",\"Medium\"]},\"action\":{\"status_id\":3,\"comment\":\"Auto-suppressed: Macie findings in sandbox account\"},\"skip_notification\":true}]" + }, + "expected_calls": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2" + } + ], + "mock_responses": [ + { + "service": "securityhub", + "method": "PATCH", + "path": "/findingsv2/batchupdatev2", + "status_code": 200, + "body": "{\"ProcessedFindings\":[{\"Id\":\"arn:aws:macie2:us-east-1:999888777666:finding/finding-006\",\"ProductArn\":\"arn:aws:securityhub:us-east-1::productv2/aws/macie\"}],\"UnprocessedFindings\":[]}", + "description": "securityhub batch update findings v2 success" + } + ] + } +] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fe1b89d --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module github.com/cruxstack/aws-securityhubv2-bot + +go 1.24.4 + +require ( + github.com/aws/aws-lambda-go v1.49.0 + github.com/aws/aws-sdk-go-v2 v1.40.0 + github.com/aws/aws-sdk-go-v2/config v1.32.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 + github.com/aws/aws-sdk-go-v2/service/securityhub v1.66.0 + github.com/cockroachdb/errors v1.12.0 + github.com/joho/godotenv v1.5.1 + github.com/slack-go/slack v0.17.3 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/redact v1.1.5 // indirect + github.com/getsentry/sentry-go v0.27.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e49cf4a --- /dev/null +++ b/go.sum @@ -0,0 +1,117 @@ +github.com/aws/aws-lambda-go v1.49.0 h1:z4VhTqkFZPM3xpEtTqWqRqsRH4TZBMJqTkRiBPYLqIQ= +github.com/aws/aws-lambda-go v1.49.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.32.1 h1:iODUDLgk3q8/flEC7ymhmxjfoAnBDwEEYEVyKZ9mzjU= +github.com/aws/aws-sdk-go-v2/config v1.32.1/go.mod h1:xoAgo17AGrPpJBSLg81W+ikM0cpOZG8ad04T2r+d5P0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= +github.com/aws/aws-sdk-go-v2/service/securityhub v1.66.0 h1:pHds0NVhV7qN/G4aYmtTk9AS3J/HQOr0gj5tvsImZw0= +github.com/aws/aws-sdk-go-v2/service/securityhub v1.66.0/go.mod h1:QO1Dvdr9q8oznnqvgiaBiOknf4wRGLeFwTeNzZygVJ0= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrCheLMGwzbRpvUMwYspcA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= +github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= +github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/actions/closer.go b/internal/actions/closer.go new file mode 100644 index 0000000..ebf14a4 --- /dev/null +++ b/internal/actions/closer.go @@ -0,0 +1,43 @@ +package actions + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/securityhub" + "github.com/cockroachdb/errors" + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +type FindingCloser struct { + client *securityhub.Client +} + +func NewFindingCloser(client *securityhub.Client) *FindingCloser { + return &FindingCloser{ + client: client, + } +} + +func (c *FindingCloser) CloseFinding(ctx context.Context, finding *events.SecurityHubV2Finding, statusID int32, comment string) error { + input := &securityhub.BatchUpdateFindingsV2Input{ + MetadataUids: []string{finding.Metadata.UID}, + StatusId: aws.Int32(statusID), + Comment: aws.String(comment), + } + + output, err := c.client.BatchUpdateFindingsV2(ctx, input) + if err != nil { + return errors.Wrap(err, "failed to update finding") + } + + if len(output.UnprocessedFindings) > 0 { + unprocessed := output.UnprocessedFindings[0] + return errors.Newf("failed to update finding %s: %s - %s", + finding.Metadata.UID, + string(unprocessed.ErrorCode), + aws.ToString(unprocessed.ErrorMessage)) + } + + return nil +} diff --git a/internal/actions/closer_test.go b/internal/actions/closer_test.go new file mode 100644 index 0000000..5eb4c7a --- /dev/null +++ b/internal/actions/closer_test.go @@ -0,0 +1,44 @@ +// Package actions tests finding update operations via Security Hub v2 API. +// +// Tests cover: +// - Finding closer construction +// - Input validation and preparation +// +// Note: Full integration testing with AWS SDK mocks is handled in cmd/verify. +// These unit tests focus on the logic within this package. +package actions + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/service/securityhub" +) + +// TestNewFindingCloser validates that a FindingCloser can be constructed +// with a Security Hub client. +func TestNewFindingCloser(t *testing.T) { + client := &securityhub.Client{} + closer := NewFindingCloser(client) + + if closer == nil { + t.Fatal("expected non-nil FindingCloser") + } + + if closer.client != client { + t.Error("expected client to be set correctly") + } +} + +// TestNewFindingCloser_NilClient validates that a FindingCloser can be +// constructed even with a nil client (will fail at runtime, but constructor works). +func TestNewFindingCloser_NilClient(t *testing.T) { + closer := NewFindingCloser(nil) + + if closer == nil { + t.Fatal("expected non-nil FindingCloser even with nil client") + } + + if closer.client != nil { + t.Error("expected client to be nil") + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..7c977a5 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,185 @@ +package app + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/securityhub" + "github.com/cockroachdb/errors" + "github.com/cruxstack/aws-securityhubv2-bot/internal/actions" + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" + "github.com/cruxstack/aws-securityhubv2-bot/internal/filters" + "github.com/cruxstack/aws-securityhubv2-bot/internal/notifiers" +) + +type App struct { + Config *Config + FilterEngine *filters.FilterEngine + FindingCloser *actions.FindingCloser + Notifier notifiers.Notifier + Logger *slog.Logger +} + +func New(ctx context.Context, cfg *Config, logger *slog.Logger) (*App, error) { + // allow custom HTTP client from context (for testing) + configOpts := []func(*config.LoadOptions) error{} + if httpClient, ok := ctx.Value("aws_http_client").(*http.Client); ok && httpClient != nil { + configOpts = append(configOpts, config.WithHTTPClient(httpClient)) + } + + awsCfg, err := config.LoadDefaultConfig(ctx, configOpts...) + if err != nil { + return nil, errors.Wrap(err, "failed to load aws config - check credentials and region") + } + + app := &App{ + Config: cfg, + FindingCloser: actions.NewFindingCloser(securityhub.NewFromConfig(awsCfg)), + Logger: logger, + } + + rules := cfg.AutoCloseRules + + if cfg.AutoCloseRulesS3Bucket != "" { + s3Client := s3.NewFromConfig(awsCfg) + loader := filters.NewS3RulesLoader(s3Client) + + s3Rules, err := app.LoadRulesFromS3(ctx, loader, cfg.AutoCloseRulesS3Bucket, cfg.AutoCloseRulesS3Prefix) + if err != nil { + return nil, errors.Wrapf(err, "failed to load rules from s3://%s/%s", cfg.AutoCloseRulesS3Bucket, cfg.AutoCloseRulesS3Prefix) + } + + if len(cfg.AutoCloseRules) > 0 { + app.Logger.Info("loaded rules from S3 and env", "s3_rules", len(s3Rules), "env_rules", len(cfg.AutoCloseRules)) + rules = append(cfg.AutoCloseRules, s3Rules...) + } else { + app.Logger.Info("loaded rules from S3", "count", len(s3Rules)) + rules = s3Rules + } + } + + app.FilterEngine = filters.NewFilterEngine(rules) + + if cfg.SlackEnabled { + app.Notifier = notifiers.NewSlackNotifier( + cfg.SlackToken, + cfg.SlackChannel, + cfg.AwsConsoleURL, + cfg.AwsAccessPortalURL, + cfg.AwsAccessRoleName, + cfg.AWSSecurityHubv2Region, + ) + } + + return app, nil +} + +type EventDetail struct { + Findings []json.RawMessage `json:"findings"` +} + +func (a *App) ParseEvent(e events.SecurityHubEventInput) (*events.SecurityHubV2Finding, error) { + if e.DetailType != "Findings Imported V2" { + return nil, errors.Newf("unsupported event type: %s (expected 'Findings Imported V2')", e.DetailType) + } + + var detail EventDetail + if err := json.Unmarshal(e.Detail, &detail); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal event detail") + } + + if len(detail.Findings) == 0 { + return nil, errors.Newf("event contains no findings (event_id: %s)", e.EventID) + } + + return events.NewSecurityHubFinding(detail.Findings[0]) +} + +func (a *App) LoadRulesFromS3(ctx context.Context, loader *filters.S3RulesLoader, bucket, prefix string) ([]filters.AutoCloseRule, error) { + a.Logger.Debug("loading rules from S3", "bucket", bucket, "prefix", prefix) + + rules, err := loader.LoadRules(ctx, bucket, prefix) + if err != nil { + return nil, err + } + + a.Logger.Debug("loaded rules from S3", "count", len(rules)) + return rules, nil +} + +func (a *App) CloseFinding(ctx context.Context, finding *events.SecurityHubV2Finding, statusID int32, comment string) error { + a.Logger.Debug("closing finding", + "uid", finding.Metadata.UID, + "status_id", statusID) + + err := a.FindingCloser.CloseFinding(ctx, finding, statusID, comment) + if err != nil { + return err + } + + return nil +} + +func (a *App) SendNotification(ctx context.Context, finding *events.SecurityHubV2Finding) error { + a.Logger.Debug("sending notification", + "uid", finding.Metadata.UID) + + err := a.Notifier.Notify(ctx, finding) + if err != nil { + a.Logger.Error("failed to send notification", + "error", err, + "uid", finding.Metadata.UID) + return err + } + + a.Logger.Info("sent notification", + "uid", finding.Metadata.UID) + + return nil +} + +func (a *App) Process(ctx context.Context, evt events.SecurityHubEventInput) error { + finding, err := a.ParseEvent(evt) + if err != nil { + return err + } + + if a.Config.DebugEnabled { + a.Logger.Debug("processing finding", + "uid", finding.Metadata.UID, + "status", finding.Status, + "severity", finding.Severity) + } + + if matchedRule, matched := a.FilterEngine.FindMatchingRule(finding); matched { + if a.Config.DebugEnabled { + a.Logger.Debug("finding matched rule", "rule", matchedRule.Name) + } + + err := a.CloseFinding(ctx, finding, matchedRule.Action.StatusID, matchedRule.Action.Comment) + if err != nil { + return errors.Wrap(err, "failed to auto-close finding") + } + + a.Logger.Info("auto-closed finding", + "uid", finding.Metadata.UID, + "rule", matchedRule.Name, + "status_id", matchedRule.Action.StatusID) + + if !matchedRule.SkipNotification && a.Notifier != nil { + return a.SendNotification(ctx, finding) + } + + return nil + } + + if a.Notifier != nil && finding.IsAlertable() { + return a.SendNotification(ctx, finding) + } + + return nil +} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..5d76b2a --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,94 @@ +package app + +import ( + "encoding/json" + "os" + "strconv" + + "github.com/cockroachdb/errors" + "github.com/cruxstack/aws-securityhubv2-bot/internal/filters" +) + +type Config struct { + DebugEnabled bool + AwsConsoleURL string + AwsAccessPortalURL string + AwsAccessRoleName string + AWSSecurityHubv2Region string + AutoCloseRules []filters.AutoCloseRule + AutoCloseRulesS3Bucket string + AutoCloseRulesS3Prefix string + SlackEnabled bool + SlackToken string + SlackChannel string +} + +func NewConfig() (*Config, error) { + debugEnabled, _ := strconv.ParseBool(os.Getenv("APP_DEBUG_ENABLED")) + + cfg := Config{ + DebugEnabled: debugEnabled, + AwsConsoleURL: os.Getenv("APP_AWS_CONSOLE_URL"), + AwsAccessPortalURL: os.Getenv("APP_AWS_ACCESS_PORTAL_URL"), + AwsAccessRoleName: os.Getenv("APP_AWS_ACCESS_ROLE_NAME"), + AWSSecurityHubv2Region: os.Getenv("APP_AWS_SECURITYHUBV2_REGION"), + AutoCloseRulesS3Bucket: os.Getenv("APP_AUTO_CLOSE_RULES_S3_BUCKET"), + AutoCloseRulesS3Prefix: os.Getenv("APP_AUTO_CLOSE_RULES_S3_PREFIX"), + SlackToken: os.Getenv("APP_SLACK_TOKEN"), + SlackChannel: os.Getenv("APP_SLACK_CHANNEL"), + } + + if cfg.AwsConsoleURL == "" { + cfg.AwsConsoleURL = "https://console.aws.amazon.com" + } + + if cfg.AutoCloseRulesS3Prefix == "" { + cfg.AutoCloseRulesS3Prefix = "rules/" + } + + rulesJSON := os.Getenv("APP_AUTO_CLOSE_RULES") + if rulesJSON != "" { + rules, err := parseAutoCloseRules(rulesJSON) + if err != nil { + return nil, errors.Wrap(err, "failed to parse APP_AUTO_CLOSE_RULES") + } + cfg.AutoCloseRules = rules + } + + if cfg.SlackToken != "" && cfg.SlackChannel == "" { + return nil, errors.New("APP_SLACK_TOKEN requires APP_SLACK_CHANNEL") + } + if cfg.SlackToken == "" && cfg.SlackChannel != "" { + return nil, errors.New("APP_SLACK_CHANNEL requires APP_SLACK_TOKEN") + } + + cfg.SlackEnabled = cfg.SlackToken != "" && cfg.SlackChannel != "" + + return &cfg, nil +} + +// parseAutoCloseRules parses auto-close rules from either JSON or JSON-encoded string format. +// supports both direct JSON arrays and JSON strings that need unescaping. +func parseAutoCloseRules(input string) ([]filters.AutoCloseRule, error) { + var rules []filters.AutoCloseRule + + // try parsing as direct JSON first + err := json.Unmarshal([]byte(input), &rules) + if err == nil { + return rules, nil + } + + // if that fails, try parsing as JSON-encoded string (double-encoded) + var unescaped string + if err := json.Unmarshal([]byte(input), &unescaped); err != nil { + // if both fail, return the original error + return nil, errors.Wrap(err, "invalid JSON format - expected array or JSON-encoded string") + } + + // parse the unescaped string + if err := json.Unmarshal([]byte(unescaped), &rules); err != nil { + return nil, errors.Wrap(err, "invalid JSON in encoded string") + } + + return rules, nil +} diff --git a/internal/app/config_test.go b/internal/app/config_test.go new file mode 100644 index 0000000..1bf8794 --- /dev/null +++ b/internal/app/config_test.go @@ -0,0 +1,227 @@ +// Package app tests configuration parsing and auto-close rule loading. +// +// Tests cover: +// - JSON rule parsing (direct and JSON-encoded strings) +// - Empty and invalid rule arrays +// - Multiple rules with different filter combinations +// - Both single-encoded and double-encoded JSON (env var format) +package app + +import ( + "encoding/json" + "testing" + + "github.com/cruxstack/aws-securityhubv2-bot/internal/filters" +) + +// TestConfig_ParseAutoCloseRules validates that a single auto-close rule +// can be parsed from JSON with all expected fields. +func TestConfig_ParseAutoCloseRules(t *testing.T) { + rulesJSON := `[ + { + "name": "test-rule", + "enabled": true, + "filters": { + "finding_types": ["PrivilegeEscalation:Runtime/ContainerMountsHostDirectory"], + "resource_tags": [ + {"name": "provider", "value": "runs-on.com"} + ] + }, + "action": { + "status_id": 5, + "comment": "Test comment" + }, + "skip_notification": true + } + ]` + + var rules []filters.AutoCloseRule + if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil { + t.Fatalf("failed to parse rules: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + rule := rules[0] + if rule.Name != "test-rule" { + t.Errorf("expected name 'test-rule', got %s", rule.Name) + } + + if !rule.Enabled { + t.Error("expected rule to be enabled") + } + + if len(rule.Filters.FindingTypes) != 1 { + t.Errorf("expected 1 finding type, got %d", len(rule.Filters.FindingTypes)) + } + + if rule.Action.StatusID != 5 { + t.Errorf("expected status_id 5, got %d", rule.Action.StatusID) + } + + if !rule.SkipNotification { + t.Error("expected skip_notification to be true") + } +} + +// TestConfig_EmptyRules verifies that an empty rule array is handled correctly. +func TestConfig_EmptyRules(t *testing.T) { + var rules []filters.AutoCloseRule + if err := json.Unmarshal([]byte("[]"), &rules); err != nil { + t.Fatalf("failed to parse empty rules: %v", err) + } + + if len(rules) != 0 { + t.Errorf("expected 0 rules, got %d", len(rules)) + } +} + +// TestConfig_InvalidJSON ensures that malformed JSON returns an error. +func TestConfig_InvalidJSON(t *testing.T) { + var rules []filters.AutoCloseRule + err := json.Unmarshal([]byte("not json"), &rules) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +// TestParseAutoCloseRules_DirectJSON validates parsing of direct JSON array format. +func TestParseAutoCloseRules_DirectJSON(t *testing.T) { + input := `[ + { + "name": "test-rule", + "enabled": true, + "filters": { + "finding_types": ["Execution:Runtime/NewBinaryExecuted"] + }, + "action": { + "status_id": 5, + "comment": "Test" + }, + "skip_notification": true + } + ]` + + rules, err := parseAutoCloseRules(input) + if err != nil { + t.Fatalf("failed to parse direct JSON: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + if rules[0].Name != "test-rule" { + t.Errorf("expected name 'test-rule', got %s", rules[0].Name) + } +} + +// TestParseAutoCloseRules_JSONEncodedString validates parsing of JSON-encoded strings. +// This format occurs when rules are passed as environment variables (double-encoded). +func TestParseAutoCloseRules_JSONEncodedString(t *testing.T) { + input := `"[{\"name\":\"test-rule\",\"enabled\":true,\"filters\":{\"finding_types\":[\"Execution:Runtime/NewBinaryExecuted\"]},\"action\":{\"status_id\":5,\"comment\":\"Test\"},\"skip_notification\":true}]"` + + rules, err := parseAutoCloseRules(input) + if err != nil { + t.Fatalf("failed to parse JSON-encoded string: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + if rules[0].Name != "test-rule" { + t.Errorf("expected name 'test-rule', got %s", rules[0].Name) + } +} + +// TestParseAutoCloseRules_EmptyArray validates handling of empty rule arrays +// in both direct and encoded formats. +func TestParseAutoCloseRules_EmptyArray(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"direct empty array", "[]"}, + {"encoded empty array", `"[]"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rules, err := parseAutoCloseRules(tt.input) + if err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if len(rules) != 0 { + t.Errorf("expected 0 rules, got %d", len(rules)) + } + }) + } +} + +// TestParseAutoCloseRules_InvalidJSON validates that various invalid JSON formats +// return appropriate errors. +func TestParseAutoCloseRules_InvalidJSON(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"completely invalid", "not json at all"}, + {"invalid array", "[{invalid}]"}, + {"encoded invalid", `"not an array"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseAutoCloseRules(tt.input) + if err == nil { + t.Error("expected error for invalid JSON") + } + }) + } +} + +// TestParseAutoCloseRules_MultipleRules validates parsing multiple rules +// with different enabled states and filter configurations. +func TestParseAutoCloseRules_MultipleRules(t *testing.T) { + input := `[ + { + "name": "rule-1", + "enabled": true, + "filters": {"severity": ["High"]}, + "action": {"status_id": 5, "comment": "Auto-close"}, + "skip_notification": true + }, + { + "name": "rule-2", + "enabled": false, + "filters": {"severity": ["Low"]}, + "action": {"status_id": 3, "comment": "Resolve"}, + "skip_notification": false + } + ]` + + rules, err := parseAutoCloseRules(input) + if err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if len(rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(rules)) + } + + if rules[0].Name != "rule-1" || rules[1].Name != "rule-2" { + t.Errorf("unexpected rule names: %s, %s", rules[0].Name, rules[1].Name) + } + + if !rules[0].Enabled { + t.Error("expected rule-1 to be enabled") + } + + if rules[1].Enabled { + t.Error("expected rule-2 to be disabled") + } +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 0000000..c4014b3 --- /dev/null +++ b/internal/events/events.go @@ -0,0 +1,15 @@ +package events + +import "encoding/json" + +// SecurityHubEventInput is a runtime-agnostic representation of a Security Hub event +type SecurityHubEventInput struct { + EventID string + DetailType string + Detail json.RawMessage +} + +type SecurityHubEvent interface { + GetEventID() string + GetDetailType() string +} diff --git a/internal/events/finding.go b/internal/events/finding.go new file mode 100644 index 0000000..b31b65b --- /dev/null +++ b/internal/events/finding.go @@ -0,0 +1,313 @@ +package events + +import ( + "encoding/json" + "fmt" + "net/url" + "slices" + "strings" + + "github.com/slack-go/slack" +) + +type SecurityHubV2Finding struct { + ActivityID int `json:"activity_id"` + ActivityName string `json:"activity_name"` + CategoryName string `json:"category_name"` + CategoryUID int `json:"category_uid"` + ClassName string `json:"class_name"` + ClassUID int `json:"class_uid"` + Cloud Cloud `json:"cloud"` + Compliance *OCSFCompliance `json:"compliance,omitempty"` + FindingInfo FindingInfo `json:"finding_info"` + Metadata Metadata `json:"metadata"` + Remediation *Remediation `json:"remediation,omitempty"` + Resources []OCSFResource `json:"resources"` + Severity string `json:"severity"` + SeverityID int `json:"severity_id"` + Status string `json:"status"` + StatusID int `json:"status_id"` + Time int64 `json:"time"` + TimeDt string `json:"time_dt"` + TypeName string `json:"type_name"` + TypeUID int `json:"type_uid"` +} + +type Cloud struct { + Account struct { + Type string `json:"type,omitempty"` + TypeID int `json:"type_id,omitempty"` + UID string `json:"uid"` + } `json:"account"` + CloudPartition string `json:"cloud_partition,omitempty"` + Provider string `json:"provider"` + Region string `json:"region"` +} + +type OCSFCompliance struct { + Assessments []struct { + Desc string `json:"desc"` + MeetsCriteria bool `json:"meets_criteria"` + Name string `json:"name"` + } `json:"assessments,omitempty"` + Control string `json:"control,omitempty"` + ControlParameters []any `json:"control_parameters,omitempty"` + Requirements []string `json:"requirements,omitempty"` + Standards []string `json:"standards,omitempty"` + Status string `json:"status,omitempty"` + StatusID int `json:"status_id,omitempty"` +} + +type FindingInfo struct { + Analytic *struct { + Type string `json:"type"` + TypeID int `json:"type_id"` + UID string `json:"uid"` + } `json:"analytic,omitempty"` + CreatedTime int64 `json:"created_time"` + CreatedTimeDt string `json:"created_time_dt"` + Desc string `json:"desc"` + FirstSeenTime int64 `json:"first_seen_time"` + FirstSeenTimeDt string `json:"first_seen_time_dt"` + LastSeenTime int64 `json:"last_seen_time"` + LastSeenTimeDt string `json:"last_seen_time_dt"` + ModifiedTime int64 `json:"modified_time"` + ModifiedTimeDt string `json:"modified_time_dt"` + Product *Product `json:"product,omitempty"` + Title string `json:"title"` + Types []string `json:"types"` + UID string `json:"uid"` + UIDalt string `json:"uid_alt,omitempty"` +} + +type Product struct { + Feature *struct { + Name string `json:"name"` + } `json:"feature,omitempty"` + Name string `json:"name,omitempty"` + UID string `json:"uid,omitempty"` + VendorName string `json:"vendor_name,omitempty"` +} + +type Metadata struct { + Product MetadataProduct `json:"product"` + Profiles []string `json:"profiles"` + UID string `json:"uid"` + Version string `json:"version"` +} + +type MetadataProduct struct { + Feature *struct { + Name string `json:"name"` + } `json:"feature,omitempty"` + Name string `json:"name"` + UID string `json:"uid"` + VendorName string `json:"vendor_name"` +} + +type Remediation struct { + Desc string `json:"desc,omitempty"` + References []string `json:"references,omitempty"` +} + +type OCSFResource struct { + CloudPartition string `json:"cloud_partition,omitempty"` + Data map[string]any `json:"data,omitempty"` + Name string `json:"name,omitempty"` + Owner *ResourceOwner `json:"owner,omitempty"` + Region string `json:"region"` + Tags []ResourceTag `json:"tags,omitempty"` + Type string `json:"type"` + UID string `json:"uid"` +} + +type ResourceOwner struct { + Account struct { + Type string `json:"type,omitempty"` + TypeID int `json:"type_id,omitempty"` + UID string `json:"uid"` + } `json:"account,omitempty"` +} + +type ResourceTag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func (shf *SecurityHubV2Finding) SlackMessage(consoleURL, accessPortalURL, accessRoleName, shRegion string) (slack.MsgOption, slack.MsgOption) { + var blocks []slack.Block + + severityEmoji := shf.GetSeverityEmoji() + headerText := fmt.Sprintf("%s %s", severityEmoji, shf.FindingInfo.Title) + header := slack.NewHeaderBlock(slack.NewTextBlockObject("plain_text", headerText, false, false)) + blocks = append(blocks, header) + + descriptionSection := slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", shf.FindingInfo.Desc, false, false), + nil, nil, + ) + blocks = append(blocks, descriptionSection) + + var detailFields []*slack.TextBlockObject + detailFields = append(detailFields, slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Severity*\n%s", shf.Severity), false, false)) + detailFields = append(detailFields, slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Source*\n%s", shf.Metadata.Product.Name), false, false)) + + findingCategory := shf.GetFindingCategory() + detailFields = append(detailFields, slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Category*\n%s", findingCategory), false, false)) + + detailFields = append(detailFields, slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Account*\n%s", shf.Cloud.Account.UID), false, false)) + + details := slack.NewSectionBlock(nil, detailFields, nil) + blocks = append(blocks, details) + + findingIDSection := slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Finding ID*\n`%s`", shf.Metadata.UID), false, false), + nil, nil, + ) + blocks = append(blocks, findingIDSection) + + if len(shf.Resources) > 0 { + resource := shf.Resources[0] + var resourceFields []*slack.TextBlockObject + resourceFields = append(resourceFields, slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Resource Type*\n`%s`", resource.Type), false, false)) + resourceFields = append(resourceFields, slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Region*\n`%s`", resource.Region), false, false)) + + resourceName := resource.UID + if resource.Name != "" { + resourceName = resource.Name + } + if len(resourceName) > 60 { + parts := strings.Split(resourceName, "/") + resourceName = parts[len(parts)-1] + } + resourceFields = append(resourceFields, slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Resource ID*\n`%s`", resourceName), false, false)) + + resourceSection := slack.NewSectionBlock(nil, resourceFields, nil) + blocks = append(blocks, resourceSection) + } + + if shf.Remediation != nil && len(shf.Remediation.References) > 0 { + remediationText := fmt.Sprintf("*Remediation*\n%s\n<%s>", + shf.Remediation.Desc, + shf.Remediation.References[0]) + remediationSection := slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", remediationText, false, false), + nil, nil, + ) + blocks = append(blocks, remediationSection) + } + + consoleUrl := shf.BuildConsoleUrl(consoleURL, accessPortalURL, accessRoleName, shRegion) + buttonSection := slack.NewActionBlock( + "actions", + slack.NewButtonBlockElement( + "view_finding", + "view", + slack.NewTextBlockObject("plain_text", "View in Security Hub", false, false), + ).WithStyle(slack.StylePrimary).WithURL(consoleUrl), + ) + blocks = append(blocks, buttonSection) + + return slack.MsgOptionText(shf.FindingInfo.Title, false), slack.MsgOptionBlocks(blocks...) +} + +func (shf *SecurityHubV2Finding) IsAlertable() bool { + if shf.Status != "New" { + return false + } + + if shf.Compliance != nil && shf.Compliance.Status == "Fail" { + return true + } + + alertSeverities := []string{"Critical", "High", "Medium"} + return slices.Contains(alertSeverities, shf.Severity) +} + +func NewSecurityHubFinding(raw json.RawMessage) (*SecurityHubV2Finding, error) { + var shf SecurityHubV2Finding + if err := json.Unmarshal(raw, &shf); err != nil { + return &SecurityHubV2Finding{}, err + } + return &shf, nil +} + +func (shf *SecurityHubV2Finding) GetFindingCategory() string { + if len(shf.FindingInfo.Types) == 0 { + return shf.CategoryName + } + + for _, findingType := range shf.FindingInfo.Types { + if strings.Contains(findingType, "Threats") { + return "Threats" + } + if strings.Contains(findingType, "Posture Management") { + return "Posture Management" + } + if strings.Contains(findingType, "Exposure") { + return "Exposure" + } + if strings.Contains(findingType, "Vulnerabilities") { + return "Vulnerabilities" + } + if strings.Contains(findingType, "Sensitive data") { + return "Sensitive Data" + } + } + + return shf.CategoryName +} + +func (shf *SecurityHubV2Finding) GetSeverityEmoji() string { + switch shf.Severity { + case "Critical": + return "🔴" + case "High": + return "🟠" + case "Medium": + return "🟡" + case "Low": + return "🔵" + case "Informational": + return "⚪" + default: + return "⚫" + } +} + +func (shf *SecurityHubV2Finding) BuildConsoleUrl(consoleURL, accessPortalURL, accessRoleName, shRegion string) string { + region := shRegion + if region == "" { + region = shf.Cloud.Region + } + + var view string + findingType := shf.GetFindingCategory() + + switch findingType { + case "Exposure": + view = "exposure" + case "Posture Management": + view = "postureManagement" + case "Threats": + view = "threats" + case "Vulnerabilities": + view = "vulnerabilities" + } + + // example: https://console.aws.amazon.com/securityhub/v2/home?region=us-east-1#/postureManagement?findingDetailId=abc123... + dst := fmt.Sprintf( + "%s/securityhub/v2/home?region=%s#/%s?findingDetailId=%s", + consoleURL, region, view, shf.Metadata.UID, + ) + + if accessPortalURL != "" && accessRoleName != "" { + dstEncoded := url.QueryEscape(dst) + return fmt.Sprintf( + "%s/#/console?account_id=%s&role_name=%s&destination=%s", + accessPortalURL, shf.Cloud.Account.UID, accessRoleName, dstEncoded, + ) + } + + return dst +} diff --git a/internal/events/finding_test.go b/internal/events/finding_test.go new file mode 100644 index 0000000..c8cf609 --- /dev/null +++ b/internal/events/finding_test.go @@ -0,0 +1,72 @@ +// Package events tests OCSF finding parsing and Slack message formatting. +// +// Tests cover: +// - OCSF Security Hub v2 finding format parsing +// - GuardDuty detection findings +// - Security Hub CSPM compliance findings +// - Alertability determination logic +package events + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestSecurityHubV2FindingParsing validates parsing of Security Hub v2 OCSF findings +// from fixtures/samples.json, including both detection and compliance finding types. +func TestSecurityHubV2FindingParsing(t *testing.T) { + path := filepath.Join("..", "..", "fixtures", "samples.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read samples: %v", err) + } + + var findings []json.RawMessage + if err := json.Unmarshal(raw, &findings); err != nil { + t.Fatalf("failed to unmarshal samples: %v", err) + } + + if len(findings) < 2 { + t.Fatalf("expected at least 2 findings, got %d", len(findings)) + } + + // test first finding (GuardDuty) + f1, err := NewSecurityHubFinding(findings[0]) + if err != nil { + t.Fatalf("failed to parse finding 1: %v", err) + } + + if f1.Metadata.Product.Name != "GuardDuty" { + t.Errorf("expected GuardDuty, got %s", f1.Metadata.Product.Name) + } + if f1.Severity != "Medium" { + t.Errorf("expected Medium severity, got %s", f1.Severity) + } + if f1.FindingInfo.Title != "A container has executed a newly created binary file." { + t.Errorf("unexpected title: %s", f1.FindingInfo.Title) + } + if !f1.IsAlertable() { + t.Error("GuardDuty finding should be alertable") + } + + // test second finding (Security Hub CSPM) + f2, err := NewSecurityHubFinding(findings[1]) + if err != nil { + t.Fatalf("failed to parse finding 2: %v", err) + } + + if f2.Metadata.Product.Name != "Security Hub" { + t.Errorf("expected Security Hub, got %s", f2.Metadata.Product.Name) + } + if f2.Severity != "Critical" { + t.Errorf("expected Critical severity, got %s", f2.Severity) + } + if f2.Compliance == nil { + t.Error("expected compliance data") + } + if !f2.IsAlertable() { + t.Error("Failed compliance finding should be alertable") + } +} diff --git a/internal/filters/filter.go b/internal/filters/filter.go new file mode 100644 index 0000000..989505e --- /dev/null +++ b/internal/filters/filter.go @@ -0,0 +1,58 @@ +package filters + +import ( + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +type FilterEngine struct { + Rules []AutoCloseRule +} + +func NewFilterEngine(rules []AutoCloseRule) *FilterEngine { + return &FilterEngine{Rules: rules} +} + +func (e *FilterEngine) FindMatchingRule(finding *events.SecurityHubV2Finding) (*AutoCloseRule, bool) { + for i := range e.Rules { + rule := &e.Rules[i] + if !rule.Enabled { + continue + } + if e.matchesFilters(finding, rule.Filters) { + return rule, true + } + } + return nil, false +} + +func (e *FilterEngine) matchesFilters(finding *events.SecurityHubV2Finding, filters RuleFilters) bool { + if len(filters.FindingTypes) > 0 && !matchesFindingTypes(finding, filters.FindingTypes) { + return false + } + + if len(filters.Severity) > 0 && !contains(filters.Severity, finding.Severity) { + return false + } + + if len(filters.ProductName) > 0 && !contains(filters.ProductName, finding.Metadata.Product.Name) { + return false + } + + if len(filters.ResourceTypes) > 0 && !matchesResourceTypes(finding, filters.ResourceTypes) { + return false + } + + if len(filters.ResourceTags) > 0 && !matchesResourceTags(finding, filters.ResourceTags) { + return false + } + + if len(filters.Accounts) > 0 && !contains(filters.Accounts, finding.Cloud.Account.UID) { + return false + } + + if len(filters.Regions) > 0 && !contains(filters.Regions, finding.Cloud.Region) { + return false + } + + return true +} diff --git a/internal/filters/filter_test.go b/internal/filters/filter_test.go new file mode 100644 index 0000000..6fe2692 --- /dev/null +++ b/internal/filters/filter_test.go @@ -0,0 +1,228 @@ +// Package filters tests the auto-close rule matching engine. +// +// Tests cover: +// - Rule matching with various filter combinations +// - Disabled rule handling +// - First-match-wins rule precedence +// - Complex multi-filter rules +// - Uses fixtures/samples.json for realistic OCSF findings +package filters + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +// TestFilterEngine_FindMatchingRule_RunsOnExample validates that a GuardDuty finding +// with "provider=runs-on.com" resource tag matches the auto-close rule. +// Uses fixtures/samples.json finding #3 (ContainerMountsHostDirectory). +func TestFilterEngine_FindMatchingRule_RunsOnExample(t *testing.T) { + rules := []AutoCloseRule{ + { + Name: "auto-close-runs-on-container-mounts", + Enabled: true, + Filters: RuleFilters{ + FindingTypes: []string{"PrivilegeEscalation:Runtime/ContainerMountsHostDirectory"}, + ResourceTags: []ResourceTagFilter{ + {Name: "provider", Value: "runs-on.com"}, + }, + }, + Action: RuleAction{ + StatusID: 5, + Comment: "Auto-closed: Expected behavior for runs-on.com ephemeral runners", + }, + SkipNotification: true, + }, + } + + engine := NewFilterEngine(rules) + + path := filepath.Join("..", "..", "fixtures", "samples.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read samples: %v", err) + } + + var findings []json.RawMessage + if err := json.Unmarshal(raw, &findings); err != nil { + t.Fatalf("failed to unmarshal samples: %v", err) + } + + if len(findings) < 3 { + t.Fatalf("expected at least 3 findings, got %d", len(findings)) + } + + runsOnFinding, err := events.NewSecurityHubFinding(findings[2]) + if err != nil { + t.Fatalf("failed to parse runs-on finding: %v", err) + } + + matchedRule, matched := engine.FindMatchingRule(runsOnFinding) + if !matched { + t.Error("runs-on.com finding should match the auto-close rule") + } + + if matchedRule == nil { + t.Fatal("matched rule should not be nil") + } + + if matchedRule.Name != "auto-close-runs-on-container-mounts" { + t.Errorf("expected rule name 'auto-close-runs-on-container-mounts', got %s", matchedRule.Name) + } + + if matchedRule.Action.StatusID != 5 { + t.Errorf("expected status ID 5, got %d", matchedRule.Action.StatusID) + } + + if !matchedRule.SkipNotification { + t.Error("expected skip_notification to be true") + } +} + +// TestFilterEngine_FindMatchingRule_NoMatch validates that a finding does not match +// when the filter criteria don't align. +func TestFilterEngine_FindMatchingRule_NoMatch(t *testing.T) { + rules := []AutoCloseRule{ + { + Name: "test-rule", + Enabled: true, + Filters: RuleFilters{ + FindingTypes: []string{"NonExistentFindingType"}, + }, + Action: RuleAction{ + StatusID: 5, + Comment: "Test comment", + }, + SkipNotification: true, + }, + } + + engine := NewFilterEngine(rules) + + path := filepath.Join("..", "..", "fixtures", "samples.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read samples: %v", err) + } + + var findings []json.RawMessage + if err := json.Unmarshal(raw, &findings); err != nil { + t.Fatalf("failed to unmarshal samples: %v", err) + } + + finding, err := events.NewSecurityHubFinding(findings[0]) + if err != nil { + t.Fatalf("failed to parse finding: %v", err) + } + + _, matched := engine.FindMatchingRule(finding) + if matched { + t.Error("finding should not match the rule") + } +} + +// TestFilterEngine_DisabledRule ensures that disabled rules are skipped +// even when filters would match. +func TestFilterEngine_DisabledRule(t *testing.T) { + rules := []AutoCloseRule{ + { + Name: "disabled-rule", + Enabled: false, + Filters: RuleFilters{ + Severity: []string{"Medium"}, + }, + Action: RuleAction{ + StatusID: 5, + Comment: "Test comment", + }, + SkipNotification: true, + }, + } + + engine := NewFilterEngine(rules) + + path := filepath.Join("..", "..", "fixtures", "samples.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read samples: %v", err) + } + + var findings []json.RawMessage + if err := json.Unmarshal(raw, &findings); err != nil { + t.Fatalf("failed to unmarshal samples: %v", err) + } + + finding, err := events.NewSecurityHubFinding(findings[0]) + if err != nil { + t.Fatalf("failed to parse finding: %v", err) + } + + _, matched := engine.FindMatchingRule(finding) + if matched { + t.Error("disabled rule should not match") + } +} + +// TestFilterEngine_MultipleFilters validates that all filter criteria must match +// (AND logic) for a rule to apply. +func TestFilterEngine_MultipleFilters(t *testing.T) { + rules := []AutoCloseRule{ + { + Name: "complex-rule", + Enabled: true, + Filters: RuleFilters{ + FindingTypes: []string{"PrivilegeEscalation:Runtime/ContainerMountsHostDirectory"}, + Severity: []string{"Medium"}, + ProductName: []string{"GuardDuty"}, + Regions: []string{"us-east-1"}, + ResourceTags: []ResourceTagFilter{ + {Name: "provider", Value: "runs-on.com"}, + }, + }, + Action: RuleAction{ + StatusID: 5, + Comment: "Multi-filter test", + }, + SkipNotification: true, + }, + } + + engine := NewFilterEngine(rules) + + path := filepath.Join("..", "..", "fixtures", "samples.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read samples: %v", err) + } + + var findings []json.RawMessage + if err := json.Unmarshal(raw, &findings); err != nil { + t.Fatalf("failed to unmarshal samples: %v", err) + } + + if len(findings) < 3 { + t.Fatalf("expected at least 3 findings, got %d", len(findings)) + } + + runsOnFinding, err := events.NewSecurityHubFinding(findings[2]) + if err != nil { + t.Fatalf("failed to parse runs-on finding: %v", err) + } + + matchedRule, matched := engine.FindMatchingRule(runsOnFinding) + if !matched { + t.Error("finding should match all filter criteria") + } + + if matchedRule == nil { + t.Fatal("matched rule should not be nil") + } + + if matchedRule.Name != "complex-rule" { + t.Errorf("expected rule name 'complex-rule', got %s", matchedRule.Name) + } +} diff --git a/internal/filters/matchers.go b/internal/filters/matchers.go new file mode 100644 index 0000000..eef1257 --- /dev/null +++ b/internal/filters/matchers.go @@ -0,0 +1,65 @@ +package filters + +import ( + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +func matchesFindingTypes(finding *events.SecurityHubV2Finding, types []string) bool { + for _, filterType := range types { + for _, findingType := range finding.FindingInfo.Types { + if findingType == filterType { + return true + } + } + } + return false +} + +func matchesResourceTypes(finding *events.SecurityHubV2Finding, types []string) bool { + for _, resource := range finding.Resources { + for _, filterType := range types { + if resource.Type == filterType { + return true + } + } + } + return false +} + +func matchesResourceTags(finding *events.SecurityHubV2Finding, tagFilters []ResourceTagFilter) bool { + if len(finding.Resources) == 0 { + return false + } + + for _, resource := range finding.Resources { + if resourceHasAllTags(resource.Tags, tagFilters) { + return true + } + } + return false +} + +func resourceHasAllTags(resourceTags []events.ResourceTag, tagFilters []ResourceTagFilter) bool { + for _, filterTag := range tagFilters { + found := false + for _, tag := range resourceTags { + if tag.Name == filterTag.Name && tag.Value == filterTag.Value { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/internal/filters/rules.go b/internal/filters/rules.go new file mode 100644 index 0000000..4c54b31 --- /dev/null +++ b/internal/filters/rules.go @@ -0,0 +1,29 @@ +package filters + +type AutoCloseRule struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Filters RuleFilters `json:"filters"` + Action RuleAction `json:"action"` + SkipNotification bool `json:"skip_notification"` +} + +type RuleFilters struct { + FindingTypes []string `json:"finding_types,omitempty"` + Severity []string `json:"severity,omitempty"` + ProductName []string `json:"product_name,omitempty"` + ResourceTypes []string `json:"resource_types,omitempty"` + ResourceTags []ResourceTagFilter `json:"resource_tags,omitempty"` + Accounts []string `json:"accounts,omitempty"` + Regions []string `json:"regions,omitempty"` +} + +type ResourceTagFilter struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type RuleAction struct { + StatusID int32 `json:"status_id"` + Comment string `json:"comment"` +} diff --git a/internal/filters/s3_loader.go b/internal/filters/s3_loader.go new file mode 100644 index 0000000..3bb9455 --- /dev/null +++ b/internal/filters/s3_loader.go @@ -0,0 +1,120 @@ +package filters + +import ( + "context" + "encoding/json" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/cockroachdb/errors" +) + +type S3Client interface { + ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) +} + +type S3RulesLoader struct { + client S3Client +} + +func NewS3RulesLoader(client S3Client) *S3RulesLoader { + return &S3RulesLoader{ + client: client, + } +} + +func (l *S3RulesLoader) LoadRules(ctx context.Context, bucket, prefix string) ([]AutoCloseRule, error) { + keys, err := l.listObjects(ctx, bucket, prefix) + if err != nil { + return nil, errors.Wrap(err, "failed to list S3 objects") + } + + if len(keys) == 0 { + return nil, errors.Newf("no objects found in s3://%s/%s", bucket, prefix) + } + + var allRules []AutoCloseRule + for _, key := range keys { + if !strings.HasSuffix(key, ".json") { + continue + } + + rules, err := l.loadRulesFromObject(ctx, bucket, key) + if err != nil { + return nil, errors.Wrapf(err, "failed to load rules from s3://%s/%s", bucket, key) + } + + allRules = append(allRules, rules...) + } + + if len(allRules) == 0 { + return nil, errors.Newf("no rules loaded from s3://%s/%s", bucket, prefix) + } + + return allRules, nil +} + +func (l *S3RulesLoader) listObjects(ctx context.Context, bucket, prefix string) ([]string, error) { + var keys []string + paginator := s3.NewListObjectsV2Paginator(l.client, &s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + Prefix: aws.String(prefix), + }) + + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, err + } + + for _, obj := range page.Contents { + if obj.Key != nil { + keys = append(keys, *obj.Key) + } + } + } + + return keys, nil +} + +func (l *S3RulesLoader) loadRulesFromObject(ctx context.Context, bucket, key string) ([]AutoCloseRule, error) { + result, err := l.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, err + } + defer result.Body.Close() + + data, err := io.ReadAll(result.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read object body") + } + + return parseRules(data) +} + +func parseRules(data []byte) ([]AutoCloseRule, error) { + data = []byte(strings.TrimSpace(string(data))) + if len(data) == 0 { + return nil, nil + } + + if data[0] == '[' { + var rules []AutoCloseRule + if err := json.Unmarshal(data, &rules); err != nil { + return nil, errors.Wrap(err, "failed to parse rules array") + } + return rules, nil + } + + var rule AutoCloseRule + if err := json.Unmarshal(data, &rule); err != nil { + return nil, errors.Wrap(err, "failed to parse single rule") + } + return []AutoCloseRule{rule}, nil +} diff --git a/internal/filters/s3_loader_test.go b/internal/filters/s3_loader_test.go new file mode 100644 index 0000000..3bcb1cb --- /dev/null +++ b/internal/filters/s3_loader_test.go @@ -0,0 +1,547 @@ +// Package filters tests S3-based auto-close rule loading. +// +// Tests cover: +// - Loading single rules from individual JSON files +// - Loading rule arrays from single files +// - Mixed single-rule and array files +// - Non-JSON file filtering +// - Empty and invalid JSON handling +// - Complex rule filter parsing +// +// Uses mock S3 client to avoid actual AWS calls. +package filters + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +type mockS3Client struct { + objects map[string]string + listErr error + getErr error +} + +func (m *mockS3Client) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { + if m.listErr != nil { + return nil, m.listErr + } + + var contents []types.Object + prefix := "" + if params.Prefix != nil { + prefix = *params.Prefix + } + + for key := range m.objects { + if strings.HasPrefix(key, prefix) { + contents = append(contents, types.Object{ + Key: aws.String(key), + }) + } + } + + return &s3.ListObjectsV2Output{ + Contents: contents, + }, nil +} + +func (m *mockS3Client) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + if m.getErr != nil { + return nil, m.getErr + } + + content, ok := m.objects[*params.Key] + if !ok { + return nil, &types.NoSuchKey{} + } + + return &s3.GetObjectOutput{ + Body: io.NopCloser(strings.NewReader(content)), + }, nil +} + +// TestS3RulesLoader_LoadRules_SingleRulePerFile validates loading multiple individual +// rule files from S3, where each file contains a single rule object. +func TestS3RulesLoader_LoadRules_SingleRulePerFile(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/rule1.json": `{ + "name": "test-rule-1", + "enabled": true, + "filters": { + "finding_types": ["Type1"] + }, + "action": { + "status_id": 5, + "comment": "Test comment 1" + } + }`, + "rules/rule2.json": `{ + "name": "test-rule-2", + "enabled": false, + "filters": { + "severity": ["High"] + }, + "action": { + "status_id": 3, + "comment": "Test comment 2" + } + }`, + }, + } + + loader := NewS3RulesLoader(client) + rules, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(rules)) + } + + // build map of rules by name (order is undefined from S3 listing) + ruleMap := make(map[string]AutoCloseRule) + for _, rule := range rules { + ruleMap[rule.Name] = rule + } + + rule1, ok1 := ruleMap["test-rule-1"] + if !ok1 { + t.Error("expected to find 'test-rule-1'") + } else if !rule1.Enabled { + t.Error("expected rule 1 to be enabled") + } + + rule2, ok2 := ruleMap["test-rule-2"] + if !ok2 { + t.Error("expected to find 'test-rule-2'") + } else if rule2.Enabled { + t.Error("expected rule 2 to be disabled") + } +} + +// TestS3RulesLoader_LoadRules_ArrayInSingleFile validates loading a single file +// containing an array of multiple rules. +func TestS3RulesLoader_LoadRules_ArrayInSingleFile(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/all-rules.json": `[ + { + "name": "test-rule-1", + "enabled": true, + "filters": { + "finding_types": ["Type1"] + }, + "action": { + "status_id": 5, + "comment": "Test comment 1" + } + }, + { + "name": "test-rule-2", + "enabled": true, + "filters": { + "severity": ["High"] + }, + "action": { + "status_id": 3, + "comment": "Test comment 2" + } + }, + { + "name": "test-rule-3", + "enabled": false, + "filters": { + "product_name": ["Inspector"] + }, + "action": { + "status_id": 5, + "comment": "Test comment 3" + } + } + ]`, + }, + } + + loader := NewS3RulesLoader(client) + rules, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 3 { + t.Fatalf("expected 3 rules, got %d", len(rules)) + } + + if rules[0].Name != "test-rule-1" { + t.Errorf("expected rule name 'test-rule-1', got '%s'", rules[0].Name) + } + + if rules[1].Name != "test-rule-2" { + t.Errorf("expected rule name 'test-rule-2', got '%s'", rules[1].Name) + } + + if rules[2].Name != "test-rule-3" { + t.Errorf("expected rule name 'test-rule-3', got '%s'", rules[2].Name) + } +} + +func TestS3RulesLoader_LoadRules_MixedArrayAndSingleFiles(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/array-rules.json": `[ + { + "name": "array-rule-1", + "enabled": true, + "filters": { + "finding_types": ["Type1"] + }, + "action": { + "status_id": 5, + "comment": "Array rule 1" + } + }, + { + "name": "array-rule-2", + "enabled": true, + "filters": { + "severity": ["High"] + }, + "action": { + "status_id": 3, + "comment": "Array rule 2" + } + } + ]`, + "rules/single-rule-1.json": `{ + "name": "single-rule-1", + "enabled": true, + "filters": { + "product_name": ["GuardDuty"] + }, + "action": { + "status_id": 5, + "comment": "Single rule 1" + } + }`, + "rules/single-rule-2.json": `{ + "name": "single-rule-2", + "enabled": false, + "filters": { + "accounts": ["123456789012"] + }, + "action": { + "status_id": 3, + "comment": "Single rule 2" + } + }`, + }, + } + + loader := NewS3RulesLoader(client) + rules, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 4 { + t.Fatalf("expected 4 rules, got %d", len(rules)) + } + + ruleNames := make(map[string]bool) + for _, rule := range rules { + ruleNames[rule.Name] = true + } + + expectedNames := []string{"array-rule-1", "array-rule-2", "single-rule-1", "single-rule-2"} + for _, name := range expectedNames { + if !ruleNames[name] { + t.Errorf("expected to find rule '%s' in loaded rules", name) + } + } +} + +func TestS3RulesLoader_LoadRules_IgnoreNonJSONFiles(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/rule1.json": `{ + "name": "test-rule-1", + "enabled": true, + "filters": {}, + "action": { + "status_id": 5, + "comment": "Test" + } + }`, + "rules/README.md": "# Rules documentation", + "rules/config.yaml": "key: value", + "rules/.gitignore": "*.log", + "rules/script.sh": "#!/bin/bash\necho test", + }, + } + + loader := NewS3RulesLoader(client) + rules, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + if rules[0].Name != "test-rule-1" { + t.Errorf("expected rule name 'test-rule-1', got '%s'", rules[0].Name) + } +} + +func TestS3RulesLoader_LoadRules_EmptyPrefix(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rule1.json": `{ + "name": "test-rule-1", + "enabled": true, + "filters": {}, + "action": { + "status_id": 5, + "comment": "Test" + } + }`, + }, + } + + loader := NewS3RulesLoader(client) + rules, err := loader.LoadRules(context.Background(), "test-bucket", "") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } +} + +func TestS3RulesLoader_LoadRules_NoJSONFiles(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/README.md": "# Documentation", + "rules/config.yaml": "key: value", + }, + } + + loader := NewS3RulesLoader(client) + _, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err == nil { + t.Fatal("expected error when no JSON files found, got nil") + } + + if !strings.Contains(err.Error(), "no rules loaded") { + t.Errorf("expected 'no rules loaded' error, got: %v", err) + } +} + +func TestS3RulesLoader_LoadRules_EmptyJSONArray(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/empty.json": `[]`, + }, + } + + loader := NewS3RulesLoader(client) + _, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err == nil { + t.Fatal("expected error when no rules found, got nil") + } + + if !strings.Contains(err.Error(), "no rules loaded") { + t.Errorf("expected 'no rules loaded' error, got: %v", err) + } +} + +func TestS3RulesLoader_LoadRules_InvalidJSON(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/invalid.json": `{invalid json`, + }, + } + + loader := NewS3RulesLoader(client) + _, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestS3RulesLoader_LoadRules_WithComplexFilters(t *testing.T) { + client := &mockS3Client{ + objects: map[string]string{ + "rules/complex.json": `{ + "name": "complex-rule", + "enabled": true, + "filters": { + "finding_types": ["Type1", "Type2"], + "severity": ["High", "Critical"], + "product_name": ["GuardDuty", "Inspector"], + "resource_types": ["AWS::EC2::Instance"], + "resource_tags": [ + { + "name": "Environment", + "value": "production" + }, + { + "name": "Team", + "value": "security" + } + ], + "accounts": ["111111111111", "222222222222"], + "regions": ["us-east-1", "us-west-2"] + }, + "action": { + "status_id": 5, + "comment": "Auto-closed by complex rule" + }, + "skip_notification": true + }`, + }, + } + + loader := NewS3RulesLoader(client) + rules, err := loader.LoadRules(context.Background(), "test-bucket", "rules/") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + rule := rules[0] + + if rule.Name != "complex-rule" { + t.Errorf("expected rule name 'complex-rule', got '%s'", rule.Name) + } + + if len(rule.Filters.FindingTypes) != 2 { + t.Errorf("expected 2 finding types, got %d", len(rule.Filters.FindingTypes)) + } + + if len(rule.Filters.Severity) != 2 { + t.Errorf("expected 2 severities, got %d", len(rule.Filters.Severity)) + } + + if len(rule.Filters.ResourceTags) != 2 { + t.Errorf("expected 2 resource tags, got %d", len(rule.Filters.ResourceTags)) + } + + if rule.SkipNotification != true { + t.Errorf("expected skip_notification to be true") + } +} + +func TestParseRules_SingleRule(t *testing.T) { + data := []byte(`{ + "name": "test-rule", + "enabled": true, + "filters": {}, + "action": { + "status_id": 5, + "comment": "Test" + } + }`) + + rules, err := parseRules(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + if rules[0].Name != "test-rule" { + t.Errorf("expected rule name 'test-rule', got '%s'", rules[0].Name) + } +} + +func TestParseRules_Array(t *testing.T) { + data := []byte(`[ + { + "name": "test-rule-1", + "enabled": true, + "filters": {}, + "action": { + "status_id": 5, + "comment": "Test 1" + } + }, + { + "name": "test-rule-2", + "enabled": true, + "filters": {}, + "action": { + "status_id": 3, + "comment": "Test 2" + } + } + ]`) + + rules, err := parseRules(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(rules)) + } + + if rules[0].Name != "test-rule-1" { + t.Errorf("expected rule name 'test-rule-1', got '%s'", rules[0].Name) + } + + if rules[1].Name != "test-rule-2" { + t.Errorf("expected rule name 'test-rule-2', got '%s'", rules[1].Name) + } +} + +func TestParseRules_EmptyData(t *testing.T) { + data := []byte("") + + rules, err := parseRules(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 0 { + t.Errorf("expected 0 rules for empty data, got %d", len(rules)) + } +} + +func TestParseRules_WhitespaceOnly(t *testing.T) { + data := []byte(" \n\t ") + + rules, err := parseRules(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(rules) != 0 { + t.Errorf("expected 0 rules for whitespace-only data, got %d", len(rules)) + } +} diff --git a/internal/notifiers/notifier.go b/internal/notifiers/notifier.go new file mode 100644 index 0000000..89b1a45 --- /dev/null +++ b/internal/notifiers/notifier.go @@ -0,0 +1,11 @@ +package notifiers + +import ( + "context" + + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" +) + +type Notifier interface { + Notify(ctx context.Context, finding *events.SecurityHubV2Finding) error +} diff --git a/internal/notifiers/slack.go b/internal/notifiers/slack.go new file mode 100644 index 0000000..debb327 --- /dev/null +++ b/internal/notifiers/slack.go @@ -0,0 +1,47 @@ +package notifiers + +import ( + "context" + "os" + + "github.com/cruxstack/aws-securityhubv2-bot/internal/events" + "github.com/slack-go/slack" +) + +type SlackNotifier struct { + client *slack.Client + channel string + consoleURL string + accessPortalURL string + accessRoleName string + securityHubv2Region string +} + +func NewSlackNotifier(token, channel, consoleURL, accessPortalURL, accessRoleName, securityHubv2Region string) *SlackNotifier { + // allow overriding slack api url for testing + opts := []slack.Option{} + if apiURL := os.Getenv("SLACK_API_URL"); apiURL != "" { + opts = append(opts, slack.OptionAPIURL(apiURL+"/")) + } + + return &SlackNotifier{ + client: slack.New(token, opts...), + channel: channel, + consoleURL: consoleURL, + accessPortalURL: accessPortalURL, + accessRoleName: accessRoleName, + securityHubv2Region: securityHubv2Region, + } +} + +func (s *SlackNotifier) Notify(ctx context.Context, finding *events.SecurityHubV2Finding) error { + m0, m1 := finding.SlackMessage( + s.consoleURL, + s.accessPortalURL, + s.accessRoleName, + s.securityHubv2Region, + ) + + _, _, err := s.client.PostMessage(s.channel, m0, m1) + return err +} diff --git a/internal/notifiers/slack_test.go b/internal/notifiers/slack_test.go new file mode 100644 index 0000000..3c586cf --- /dev/null +++ b/internal/notifiers/slack_test.go @@ -0,0 +1,105 @@ +// Package notifiers tests Slack notification functionality. +// +// Tests cover: +// - Slack notifier construction +// - Configuration handling +// - Custom API URL support for testing +// +// Note: Full integration testing with Slack SDK mocks is handled in cmd/verify. +// These unit tests focus on the construction and configuration logic. +package notifiers + +import ( + "os" + "testing" +) + +// TestNewSlackNotifier validates that a SlackNotifier can be constructed +// with required configuration parameters. +func TestNewSlackNotifier(t *testing.T) { + notifier := NewSlackNotifier( + "xoxb-test-token", + "C01234TEST", + "https://console.aws.amazon.com", + "https://portal.example.com", + "SecurityAuditorRole", + "us-east-1", + ) + + if notifier == nil { + t.Fatal("expected non-nil SlackNotifier") + } + + if notifier.channel != "C01234TEST" { + t.Errorf("expected channel 'C01234TEST', got %s", notifier.channel) + } + + if notifier.consoleURL != "https://console.aws.amazon.com" { + t.Errorf("expected consoleURL 'https://console.aws.amazon.com', got %s", notifier.consoleURL) + } + + if notifier.securityHubv2Region != "us-east-1" { + t.Errorf("expected region 'us-east-1', got %s", notifier.securityHubv2Region) + } +} + +// TestNewSlackNotifier_CustomAPIURL validates that SLACK_API_URL environment +// variable is respected for testing purposes. +func TestNewSlackNotifier_CustomAPIURL(t *testing.T) { + // set custom API URL + originalURL := os.Getenv("SLACK_API_URL") + os.Setenv("SLACK_API_URL", "https://mock-slack:9002/api") + defer func() { + if originalURL == "" { + os.Unsetenv("SLACK_API_URL") + } else { + os.Setenv("SLACK_API_URL", originalURL) + } + }() + + notifier := NewSlackNotifier( + "xoxb-test-token", + "C01234TEST", + "https://console.aws.amazon.com", + "", + "", + "us-east-1", + ) + + if notifier == nil { + t.Fatal("expected non-nil SlackNotifier") + } + + if notifier.client == nil { + t.Fatal("expected non-nil Slack client") + } +} + +// TestNewSlackNotifier_EmptyOptionalParams validates that optional parameters +// can be empty strings without causing issues. +func TestNewSlackNotifier_EmptyOptionalParams(t *testing.T) { + notifier := NewSlackNotifier( + "xoxb-test-token", + "C01234TEST", + "", + "", + "", + "us-east-1", + ) + + if notifier == nil { + t.Fatal("expected non-nil SlackNotifier") + } + + if notifier.consoleURL != "" { + t.Error("expected empty consoleURL") + } + + if notifier.accessPortalURL != "" { + t.Error("expected empty accessPortalURL") + } + + if notifier.accessRoleName != "" { + t.Error("expected empty accessRoleName") + } +}