diff --git a/.claude/skills/create-module/checklist.md b/.claude/skills/create-module/checklist.md index 239296a..37dd19c 100644 --- a/.claude/skills/create-module/checklist.md +++ b/.claude/skills/create-module/checklist.md @@ -45,7 +45,9 @@ Use this checklist after generating a new module to verify everything is complet - `CleanupMethod` set to the API action - `ModuleID` set to the path ID - `Metadata` includes info needed for cleanup -- [ ] Cleanup handler exists in `pkg/core/repl/session.go` for each resource type created +- [ ] Cleanup handler exists in `pkg/core/repl/session.go` `sessionCleanup()` for each resource type created +- [ ] `CleanupCommand()` in `pkg/report/cleanup.go` has a `case` for each resource type — this drives the AWS CLI command shown in `workspace cleanup`, the terminal report, and every exported report (HTML/Markdown). Falling through to `default` produces "manual cleanup required" instead of a real command. +- [ ] `cleanupOperationFromType()` in `pkg/core/repl/session.go` has a `case` for each resource type — this maps the type to an AWS operation name (e.g., `DeleteFunction`) for CloudTrail audit log entries emitted after cleanup. ## Side Effect Tracking (payload modifications) - [ ] After execution, module checks if payload implements `SideEffectReporter` diff --git a/.gitignore b/.gitignore index d3c4eea..2a34e25 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ *.out coverage.html +# Generated HTML reports +*.html + # Go workspace file go.work diff --git a/CLAUDE.md b/CLAUDE.md index 1c41f90..f8ebdef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ REPL surface: **Resource Tracking**: Modules must call `tracker.TrackResource()` for created resources and check for `SideEffectReporter` on payloads to track modifications. Cleanup is region-aware, interactive via `survey/v2`, with permission error guidance. -**Cleanup Report**: `workspace report` generates handoff report with manual AWS CLI cleanup commands. Supports `--module ` filtering. +**Cleanup Report**: `workspace report` generates handoff report with manual AWS CLI cleanup commands. Supports `--output ` for export and `--module ` filtering. **When adding a new resource type**, you must add a `case` to both `CleanupCommand()` in `pkg/report/cleanup.go` (drives the CLI command shown in the terminal, HTML, and Markdown reports) and `cleanupOperationFromType()` in `pkg/core/repl/session.go` (maps the type to an AWS operation name for CloudTrail audit log entries). Omitting either causes the resource to fall through to `default` and print "manual cleanup required" instead of a real command. The `/create-module` checklist enforces this. **Timeouts**: 30-second timeouts for AWS operations (SSO credential resolution). diff --git a/go.mod b/go.mod index d0eb926..81b654d 100644 --- a/go.mod +++ b/go.mod @@ -1,38 +1,38 @@ module github.com/DataDog/pathrunner -go 1.25.0 +go 1.26.0 require ( - github.com/aws/aws-sdk-go-v2 v1.45.1 - github.com/aws/aws-sdk-go-v2/config v1.33.1 - github.com/aws/aws-sdk-go-v2/credentials v1.20.1 - github.com/aws/aws-sdk-go-v2/service/apprunner v1.44.1 - github.com/aws/aws-sdk-go-v2/service/autoscaling v1.75.1 - github.com/aws/aws-sdk-go-v2/service/batch v1.72.1 - github.com/aws/aws-sdk-go-v2/service/bedrockagentcore v1.43.0 - github.com/aws/aws-sdk-go-v2/service/bedrockagentcorecontrol v1.61.1 - github.com/aws/aws-sdk-go-v2/service/braket v1.45.1 - github.com/aws/aws-sdk-go-v2/service/cloudformation v1.78.1 - github.com/aws/aws-sdk-go-v2/service/codebuild v1.75.1 - github.com/aws/aws-sdk-go-v2/service/codedeploy v1.40.1 - github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.39.0 - github.com/aws/aws-sdk-go-v2/service/dynamodb v1.65.1 - github.com/aws/aws-sdk-go-v2/service/ec2 v1.325.1 - github.com/aws/aws-sdk-go-v2/service/ecr v1.62.1 - github.com/aws/aws-sdk-go-v2/service/ecs v1.93.0 - github.com/aws/aws-sdk-go-v2/service/emr v1.66.1 - github.com/aws/aws-sdk-go-v2/service/emrserverless v1.46.1 - github.com/aws/aws-sdk-go-v2/service/gamelift v1.63.1 - github.com/aws/aws-sdk-go-v2/service/glue v1.155.1 - github.com/aws/aws-sdk-go-v2/service/iam v1.61.1 - github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.60.1 - github.com/aws/aws-sdk-go-v2/service/kinesisanalyticsv2 v1.44.1 - github.com/aws/aws-sdk-go-v2/service/lambda v1.104.1 - github.com/aws/aws-sdk-go-v2/service/omics v1.51.1 - github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.46.1 - github.com/aws/aws-sdk-go-v2/service/ssm v1.75.1 - github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 + github.com/aws/aws-sdk-go-v2 v1.47.1 + github.com/aws/aws-sdk-go-v2/config v1.33.6 + github.com/aws/aws-sdk-go-v2/credentials v1.20.6 + github.com/aws/aws-sdk-go-v2/service/apprunner v1.48.1 + github.com/aws/aws-sdk-go-v2/service/autoscaling v1.78.1 + github.com/aws/aws-sdk-go-v2/service/batch v1.78.0 + github.com/aws/aws-sdk-go-v2/service/bedrockagentcore v1.50.1 + github.com/aws/aws-sdk-go-v2/service/bedrockagentcorecontrol v1.72.0 + github.com/aws/aws-sdk-go-v2/service/braket v1.48.1 + github.com/aws/aws-sdk-go-v2/service/cloudformation v1.81.1 + github.com/aws/aws-sdk-go-v2/service/codebuild v1.78.1 + github.com/aws/aws-sdk-go-v2/service/codedeploy v1.45.1 + github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.42.1 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.70.0 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.338.1 + github.com/aws/aws-sdk-go-v2/service/ecr v1.66.1 + github.com/aws/aws-sdk-go-v2/service/ecs v1.100.0 + github.com/aws/aws-sdk-go-v2/service/emr v1.70.1 + github.com/aws/aws-sdk-go-v2/service/emrserverless v1.49.1 + github.com/aws/aws-sdk-go-v2/service/gamelift v1.66.2 + github.com/aws/aws-sdk-go-v2/service/glue v1.167.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.64.1 + github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.66.1 + github.com/aws/aws-sdk-go-v2/service/kinesisanalyticsv2 v1.47.1 + github.com/aws/aws-sdk-go-v2/service/lambda v1.110.0 + github.com/aws/aws-sdk-go-v2/service/omics v1.55.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.1 + github.com/aws/aws-sdk-go-v2/service/ssm v1.79.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 @@ -43,25 +43,25 @@ require ( github.com/spf13/cobra v1.10.2 ) -require github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.84.1 +require github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.89.0 require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect // bedrockagentcore and bedrockagentcorecontrol moved to direct dependencies above (used by bedrock_startbrowsersession_cdp module) github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect - github.com/aws/smithy-go v1.28.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect + github.com/aws/smithy-go v1.28.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect @@ -71,20 +71,20 @@ require ( github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dustin/go-humanize v1.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.28 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/xo/terminfo v1.0.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 - golang.org/x/text v0.41.0 // indirect + github.com/xo/terminfo v1.2.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 + golang.org/x/text v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 4a946b1..0b80788 100644 --- a/go.sum +++ b/go.sum @@ -2,96 +2,96 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= -github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= +github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= -github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw= -github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A= -github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ= -github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q= -github.com/aws/aws-sdk-go-v2/service/apprunner v1.44.1 h1:Ii6e7SBs7ceSw47s2KRql96HULrT2ejGFNdCRtjRceI= -github.com/aws/aws-sdk-go-v2/service/apprunner v1.44.1/go.mod h1:fx47yZV4HnSFGxQBVUuuXiz9UlTmPuFawnUI6azr+eA= -github.com/aws/aws-sdk-go-v2/service/autoscaling v1.75.1 h1:bVjY9oIRGBNMkOicaoXWjzNV7ZucqCDGLL+48+3pPzI= -github.com/aws/aws-sdk-go-v2/service/autoscaling v1.75.1/go.mod h1:wh20TPxjGHR8c/e3+NIZTNzMsrTlkSXODJnxIaaf5a0= -github.com/aws/aws-sdk-go-v2/service/batch v1.72.1 h1:ugjT1jXZ2DyMBECG/w7C5g+t73gIyoop+bsAfL4Vmag= -github.com/aws/aws-sdk-go-v2/service/batch v1.72.1/go.mod h1:1Ip776OeE7Lyu8AiZuo1rM4v/qL3wMcFKtqJMJzrviM= -github.com/aws/aws-sdk-go-v2/service/bedrockagentcore v1.43.0 h1:hhELWYfABV0jVkZ7v6nx6odG8zI3dUw+XDe5oV1xps8= -github.com/aws/aws-sdk-go-v2/service/bedrockagentcore v1.43.0/go.mod h1:7nvltf+SxVHxk1uZAN4U+uMTSgsxUi1wUY++zRQNxMU= -github.com/aws/aws-sdk-go-v2/service/bedrockagentcorecontrol v1.61.1 h1:VkNnCUjMkGzBG89J23l/LGbtmJXMoS8trNTiXWjaK/E= -github.com/aws/aws-sdk-go-v2/service/bedrockagentcorecontrol v1.61.1/go.mod h1:8GtpxB95gE15Wif0/Kdak56z23RRksg4pYO78hFHAY8= -github.com/aws/aws-sdk-go-v2/service/braket v1.45.1 h1:RdqhusFZSZtddJk1axZHiYudCAaXjo33rT26gai9yno= -github.com/aws/aws-sdk-go-v2/service/braket v1.45.1/go.mod h1:dmmlGvPt9TMRLo8g6039gz4YSmc1XCguzbhbt7BrDSQ= -github.com/aws/aws-sdk-go-v2/service/cloudformation v1.78.1 h1:QYFHk3rakk4vryQg4PG36RtJIXOKQQqzxIoSbGLTSsc= -github.com/aws/aws-sdk-go-v2/service/cloudformation v1.78.1/go.mod h1:Qu13pjg01PcSNWKQVqdC19JK/FkT5q9m+J+dyTwfZjw= -github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.84.1 h1:GTN8kHGLbUlnaXPyTuZAhsXMbj77Pnj52P7SOX919Jo= -github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.84.1/go.mod h1:hp3qwCtX+QPXVJMyKsFO/sLXkirzH0R3UHRVM4QBGV4= -github.com/aws/aws-sdk-go-v2/service/codebuild v1.75.1 h1:ZkDjx6AAqpGXk1MC0NW8TNsJstfj/dB+ujVqVasXCc4= -github.com/aws/aws-sdk-go-v2/service/codebuild v1.75.1/go.mod h1:ucVILPTAAjdylPG3j4w7JwKEVWdpoM+QwHMXWGpQINM= -github.com/aws/aws-sdk-go-v2/service/codedeploy v1.40.1 h1:/f4CPTqx8aFXTsB1IngYJphQpfvuoVAZ5I+3dxO0/no= -github.com/aws/aws-sdk-go-v2/service/codedeploy v1.40.1/go.mod h1:FMnvx0W9AZ7rY5BB7euOxubLIBG2jgIp5qASMBu+OW0= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.39.0 h1:VRX0OaAM28h1rhhuqAkURMyQEOCRt5yYvkwqSnAPdn4= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.39.0/go.mod h1:iePGGM7z8ULcQ7mW9a6JDEfLMjXp8Xp2SnKjKZaEXlg= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.65.1 h1:KD7o9P8cdTqYWMSCslCfFTaOB1SWbKtj+dayFrbQVGI= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.65.1/go.mod h1:1n7X9A0OjJ8CZjeac5PsFxEkIzBioOs223JPLDZ5bbQ= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.325.1 h1:rL19vNlxMMJHDSbX5JAwnpR/94kRgcebkdFRvmG6MWM= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.325.1/go.mod h1:M8AJ/M7737nKBNqZvXUNrADGBy+d3PNggJGmAStCWxI= -github.com/aws/aws-sdk-go-v2/service/ecr v1.62.1 h1:is/YQsDD3gNYwHakX0dlFbKot+uFV5YUhJIJWCuw39c= -github.com/aws/aws-sdk-go-v2/service/ecr v1.62.1/go.mod h1:BITpKEMb6U6dnjcbrlonpiStseuI9I0GZ21p61iqeFA= -github.com/aws/aws-sdk-go-v2/service/ecs v1.93.0 h1:vGID9MLvMp5goA8erHQceyoQ+bXx2Bbm1cuqQu1uZjM= -github.com/aws/aws-sdk-go-v2/service/ecs v1.93.0/go.mod h1:SWnb6UqGrRl+8szKjonPFm9cO31HpS9NdqdOLXS9vbA= -github.com/aws/aws-sdk-go-v2/service/emr v1.66.1 h1:V/wJdjLLrvn62z83IGS0OoU5ScaFm9yFS3Stn64jPQs= -github.com/aws/aws-sdk-go-v2/service/emr v1.66.1/go.mod h1:8vIAU4t7I34p+ODNJjVDnRSiI8P1JQNWzWEfpPMFHWw= -github.com/aws/aws-sdk-go-v2/service/emrserverless v1.46.1 h1:ua/pYuQJ82uqGs9OCMtn8VDS6gW0Z8gbJvl36Nz0eoc= -github.com/aws/aws-sdk-go-v2/service/emrserverless v1.46.1/go.mod h1:Xnfaskmp8OGSUMI+o2vS7kJpiiVoTSYMyIz7mOGlVKo= -github.com/aws/aws-sdk-go-v2/service/gamelift v1.63.1 h1:jifqZjl2XmuTGAJdu/umWx7xpBX0KD0UOdt22qVnWNQ= -github.com/aws/aws-sdk-go-v2/service/gamelift v1.63.1/go.mod h1:BK27zV5R/0LAfP0Vo3/JDg0mugJD4PqNq9xK+2YaF+M= -github.com/aws/aws-sdk-go-v2/service/glue v1.155.1 h1:PIG94g4O8ZVGcRN0B3dO4PZI+lwNCFEPw77WJk241Hc= -github.com/aws/aws-sdk-go-v2/service/glue v1.155.1/go.mod h1:rFkH3UgvXgadg/HuhbCEInqFOqC6ZpcOgI/Z87O0N7E= -github.com/aws/aws-sdk-go-v2/service/iam v1.61.1 h1:89xLwRWrLsCaIpxpVqtWiwWmXAcpJZXk5a73SJaqd40= -github.com/aws/aws-sdk-go-v2/service/iam v1.61.1/go.mod h1:k0YVlnUF0eaJtm8g0VcFDn9LVfK38XdrXTHRd/EeflQ= -github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.60.1 h1:JAM6TAGS09o5JYqdCBjCaZ+E9cSVHacYVlRKko0tCdU= -github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.60.1/go.mod h1:LrrgYcCbFV+b5K+M84wVKRcuQlL0sk4FDcQUt/TrUXM= +github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug= +github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ= +github.com/aws/aws-sdk-go-v2/service/apprunner v1.48.1 h1:D0+uvApN5GnhIRMuK9pK9ecFkP9Vkcs7TtqDHXUuib4= +github.com/aws/aws-sdk-go-v2/service/apprunner v1.48.1/go.mod h1:34k28mhgwTcU9USLZC+cto3FdwM66xqM053prr37Oyc= +github.com/aws/aws-sdk-go-v2/service/autoscaling v1.78.1 h1:nKss1SHiv0fjLRpgy9RyPT8QsEP8ufj8ZgvG62s2Wdg= +github.com/aws/aws-sdk-go-v2/service/autoscaling v1.78.1/go.mod h1:4roDw8gYFhAVo1b2ckuzEa0QPtpRXgU4o+dn44IvNF0= +github.com/aws/aws-sdk-go-v2/service/batch v1.78.0 h1:q0CHgaDB3z4L8x/uWExoGGXrGdI1nU2DTZMe5WeMJKw= +github.com/aws/aws-sdk-go-v2/service/batch v1.78.0/go.mod h1:uUOErH221hJ6rx9vPDRUA9FdhUxXXlZv0SND9USk4NQ= +github.com/aws/aws-sdk-go-v2/service/bedrockagentcore v1.50.1 h1:YGrhdITJ0jywGMqjZSbPqrnPmGsHFHr7J6D4HZdXq6k= +github.com/aws/aws-sdk-go-v2/service/bedrockagentcore v1.50.1/go.mod h1:Ruzh/1hoyFK40ZjetWdNV+UWnnC/4dTmxaFAeCB6BgA= +github.com/aws/aws-sdk-go-v2/service/bedrockagentcorecontrol v1.72.0 h1:qC+xWR6oIsx34eYRq/1NNY3H1NEPZZ1ZyKk9muR8kmk= +github.com/aws/aws-sdk-go-v2/service/bedrockagentcorecontrol v1.72.0/go.mod h1:f2dqxth5BW/PFe5UMYc/i4bJ+QssXb6gK8P63f8IXCI= +github.com/aws/aws-sdk-go-v2/service/braket v1.48.1 h1:qQ6aL8jw3xpksDepdhHSqhEQfFWAshG/FbWCaLv4nQc= +github.com/aws/aws-sdk-go-v2/service/braket v1.48.1/go.mod h1:G1/cg6A257mAQ28fCIUPa4IOOm1xe4OXGXNeKaqMpEg= +github.com/aws/aws-sdk-go-v2/service/cloudformation v1.81.1 h1:aQ9rndpdklEc+4PvbsBaK5vZ7lEA577Uv/QZiy0AoN4= +github.com/aws/aws-sdk-go-v2/service/cloudformation v1.81.1/go.mod h1:QXZr5EpgRNj71Y8uj/ACN+VrxiHYKaLRnm+cLgdmccc= +github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.89.0 h1:AnkIUxf4cun23h88TeYyXJoltJTEbJz1RT68Lcg9mMk= +github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.89.0/go.mod h1:exErhqgSxrpHC1W1zKuAPcol+xft1vq6/HNmq2xBA4o= +github.com/aws/aws-sdk-go-v2/service/codebuild v1.78.1 h1:Kiq106ORrqNZsHXSxtRbp2I0hneFIyNh/Ey0dZcOK1o= +github.com/aws/aws-sdk-go-v2/service/codebuild v1.78.1/go.mod h1:CjvFMbrzQPRprrEDELROZZ5ueBcD99xAcWn5bKr2Ozs= +github.com/aws/aws-sdk-go-v2/service/codedeploy v1.45.1 h1:xrUOPHNH9PwzLXzg1O7hXswZ48+bCfaVZVWXSgpIew8= +github.com/aws/aws-sdk-go-v2/service/codedeploy v1.45.1/go.mod h1:deUD6H2BRc4/cEoPyV2hYtN4uOz9FnsrQRVjNydsBsE= +github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.42.1 h1:Dn59f+SXDZpxtW4PeHLX9NpTUviRa7PYKljjtrTQogQ= +github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.42.1/go.mod h1:1QRD/NN0eVdK/AeDi5c2pSx1BOLSD8kYyDBdX0/Pj1g= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.70.0 h1:fgV0Q447Bgc0IPEf1dSl35bLoAxU5wqo2lRgRjJ+bUs= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.70.0/go.mod h1:Gm+i2GlUsFNlzoBq8VXF44XHbKANn3tV8nYBBp3rN8Q= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.338.1 h1:sfwX4gbR9CGsMgBsOQNFMGigRjiZeIG0CF4BlWP/LBQ= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.338.1/go.mod h1:d0e0acsyS3WnFCFJiByGwnUgPpn2wAk97PTIksHN2NI= +github.com/aws/aws-sdk-go-v2/service/ecr v1.66.1 h1:H63vyEXid/tHpv/UlvQUyM1c2QK5WgQRB3MK5gnAo8A= +github.com/aws/aws-sdk-go-v2/service/ecr v1.66.1/go.mod h1:WglfLchOYcHrYOwNV7jERuy0Xc+7jArLkEnQay93auY= +github.com/aws/aws-sdk-go-v2/service/ecs v1.100.0 h1:kmyHs4PWLEEXRLS57M/kkIWCurEBiDAG6Iz9atEp/TU= +github.com/aws/aws-sdk-go-v2/service/ecs v1.100.0/go.mod h1:1BjycrF8UaNiy2N2Y+piEMKuOtoR7FeYwYTMhEY5Gp8= +github.com/aws/aws-sdk-go-v2/service/emr v1.70.1 h1:y+0Z7uFgyPLvosgheKQwIfO42SCkLM6p3/PWB32qyis= +github.com/aws/aws-sdk-go-v2/service/emr v1.70.1/go.mod h1:xXcDuqoP8sQoZ+H57QUkWE3vTYdfzLe0kA/lcKCwwjg= +github.com/aws/aws-sdk-go-v2/service/emrserverless v1.49.1 h1:xpoxty9iWIPRrXAAqE6t7JzXZ67V4L3RWswISF9SlTs= +github.com/aws/aws-sdk-go-v2/service/emrserverless v1.49.1/go.mod h1:s72BXgyYMDZJYzSy6W986/3ulSl/kWhlivzeJ11Hrzc= +github.com/aws/aws-sdk-go-v2/service/gamelift v1.66.2 h1:QbMCtF9r5aNH0Hfb4BElEDOC3ZcKtEItdrFx64yO5OA= +github.com/aws/aws-sdk-go-v2/service/gamelift v1.66.2/go.mod h1:tHbCUFCUkNOM63XvTJ7Jzf5hXde9jSk8gl2EPgk9cNM= +github.com/aws/aws-sdk-go-v2/service/glue v1.167.0 h1:uYfHGAd5JxuSFAGcj9yFfdxxnbNvlkvf12rBtOwTrok= +github.com/aws/aws-sdk-go-v2/service/glue v1.167.0/go.mod h1:2iTyCtEBIYYb+gu9TF8O5rTheE5ZM3o81fXuSmh1FiM= +github.com/aws/aws-sdk-go-v2/service/iam v1.64.1 h1:Uwitin0mXJ7iG5rFuuja3aG9/c84LpyyZUhaTiwZj7w= +github.com/aws/aws-sdk-go-v2/service/iam v1.64.1/go.mod h1:UUmRA59lum0YCVY7b8pz1Qaxa2Jx0rWFm0vX6YZPGfU= +github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.66.1 h1:YrUksrDp51qQeVMtzn2DCqxpPVEXykhcr8NA0wBlZDE= +github.com/aws/aws-sdk-go-v2/service/imagebuilder v1.66.1/go.mod h1:rXefIcTSzFqP9nMY15OOMAmVqMHMTE8mAKlfpBIFHuM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.13.1 h1:kIfDHRmq8zgw3DB0XxzGTZi19BX7OwIn+yDE9BJ2I1g= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.13.1/go.mod h1:o6YX/HIkSNfumuKUum+FrqrsebMFVJUiGcmGp03WnsQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4= -github.com/aws/aws-sdk-go-v2/service/kinesisanalyticsv2 v1.44.1 h1:zwUSEfnbm8Xc+TWHuTdkQghUGlMGLeEfKWJ/ONBOHyg= -github.com/aws/aws-sdk-go-v2/service/kinesisanalyticsv2 v1.44.1/go.mod h1:Q7hDM/W/5o16RcHw+EGqLIa1Z3mGZsIJ+mLcbzhz9Ys= -github.com/aws/aws-sdk-go-v2/service/lambda v1.104.1 h1:5hDVbKi9kocpTnH0/xFJbT3rz7hRNv+fNjJIJDMQCSQ= -github.com/aws/aws-sdk-go-v2/service/lambda v1.104.1/go.mod h1:xFO5LMHsvX9T3B9lLauhTYYyYSCoCn46gxiqpVfK+Sw= -github.com/aws/aws-sdk-go-v2/service/omics v1.51.1 h1:tN36y5pLNV4+pu8SDEeyT/FCQG0yl9kXM2r5ZIL8A0M= -github.com/aws/aws-sdk-go-v2/service/omics v1.51.1/go.mod h1:JW9hKRDcIuclClKU+1xvTU57DwCpaux51jkFMlXDaH8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk= -github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.46.1 h1:WnWkXm0wdenvdzG5QhMvTMWlG7Kh4MNTPcjQn4iuD4M= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.46.1/go.mod h1:2rdJeO95tT+EGmjF4jWmIzNKRwIMpr7Dnf7W6mIB9EQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE= -github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= -github.com/aws/aws-sdk-go-v2/service/ssm v1.75.1 h1:u1DO++wfJop+QfEA0D2Ks2XmV0LiKyxpzwPhBUZmWe4= -github.com/aws/aws-sdk-go-v2/service/ssm v1.75.1/go.mod h1:l+OkBEbZ5r0XEHQ+R7c27Zw3LO89fau+FBxsDoAW1TU= -github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM= -github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= -github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0= -github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= -github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= -github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.13.4 h1:6HvmOQ1rBRrZ4qPJSWxd5szPKUsngXCwSw+V3UaJHmw= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.13.4/go.mod h1:zv2N29aiQUhG2XZNM9zgwCnAyVBdTBbcIpfNAlNmA20= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk= +github.com/aws/aws-sdk-go-v2/service/kinesisanalyticsv2 v1.47.1 h1:uE3bHqkSN2ay6XD7J7GM3F84+ROv9IB5gNuo1uLSrtU= +github.com/aws/aws-sdk-go-v2/service/kinesisanalyticsv2 v1.47.1/go.mod h1:BOHQJhm4BgjvRLTa4/IjXrpxm3PG3BSQRMI0WjVHzMU= +github.com/aws/aws-sdk-go-v2/service/lambda v1.110.0 h1:fJUTGbCN/EKBq/TIR84MDI0qr4eY9qNaw19dT+S2LCA= +github.com/aws/aws-sdk-go-v2/service/lambda v1.110.0/go.mod h1:jUmFXtUKRVCKTaKap+NgL32pmSkVehamqqMENlGMApk= +github.com/aws/aws-sdk-go-v2/service/omics v1.55.1 h1:RS6DIdcZtQjUmIBIPUKzF0gCb/1NFJiZjsmsHkYowo0= +github.com/aws/aws-sdk-go-v2/service/omics v1.55.1/go.mod h1:L/xju4ansQuZ9tXv+sSIkn3QpYAhqzjMCrYnfuRG4MU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.1 h1:xYoGDAZtoSXI5wOfjv1jzG1AUOdXZthz4YL9DFvunrQ= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.1/go.mod h1:dgXxccOMNsXm/eOkrQbBfxm4a6H8IiRphA7z69RG8hM= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw= +github.com/aws/aws-sdk-go-v2/service/ssm v1.79.0 h1:q1PpzCnGQqvWowbCR1h3a799hYhaT4l7SHEHwnwhIG0= +github.com/aws/aws-sdk-go-v2/service/ssm v1.79.0/go.mod h1:FLwEDLnpYkC/SwNx9gbsPcG25uMUk7Pxsx8ixaA9xmE= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM= +github.com/aws/smithy-go v1.28.2 h1:myhcykQcatTul2B/zITjDk203G7t0awUAs1hVry5Bvg= +github.com/aws/smithy-go v1.28.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -141,8 +141,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -153,8 +153,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= -github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -171,17 +171,15 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= -github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index b77c786..352c885 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -56,7 +56,11 @@ func (c *CLI) createIdentityCmd() *cobra.Command { // Build command based on flags var replArgs []string - if profile, _ := cmd.Flags().GetString("profile"); profile != "" { + profile, _ := cmd.Flags().GetString("profile") + if fromProfile, _ := cmd.Flags().GetString("from-profile"); fromProfile != "" && profile == "" { + profile = fromProfile + } + if profile != "" { replArgs = append(replArgs, "--profile", profile) } else if accessKey, _ := cmd.Flags().GetString("access"); accessKey != "" { secretKey, _ := cmd.Flags().GetString("secret") @@ -95,6 +99,7 @@ func (c *CLI) createIdentityCmd() *cobra.Command { }, } addCmd.Flags().String("profile", "", "AWS profile name") + addCmd.Flags().String("from-profile", "", "AWS profile name (alias for --profile)") addCmd.Flags().String("access", "", "Access key ID (requires --secret)") addCmd.Flags().String("secret", "", "Secret access key") addCmd.Flags().String("token", "", "Session token (optional)") @@ -267,11 +272,15 @@ func (c *CLI) createWorkspaceCmd() *cobra.Command { if module, _ := cmd.Flags().GetString("module"); module != "" { replArgs = append(replArgs, "--module", module) } + if output, _ := cmd.Flags().GetString("output"); output != "" { + replArgs = append(replArgs, "--output", output) + } _ = c.executeREPLCommand(strings.Join(replArgs, " ")) }, } reportCmd.Flags().String("module", "", "Only report resources from a specific module ID") + reportCmd.Flags().StringP("output", "o", "", "Write report to file (format inferred from extension: .html or .md)") workspaceCmd.AddCommand(reportCmd) // workspace history diff --git a/pkg/core/repl/cleanup_extra.go b/pkg/core/repl/cleanup_extra.go index b433da7..666d414 100644 --- a/pkg/core/repl/cleanup_extra.go +++ b/pkg/core/repl/cleanup_extra.go @@ -15,6 +15,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/apprunner" "github.com/aws/aws-sdk-go-v2/service/batch" batchtypes "github.com/aws/aws-sdk-go-v2/service/batch/types" + "github.com/aws/aws-sdk-go-v2/service/bedrockagentcore" + agentcoretypes "github.com/aws/aws-sdk-go-v2/service/bedrockagentcore/types" "github.com/aws/aws-sdk-go-v2/service/bedrockagentcorecontrol" "github.com/aws/aws-sdk-go-v2/service/braket" "github.com/aws/aws-sdk-go-v2/service/cloudformation" @@ -283,8 +285,32 @@ func (r *REPL) cleanupBedrockCodeInterpreter(ctx context.Context, config aws.Con if interpreterID == "" { interpreterID = resource.Name } - client := bedrockagentcorecontrol.NewFromConfig(config) - _, err := client.DeleteCodeInterpreter(ctx, &bedrockagentcorecontrol.DeleteCodeInterpreterInput{ + + runtimeClient := bedrockagentcore.NewFromConfig(config) + + // Stop any active sessions before attempting to delete — the API returns + // a 409 ConflictException if active sessions exist. + listOut, err := runtimeClient.ListCodeInterpreterSessions(ctx, &bedrockagentcore.ListCodeInterpreterSessionsInput{ + CodeInterpreterIdentifier: aws.String(interpreterID), + Status: agentcoretypes.CodeInterpreterSessionStatusReady, + }) + if err == nil { + for _, session := range listOut.Items { + if session.SessionId == nil { + continue + } + _, stopErr := runtimeClient.StopCodeInterpreterSession(ctx, &bedrockagentcore.StopCodeInterpreterSessionInput{ + CodeInterpreterIdentifier: aws.String(interpreterID), + SessionId: session.SessionId, + }) + if stopErr != nil { + fmt.Printf(" Warning: failed to stop session %s: %v\n", *session.SessionId, stopErr) + } + } + } + + controlClient := bedrockagentcorecontrol.NewFromConfig(config) + _, err = controlClient.DeleteCodeInterpreter(ctx, &bedrockagentcorecontrol.DeleteCodeInterpreterInput{ CodeInterpreterId: aws.String(interpreterID), }) return err @@ -395,7 +421,7 @@ func (r *REPL) cleanupEC2UserData(ctx context.Context, config aws.Config, resour } // Restore original user-data. The stored value is base64-encoded (as returned by the API). - // BlobAttributeValue.Value takes raw bytes; the SDK re-encodes them automatically. + // SecureBlobAttributeValue.Value takes raw bytes; the SDK re-encodes them automatically. var rawUserData []byte if originalUserData != "" { decoded, decErr := base64.StdEncoding.DecodeString(originalUserData) @@ -408,7 +434,7 @@ func (r *REPL) cleanupEC2UserData(ctx context.Context, config aws.Config, resour _, err = client.ModifyInstanceAttribute(longCtx, &ec2.ModifyInstanceAttributeInput{ InstanceId: aws.String(instanceID), - UserData: &ec2types.BlobAttributeValue{ + UserData: &ec2types.SecureBlobAttributeValue{ Value: rawUserData, }, }) diff --git a/pkg/core/repl/commands.go b/pkg/core/repl/commands.go index 247415f..56c7096 100644 --- a/pkg/core/repl/commands.go +++ b/pkg/core/repl/commands.go @@ -617,21 +617,31 @@ func (r *REPL) showWorkspaceCleanupHelp() error { func (r *REPL) showWorkspaceReportHelp() error { fmt.Println("Workspace Report Command:") - fmt.Println(" workspace report - Generate full cleanup report") - fmt.Println(" workspace report --module - Report for a specific module only") + fmt.Println(" workspace report - Print report to terminal") + fmt.Println(" workspace report --module - Report for a specific module only") + fmt.Println(" workspace report --output - Export as self-contained HTML") + fmt.Println(" workspace report --output - Export as Markdown") fmt.Println() fmt.Println("Generates a cleanup report listing all resources created or modified") fmt.Println("by pathrunner in the current workspace. The report includes:") fmt.Println(" - Created resources (Lambda functions, EC2 instances, IAM entities)") fmt.Println(" - Modified resources (policy attachments that need reversal)") fmt.Println(" - Manual AWS CLI cleanup commands for each resource") + fmt.Println(" - CloudTrail events for blue team detection reference") + fmt.Println() + fmt.Println("Supported export formats (inferred from file extension):") + fmt.Println(" .html Self-contained single-page HTML with dark/light theme") + fmt.Println(" .md Markdown with YAML frontmatter and tables") fmt.Println() - fmt.Println("Useful for handing off to a client, admin, or point of contact when") + fmt.Println("Useful for handing off to a client, admin, or blue team when") fmt.Println("the penetration tester cannot or should not delete resources themselves.") fmt.Println() fmt.Println("Examples:") - fmt.Println(" workspace report # Full report") - fmt.Println(" workspace report --module lambda-002 # Only lambda-002 resources") + fmt.Println(" workspace report # Full report in terminal") + fmt.Println(" workspace report --module lambda-002 # Only lambda-002 resources") + fmt.Println(" workspace report --output audit.html # Export as HTML") + fmt.Println(" workspace report --output audit.md # Export as Markdown") + fmt.Println(" workspace report --module iam-001 --output iam-001.html") return nil } diff --git a/pkg/core/repl/completion.go b/pkg/core/repl/completion.go index 81fbf8e..a65f5fa 100644 --- a/pkg/core/repl/completion.go +++ b/pkg/core/repl/completion.go @@ -336,6 +336,7 @@ func (r *REPL) buildWorkspaceCompleter() readline.PrefixCompleterInterface { ), readline.PcItem("report", readline.PcItem("--module"), + readline.PcItem("--output"), ), readline.PcItem("history"), readline.PcItem("help"), diff --git a/pkg/core/repl/module.go b/pkg/core/repl/module.go index df9b322..9b89cc9 100644 --- a/pkg/core/repl/module.go +++ b/pkg/core/repl/module.go @@ -456,6 +456,11 @@ func (r *REPL) discoverAndSetOption(discoverable modules.Discoverable, optionNam accountID := r.getCurrentAccountID() + // Inject the CloudTrail logger so discovery functions record their AWS API calls. + if dl, ok := discoverable.(modules.DiscoverLogged); ok { + dl.SetDiscoveryLogger(r.sessionManager) + } + // Run live API discovery choices, discoverErr := discoverable.Discover(optionName, identity, r.options) @@ -1010,11 +1015,25 @@ func (r *REPL) cmdExploit(repl *REPL, args []string) error { fmt.Printf("Using identity: %s\n", identity.Name) fmt.Println() + // Build a resolved options map: start with explicitly set values, then fill in + // defaults for any option the user has not overridden. This ensures modules always + // receive the default value when one is defined, even if the user never ran `set`. + resolvedOptions := make(map[string]string, len(r.options)) + for k, v := range r.options { + resolvedOptions[k] = v + } + for _, opt := range r.currentModule.Options() { + if resolvedOptions[opt.Name] == "" && opt.Default != "" { + resolvedOptions[opt.Name] = opt.Default + } + } + result, err := r.currentModule.Execute(modules.ExecutionContext{ Identity: identity, - Options: r.options, + Options: resolvedOptions, Tracker: r.sessionManager, AttackerIdentity: r.identityManager.GetAttackerIdentity(), + Logger: r.sessionManager, }) if err != nil { return NewExecutionError(fmt.Sprintf("module execution failed: %v", err), err) diff --git a/pkg/core/repl/repl.go b/pkg/core/repl/repl.go index 17238ea..51843cf 100644 --- a/pkg/core/repl/repl.go +++ b/pkg/core/repl/repl.go @@ -76,6 +76,21 @@ type SessionManager interface { RemoveCreatedResource(resourceName string) GetCreatedResources() []CreatedResource TrackResource(resource modules.CreatedResource) + LogAWSCall(service, operation, region, description string, metadata map[string]string) + GetCloudTrailEvents() []CloudTrailEvent +} + +// CloudTrailEvent is the repl-layer representation of an AWS API call recorded +// during module execution, for use in purple team detection-reference reports. +type CloudTrailEvent struct { + Timestamp string `json:"timestamp"` + ModuleID string `json:"module_id"` + Service string `json:"service"` + Operation string `json:"operation"` + Region string `json:"region,omitempty"` + Description string `json:"description"` + Principal string `json:"principal,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } // Session interface to avoid circular dependencies diff --git a/pkg/core/repl/session.go b/pkg/core/repl/session.go index 8124a70..2a49358 100644 --- a/pkg/core/repl/session.go +++ b/pkg/core/repl/session.go @@ -13,6 +13,7 @@ import ( "time" "github.com/DataDog/pathrunner/pkg/modules" + "github.com/DataDog/pathrunner/pkg/report" "github.com/DataDog/pathrunner/pkg/ui" "github.com/DataDog/pathrunner/pkg/attacker" @@ -367,6 +368,13 @@ func (r *REPL) sessionCleanup(args []string) error { } if removeIt { r.sessionManager.RemoveCreatedResource(resource.Name) + r.sessionManager.LogAWSCall( + cleanupServiceFromType(resource.Type), + cleanupOperationFromType(resource.Type), + resource.Region, + fmt.Sprintf("Cleanup: %s '%s' not found in AWS — removed from tracking", resource.Type, resource.Name), + map[string]string{"resource_type": resource.Type, "resource_name": resource.Name, "outcome": "not_found"}, + ) gone++ } else if !quit { failed++ @@ -382,6 +390,13 @@ func (r *REPL) sessionCleanup(args []string) error { fmt.Printf(" OK\n") cleaned++ r.sessionManager.RemoveCreatedResource(resource.Name) + r.sessionManager.LogAWSCall( + cleanupServiceFromType(resource.Type), + cleanupOperationFromType(resource.Type), + resource.Region, + fmt.Sprintf("Cleanup: deleted %s '%s'", resource.Type, resource.Name), + map[string]string{"resource_type": resource.Type, "resource_name": resource.Name, "outcome": "deleted"}, + ) } } @@ -437,35 +452,51 @@ func isNotFoundError(err error) bool { } // sessionReport generates a cleanup report for handoff to a client or admin. +// Supports --module to filter by module and --output to write HTML or Markdown. func (r *REPL) sessionReport(args []string) error { if len(args) > 0 && args[0] == "help" { return r.showWorkspaceReportHelp() } resources := r.sessionManager.GetCreatedResources() - if len(resources) == 0 { - fmt.Println("No tracked resources in current workspace. Nothing to report.") + events := r.sessionManager.GetCloudTrailEvents() + + if len(resources) == 0 && len(events) == 0 { + fmt.Println("No tracked resources or CloudTrail events in current workspace. Nothing to report.") return nil } - // Parse optional --module filter + // Parse optional flags moduleFilter := "" + outputPath := "" for i, arg := range args { if arg == "--module" && i+1 < len(args) { moduleFilter = args[i+1] } + if arg == "--output" && i+1 < len(args) { + outputPath = args[i+1] + } } if moduleFilter != "" { - var filtered []CreatedResource + var filteredResources []CreatedResource for _, res := range resources { if res.ModuleID == moduleFilter { - filtered = append(filtered, res) + filteredResources = append(filteredResources, res) } } - resources = filtered - if len(resources) == 0 { - fmt.Printf("No tracked resources for module '%s'.\n", moduleFilter) + resources = filteredResources + + var filteredEvents []CloudTrailEvent + for _, ev := range events { + if ev.ModuleID == moduleFilter { + filteredEvents = append(filteredEvents, ev) + } + } + events = filteredEvents + + if len(resources) == 0 && len(events) == 0 { + fmt.Printf("No tracked resources or CloudTrail events for module '%s'.\n", moduleFilter) return nil } } @@ -486,10 +517,14 @@ func (r *REPL) sessionReport(args []string) error { } } - // Header + // Export to file when --output is given + if outputPath != "" { + return r.exportReport(buildReportData(workspaceName, moduleFilter, created, modified, events), outputPath) + } + + // Terminal rendering (unchanged) ui.ReportHeader(workspaceName, time.Now().Format("2006-01-02 15:04:05 MST"), len(resources), len(created), len(modified)) - // Created resources if len(created) > 0 { ui.ReportSection("CREATED RESOURCES (delete to clean up)") for _, res := range created { @@ -509,11 +544,11 @@ func (r *REPL) sessionReport(args []string) error { if res.Created != "" { fmt.Printf(" Created: %s\n", res.Created) } + printManualCleanupCommand(res) } fmt.Println() } - // Modified resources if len(modified) > 0 { ui.ReportSection("MODIFIED RESOURCES (revert to clean up)") for _, res := range modified { @@ -534,25 +569,129 @@ func (r *REPL) sessionReport(args []string) error { fmt.Printf(" Module: %s\n", res.ModuleID) } fmt.Printf(" Reversal: %s\n", res.CleanupMethod) + printManualCleanupCommand(res) } fmt.Println() } - // Manual cleanup instructions - ui.ReportSection("MANUAL CLEANUP COMMANDS") - fmt.Println() - for _, res := range created { - printManualCleanupCommand(res) - } - for _, res := range modified { - printManualCleanupCommand(res) + if len(events) > 0 { + printCloudTrailEventsSection(events) } ui.ReportFooter() + return nil +} + +// buildReportData converts repl-layer types into the format-agnostic report.ReportData struct. +func buildReportData(workspaceName, moduleFilter string, created, modified []CreatedResource, events []CloudTrailEvent) report.ReportData { + toResource := func(res CreatedResource) report.Resource { + return report.Resource{ + Type: res.Type, + Name: res.Name, + ARN: res.ARN, + Region: res.Region, + ModuleID: res.ModuleID, + CleanupMethod: res.CleanupMethod, + Created: res.Created, + Metadata: res.Metadata, + } + } + + reportCreated := make([]report.Resource, len(created)) + for i, res := range created { + reportCreated[i] = toResource(res) + } + reportModified := make([]report.Resource, len(modified)) + for i, res := range modified { + reportModified[i] = toResource(res) + } + + reportEvents := make([]report.Event, len(events)) + for i, ev := range events { + reportEvents[i] = report.Event{ + Timestamp: ev.Timestamp, + ModuleID: ev.ModuleID, + Service: ev.Service, + Operation: ev.Operation, + Region: ev.Region, + Principal: ev.Principal, + Description: ev.Description, + } + } + + return report.ReportData{ + WorkspaceName: workspaceName, + GeneratedAt: time.Now().UTC(), + ModuleFilter: moduleFilter, + Created: reportCreated, + Modified: reportModified, + Events: reportEvents, + } +} + +// exportReport renders data in the format inferred from outputPath's extension and writes it to disk. +func (r *REPL) exportReport(data report.ReportData, outputPath string) error { + lower := strings.ToLower(outputPath) + + var content string + var format string + + switch { + case strings.HasSuffix(lower, ".html"): + format = "HTML" + rendered, err := report.RenderHTML(data) + if err != nil { + return fmt.Errorf("failed to render HTML report: %w", err) + } + content = rendered + case strings.HasSuffix(lower, ".md"), strings.HasSuffix(lower, ".markdown"): + format = "Markdown" + content = report.RenderMarkdown(data) + default: + return NewInvalidArgumentsError("unsupported output format (use .html or .md)") + } + + if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write %s report: %w", format, err) + } + fmt.Printf("Report written to %s\n", outputPath) return nil } +// printCloudTrailEventsSection renders the CloudTrail events table for blue team reference. +func printCloudTrailEventsSection(events []CloudTrailEvent) { + ui.ReportSection("CLOUDTRAIL EVENTS (blue team detection reference)") + fmt.Println() + + // Column widths: timestamp(23) module(12) service(10) operation(25) principal(40) description(remainder) + fmt.Printf(" %-23s %-12s %-10s %-25s %-40s %s\n", "Timestamp", "Module", "Service", "Operation", "Principal", "Description") + fmt.Printf(" %-23s %-12s %-10s %-25s %-40s %s\n", + "-----------------------", "------------", "----------", "-------------------------", "----------------------------------------", "-----------") + + for _, ev := range events { + moduleID := ev.ModuleID + if len(moduleID) > 12 { + moduleID = moduleID[:11] + "…" + } + service := ev.Service + if len(service) > 10 { + service = service[:9] + "…" + } + operation := ev.Operation + if len(operation) > 25 { + operation = operation[:24] + "…" + } + principal := ev.Principal + if len(principal) > 40 { + principal = "…" + principal[len(principal)-39:] + } + fmt.Printf(" %-23s %-12s %-10s %-25s %-40s %s\n", + ev.Timestamp, moduleID, service, operation, principal, ev.Description) + } + fmt.Println() +} + // isModificationResource returns true for resources that represent modifications // to existing AWS resources (e.g., policy attachments) rather than new creations. func isModificationResource(res CreatedResource) bool { @@ -561,146 +700,162 @@ func isModificationResource(res CreatedResource) bool { res.Type == "iam:trust-policy" } -// printManualCleanupCommand prints the AWS CLI command to clean up a resource. +// printManualCleanupCommand prints the AWS CLI command(s) for a resource paired with its listing. +// Multi-line commands (e.g. stop then delete) are printed with continuation indentation. func printManualCleanupCommand(res CreatedResource) { - region := res.Region - if region == "" { - region = "us-east-1" + cmd := report.CleanupCommand(report.Resource{ + Type: res.Type, + Name: res.Name, + ARN: res.ARN, + Region: res.Region, + Metadata: res.Metadata, + }) + if cmd == "" { + return + } + lines := strings.SplitSeq(cmd, "\n") + const label = " Command: " + const cont = " " // same width as label for continuation lines + first := true + for line := range lines { + if first { + fmt.Printf("%s%s\n", label, line) + first = false + } else { + fmt.Printf("%s%s\n", cont, line) + } + } +} + +// cleanupServiceFromType extracts the AWS service name from a resource type string (e.g. "lambda:function" → "lambda"). +func cleanupServiceFromType(resourceType string) string { + if idx := strings.Index(resourceType, ":"); idx > 0 { + return resourceType[:idx] } + return resourceType +} - switch res.Type { +// cleanupOperationFromType maps a resource type to its primary AWS delete/detach/revert operation name. +func cleanupOperationFromType(resourceType string) string { + switch resourceType { case "lambda:function": - fmt.Printf(" aws lambda delete-function --function-name %s --region %s\n", res.Name, region) + return "DeleteFunction" case "lambda:event-source-mapping": - uuid := res.Metadata["uuid"] - if uuid == "" { - uuid = res.Name - } - fmt.Printf(" aws lambda delete-event-source-mapping --uuid %s --region %s\n", uuid, region) + return "DeleteEventSourceMapping" case "lambda:permission": - funcName := res.Metadata["function_name"] - stmtID := res.Metadata["statement_id"] - fmt.Printf(" aws lambda remove-permission --function-name %s --statement-id %s --region %s\n", funcName, stmtID, region) + return "RemovePermission" case "ec2:instance": - instanceID := res.Metadata["instance_id"] - if instanceID == "" { - instanceID = res.Name - } - fmt.Printf(" aws ec2 terminate-instances --instance-ids %s --region %s\n", instanceID, region) + return "TerminateInstances" case "ec2:spot-instance-request": - spotRequestID := res.Metadata["spot_request_id"] - if spotRequestID == "" { - spotRequestID = res.Name - } - fmt.Printf(" aws ec2 cancel-spot-instance-requests --spot-instance-request-ids %s --region %s\n", spotRequestID, region) + return "CancelSpotInstanceRequests" case "iam:attached-policy": - principalType := res.Metadata["principal_type"] - principalName := res.Metadata["principal_name"] - policyArn := res.Metadata["policy_arn"] - switch principalType { - case "role": - fmt.Printf(" aws iam detach-role-policy --role-name %s --policy-arn %s\n", principalName, policyArn) - case "group": - fmt.Printf(" aws iam detach-group-policy --group-name %s --policy-arn %s\n", principalName, policyArn) - default: - fmt.Printf(" aws iam detach-user-policy --user-name %s --policy-arn %s\n", principalName, policyArn) - } + return "DetachPolicy" case "iam:inline-policy": - principalType := res.Metadata["principal_type"] - principalName := res.Metadata["principal_name"] - policyName := res.Metadata["policy_name"] - switch principalType { - case "role": - fmt.Printf(" aws iam delete-role-policy --role-name %s --policy-name %s\n", principalName, policyName) - case "group": - fmt.Printf(" aws iam delete-group-policy --group-name %s --policy-name %s\n", principalName, policyName) - default: - fmt.Printf(" aws iam delete-user-policy --user-name %s --policy-name %s\n", principalName, policyName) - } + return "DeleteRolePolicy" case "iam:group-membership": - userName := res.Metadata["user_name"] - groupName := res.Metadata["group_name"] - fmt.Printf(" aws iam remove-user-from-group --user-name %s --group-name %s\n", userName, groupName) + return "RemoveUserFromGroup" case "iam:trust-policy": - roleName := res.Metadata["role_name"] - fmt.Printf(" aws iam update-assume-role-policy --role-name %s --policy-document ''\n", roleName) + return "UpdateAssumeRolePolicy" case "iam:policy-version": - policyArn := res.Metadata["policy_arn"] - versionID := res.Metadata["version_id"] - fmt.Printf(" aws iam delete-policy-version --policy-arn %s --version-id %s\n", policyArn, versionID) + return "DeletePolicyVersion" case "iam:access-key": - username := res.Metadata["username"] - accessKeyID := res.Metadata["access_key_id"] - if accessKeyID == "" { - accessKeyID = res.Name - } - fmt.Printf(" aws iam delete-access-key --user-name %s --access-key-id %s\n", username, accessKeyID) + return "DeleteAccessKey" case "iam:login-profile": - username := res.Metadata["username"] - if username == "" { - username = res.Name - } - fmt.Printf(" aws iam delete-login-profile --user-name %s\n", username) + return "DeleteLoginProfile" case "iam:role": - fmt.Printf(" aws iam delete-role --role-name %s\n", res.Name) + return "DeleteRole" case "iam:user": - fmt.Printf(" aws iam delete-user --user-name %s\n", res.Name) + return "DeleteUser" case "ecs:service": - cluster := res.Metadata["cluster"] - fmt.Printf(" aws ecs delete-service --cluster %s --service %s --force --region %s\n", cluster, res.Name, region) + return "DeleteService" case "ecs:cluster": - fmt.Printf(" aws ecs delete-cluster --cluster %s --region %s\n", res.Name, region) + return "DeleteCluster" case "s3_bucket": - fmt.Printf(" aws s3 rm s3://%s --recursive --region %s\n", res.Name, region) - fmt.Printf(" aws s3api delete-bucket --bucket %s --region %s\n", res.Name, region) + return "DeleteBucket" case "glue:dev-endpoint": - fmt.Printf(" aws glue delete-dev-endpoint --endpoint-name %s --region %s\n", res.Name, region) + return "DeleteDevEndpoint" case "glue:job": - fmt.Printf(" aws glue delete-job --job-name %s --region %s\n", res.Name, region) + return "DeleteJob" case "glue:session": - fmt.Printf(" aws glue stop-session --id %s --region %s\n", res.Name, region) - fmt.Printf(" aws glue delete-session --id %s --region %s\n", res.Name, region) + return "DeleteSession" case "glue:trigger": - fmt.Printf(" aws glue stop-trigger --name %s --region %s\n", res.Name, region) - fmt.Printf(" aws glue delete-trigger --name %s --region %s\n", res.Name, region) + return "DeleteTrigger" case "imagebuilder:component": - componentArn := res.Metadata["component_arn"] - if componentArn == "" { - componentArn = res.ARN - } - fmt.Printf(" aws imagebuilder delete-component --component-build-version-arn %s --region %s\n", componentArn, region) + return "DeleteComponent" case "imagebuilder:recipe": - recipeArn := res.Metadata["recipe_arn"] - if recipeArn == "" { - recipeArn = res.ARN - } - fmt.Printf(" aws imagebuilder delete-image-recipe --image-recipe-arn %s --region %s\n", recipeArn, region) + return "DeleteImageRecipe" case "imagebuilder:infra-config": - infraArn := res.Metadata["infra_config_arn"] - if infraArn == "" { - infraArn = res.ARN - } - fmt.Printf(" aws imagebuilder delete-infrastructure-configuration --infrastructure-configuration-arn %s --region %s\n", infraArn, region) + return "DeleteInfrastructureConfiguration" case "imagebuilder:image": - imageArn := res.Metadata["image_build_arn"] - if imageArn == "" { - imageArn = res.ARN - } - fmt.Printf(" aws imagebuilder cancel-image-creation --image-build-version-arn %s --region %s 2>/dev/null || true\n", imageArn, region) - fmt.Printf(" aws imagebuilder delete-image --image-build-version-arn %s --region %s\n", imageArn, region) + return "DeleteImage" case "kinesisanalyticsv2:application": - createTimestamp := res.Metadata["create_timestamp"] - fmt.Printf(" # First stop the application, then delete it using its original CreateTimestamp\n") - fmt.Printf(" aws kinesisanalyticsv2 stop-application --application-name %s --force --region %s 2>/dev/null || true\n", res.Name, region) - fmt.Printf(" aws kinesisanalyticsv2 delete-application --application-name %s --create-timestamp %s --region %s\n", res.Name, createTimestamp, region) + return "DeleteApplication" case "local:file": - path := res.Metadata["path"] - if path == "" { - path = res.Name - } - fmt.Printf(" rm -f %s\n", path) + return "DeleteLocalFile" + case "batch:job-definition": + return "DeregisterJobDefinition" + case "batch:job-queue": + return "DeleteJobQueue" + case "batch:compute-environment": + return "DeleteComputeEnvironment" + case "bedrock-agentcore:code-interpreter": + return "DeleteCodeInterpreter" + case "bedrock-agentcore:agent-runtime": + return "DeleteAgentRuntime" + case "bedrock-agentcore:browser": + return "DeleteBrowser" + case "bedrock-agentcore:harness": + return "DeleteHarness" + case "apprunner:service": + return "DeleteService" + case "braket:job": + return "CancelJob" + case "cloudformation:stack": + return "DeleteStack" + case "cloudformation:stack-update": + return "UpdateStack" + case "cloudformation:stackset": + return "DeleteStackSet" + case "cloudformation:stackset-update": + return "UpdateStackSet" + case "codebuild:project": + return "DeleteProject" + case "cognito-identity:identity-pool-roles": + return "SetIdentityPoolRoles" + case "ec2:launch-template-version": + return "DeleteLaunchTemplateVersions" + case "ec2:launch-template-default": + return "ModifyLaunchTemplate" + case "ec2:userdata": + return "ModifyInstanceAttribute" + case "ecs:task-definition": + return "DeregisterTaskDefinition" + case "ecs:task": + return "StopTask" + case "emr:cluster": + return "TerminateJobFlows" + case "emrserverless:application": + return "DeleteApplication" + case "gamelift:build": + return "DeleteBuild" + case "gamelift:fleet": + return "DeleteFleet" + case "omics:workflow": + return "DeleteWorkflow" + case "omics:run": + return "DeleteRun" + case "ssm:automation-document": + return "DeleteDocument" + case "lambda:function-code": + return "UpdateFunctionCode" + case "glue:job-update": + return "UpdateJob" + case "apprunner:service-update": + return "UpdateService" + case "iam:login-profile-update": + return "UpdateLoginProfile" default: - fmt.Printf(" # %s: %s (manual cleanup required)\n", res.Type, res.Name) + return "Delete" } } diff --git a/pkg/core/repl_adapters.go b/pkg/core/repl_adapters.go index 696a4f6..fe4ffd6 100644 --- a/pkg/core/repl_adapters.go +++ b/pkg/core/repl_adapters.go @@ -78,6 +78,30 @@ func (sa *SessionAdapter) GetCreatedResources() []repl.CreatedResource { return result } +// LogAWSCall satisfies modules.ActionLogger and repl.SessionManager. +func (sa *SessionAdapter) LogAWSCall(service, operation, region, description string, metadata map[string]string) { + sa.SessionManager.LogCloudTrailEvent(service, operation, region, description, metadata) +} + +// GetCloudTrailEvents converts core.CloudTrailEvent slice to the repl-layer type. +func (sa *SessionAdapter) GetCloudTrailEvents() []repl.CloudTrailEvent { + events := sa.SessionManager.GetCloudTrailEvents() + result := make([]repl.CloudTrailEvent, len(events)) + for i, e := range events { + result[i] = repl.CloudTrailEvent{ + Timestamp: e.Timestamp.Format("2006-01-02 15:04:05 MST"), + ModuleID: e.ModuleID, + Service: e.Service, + Operation: e.Operation, + Region: e.Region, + Description: e.Description, + Principal: e.Principal, + Metadata: e.Metadata, + } + } + return result +} + // SessionInterfaceAdapter wraps Session to implement repl.Session interface type SessionInterfaceAdapter struct { *Session diff --git a/pkg/core/session.go b/pkg/core/session.go index ea72ddf..f4ae32a 100644 --- a/pkg/core/session.go +++ b/pkg/core/session.go @@ -17,17 +17,30 @@ import ( ) type Session struct { - Name string `json:"name"` - Created time.Time `json:"created"` - LastAccessed time.Time `json:"last_accessed"` - Identities map[string]*modules.Identity `json:"identities"` - CurrentIdentity string `json:"current_identity"` - AttackerIdentity *modules.Identity `json:"attacker_identity,omitempty"` - CurrentModule string `json:"current_module"` - Options map[string]string `json:"options"` - CommandLog []CommandLogEntry `json:"command_log"` - CreatedResources []CreatedResource `json:"created_resources"` - LastResult string `json:"last_result"` + Name string `json:"name"` + Created time.Time `json:"created"` + LastAccessed time.Time `json:"last_accessed"` + Identities map[string]*modules.Identity `json:"identities"` + CurrentIdentity string `json:"current_identity"` + AttackerIdentity *modules.Identity `json:"attacker_identity,omitempty"` + CurrentModule string `json:"current_module"` + Options map[string]string `json:"options"` + CommandLog []CommandLogEntry `json:"command_log"` + CreatedResources []CreatedResource `json:"created_resources"` + CloudTrailEvents []CloudTrailEvent `json:"cloudtrail_events,omitempty"` + LastResult string `json:"last_result"` +} + +// CloudTrailEvent mirrors modules.CloudTrailEvent for workspace-persisted storage. +type CloudTrailEvent struct { + Timestamp time.Time `json:"timestamp"` + ModuleID string `json:"module_id"` + Service string `json:"service"` + Operation string `json:"operation"` + Region string `json:"region,omitempty"` + Description string `json:"description"` + Principal string `json:"principal,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } type CommandLogEntry struct { @@ -335,4 +348,51 @@ func (sm *SessionManager) TrackResource(resource modules.CreatedResource) { } sm.currentSession.CreatedResources = append(sm.currentSession.CreatedResources, coreResource) +} + +// LogCloudTrailEvent records an AWS API call made during module execution. +// The module ID is taken from the session's CurrentModule field so callers +// don't need to supply it. +func (sm *SessionManager) LogCloudTrailEvent(service, operation, region, description string, metadata map[string]string) { + if sm.currentSession == nil { + return + } + + // Capture the current identity's ARN as the acting principal. + var principal string + if sm.currentSession.CurrentIdentity != "" { + if identity, ok := sm.currentSession.Identities[sm.currentSession.CurrentIdentity]; ok { + if identity.CallerARN != "" { + principal = identity.CallerARN + } else { + principal = sm.currentSession.CurrentIdentity + } + } + } + + event := CloudTrailEvent{ + Timestamp: time.Now(), + ModuleID: sm.currentSession.CurrentModule, + Service: service, + Operation: operation, + Region: region, + Description: description, + Principal: principal, + Metadata: metadata, + } + + sm.currentSession.CloudTrailEvents = append(sm.currentSession.CloudTrailEvents, event) + + // Cap at 5000 events to prevent excessive growth. + if len(sm.currentSession.CloudTrailEvents) > 5000 { + sm.currentSession.CloudTrailEvents = sm.currentSession.CloudTrailEvents[len(sm.currentSession.CloudTrailEvents)-5000:] + } +} + +// GetCloudTrailEvents returns all recorded CloudTrail events for the current session. +func (sm *SessionManager) GetCloudTrailEvents() []CloudTrailEvent { + if sm.currentSession == nil { + return nil + } + return sm.currentSession.CloudTrailEvents } \ No newline at end of file diff --git a/pkg/discovery/batch.go b/pkg/discovery/batch.go index 3e662df..b1b1053 100644 --- a/pkg/discovery/batch.go +++ b/pkg/discovery/batch.go @@ -19,7 +19,12 @@ import ( // DiscoverBatchJobDefinitions lists active Batch job definitions and enriches each // with the jobRoleArn so the operator can identify definitions with privileged roles. // Returns choices with job definition names as values. -func DiscoverBatchJobDefinitions(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverBatchJobDefinitions(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + batchClient := batch.NewFromConfig(config) listCtx, listCancel := context.WithTimeout(ctx, 30*time.Second) @@ -34,6 +39,9 @@ func DiscoverBatchJobDefinitions(ctx context.Context, config aws.Config) ([]modu } return nil, fmt.Errorf("failed to describe Batch job definitions: %v", err) } + if log != nil { + log.LogAWSCall("batch", "DescribeJobDefinitions", config.Region, "Enumerated active Batch job definitions for discovery", nil) + } var choices []modules.DiscoveryChoice for _, jd := range output.JobDefinitions { @@ -68,7 +76,12 @@ func DiscoverBatchJobDefinitions(ctx context.Context, config aws.Config) ([]modu // DiscoverBatchJobQueues lists active Batch job queues. // Returns choices with job queue names as values. -func DiscoverBatchJobQueues(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverBatchJobQueues(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + batchClient := batch.NewFromConfig(config) listCtx, listCancel := context.WithTimeout(ctx, 30*time.Second) @@ -81,6 +94,9 @@ func DiscoverBatchJobQueues(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to describe Batch job queues: %v", err) } + if log != nil { + log.LogAWSCall("batch", "DescribeJobQueues", config.Region, "Enumerated Batch job queues for discovery", nil) + } var choices []modules.DiscoveryChoice for _, jq := range output.JobQueues { diff --git a/pkg/discovery/bedrock.go b/pkg/discovery/bedrock.go index 17a2152..c6814cf 100644 --- a/pkg/discovery/bedrock.go +++ b/pkg/discovery/bedrock.go @@ -16,7 +16,12 @@ import ( // DiscoverBedrockAgentRuntimes lists Bedrock AgentCore agent runtimes. // Returns choices with runtime ARNs as values. -func DiscoverBedrockAgentRuntimes(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverBedrockAgentRuntimes(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := bedrockagentcorecontrol.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -31,6 +36,9 @@ func DiscoverBedrockAgentRuntimes(ctx context.Context, config aws.Config) ([]mod } return nil, fmt.Errorf("failed to list Bedrock AgentCore runtimes: %v", err) } + if log != nil { + log.LogAWSCall("bedrock-agentcore", "ListAgentRuntimes", config.Region, "Enumerated Bedrock AgentCore runtimes for discovery", nil) + } var choices []modules.DiscoveryChoice for _, runtime := range result.AgentRuntimes { diff --git a/pkg/discovery/codebuild.go b/pkg/discovery/codebuild.go index 2120958..c8b7c74 100644 --- a/pkg/discovery/codebuild.go +++ b/pkg/discovery/codebuild.go @@ -18,7 +18,12 @@ import ( // DiscoverCodeBuildProjects lists CodeBuild projects and enriches each with // service role information so the operator can identify privileged projects. // Returns choices with project names as values. -func DiscoverCodeBuildProjects(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverCodeBuildProjects(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + cbClient := codebuild.NewFromConfig(config) listCtx, listCancel := context.WithTimeout(ctx, 30*time.Second) @@ -32,6 +37,9 @@ func DiscoverCodeBuildProjects(ctx context.Context, config aws.Config) ([]module } return nil, fmt.Errorf("failed to list CodeBuild projects: %v", err) } + if log != nil { + log.LogAWSCall("codebuild", "ListProjects", config.Region, "Enumerated CodeBuild projects for discovery", nil) + } if len(listResult.Projects) == 0 { return nil, nil diff --git a/pkg/discovery/codedeploy.go b/pkg/discovery/codedeploy.go index 1c78472..76baaee 100644 --- a/pkg/discovery/codedeploy.go +++ b/pkg/discovery/codedeploy.go @@ -16,7 +16,12 @@ import ( // DiscoverCodeDeployApps lists CodeDeploy applications. // Returns choices with application names as values. -func DiscoverCodeDeployApps(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverCodeDeployApps(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := codedeploy.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -29,6 +34,9 @@ func DiscoverCodeDeployApps(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to list CodeDeploy applications: %v", err) } + if log != nil { + log.LogAWSCall("codedeploy", "ListApplications", config.Region, "Enumerated CodeDeploy applications for discovery", nil) + } if len(result.Applications) == 0 { return nil, nil @@ -47,7 +55,12 @@ func DiscoverCodeDeployApps(ctx context.Context, config aws.Config) ([]modules.D // DiscoverCodeDeployGroups lists deployment groups for a given CodeDeploy application. // Returns choices with deployment group names as values. -func DiscoverCodeDeployGroups(ctx context.Context, config aws.Config, appName string) ([]modules.DiscoveryChoice, error) { +func DiscoverCodeDeployGroups(ctx context.Context, config aws.Config, appName string, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := codedeploy.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -62,6 +75,11 @@ func DiscoverCodeDeployGroups(ctx context.Context, config aws.Config, appName st } return nil, fmt.Errorf("failed to list deployment groups for %s: %v", appName, err) } + if log != nil { + log.LogAWSCall("codedeploy", "ListDeploymentGroups", config.Region, + fmt.Sprintf("Enumerated CodeDeploy deployment groups for app %s", appName), + map[string]string{"app_name": appName}) + } if len(result.DeploymentGroups) == 0 { return nil, nil diff --git a/pkg/discovery/dynamodb.go b/pkg/discovery/dynamodb.go index 001df2e..5313c80 100644 --- a/pkg/discovery/dynamodb.go +++ b/pkg/discovery/dynamodb.go @@ -16,7 +16,12 @@ import ( // DiscoverDynamoDBStreams lists DynamoDB tables with streams enabled // and returns their stream ARNs as discovery choices. -func DiscoverDynamoDBStreams(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverDynamoDBStreams(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := dynamodb.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -25,6 +30,7 @@ func DiscoverDynamoDBStreams(ctx context.Context, config aws.Config) ([]modules. // List all tables var tableNames []string var lastTable *string + listedTables := false for { input := &dynamodb.ListTablesInput{} if lastTable != nil { @@ -38,6 +44,12 @@ func DiscoverDynamoDBStreams(ctx context.Context, config aws.Config) ([]modules. } return nil, fmt.Errorf("failed to list tables: %v", err) } + if !listedTables { + if log != nil { + log.LogAWSCall("dynamodb", "ListTables", config.Region, "Enumerated DynamoDB tables for stream discovery", nil) + } + listedTables = true + } tableNames = append(tableNames, result.TableNames...) @@ -82,9 +94,9 @@ func DiscoverDynamoDBStreams(ctx context.Context, config aws.Config) ([]modules. // DiscoverDynamoDBTableNames lists DynamoDB tables with streams enabled // and returns their table names as discovery choices. -func DiscoverDynamoDBTableNames(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverDynamoDBTableNames(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { // Reuse the stream discovery and convert to table name choices - streamChoices, err := DiscoverDynamoDBStreams(ctx, config) + streamChoices, err := DiscoverDynamoDBStreams(ctx, config, logger...) if err != nil { return nil, err } diff --git a/pkg/discovery/ec2.go b/pkg/discovery/ec2.go index 9329a00..d4bc90e 100644 --- a/pkg/discovery/ec2.go +++ b/pkg/discovery/ec2.go @@ -17,7 +17,12 @@ import ( ) // DiscoverSubnets lists VPC subnets available in the current region. -func DiscoverSubnets(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverSubnets(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := ec2.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -30,6 +35,9 @@ func DiscoverSubnets(ctx context.Context, config aws.Config) ([]modules.Discover } return nil, fmt.Errorf("failed to describe subnets: %v", err) } + if log != nil { + log.LogAWSCall("ec2", "DescribeSubnets", config.Region, "Enumerated VPC subnets for discovery", nil) + } var choices []modules.DiscoveryChoice for _, subnet := range result.Subnets { @@ -65,13 +73,19 @@ func DiscoverSubnets(ctx context.Context, config aws.Config) ([]modules.Discover } // DiscoverSecurityGroups lists security groups available in the current region. -func DiscoverSecurityGroups(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverSecurityGroups(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := ec2.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allGroups []modules.DiscoveryChoice + loggedOnce := false paginator := ec2.NewDescribeSecurityGroupsPaginator(client, &ec2.DescribeSecurityGroupsInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -81,6 +95,10 @@ func DiscoverSecurityGroups(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to describe security groups: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("ec2", "DescribeSecurityGroups", config.Region, "Enumerated security groups for discovery", nil) + loggedOnce = true + } for _, sg := range page.SecurityGroups { sgID := aws.ToString(sg.GroupId) @@ -119,7 +137,12 @@ func DiscoverSecurityGroups(ctx context.Context, config aws.Config) ([]modules.D // DiscoverEC2InstancesWithProfiles lists running EC2 instances that have an IAM // instance profile attached. Results include the instance ID, public IP, and // profile ARN so the caller can assess privilege escalation potential. -func DiscoverEC2InstancesWithProfiles(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverEC2InstancesWithProfiles(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := ec2.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -132,6 +155,7 @@ func DiscoverEC2InstancesWithProfiles(ctx context.Context, config aws.Config) ([ } var choices []modules.DiscoveryChoice + loggedOnce := false paginator := ec2.NewDescribeInstancesPaginator(client, input) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -141,6 +165,10 @@ func DiscoverEC2InstancesWithProfiles(ctx context.Context, config aws.Config) ([ } return nil, fmt.Errorf("failed to describe instances: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("ec2", "DescribeInstances", config.Region, "Enumerated running EC2 instances with IAM profiles for discovery", nil) + loggedOnce = true + } for _, reservation := range page.Reservations { for _, inst := range reservation.Instances { @@ -186,13 +214,19 @@ func DiscoverEC2InstancesWithProfiles(ctx context.Context, config aws.Config) ([ // DiscoverEC2Instances lists EC2 instances that are running or stopped. // Both states are valid targets for ec2-002 (ModifyInstanceAttribute requires // the instance to be stopped, but if it's running the module will stop it first). -func DiscoverEC2Instances(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverEC2Instances(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := ec2.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var choices []modules.DiscoveryChoice + loggedOnce := false paginator := ec2.NewDescribeInstancesPaginator(client, &ec2.DescribeInstancesInput{ Filters: []types.Filter{ { @@ -210,6 +244,10 @@ func DiscoverEC2Instances(ctx context.Context, config aws.Config) ([]modules.Dis } return nil, fmt.Errorf("failed to describe instances: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("ec2", "DescribeInstances", config.Region, "Enumerated running and stopped EC2 instances for discovery", nil) + loggedOnce = true + } for _, reservation := range page.Reservations { for _, instance := range reservation.Instances { diff --git a/pkg/discovery/glue.go b/pkg/discovery/glue.go index ae16bd0..aa2a34a 100644 --- a/pkg/discovery/glue.go +++ b/pkg/discovery/glue.go @@ -17,7 +17,12 @@ import ( // DiscoverGlueJobs lists existing Glue jobs and returns them as discovery choices. // Each choice includes the job name as the value and the current role as metadata. -func DiscoverGlueJobs(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverGlueJobs(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + glueClient := glue.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -30,6 +35,9 @@ func DiscoverGlueJobs(ctx context.Context, config aws.Config) ([]modules.Discove } return nil, fmt.Errorf("failed to list Glue jobs: %v", err) } + if log != nil { + log.LogAWSCall("glue", "ListJobs", config.Region, "Enumerated Glue jobs for discovery", nil) + } var choices []modules.DiscoveryChoice for _, jobName := range result.JobNames { diff --git a/pkg/discovery/iam.go b/pkg/discovery/iam.go index 1b65edb..696af41 100644 --- a/pkg/discovery/iam.go +++ b/pkg/discovery/iam.go @@ -49,13 +49,19 @@ type trustPolicyStatement struct { // DiscoverRolesForService lists IAM roles whose trust policy allows // the given service principal (e.g., "lambda.amazonaws.com"). // Enriches with attached policy names when possible. -func DiscoverRolesForService(ctx context.Context, config aws.Config, servicePrincipal string) ([]modules.DiscoveryChoice, error) { +func DiscoverRolesForService(ctx context.Context, config aws.Config, servicePrincipal string, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := iam.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allRoles []iamtypes.Role + loggedOnce := false paginator := iam.NewListRolesPaginator(client, &iam.ListRolesInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -65,6 +71,12 @@ func DiscoverRolesForService(ctx context.Context, config aws.Config, servicePrin } return nil, fmt.Errorf("failed to list roles: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListRoles", config.Region, + fmt.Sprintf("Enumerated IAM roles with trust for %s", servicePrincipal), + map[string]string{"service_principal": servicePrincipal}) + loggedOnce = true + } allRoles = append(allRoles, page.Roles...) } @@ -130,13 +142,19 @@ func DiscoverRolesForService(ctx context.Context, config aws.Config, servicePrin // DiscoverIAMUsers lists IAM users with enriched metadata (attached policies, // access key count, login profile status). Useful for modules targeting specific users. -func DiscoverIAMUsers(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverIAMUsers(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + iamClient := iam.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allUsers []iamtypes.User + loggedOnce := false paginator := iam.NewListUsersPaginator(iamClient, &iam.ListUsersInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -146,6 +164,10 @@ func DiscoverIAMUsers(ctx context.Context, config aws.Config) ([]modules.Discove } return nil, fmt.Errorf("failed to list users: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListUsers", config.Region, "Enumerated IAM users for discovery", nil) + loggedOnce = true + } allUsers = append(allUsers, page.Users...) } @@ -262,13 +284,19 @@ func hasLoginProfile(ctx context.Context, client *iam.Client, userName string) ( } // DiscoverInstanceProfiles lists IAM instance profiles and their associated roles. -func DiscoverInstanceProfiles(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverInstanceProfiles(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := iam.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allProfiles []iamtypes.InstanceProfile + loggedOnce := false paginator := iam.NewListInstanceProfilesPaginator(client, &iam.ListInstanceProfilesInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -278,6 +306,10 @@ func DiscoverInstanceProfiles(ctx context.Context, config aws.Config) ([]modules } return nil, fmt.Errorf("failed to list instance profiles: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListInstanceProfiles", config.Region, "Enumerated IAM instance profiles for discovery", nil) + loggedOnce = true + } allProfiles = append(allProfiles, page.InstanceProfiles...) } @@ -373,7 +405,12 @@ func trustsService(policyDoc string, servicePrincipal string) bool { // DiscoverAssumableRoles lists IAM roles whose trust policy allows the // current caller to assume them. Calls sts:GetCallerIdentity to determine // the caller's ARN and account, then filters roles by trust policy. -func DiscoverAssumableRoles(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverAssumableRoles(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + // Get caller identity to know who we are stsClient := sts.NewFromConfig(config) identityCtx, identityCancel := context.WithTimeout(ctx, 15*time.Second) @@ -386,6 +423,9 @@ func DiscoverAssumableRoles(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to get caller identity: %v", err) } + if log != nil { + log.LogAWSCall("sts", "GetCallerIdentity", config.Region, "Resolved caller ARN to filter assumable roles", nil) + } callerArn := aws.ToString(callerIdentity.Arn) callerAccount := aws.ToString(callerIdentity.Account) @@ -399,6 +439,7 @@ func DiscoverAssumableRoles(ctx context.Context, config aws.Config) ([]modules.D defer cancel() var allRoles []iamtypes.Role + loggedOnce := false paginator := iam.NewListRolesPaginator(iamClient, &iam.ListRolesInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -408,6 +449,10 @@ func DiscoverAssumableRoles(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to list roles: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListRoles", config.Region, "Enumerated IAM roles to find assumable targets", nil) + loggedOnce = true + } allRoles = append(allRoles, page.Roles...) } @@ -596,7 +641,12 @@ func actionAllowsAssumeRole(action any) bool { // DiscoverCallerPolicies lists customer-managed IAM policies attached to the // current caller (user or role). Useful for modules like iam:CreatePolicyVersion // that need to target a specific policy ARN. -func DiscoverCallerPolicies(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverCallerPolicies(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + stsClient := sts.NewFromConfig(config) identityCtx, identityCancel := context.WithTimeout(ctx, 15*time.Second) defer identityCancel() @@ -608,6 +658,9 @@ func DiscoverCallerPolicies(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to get caller identity: %v", err) } + if log != nil { + log.LogAWSCall("sts", "GetCallerIdentity", config.Region, "Resolved caller identity to discover attached policies", nil) + } callerArn := aws.ToString(callerIdentity.Arn) callerAccount := aws.ToString(callerIdentity.Account) @@ -631,6 +684,7 @@ func DiscoverCallerPolicies(ctx context.Context, config aws.Config) ([]modules.D listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() + userPolicyLoggedOnce := false paginator := iam.NewListAttachedUserPoliciesPaginator(iamClient, &iam.ListAttachedUserPoliciesInput{ UserName: aws.String(userName), }) @@ -642,6 +696,12 @@ func DiscoverCallerPolicies(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to list attached user policies: %v", err) } + if log != nil && !userPolicyLoggedOnce { + log.LogAWSCall("iam", "ListAttachedUserPolicies", config.Region, + fmt.Sprintf("Enumerated policies attached to user %s for discovery", userName), + map[string]string{"user_name": userName}) + userPolicyLoggedOnce = true + } attachedPolicies = append(attachedPolicies, page.AttachedPolicies...) } @@ -678,6 +738,7 @@ func DiscoverCallerPolicies(ctx context.Context, config aws.Config) ([]modules.D listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() + rolePolicyLoggedOnce := false paginator := iam.NewListAttachedRolePoliciesPaginator(iamClient, &iam.ListAttachedRolePoliciesInput{ RoleName: aws.String(roleName), }) @@ -689,6 +750,12 @@ func DiscoverCallerPolicies(ctx context.Context, config aws.Config) ([]modules.D } return nil, fmt.Errorf("failed to list attached role policies: %v", err) } + if log != nil && !rolePolicyLoggedOnce { + log.LogAWSCall("iam", "ListAttachedRolePolicies", config.Region, + fmt.Sprintf("Enumerated policies attached to role %s for discovery", roleName), + map[string]string{"role_name": roleName}) + rolePolicyLoggedOnce = true + } attachedPolicies = append(attachedPolicies, page.AttachedPolicies...) } } else { @@ -767,7 +834,12 @@ func listUserGroupPolicies(ctx context.Context, client *iam.Client, userName str // DiscoverCallerGroups lists IAM groups that the calling user belongs to. // Useful for self-escalation modules targeting group policies (iam-010, iam-011). -func DiscoverCallerGroups(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverCallerGroups(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + // Get caller identity to determine username stsClient := sts.NewFromConfig(config) identityCtx, identityCancel := context.WithTimeout(ctx, 15*time.Second) @@ -780,6 +852,9 @@ func DiscoverCallerGroups(ctx context.Context, config aws.Config) ([]modules.Dis } return nil, fmt.Errorf("failed to get caller identity: %v", err) } + if log != nil { + log.LogAWSCall("sts", "GetCallerIdentity", config.Region, "Resolved caller identity to discover group memberships", nil) + } callerArn := aws.ToString(callerIdentity.Arn) @@ -809,6 +884,11 @@ func DiscoverCallerGroups(ctx context.Context, config aws.Config) ([]modules.Dis } return nil, fmt.Errorf("failed to list groups for user %s: %v", userName, err) } + if log != nil { + log.LogAWSCall("iam", "ListGroupsForUser", config.Region, + fmt.Sprintf("Enumerated IAM groups for user %s", userName), + map[string]string{"user_name": userName}) + } var choices []modules.DiscoveryChoice for _, group := range result.Groups { @@ -855,13 +935,19 @@ func DiscoverCallerGroups(ctx context.Context, config aws.Config) ([]modules.Dis // DiscoverIAMGroups lists all IAM groups with enriched metadata (attached policies). // Useful for modules like iam:AddUserToGroup that need to find privileged groups. -func DiscoverIAMGroups(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverIAMGroups(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + iamClient := iam.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allGroups []iamtypes.Group + loggedOnce := false paginator := iam.NewListGroupsPaginator(iamClient, &iam.ListGroupsInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -871,6 +957,10 @@ func DiscoverIAMGroups(ctx context.Context, config aws.Config) ([]modules.Discov } return nil, fmt.Errorf("failed to list groups: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListGroups", config.Region, "Enumerated IAM groups for discovery", nil) + loggedOnce = true + } allGroups = append(allGroups, page.Groups...) } @@ -921,13 +1011,19 @@ func DiscoverIAMGroups(ctx context.Context, config aws.Config) ([]modules.Discov // DiscoverIAMRoles lists all IAM roles with enriched metadata. // Unlike DiscoverAssumableRoles, this does not filter by trust policy. // Useful for modules that modify trust policies (iam-012, iam-019, iam-020, iam-021). -func DiscoverIAMRoles(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverIAMRoles(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + iamClient := iam.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allRoles []iamtypes.Role + loggedOnce := false paginator := iam.NewListRolesPaginator(iamClient, &iam.ListRolesInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -937,6 +1033,10 @@ func DiscoverIAMRoles(ctx context.Context, config aws.Config) ([]modules.Discove } return nil, fmt.Errorf("failed to list roles: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListRoles", config.Region, "Enumerated IAM roles for discovery", nil) + loggedOnce = true + } allRoles = append(allRoles, page.Roles...) } @@ -998,13 +1098,19 @@ func DiscoverIAMRoles(ctx context.Context, config aws.Config) ([]modules.Discove // This distinguishes execution roles from administration roles, which trust // cloudformation.amazonaws.com and the account root (":root") instead of a specific role ARN. // Returns role names (not ARNs) since CloudFormation's ExecutionRoleName API field expects names. -func DiscoverCFNStackSetExecutionRoles(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverCFNStackSetExecutionRoles(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := iam.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allRoles []iamtypes.Role + loggedOnce := false paginator := iam.NewListRolesPaginator(client, &iam.ListRolesInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -1014,6 +1120,10 @@ func DiscoverCFNStackSetExecutionRoles(ctx context.Context, config aws.Config) ( } return nil, fmt.Errorf("failed to list roles: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListRoles", config.Region, "Enumerated IAM roles to find CloudFormation StackSet execution roles", nil) + loggedOnce = true + } allRoles = append(allRoles, page.Roles...) } @@ -1075,13 +1185,19 @@ func DiscoverCFNStackSetExecutionRoles(ctx context.Context, config aws.Config) ( // an execution role when the administration role ARN is already known: the execution role's // trust policy must contain the administration role's ARN as an AWS principal. // Returns role names (not ARNs) since CloudFormation's ExecutionRoleName API field expects names. -func DiscoverCFNExecutionRoleForAdminRole(ctx context.Context, config aws.Config, adminRoleARN string) ([]modules.DiscoveryChoice, error) { +func DiscoverCFNExecutionRoleForAdminRole(ctx context.Context, config aws.Config, adminRoleARN string, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := iam.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() var allRoles []iamtypes.Role + loggedOnce := false paginator := iam.NewListRolesPaginator(client, &iam.ListRolesInput{}) for paginator.HasMorePages() { page, err := paginator.NextPage(listCtx) @@ -1091,6 +1207,12 @@ func DiscoverCFNExecutionRoleForAdminRole(ctx context.Context, config aws.Config } return nil, fmt.Errorf("failed to list roles: %v", err) } + if log != nil && !loggedOnce { + log.LogAWSCall("iam", "ListRoles", config.Region, + fmt.Sprintf("Enumerated IAM roles to find StackSet execution role for admin role %s", adminRoleARN), + map[string]string{"admin_role_arn": adminRoleARN}) + loggedOnce = true + } allRoles = append(allRoles, page.Roles...) } diff --git a/pkg/discovery/lambda.go b/pkg/discovery/lambda.go index aab0599..071d808 100644 --- a/pkg/discovery/lambda.go +++ b/pkg/discovery/lambda.go @@ -28,7 +28,12 @@ type LambdaFunctionInfo struct { // DiscoverLambdaFunctions lists Lambda functions and enriches them with // execution role information. Returns choices with function names as values. -func DiscoverLambdaFunctions(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverLambdaFunctions(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + lambdaClient := lambda.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -41,6 +46,9 @@ func DiscoverLambdaFunctions(ctx context.Context, config aws.Config) ([]modules. } return nil, fmt.Errorf("failed to list Lambda functions: %v", err) } + if log != nil { + log.LogAWSCall("lambda", "ListFunctions", config.Region, "Enumerated Lambda functions for discovery", nil) + } var choices []modules.DiscoveryChoice for _, fn := range result.Functions { diff --git a/pkg/discovery/s3.go b/pkg/discovery/s3.go index 3ee6391..1ab9c98 100644 --- a/pkg/discovery/s3.go +++ b/pkg/discovery/s3.go @@ -16,7 +16,12 @@ import ( // DiscoverS3Buckets lists S3 buckets accessible to the current identity. // Returns choices with bucket names as values. -func DiscoverS3Buckets(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { +func DiscoverS3Buckets(ctx context.Context, config aws.Config, logger ...modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + var log modules.ActionLogger + if len(logger) > 0 { + log = logger[0] + } + client := s3.NewFromConfig(config) listCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -29,6 +34,9 @@ func DiscoverS3Buckets(ctx context.Context, config aws.Config) ([]modules.Discov } return nil, fmt.Errorf("failed to list S3 buckets: %v", err) } + if log != nil { + log.LogAWSCall("s3", "ListAllMyBuckets", config.Region, "Enumerated S3 buckets for discovery", nil) + } if len(result.Buckets) == 0 { return nil, nil diff --git a/pkg/exploits/apprunner_passrole/module.go b/pkg/exploits/apprunner_passrole/module.go index 770aa41..d1bbce6 100644 --- a/pkg/exploits/apprunner_passrole/module.go +++ b/pkg/exploits/apprunner_passrole/module.go @@ -106,7 +106,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "tasks.apprunner.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "tasks.apprunner.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -157,6 +157,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -265,6 +266,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { serviceArn := aws.ToString(createResult.Service.ServiceArn) fmt.Printf("App Runner service created: %s\n", serviceArn) + if logger != nil { + logger.LogAWSCall("apprunner", "CreateService", region, + fmt.Sprintf("Created App Runner service %s with instance role %s", serviceName, roleArn), + map[string]string{"service_arn": serviceArn, "role_arn": roleArn, "payload": payloadType}) + } // Track the created service for cleanup. if tracker != nil { diff --git a/pkg/exploits/apprunner_updateservice/module.go b/pkg/exploits/apprunner_updateservice/module.go index 74f97ca..2be90e6 100644 --- a/pkg/exploits/apprunner_updateservice/module.go +++ b/pkg/exploits/apprunner_updateservice/module.go @@ -196,6 +196,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger serviceArn := options["SERVICE_ARN"] payloadType := options["PAYLOAD"] @@ -325,6 +326,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Service name: %s\n", serviceName) fmt.Printf("Instance role ARN: %s\n", instanceRoleArn) + if logger != nil { + logger.LogAWSCall("apprunner", "DescribeService", region, + fmt.Sprintf("Retrieved configuration for App Runner service %s (instance role: %s)", serviceName, instanceRoleArn), + map[string]string{"service_arn": serviceArn}) + } // Back up the original source configuration for restoration. originalSourceConfig := service.SourceConfiguration @@ -362,6 +368,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to update App Runner service: %v", err) } fmt.Println("App Runner service update initiated. Service will redeploy with the exploit configuration.") + if logger != nil { + logger.LogAWSCall("apprunner", "UpdateService", region, + fmt.Sprintf("Updated App Runner service %s to execute exploit payload via StartCommand", serviceName), + map[string]string{"service_arn": serviceArn, "payload": payloadType, "instance_role": instanceRoleArn}) + } // Step 3: Poll until service reaches RUNNING. // Demo timing: 15s poll interval, 420s max (demo_attack.sh MAX_WAIT=420). @@ -426,6 +437,20 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } else { fmt.Printf("Service '%s' left with exploit configuration (CLEANUP=false).\n", serviceName) fmt.Printf("To restore original config: aws apprunner update-service --service-arn %s --region %s\n", serviceArn, region) + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "apprunner:service-update", + Name: serviceName, + ARN: serviceArn, + Region: region, + CleanupMethod: "apprunner:UpdateService", + ModuleID: "apprunner-002", + Metadata: map[string]string{ + "service_arn": serviceArn, + "service_name": serviceName, + }, + }) + } } // Build result. diff --git a/pkg/exploits/batch_passrole_createcomputeenvironment_createjobqueue_registerjobdefinition_submitjob/module.go b/pkg/exploits/batch_passrole_createcomputeenvironment_createjobqueue_registerjobdefinition_submitjob/module.go index 1876074..0393c30 100644 --- a/pkg/exploits/batch_passrole_createcomputeenvironment_createjobqueue_registerjobdefinition_submitjob/module.go +++ b/pkg/exploits/batch_passrole_createcomputeenvironment_createjobqueue_registerjobdefinition_submitjob/module.go @@ -127,12 +127,12 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ADMIN_ROLE_ARN": // Batch jobs run as ECS tasks, so the jobRoleArn must trust ecs-tasks.amazonaws.com. - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "EXECUTION_ROLE_ARN": // The execution role must also trust ecs-tasks.amazonaws.com (Fargate pulls images as ECS tasks). - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "SUBNET_ID": - choices, err := discovery.DiscoverSubnets(context.Background(), config) + choices, err := discovery.DiscoverSubnets(context.Background(), config, m.DiscoveryLogger) if err != nil { return nil, err } @@ -144,7 +144,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current } return choices, nil case "SECURITY_GROUP_ID": - choices, err := discovery.DiscoverSecurityGroups(context.Background(), config) + choices, err := discovery.DiscoverSecurityGroups(context.Background(), config, m.DiscoveryLogger) if err != nil { return nil, err } @@ -264,6 +264,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger adminRoleArn := options["ADMIN_ROLE_ARN"] executionRoleArn := options["EXECUTION_ROLE_ARN"] @@ -386,6 +387,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { computeEnvArn := aws.ToString(createCEOutput.ComputeEnvironmentArn) fmt.Printf("Compute environment created: %s\n", computeEnvArn) + if logger != nil { + logger.LogAWSCall("batch", "CreateComputeEnvironment", config.Region, + fmt.Sprintf("Created Fargate compute environment %s with role passthrough", computeEnvName), + map[string]string{"compute_environment_name": computeEnvName, "admin_role_arn": adminRoleArn}) + } if tracker != nil { tracker.TrackResource(modules.CreatedResource{ @@ -441,6 +447,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobQueueArn := aws.ToString(createJQOutput.JobQueueArn) fmt.Printf("Job queue created: %s\n", jobQueueArn) + if logger != nil { + logger.LogAWSCall("batch", "CreateJobQueue", config.Region, + fmt.Sprintf("Created job queue %s wired to compute environment %s", jobQueueName, computeEnvName), + map[string]string{"job_queue_name": jobQueueName}) + } if tracker != nil { tracker.TrackResource(modules.CreatedResource{ @@ -504,6 +515,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobDefArn := aws.ToString(registerOutput.JobDefinitionArn) jobDefRevision := aws.ToInt32(registerOutput.Revision) fmt.Printf("Job definition registered: %s (revision %d)\n", jobDefArn, jobDefRevision) + if logger != nil { + logger.LogAWSCall("batch", "RegisterJobDefinition", config.Region, + fmt.Sprintf("Registered job definition %s with admin role %s as jobRoleArn", jobDefName, adminRoleArn), + map[string]string{"job_definition_name": jobDefName, "admin_role_arn": adminRoleArn}) + } if tracker != nil { tracker.TrackResource(modules.CreatedResource{ @@ -542,6 +558,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobID := aws.ToString(submitOutput.JobId) fmt.Printf("Batch job submitted: %s\n", jobID) + if logger != nil { + logger.LogAWSCall("batch", "SubmitJob", config.Region, + fmt.Sprintf("Submitted job %s to queue %s to execute payload %s", jobName, jobQueueName, payloadType), + map[string]string{"job_id": jobID, "job_name": jobName, "job_queue": jobQueueName}) + } // Step 9: Poll for job completion. fmt.Println("Polling for Batch job completion (15s interval, max 6 minutes)...") diff --git a/pkg/exploits/batch_passrole_registerjobdefinition_submitjob/module.go b/pkg/exploits/batch_passrole_registerjobdefinition_submitjob/module.go index a766f43..1206c42 100644 --- a/pkg/exploits/batch_passrole_registerjobdefinition_submitjob/module.go +++ b/pkg/exploits/batch_passrole_registerjobdefinition_submitjob/module.go @@ -115,13 +115,13 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "JOB_QUEUE": - return discovery.DiscoverBatchJobQueues(context.Background(), config) + return discovery.DiscoverBatchJobQueues(context.Background(), config, m.DiscoveryLogger) case "ADMIN_ROLE_ARN": // Batch jobs run as ECS tasks, so the jobRoleArn must trust ecs-tasks.amazonaws.com. - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "EXECUTION_ROLE_ARN": // The execution role must also trust ecs-tasks.amazonaws.com (Fargate pulls images as ECS tasks). - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -210,6 +210,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger adminRoleArn := options["ADMIN_ROLE_ARN"] executionRoleArn := options["EXECUTION_ROLE_ARN"] @@ -330,6 +331,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobDefArn := aws.ToString(registerOutput.JobDefinitionArn) jobDefRevision := aws.ToInt32(registerOutput.Revision) fmt.Printf("Job definition registered: %s (revision %d)\n", jobDefArn, jobDefRevision) + if logger != nil { + logger.LogAWSCall("batch", "RegisterJobDefinition", config.Region, + fmt.Sprintf("Registered job definition %s with admin role %s as jobRoleArn", jobDefName, adminRoleArn), + map[string]string{"job_definition_name": jobDefName, "admin_role_arn": adminRoleArn}) + } // Track the job definition for workspace cleanup. if tracker != nil { @@ -371,6 +377,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobID := aws.ToString(submitOutput.JobId) fmt.Printf("Batch job submitted: %s\n", jobID) + if logger != nil { + logger.LogAWSCall("batch", "SubmitJob", config.Region, + fmt.Sprintf("Submitted job %s to queue %s to execute payload %s", jobName, jobQueue, payloadType), + map[string]string{"job_id": jobID, "job_queue": jobQueue}) + } // Step 5: Poll for job completion. // From demo_attack.sh: POLL_INTERVAL=15, MAX_WAIT=360 (6 minutes). diff --git a/pkg/exploits/batch_submitjob/module.go b/pkg/exploits/batch_submitjob/module.go index 99ca77b..e05cfe2 100644 --- a/pkg/exploits/batch_submitjob/module.go +++ b/pkg/exploits/batch_submitjob/module.go @@ -109,9 +109,9 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "JOB_DEFINITION": - return discovery.DiscoverBatchJobDefinitions(context.Background(), config) + return discovery.DiscoverBatchJobDefinitions(context.Background(), config, m.DiscoveryLogger) case "JOB_QUEUE": - return discovery.DiscoverBatchJobQueues(context.Background(), config) + return discovery.DiscoverBatchJobQueues(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -176,6 +176,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger jobDefinition := options["JOB_DEFINITION"] jobQueue := options["JOB_QUEUE"] @@ -270,6 +271,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobID := aws.ToString(submitOutput.JobId) fmt.Printf("Batch job submitted: %s\n", jobID) + if logger != nil { + logger.LogAWSCall("batch", "SubmitJob", config.Region, + fmt.Sprintf("Submitted Batch job %s to queue %s using definition %s with ContainerOverrides to execute payload", jobName, jobQueue, jobDefinition), + map[string]string{"job_id": jobID, "job_name": jobName, "job_definition": jobDefinition, "job_queue": jobQueue, "payload": payloadType}) + } // Step 5: Poll for job completion. // From demo_attack.sh: 30s interval, max 600s (10 minutes). diff --git a/pkg/exploits/bedrock_createbrowser_cdp/module.go b/pkg/exploits/bedrock_createbrowser_cdp/module.go index fe041d7..dd6afb5 100644 --- a/pkg/exploits/bedrock_createbrowser_cdp/module.go +++ b/pkg/exploits/bedrock_createbrowser_cdp/module.go @@ -199,7 +199,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -226,6 +226,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger executionRoleARN := options["ROLE_ARN"] if executionRoleARN == "" { @@ -292,6 +293,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { browserID := aws.ToString(createOut.BrowserId) browserARN := aws.ToString(createOut.BrowserArn) fmt.Printf("Browser created: %s\n", browserID) + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "CreateBrowser", region, + fmt.Sprintf("Created AgentCore Custom Browser %s with execution role %s", browserName, executionRoleARN), + map[string]string{"browser_id": browserID, "browser_arn": browserARN, "execution_role": executionRoleARN}) + } // ------------------------------------------------------------------------- // Step 2: Track the browser immediately. @@ -376,6 +382,11 @@ ready: fmt.Printf("Session ID : %s\n", sessionID) fmt.Printf("WebSocket URL: %s...\n", truncate(wsURL, 80)) + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "StartBrowserSession", region, + fmt.Sprintf("Started browser session on AgentCore Custom Browser %s to obtain CDP WebSocket URL for credential extraction", browserID), + map[string]string{"browser_id": browserID, "session_id": sessionID}) + } // ------------------------------------------------------------------------- // Step 5: Write the Playwright Python script to a temp file. diff --git a/pkg/exploits/bedrock_invokeagentruntime/module.go b/pkg/exploits/bedrock_invokeagentruntime/module.go index 1b57e1e..e9e4f8f 100644 --- a/pkg/exploits/bedrock_invokeagentruntime/module.go +++ b/pkg/exploits/bedrock_invokeagentruntime/module.go @@ -163,7 +163,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "TARGET_RUNTIME_ARN": - return discovery.DiscoverBedrockAgentRuntimes(context.Background(), config) + return discovery.DiscoverBedrockAgentRuntimes(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -178,6 +178,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options + logger := ectx.Logger targetRuntimeARN := options["TARGET_RUNTIME_ARN"] if targetRuntimeARN == "" { @@ -244,6 +245,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } fmt.Println("Payload executed — processing output...") + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "InvokeAgentRuntimeCommand", region, + fmt.Sprintf("Invoked AgentCore runtime %s with payload %s", targetRuntimeARN, payloadType), + map[string]string{"target_runtime_arn": targetRuntimeARN, "payload": payloadType}) + } return payload.ProcessResult(rawOutput) } diff --git a/pkg/exploits/bedrock_passrole_codeinterpreter/module.go b/pkg/exploits/bedrock_passrole_codeinterpreter/module.go index 2d95ddb..fb2b0af 100644 --- a/pkg/exploits/bedrock_passrole_codeinterpreter/module.go +++ b/pkg/exploits/bedrock_passrole_codeinterpreter/module.go @@ -181,7 +181,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -201,6 +201,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger executionRoleARN := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -279,6 +280,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { interpreterARN := aws.ToString(createOutput.CodeInterpreterArn) fmt.Printf("Code interpreter created: %s\n", interpreterID) + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "CreateCodeInterpreter", region, + fmt.Sprintf("Created Bedrock AgentCore code interpreter %s with execution role %s", interpreterName, executionRoleARN), + map[string]string{"interpreter_id": interpreterID, "interpreter_arn": interpreterARN, "execution_role": executionRoleARN, "payload": payloadType}) + } // Track the code interpreter for cleanup. if tracker != nil { @@ -287,12 +293,13 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { Name: interpreterName, ARN: interpreterARN, Region: region, + Created: time.Now(), CleanupMethod: "bedrock-agentcore-control:DeleteCodeInterpreter", ModuleID: "bedrock-001", Metadata: map[string]string{ - "interpreter_id": interpreterID, - "execution_role": executionRoleARN, - "payload_type": payloadType, + "interpreter_id": interpreterID, + "execution_role": executionRoleARN, + "payload_type": payloadType, }, } tracker.TrackResource(resource) @@ -324,6 +331,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { sessionID := aws.ToString(sessionOutput.SessionId) fmt.Printf("Session started: %s\n", sessionID) + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "StartCodeInterpreterSession", region, + fmt.Sprintf("Started session on code interpreter %s to invoke payload code inside Firecracker microVM", interpreterID), + map[string]string{"interpreter_id": interpreterID, "session_id": sessionID}) + } // Step 4: Invoke the payload's Python code inside the interpreter. fmt.Printf("Invoking payload '%s' inside code interpreter...\n", payloadType) @@ -340,6 +352,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to invoke code interpreter: %v", err) } + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "InvokeCodeInterpreter", region, + fmt.Sprintf("Invoked payload code inside Bedrock code interpreter %s to execute under execution role permissions", interpreterID), + map[string]string{"interpreter_id": interpreterID, "session_id": sessionID, "payload": payloadType}) + } // Step 5: Collect stdout from the event stream. stdout, invokeErr := collectInterpreterStdout(invokeOutput) diff --git a/pkg/exploits/bedrock_passrole_createagentruntime/module.go b/pkg/exploits/bedrock_passrole_createagentruntime/module.go index 20fcb2d..e82d01b 100644 --- a/pkg/exploits/bedrock_passrole_createagentruntime/module.go +++ b/pkg/exploits/bedrock_passrole_createagentruntime/module.go @@ -205,7 +205,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "EXECUTION_ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -232,6 +232,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger executionRoleARN := options["EXECUTION_ROLE_ARN"] if executionRoleARN == "" { @@ -364,6 +365,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Runtime ARN : %s\n", runtimeARN) fmt.Printf("Runtime ID : %s\n", runtimeID) + if logger != nil { + logger.LogAWSCall("bedrock-agentcore-control", "CreateAgentRuntime", region, + fmt.Sprintf("Created AgentCore runtime %s with execution role %s", runtimeName, executionRoleARN), + map[string]string{"runtime_name": runtimeName, "runtime_arn": runtimeARN, "execution_role_arn": executionRoleARN}) + } // Track the runtime for workspace cleanup. if tracker != nil { @@ -461,6 +467,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("InvokeAgentRuntimeCommand failed: %v", err) } + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "InvokeAgentRuntimeCommand", region, + fmt.Sprintf("Invoked AgentCore runtime %s with payload %s to execute as execution role", runtimeName, payloadType), + map[string]string{"runtime_arn": runtimeARN, "runtime_name": runtimeName, "payload": payloadType}) + } // Collect stdout chunks from the event stream. stream := invokeOutput.GetStream() diff --git a/pkg/exploits/bedrock_passrole_createharness/module.go b/pkg/exploits/bedrock_passrole_createharness/module.go index 19cbb63..c19fdd5 100644 --- a/pkg/exploits/bedrock_passrole_createharness/module.go +++ b/pkg/exploits/bedrock_passrole_createharness/module.go @@ -203,7 +203,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "bedrock-agentcore.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -228,6 +228,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger executionRoleARN := options["ROLE_ARN"] if executionRoleARN == "" { @@ -330,6 +331,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Harness ARN : %s\n", harnessARN) fmt.Printf("Harness ID : %s\n", harnessID) + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "CreateHarness", region, + fmt.Sprintf("Created AgentCore Harness %s with execution role %s", harnessName, executionRoleARN), + map[string]string{"harness_id": harnessID, "harness_arn": harnessARN, "execution_role": executionRoleARN, "payload": payloadType}) + } // Track the harness for workspace cleanup. if tracker != nil { @@ -427,6 +433,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("InvokeAgentRuntimeCommand failed: %v", err) } + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "InvokeAgentRuntimeCommand", region, + fmt.Sprintf("Invoked shell command on AgentCore Harness %s to execute payload under execution role permissions", harnessID), + map[string]string{"harness_id": harnessID, "payload": payloadType}) + } // Collect stdout chunks from the event stream. stream := invokeOutput.GetStream() diff --git a/pkg/exploits/bedrock_startbrowsersession_cdp/module.go b/pkg/exploits/bedrock_startbrowsersession_cdp/module.go index 2ff2a15..f89ab87 100644 --- a/pkg/exploits/bedrock_startbrowsersession_cdp/module.go +++ b/pkg/exploits/bedrock_startbrowsersession_cdp/module.go @@ -336,6 +336,7 @@ func (m *Module) Options() []modules.Option { func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options + logger := ectx.Logger browserID := options["BROWSER_ID"] if browserID == "" { @@ -386,6 +387,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Browser session started (session ID: %s)\n", sessionID) fmt.Printf("CDP WebSocket URL obtained (length: %d chars)\n", len(wsURL)) + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "StartBrowserSession", region, + fmt.Sprintf("Started browser session on existing AgentCore Custom Browser %s to obtain CDP WebSocket URL for credential extraction", browserID), + map[string]string{"browser_id": browserID, "session_id": sessionID}) + } // --- Step 2: Write the Python extraction script to a temp file --- scriptFile, err := os.CreateTemp("", "pathrunner-bedrock-007-*.py") diff --git a/pkg/exploits/bedrock_startsession_invoke/module.go b/pkg/exploits/bedrock_startsession_invoke/module.go index 1155f52..9288c0b 100644 --- a/pkg/exploits/bedrock_startsession_invoke/module.go +++ b/pkg/exploits/bedrock_startsession_invoke/module.go @@ -213,6 +213,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options + logger := ectx.Logger interpreterID := options["INTERPRETER_ID"] if interpreterID == "" { @@ -276,6 +277,14 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } fmt.Println("Payload executed — processing output...") + if logger != nil { + logger.LogAWSCall("bedrock-agentcore", "StartCodeInterpreterSession", region, + fmt.Sprintf("Started session on existing Bedrock code interpreter %s", interpreterID), + map[string]string{"interpreter_id": interpreterID, "payload": payloadType}) + logger.LogAWSCall("bedrock-agentcore", "InvokeCodeInterpreter", region, + fmt.Sprintf("Invoked payload code inside Bedrock code interpreter %s to execute under execution role permissions", interpreterID), + map[string]string{"interpreter_id": interpreterID, "payload": payloadType}) + } return payload.ProcessResult(rawOutput) } diff --git a/pkg/exploits/braket_passrole/module.go b/pkg/exploits/braket_passrole/module.go index aac6526..c959775 100644 --- a/pkg/exploits/braket_passrole/module.go +++ b/pkg/exploits/braket_passrole/module.go @@ -125,7 +125,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "braket.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "braket.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -178,6 +178,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -337,6 +338,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobArn := aws.ToString(createResult.JobArn) fmt.Printf("Braket Hybrid Job created: %s\n", jobArn) + if logger != nil { + logger.LogAWSCall("braket", "CreateJob", region, + fmt.Sprintf("Created Braket Hybrid Job %s with execution role %s to run payload %s", jobName, roleArn, payloadType), + map[string]string{"job_arn": jobArn, "job_name": jobName, "role_arn": roleArn, "payload": payloadType, "script_uri": scriptS3URI}) + } + // Track the job for workspace cleanup reporting if tracker != nil { tracker.TrackResource(modules.CreatedResource{ diff --git a/pkg/exploits/cloudformation_createchangeset_executechangeset/module.go b/pkg/exploits/cloudformation_createchangeset_executechangeset/module.go index f908dee..1bed432 100644 --- a/pkg/exploits/cloudformation_createchangeset_executechangeset/module.go +++ b/pkg/exploits/cloudformation_createchangeset_executechangeset/module.go @@ -173,6 +173,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger stackName := options["STACK_NAME"] if stackName == "" { @@ -199,6 +200,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + fmt.Sprintf("Retrieved caller identity: %s", callerArn), + map[string]string{"arn": callerArn, "account": callerAccount}) + } + // Step 2: Resolve trust principal — defaults to caller, normalized from assumed-role to role ARN. trustPrincipal := options["TRUST_PRINCIPAL"] if trustPrincipal == "" { @@ -251,6 +258,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { originalTemplateBody = "" // signals buildMaliciousTemplate to build from scratch } else { originalTemplateBody = aws.ToString(templateResult.TemplateBody) + if logger != nil { + logger.LogAWSCall("cloudformation", "GetTemplate", config.Region, + fmt.Sprintf("Retrieved current template from CloudFormation stack %s for safe template merging", stackName), + map[string]string{"stack_name": stackName}) + } } // Step 5: Generate the payload template and merge it into the existing stack template. @@ -307,6 +319,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Println("Change set creation initiated.") + if logger != nil { + logger.LogAWSCall("cloudformation", "CreateChangeSet", config.Region, + fmt.Sprintf("Created change set %s on CloudFormation stack %s to inject escalated IAM role %s", changeSetName, stackName, escalatedRoleName), + map[string]string{"stack_name": stackName, "changeset_name": changeSetName, "escalated_role": escalatedRoleName, "payload": payloadType}) + } + // Step 7: Wait 15 seconds for the change set to reach CREATE_COMPLETE status. // This delay is calibrated from demo_attack.sh testing. fmt.Println("Waiting 15 seconds for change set to be ready...") @@ -337,6 +355,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Println("Change set execution initiated. Waiting for stack update to complete...") + if logger != nil { + logger.LogAWSCall("cloudformation", "ExecuteChangeSet", config.Region, + fmt.Sprintf("Executed change set %s on CloudFormation stack %s — stack admin service role will create escalated role %s", changeSetName, stackName, escalatedRoleName), + map[string]string{"stack_name": stackName, "changeset_name": changeSetName, "escalated_role": escalatedRoleName}) + } + // Step 10: Poll for stack update completion. // demo_attack.sh used `cloudformation wait stack-update-complete` which polls every 30s. // We use a polling loop with a 5-minute total timeout, matching the lab's observed timing. @@ -423,6 +447,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Successfully assumed escalated role '%s'!\n", escalatedRoleName) + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed escalated IAM role %s created by CloudFormation stack %s change set", escalatedRoleArn, stackName), + map[string]string{"role_arn": escalatedRoleArn, "stack_name": stackName, "changeset_name": changeSetName}) + } + identityName := fmt.Sprintf("cfn005_%s_%d", escalatedRoleName, time.Now().Unix()) var outputBuilder strings.Builder diff --git a/pkg/exploits/cloudformation_passrole/module.go b/pkg/exploits/cloudformation_passrole/module.go index 734dd94..77ccbe0 100644 --- a/pkg/exploits/cloudformation_passrole/module.go +++ b/pkg/exploits/cloudformation_passrole/module.go @@ -108,7 +108,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "cloudformation.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "cloudformation.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -173,6 +173,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger cfnRoleArn := options["ROLE_ARN"] if cfnRoleArn == "" { @@ -199,6 +200,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + fmt.Sprintf("Retrieved caller identity: %s", callerArn), + map[string]string{"arn": callerArn, "account": callerAccount}) + } + // Step 2: Resolve the trust principal. If the caller is an assumed-role session, // normalize to the underlying role ARN so the trust policy remains durable across sessions. trustPrincipal := options["TRUST_PRINCIPAL"] @@ -276,6 +283,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Println("Stack creation initiated.") + if logger != nil { + logger.LogAWSCall("cloudformation", "CreateStack", config.Region, + fmt.Sprintf("Created CloudFormation stack %s with service role %s to provision escalated IAM role %s", stackName, cfnRoleArn, escalatedRoleName), + map[string]string{"stack_name": stackName, "cfn_role_arn": cfnRoleArn, "escalated_role": escalatedRoleName, "payload": payloadType}) + } + // Track the stack immediately after creation so cleanup works even if we fail mid-exploit. escalatedRoleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", callerAccount, escalatedRoleName) if tracker != nil { @@ -371,6 +384,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Successfully assumed escalated role '%s'!\n", escalatedRoleName) + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed escalated IAM role %s created by CloudFormation stack %s", escalatedRoleArn, stackName), + map[string]string{"role_arn": escalatedRoleArn, "stack_name": stackName, "cfn_role_arn": cfnRoleArn}) + } + // Step 10: Optionally clean up the stack now that we have the escalated credentials. if options["CLEANUP"] == "true" { fmt.Printf("Cleanup enabled, deleting stack '%s'...\n", stackName) diff --git a/pkg/exploits/cloudformation_passrole_createstackset_createstackinstances/module.go b/pkg/exploits/cloudformation_passrole_createstackset_createstackinstances/module.go index b25de8a..61fd02d 100644 --- a/pkg/exploits/cloudformation_passrole_createstackset_createstackinstances/module.go +++ b/pkg/exploits/cloudformation_passrole_createstackset_createstackinstances/module.go @@ -136,11 +136,11 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current // to a single result even when multiple CloudFormation scenarios are deployed in parallel. adminRoleArn := currentOptions["ROLE_ARN"] if adminRoleArn != "" { - return discovery.DiscoverCFNExecutionRoleForAdminRole(context.Background(), config, adminRoleArn) + return discovery.DiscoverCFNExecutionRoleForAdminRole(context.Background(), config, adminRoleArn, m.DiscoveryLogger) } // Fallback: use structural trust-policy analysis to distinguish execution roles // (trust cfn + specific role ARN) from administration roles (trust cfn + :root). - return discovery.DiscoverCFNStackSetExecutionRoles(context.Background(), config) + return discovery.DiscoverCFNStackSetExecutionRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -213,6 +213,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger executionRoleName := options["EXECUTION_ROLE_NAME"] if executionRoleName == "" { @@ -239,6 +240,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + fmt.Sprintf("Retrieved caller identity: %s", callerArn), + map[string]string{"arn": callerArn, "account": callerAccount}) + } + // Step 2: Resolve trust principal. Normalize assumed-role sessions to the underlying // role ARN so the trust policy remains durable across session rotations. trustPrincipal := options["TRUST_PRINCIPAL"] @@ -332,6 +339,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Println("StackSet creation initiated.") + if logger != nil { + logger.LogAWSCall("cloudformation", "CreateStackSet", config.Region, + fmt.Sprintf("Created CloudFormation StackSet %s with execution role %s to provision escalated IAM role %s", stackSetName, executionRoleName, escalatedRoleName), + map[string]string{"stackset_name": stackSetName, "admin_role_arn": adminRoleArn, "execution_role_name": executionRoleName, "escalated_role": escalatedRoleName, "payload": payloadType}) + } + // Track the StackSet immediately so cleanup works even if we fail mid-exploit. escalatedRoleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", callerAccount, escalatedRoleName) if tracker != nil { @@ -395,6 +408,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { operationID := aws.ToString(createInstancesResult.OperationId) fmt.Printf("Stack instance deployment operation ID: %s\n", operationID) + if logger != nil { + logger.LogAWSCall("cloudformation", "CreateStackInstances", config.Region, + fmt.Sprintf("Deployed CloudFormation StackSet %s instances to account %s region %s — execution role %s will create escalated role %s", stackSetName, callerAccount, config.Region, executionRoleName, escalatedRoleName), + map[string]string{"stackset_name": stackSetName, "operation_id": operationID, "account_id": callerAccount, "execution_role_name": executionRoleName, "escalated_role": escalatedRoleName}) + } + // Update the tracked resource with the operation ID for status tracking. if tracker != nil { tracker.TrackResource(modules.CreatedResource{ @@ -473,6 +492,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Successfully assumed escalated role '%s'!\n", escalatedRoleName) + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed escalated IAM role %s created by CloudFormation StackSet %s", escalatedRoleArn, stackSetName), + map[string]string{"role_arn": escalatedRoleArn, "stackset_name": stackSetName, "execution_role_name": executionRoleName}) + } + // Step 12: Optionally clean up the StackSet after successfully obtaining escalated credentials. if options["CLEANUP"] == "true" { fmt.Printf("Cleanup enabled, deleting StackSet '%s' and its instances...\n", stackSetName) diff --git a/pkg/exploits/cloudformation_updatestack/module.go b/pkg/exploits/cloudformation_updatestack/module.go index 21ce1f2..4f9eb32 100644 --- a/pkg/exploits/cloudformation_updatestack/module.go +++ b/pkg/exploits/cloudformation_updatestack/module.go @@ -175,6 +175,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger stackName := options["STACK_NAME"] if stackName == "" { @@ -201,6 +202,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + fmt.Sprintf("Retrieved caller identity: %s", callerArn), + map[string]string{"arn": callerArn, "account": callerAccount}) + } + // Step 2: Resolve the trust principal. If unset, default to the caller. Normalize assumed-role // ARNs to the underlying role ARN so the trust policy remains durable across sessions. trustPrincipal := options["TRUST_PRINCIPAL"] @@ -239,6 +246,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } originalTemplateBody := aws.ToString(templateResult.TemplateBody) + if logger != nil { + logger.LogAWSCall("cloudformation", "GetTemplate", config.Region, + fmt.Sprintf("Retrieved current template from CloudFormation stack %s for safe template injection", stackName), + map[string]string{"stack_name": stackName}) + } + // Step 5: Build the modified template by generating the payload template and merging its // Resources into the existing stack template alongside all existing resources. // CAPABILITY_NAMED_IAM is required because we create an IAM role with an explicit name. @@ -288,6 +301,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Println("Stack update initiated.") + if logger != nil { + logger.LogAWSCall("cloudformation", "UpdateStack", config.Region, + fmt.Sprintf("Updated CloudFormation stack %s template to inject escalated IAM role %s — stack admin service role will create it", stackName, escalatedRoleName), + map[string]string{"stack_name": stackName, "escalated_role": escalatedRoleName, "payload": payloadType}) + } + // Track the modified stack for cleanup reference. Stored before polling so cleanup // can run even if we fail mid-exploit (e.g., timed out waiting for the update). escalatedRoleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", callerAccount, escalatedRoleName) @@ -384,6 +403,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Successfully assumed escalated role '%s'!\n", escalatedRoleName) + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed escalated IAM role %s created by CloudFormation stack %s update", escalatedRoleArn, stackName), + map[string]string{"role_arn": escalatedRoleArn, "stack_name": stackName}) + } + // Step 11: Optionally clean up the stack now that we have escalated credentials. if options["CLEANUP"] == "true" { fmt.Printf("Cleanup enabled, reverting stack '%s' to original template...\n", stackName) diff --git a/pkg/exploits/cloudformation_updatestackset/module.go b/pkg/exploits/cloudformation_updatestackset/module.go index 1f39380..dc95101 100644 --- a/pkg/exploits/cloudformation_updatestackset/module.go +++ b/pkg/exploits/cloudformation_updatestackset/module.go @@ -187,6 +187,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger stackSetName := options["STACKSET_NAME"] if stackSetName == "" { @@ -213,6 +214,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + fmt.Sprintf("Retrieved caller identity: %s", callerArn), + map[string]string{"arn": callerArn, "account": callerAccount}) + } + // Step 2: Resolve trust principal — defaults to caller, normalized from assumed-role to role ARN. trustPrincipal := options["TRUST_PRINCIPAL"] if trustPrincipal == "" { @@ -254,6 +261,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { stackSet := descResult.StackSet originalTemplateBody := aws.ToString(stackSet.TemplateBody) + if logger != nil { + logger.LogAWSCall("cloudformation", "DescribeStackSet", config.Region, + fmt.Sprintf("Retrieved current configuration and template from CloudFormation StackSet %s for template injection", stackSetName), + map[string]string{"stackset_name": stackSetName}) + } + // Resolve administration role ARN. adminRoleArn := options["ADMIN_ROLE_ARN"] if adminRoleArn == "" { @@ -325,6 +338,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { escalatedRoleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", callerAccount, escalatedRoleName) + if logger != nil { + logger.LogAWSCall("cloudformation", "UpdateStackSet", config.Region, + fmt.Sprintf("Updated CloudFormation StackSet %s template to inject escalated IAM role %s — execution role %s will create it", stackSetName, escalatedRoleName, executionRoleName), + map[string]string{"stackset_name": stackSetName, "operation_id": operationID, "admin_role_arn": adminRoleArn, "execution_role_name": executionRoleName, "escalated_role": escalatedRoleName, "payload": payloadType}) + } + // Step 7: Track the modified StackSet for cleanup. if tracker != nil { tracker.TrackResource(modules.CreatedResource{ @@ -410,6 +429,12 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Successfully assumed escalated role '%s'!\n", escalatedRoleName) + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed escalated IAM role %s created by CloudFormation StackSet %s update", escalatedRoleArn, stackSetName), + map[string]string{"role_arn": escalatedRoleArn, "stackset_name": stackSetName, "execution_role_name": executionRoleName}) + } + identityName := fmt.Sprintf("cfn004_%s_%d", escalatedRoleName, time.Now().Unix()) var outputBuilder strings.Builder diff --git a/pkg/exploits/codebuild_passrole/module.go b/pkg/exploits/codebuild_passrole/module.go index bf77956..27910d9 100644 --- a/pkg/exploits/codebuild_passrole/module.go +++ b/pkg/exploits/codebuild_passrole/module.go @@ -109,7 +109,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "codebuild.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "codebuild.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -158,6 +158,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -249,6 +250,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create CodeBuild project: %v", err) } fmt.Printf("CodeBuild project created: %s\n", projectName) + if logger != nil { + logger.LogAWSCall("codebuild", "CreateProject", region, + fmt.Sprintf("Created CodeBuild project %s with service role %s", projectName, roleArn), + map[string]string{"project_name": projectName, "role_arn": roleArn}) + } // Track the project for cleanup if tracker != nil { @@ -280,6 +286,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { buildID := aws.ToString(startResult.Build.Id) fmt.Printf("Build started: %s\n", buildID) + if logger != nil { + logger.LogAWSCall("codebuild", "StartBuild", region, + fmt.Sprintf("Started build on project %s to execute payload", projectName), + map[string]string{"project_name": projectName, "build_id": buildID}) + } // Poll for build completion: check every 10s, give up after 120s. // These intervals match the calibrated timing in the lab's demo_attack.sh. diff --git a/pkg/exploits/codebuild_passrole_startbuildbatch/module.go b/pkg/exploits/codebuild_passrole_startbuildbatch/module.go index 2f3ad6e..055f80a 100644 --- a/pkg/exploits/codebuild_passrole_startbuildbatch/module.go +++ b/pkg/exploits/codebuild_passrole_startbuildbatch/module.go @@ -114,7 +114,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "codebuild.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "codebuild.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -174,6 +174,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -272,6 +273,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create CodeBuild project: %v", err) } fmt.Printf("CodeBuild project created: %s\n", projectName) + if logger != nil { + logger.LogAWSCall("codebuild", "CreateProject", region, + fmt.Sprintf("Created CodeBuild project %s with service role %s", projectName, roleArn), + map[string]string{"project_name": projectName, "role_arn": roleArn}) + } // Track the project for cleanup regardless of what happens next. if tracker != nil { @@ -303,6 +309,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { buildBatchID := aws.ToString(startResult.BuildBatch.Id) fmt.Printf("Build batch started: %s\n", buildBatchID) + if logger != nil { + logger.LogAWSCall("codebuild", "StartBuildBatch", region, + fmt.Sprintf("Started batch build on project %s to execute payload", projectName), + map[string]string{"project_name": projectName, "build_batch_id": buildBatchID}) + } // Poll for build batch completion. Batch builds take 2-4 minutes to orchestrate, // so use a 240s timeout (matching MAX_WAIT in demo_attack.sh), polling every 10s. diff --git a/pkg/exploits/codebuild_startbuild/module.go b/pkg/exploits/codebuild_startbuild/module.go index 5a26a53..b9b896c 100644 --- a/pkg/exploits/codebuild_startbuild/module.go +++ b/pkg/exploits/codebuild_startbuild/module.go @@ -107,7 +107,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverCodeBuildProjects(context.Background(), config) + return discovery.DiscoverCodeBuildProjects(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -158,6 +158,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger projectName := options["PROJECT_NAME"] payloadType := options["PAYLOAD"] @@ -219,6 +220,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { buildID := aws.ToString(startResult.Build.Id) fmt.Printf("Build started: %s\n", buildID) + if logger != nil { + logger.LogAWSCall("codebuild", "StartBuild", config.Region, + fmt.Sprintf("Started build on project %s with buildspec override to execute payload", projectName), + map[string]string{"project_name": projectName, "build_id": buildID}) + } // Poll for build completion (every 10s, max 120s — calibrated from demo_attack.sh). fmt.Println("Polling for build completion (every 10s, max 120s)...") diff --git a/pkg/exploits/codebuild_startbuildbatch/module.go b/pkg/exploits/codebuild_startbuildbatch/module.go index ca44f20..8e17e44 100644 --- a/pkg/exploits/codebuild_startbuildbatch/module.go +++ b/pkg/exploits/codebuild_startbuildbatch/module.go @@ -109,7 +109,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverCodeBuildProjects(context.Background(), config) + return discovery.DiscoverCodeBuildProjects(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -161,6 +161,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger projectName := options["PROJECT_NAME"] payloadType := options["PAYLOAD"] @@ -227,6 +228,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { buildBatchID := aws.ToString(startResult.BuildBatch.Id) fmt.Printf("Batch build started: %s\n", buildBatchID) + if logger != nil { + logger.LogAWSCall("codebuild", "StartBuildBatch", config.Region, + fmt.Sprintf("Started batch build on project %s with buildspec override to execute payload", projectName), + map[string]string{"project_name": projectName, "build_batch_id": buildBatchID}) + } // Poll for batch build completion (every 10s, max 240s — calibrated from demo_attack.sh). fmt.Println("Polling for batch build completion (every 10s, max 240s)...") diff --git a/pkg/exploits/codedeploy_createdeployment/module.go b/pkg/exploits/codedeploy_createdeployment/module.go index 45662ae..486d76a 100644 --- a/pkg/exploits/codedeploy_createdeployment/module.go +++ b/pkg/exploits/codedeploy_createdeployment/module.go @@ -164,17 +164,17 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "APP_NAME": - return discovery.DiscoverCodeDeployApps(context.Background(), config) + return discovery.DiscoverCodeDeployApps(context.Background(), config, m.DiscoveryLogger) case "DEPLOYMENT_GROUP": appName := currentOptions["APP_NAME"] if appName == "" { return nil, fmt.Errorf("set APP_NAME first before discovering deployment groups") } - return discovery.DiscoverCodeDeployGroups(context.Background(), config, appName) + return discovery.DiscoverCodeDeployGroups(context.Background(), config, appName, m.DiscoveryLogger) case "BUCKET": - return discovery.DiscoverS3Buckets(context.Background(), config) + return discovery.DiscoverS3Buckets(context.Background(), config, m.DiscoveryLogger) case "TARGET_ARN": - return discovery.DiscoverIAMUsers(context.Background(), config) + return discovery.DiscoverIAMUsers(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -192,6 +192,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger appName := options["APP_NAME"] deploymentGroup := options["DEPLOYMENT_GROUP"] @@ -268,6 +269,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { deploymentID := aws.ToString(createOutput.DeploymentId) fmt.Printf("Deployment created: %s\n", deploymentID) + if logger != nil { + logger.LogAWSCall("codedeploy", "CreateDeployment", config.Region, + fmt.Sprintf("Created CodeDeploy deployment on application %s, group %s", appName, deploymentGroup), + map[string]string{"deployment_id": deploymentID, "app_name": appName, "deployment_group": deploymentGroup}) + } fmt.Println("The CodeDeploy agent on the target EC2 instance will execute the lifecycle hook") fmt.Println("using the pre-attached admin IAM instance profile (AdministratorAccess).") fmt.Println("The hook calls iam:AttachUserPolicy to grant AdministratorAccess to the starting user.") diff --git a/pkg/exploits/cognitoidentity_passrole/module.go b/pkg/exploits/cognitoidentity_passrole/module.go index 0cb3005..73f0d27 100644 --- a/pkg/exploits/cognitoidentity_passrole/module.go +++ b/pkg/exploits/cognitoidentity_passrole/module.go @@ -105,7 +105,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "cognito-identity.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "cognito-identity.amazonaws.com", m.DiscoveryLogger) case "IDENTITY_POOL_ID": return discoverIdentityPools(context.Background(), config) default: @@ -165,6 +165,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] identityPoolID := options["IDENTITY_POOL_ID"] @@ -209,6 +210,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("SetIdentityPoolRoles failed: %v", err) } fmt.Println(" Admin role bound to unauthenticated identity slot (HTTP 200, empty body).") + if logger != nil { + logger.LogAWSCall("cognito-identity", "SetIdentityPoolRoles", region, + fmt.Sprintf("Bound admin role %s to unauthenticated slot of identity pool %s", roleArn, identityPoolID), + map[string]string{"identity_pool_id": identityPoolID, "role_arn": roleArn}) + } // Track the pool modification for cleanup. This is a side-effect on an existing resource, // not a newly created one, so we record the original binding for remediation. diff --git a/pkg/exploits/ec2_instanceconnect/module.go b/pkg/exploits/ec2_instanceconnect/module.go index 2f72b76..4c61cc4 100644 --- a/pkg/exploits/ec2_instanceconnect/module.go +++ b/pkg/exploits/ec2_instanceconnect/module.go @@ -112,7 +112,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_ID": - choices, err := discoverInstances(context.Background(), config) + choices, err := discoverInstances(context.Background(), config, m.DiscoveryLogger) if err != nil { return nil, err } @@ -192,7 +192,9 @@ type imdsCredentials struct { func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options + logger := ectx.Logger + tracker := ectx.Tracker instanceID := options["INSTANCE_ID"] ec2User := options["EC2_USER"] if ec2User == "" { @@ -258,6 +260,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err := sendSSHPublicKey(region, instanceID, ec2User, publicKey, creds); err != nil { return "", fmt.Errorf("failed to push SSH public key via EC2 Instance Connect: %v", err) } + if logger != nil { + logger.LogAWSCall("ec2-instance-connect", "SendSSHPublicKey", region, + fmt.Sprintf("Pushed temporary SSH public key to instance %s as user %s (60s validity window)", instanceID, ec2User), + map[string]string{"instance_id": instanceID, "ec2_user": ec2User}) + } fmt.Println("Public key pushed successfully. SSH window is open.") // Step 4: SSH into the instance and extract credentials from IMDS. @@ -303,6 +310,20 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Print(policyAttachOutput) fmt.Println("Waiting 15 seconds for IAM policy propagation...") time.Sleep(15 * time.Second) + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "iam:attached-policy", + Name: fmt.Sprintf("%s/AdministratorAccess", targetUser), + Region: region, + CleanupMethod: "iam:DetachUserPolicy", + ModuleID: "ec2-003", + Metadata: map[string]string{ + "principal_type": "user", + "principal_name": targetUser, + "policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess", + }, + }) + } } } @@ -534,8 +555,8 @@ func buildAWSEnv(creds aws.Credentials, region string) []string { } // discoverInstances lists running EC2 instances that have an IAM instance profile. -func discoverInstances(ctx context.Context, config aws.Config) ([]modules.DiscoveryChoice, error) { - return discovery.DiscoverEC2InstancesWithProfiles(ctx, config) +func discoverInstances(ctx context.Context, config aws.Config, logger modules.ActionLogger) ([]modules.DiscoveryChoice, error) { + return discovery.DiscoverEC2InstancesWithProfiles(ctx, config, logger) } // min returns the smaller of a and b. diff --git a/pkg/exploits/ec2_launch_template_version/module.go b/pkg/exploits/ec2_launch_template_version/module.go index 593b67f..00d240a 100644 --- a/pkg/exploits/ec2_launch_template_version/module.go +++ b/pkg/exploits/ec2_launch_template_version/module.go @@ -189,6 +189,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger templateName := options["LAUNCH_TEMPLATE_NAME"] asgName := options["ASG_NAME"] @@ -276,6 +277,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { newVersionNumber := aws.ToInt64(newVersionResult.LaunchTemplateVersion.VersionNumber) fmt.Printf("Created malicious launch template version: %d\n", newVersionNumber) + if logger != nil { + logger.LogAWSCall("ec2", "CreateLaunchTemplateVersion", config.Region, + fmt.Sprintf("Created new launch template version %d for template %s with malicious user-data and profile %s", newVersionNumber, templateName, instanceProfile), + map[string]string{"template_id": templateID, "template_name": templateName, "new_version": fmt.Sprintf("%d", newVersionNumber)}) + } // Track the new template version for cleanup if tracker != nil { diff --git a/pkg/exploits/ec2_modifyinstanceattribute/module.go b/pkg/exploits/ec2_modifyinstanceattribute/module.go index 94e6d33..a38a78c 100644 --- a/pkg/exploits/ec2_modifyinstanceattribute/module.go +++ b/pkg/exploits/ec2_modifyinstanceattribute/module.go @@ -105,7 +105,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_ID": - return discovery.DiscoverEC2Instances(context.Background(), config) + return discovery.DiscoverEC2Instances(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -158,6 +158,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger instanceID := options["INSTANCE_ID"] payloadType := options["PAYLOAD"] cleanup := options["CLEANUP"] != "false" @@ -283,7 +284,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Injecting malicious cloud-init payload into '%s'...\n", instanceID) _, err = ec2Client.ModifyInstanceAttribute(ctx, &ec2.ModifyInstanceAttributeInput{ InstanceId: aws.String(instanceID), - UserData: &types.BlobAttributeValue{ + UserData: &types.SecureBlobAttributeValue{ // The SDK base64-encodes []byte automatically when serializing to the API. Value: []byte(cloudInitPayload), }, @@ -297,6 +298,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to modify instance userData: %v", err) } fmt.Println("Malicious userData injected successfully") + if logger != nil { + logger.LogAWSCall("ec2", "ModifyInstanceAttribute", config.Region, + fmt.Sprintf("Injected malicious cloud-init payload into userData of instance %s", instanceID), + map[string]string{"instance_id": instanceID}) + } // Step 5: Start instance to trigger payload execution. fmt.Printf("Starting instance '%s' to trigger cloud-init payload...\n", instanceID) @@ -315,6 +321,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("timed out waiting for instance to start: %v", err) } fmt.Println("Instance is running — malicious cloud-init script will execute shortly") + if logger != nil { + logger.LogAWSCall("ec2", "StartInstances", config.Region, + fmt.Sprintf("Started instance %s to trigger malicious cloud-init payload execution", instanceID), + map[string]string{"instance_id": instanceID}) + } // Step 6: Wait for cloud-init to execute. // The demo_attack.sh uses `sleep 30` after the instance reaches running state. @@ -417,7 +428,7 @@ func (m *Module) restoreUserData(ctx context.Context, ec2Client *ec2.Client, ins // Clear by setting an empty blob. _, err = ec2Client.ModifyInstanceAttribute(ctx, &ec2.ModifyInstanceAttributeInput{ InstanceId: aws.String(instanceID), - UserData: &types.BlobAttributeValue{ + UserData: &types.SecureBlobAttributeValue{ Value: []byte{}, }, }) @@ -431,7 +442,7 @@ func (m *Module) restoreUserData(ctx context.Context, ec2Client *ec2.Client, ins } _, err = ec2Client.ModifyInstanceAttribute(ctx, &ec2.ModifyInstanceAttributeInput{ InstanceId: aws.String(instanceID), - UserData: &types.BlobAttributeValue{ + UserData: &types.SecureBlobAttributeValue{ Value: rawOriginal, }, }) diff --git a/pkg/exploits/ec2_passrole/module.go b/pkg/exploits/ec2_passrole/module.go index 5a710d8..a203f00 100644 --- a/pkg/exploits/ec2_passrole/module.go +++ b/pkg/exploits/ec2_passrole/module.go @@ -97,11 +97,11 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_PROFILE": - return discovery.DiscoverInstanceProfiles(context.Background(), getConfig()) + return discovery.DiscoverInstanceProfiles(context.Background(), getConfig(), m.DiscoveryLogger) case "SUBNET_ID": - return discovery.DiscoverSubnets(context.Background(), getConfig()) + return discovery.DiscoverSubnets(context.Background(), getConfig(), m.DiscoveryLogger) case "SECURITY_GROUP_ID": - return discovery.DiscoverSecurityGroups(context.Background(), getConfig()) + return discovery.DiscoverSecurityGroups(context.Background(), getConfig(), m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -174,6 +174,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger instanceProfile := options["INSTANCE_PROFILE"] payloadType := options["PAYLOAD"] instanceName := options["INSTANCE_NAME"] @@ -308,6 +309,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { instanceID := aws.ToString(instance.InstanceId) fmt.Printf("EC2 instance launched: %s\n", instanceID) + if logger != nil { + logger.LogAWSCall("ec2", "RunInstances", config.Region, + fmt.Sprintf("Launched EC2 instance %s with instance profile %s", instanceID, instanceProfile), + map[string]string{"instance_id": instanceID, "instance_name": instanceName, "instance_profile": instanceProfile}) + } // Track the created EC2 instance resource if tracker != nil { diff --git a/pkg/exploits/ec2_passrole_requestspotinstances/module.go b/pkg/exploits/ec2_passrole_requestspotinstances/module.go index 680b37c..c7b187c 100644 --- a/pkg/exploits/ec2_passrole_requestspotinstances/module.go +++ b/pkg/exploits/ec2_passrole_requestspotinstances/module.go @@ -109,9 +109,9 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_PROFILE": - return discovery.DiscoverInstanceProfiles(context.Background(), getConfig()) + return discovery.DiscoverInstanceProfiles(context.Background(), getConfig(), m.DiscoveryLogger) case "SUBNET_ID": - return discovery.DiscoverSubnets(context.Background(), getConfig()) + return discovery.DiscoverSubnets(context.Background(), getConfig(), m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -178,6 +178,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger instanceProfile := options["INSTANCE_PROFILE"] payloadType := options["PAYLOAD"] @@ -290,6 +291,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { spotRequestID := aws.ToString(spotOutput.SpotInstanceRequests[0].SpotInstanceRequestId) fmt.Printf("Spot instance request submitted: %s\n", spotRequestID) + if logger != nil { + logger.LogAWSCall("ec2", "RequestSpotInstances", config.Region, + fmt.Sprintf("Requested spot instance with profile %s, type %s", instanceProfile, instanceType), + map[string]string{"spot_request_id": spotRequestID, "instance_profile": instanceProfile}) + } // Track the spot request immediately so cleanup can cancel it even if we time out. if tracker != nil { diff --git a/pkg/exploits/ecs_executecommand/module.go b/pkg/exploits/ecs_executecommand/module.go index 32070ef..1fd5d48 100644 --- a/pkg/exploits/ecs_executecommand/module.go +++ b/pkg/exploits/ecs_executecommand/module.go @@ -159,6 +159,7 @@ type ecsMetadataCredentials struct { func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options + logger := ectx.Logger clusterName := options["CLUSTER_NAME"] taskArn := options["TASK_ARN"] @@ -261,6 +262,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to execute command in container: %v", err) } + if logger != nil { + logger.LogAWSCall("ecs", "ExecuteCommand", region, + fmt.Sprintf("Executed command in ECS task %s (cluster %s) to retrieve task role credentials", taskArn, clusterName), + map[string]string{"cluster": clusterName, "task_arn": taskArn, "container": containerName}) + } // Step 5: Parse the credentials from the metadata response var metadataCreds ecsMetadataCredentials diff --git a/pkg/exploits/ecs_passrole/module.go b/pkg/exploits/ecs_passrole/module.go index 6dfe955..4c037dd 100644 --- a/pkg/exploits/ecs_passrole/module.go +++ b/pkg/exploits/ecs_passrole/module.go @@ -101,7 +101,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -162,6 +162,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -278,6 +279,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskDefRevision := registerResult.TaskDefinition.Revision taskDefRef := fmt.Sprintf("%s:%d", taskFamily, taskDefRevision) fmt.Printf("Task definition registered: %s (revision %d)\n", taskDefArn, taskDefRevision) + if logger != nil { + logger.LogAWSCall("ecs", "RegisterTaskDefinition", region, + fmt.Sprintf("Registered ECS task definition %s with role %s", taskFamily, roleArn), + map[string]string{"task_definition_arn": taskDefArn, "role_arn": roleArn}) + } // Track the task definition for cleanup if tracker != nil { @@ -326,6 +332,24 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskArn := aws.ToString(runResult.Tasks[0].TaskArn) fmt.Printf("Task started: %s\n", taskArn) + if logger != nil { + logger.LogAWSCall("ecs", "RunTask", region, + fmt.Sprintf("Ran ECS task %s on cluster %s with role %s", taskArn, clusterName, roleArn), + map[string]string{"task_arn": taskArn, "cluster_name": clusterName, "role_arn": roleArn}) + } + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "ecs:task", + Name: taskArn, + ARN: taskArn, + Region: region, + CleanupMethod: "ecs:StopTask", + ModuleID: "ecs-004", + Metadata: map[string]string{ + "cluster": clusterName, + }, + }) + } // Step 3: Wait for the task to complete fmt.Println("Waiting for ECS task to complete (polling every 10s, timeout 3m)...") diff --git a/pkg/exploits/ecs_passrole_createcluster/module.go b/pkg/exploits/ecs_passrole_createcluster/module.go index a51c889..e0b5f46 100644 --- a/pkg/exploits/ecs_passrole_createcluster/module.go +++ b/pkg/exploits/ecs_passrole_createcluster/module.go @@ -100,7 +100,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -161,6 +161,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -259,6 +260,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { clusterArn := aws.ToString(createClusterResult.Cluster.ClusterArn) fmt.Printf("Cluster created: %s\n", clusterArn) + if logger != nil { + logger.LogAWSCall("ecs", "CreateCluster", region, + fmt.Sprintf("Created ECS cluster %s", clusterName), + map[string]string{"cluster_name": clusterName, "cluster_arn": clusterArn}) + } // Track the cluster for cleanup if tracker != nil { @@ -309,6 +315,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskDefArn := aws.ToString(registerResult.TaskDefinition.TaskDefinitionArn) taskDefRevision := registerResult.TaskDefinition.Revision fmt.Printf("Task definition registered: %s (revision %d)\n", taskDefArn, taskDefRevision) + if logger != nil { + logger.LogAWSCall("ecs", "RegisterTaskDefinition", region, + fmt.Sprintf("Registered ECS task definition %s with role %s", taskFamily, roleArn), + map[string]string{"task_definition_arn": taskDefArn, "role_arn": roleArn}) + } // Track the task definition for cleanup if tracker != nil { @@ -360,6 +371,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskArn := aws.ToString(runResult.Tasks[0].TaskArn) fmt.Printf("Task started: %s\n", taskArn) + if logger != nil { + logger.LogAWSCall("ecs", "RunTask", region, + fmt.Sprintf("Ran ECS task %s on cluster %s with role %s", taskArn, clusterName, roleArn), + map[string]string{"task_arn": taskArn, "cluster_name": clusterName, "role_arn": roleArn}) + } // Track the running task if tracker != nil { diff --git a/pkg/exploits/ecs_passrole_createcluster_createservice/module.go b/pkg/exploits/ecs_passrole_createcluster_createservice/module.go index 0f1da32..a21fe19 100644 --- a/pkg/exploits/ecs_passrole_createcluster_createservice/module.go +++ b/pkg/exploits/ecs_passrole_createcluster_createservice/module.go @@ -100,7 +100,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -167,6 +167,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -269,6 +270,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { clusterArn := aws.ToString(createClusterResult.Cluster.ClusterArn) fmt.Printf("ECS cluster created: %s\n", clusterArn) + if logger != nil { + logger.LogAWSCall("ecs", "CreateCluster", region, + fmt.Sprintf("Created ECS cluster %s", clusterName), + map[string]string{"cluster_name": clusterName, "cluster_arn": clusterArn}) + } // Track the cluster for cleanup if tracker != nil { @@ -319,6 +325,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskDefArn := aws.ToString(registerResult.TaskDefinition.TaskDefinitionArn) taskDefRevision := registerResult.TaskDefinition.Revision fmt.Printf("Task definition registered: %s (revision %d)\n", taskDefArn, taskDefRevision) + if logger != nil { + logger.LogAWSCall("ecs", "RegisterTaskDefinition", region, + fmt.Sprintf("Registered ECS task definition %s with role %s", taskFamily, roleArn), + map[string]string{"task_definition_arn": taskDefArn, "role_arn": roleArn}) + } // Track the task definition for cleanup if tracker != nil { @@ -366,6 +377,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { serviceArn := aws.ToString(createServiceResult.Service.ServiceArn) fmt.Printf("ECS service created: %s\n", serviceArn) + if logger != nil { + logger.LogAWSCall("ecs", "CreateService", region, + fmt.Sprintf("Created ECS service %s on cluster %s with task role %s", serviceName, clusterName, roleArn), + map[string]string{"service_arn": serviceArn, "cluster_name": clusterName, "role_arn": roleArn}) + } // Track the service for cleanup if tracker != nil { diff --git a/pkg/exploits/ecs_passrole_createservice/module.go b/pkg/exploits/ecs_passrole_createservice/module.go index c026143..1b8debd 100644 --- a/pkg/exploits/ecs_passrole_createservice/module.go +++ b/pkg/exploits/ecs_passrole_createservice/module.go @@ -101,7 +101,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "CLUSTER_ARN": return discoverECSClusters(context.Background(), config) default: @@ -169,6 +169,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] clusterArn := options["CLUSTER_ARN"] @@ -287,6 +288,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskDefArn := aws.ToString(registerResult.TaskDefinition.TaskDefinitionArn) taskDefRevision := registerResult.TaskDefinition.Revision fmt.Printf("Task definition registered: %s (revision %d)\n", taskDefArn, taskDefRevision) + if logger != nil { + logger.LogAWSCall("ecs", "RegisterTaskDefinition", region, + fmt.Sprintf("Registered ECS task definition %s with role %s", taskFamily, roleArn), + map[string]string{"task_definition_arn": taskDefArn, "role_arn": roleArn}) + } // Track the task definition for cleanup if tracker != nil { @@ -334,6 +340,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { serviceArn := aws.ToString(createServiceResult.Service.ServiceArn) fmt.Printf("ECS service created: %s\n", serviceArn) + if logger != nil { + logger.LogAWSCall("ecs", "CreateService", region, + fmt.Sprintf("Created ECS service %s on cluster %s with task role %s", serviceName, clusterArn, roleArn), + map[string]string{"service_arn": serviceArn, "cluster_arn": clusterArn, "role_arn": roleArn}) + } // Track the service for cleanup if tracker != nil { diff --git a/pkg/exploits/ecs_passrole_runtask/module.go b/pkg/exploits/ecs_passrole_runtask/module.go index ef46670..213f859 100644 --- a/pkg/exploits/ecs_passrole_runtask/module.go +++ b/pkg/exploits/ecs_passrole_runtask/module.go @@ -107,7 +107,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "CLUSTER_NAME": return discoverClusters(context.Background(), config) case "TASK_DEFINITION": @@ -164,6 +164,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] clusterName := options["CLUSTER_NAME"] @@ -290,6 +291,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskArn := aws.ToString(runResult.Tasks[0].TaskArn) fmt.Printf("Task started: %s\n", taskArn) + if logger != nil { + logger.LogAWSCall("ecs", "RunTask", region, + fmt.Sprintf("Ran ECS task %s with task role override %s", taskArn, roleArn), + map[string]string{"task_arn": taskArn, "cluster": clusterName, "role_arn": roleArn}) + } // Note: ECS tasks are not tracked for cleanup because they are ephemeral — // the task runs to completion and stops on its own. By the time the exploit diff --git a/pkg/exploits/ecs_passrole_starttask/module.go b/pkg/exploits/ecs_passrole_starttask/module.go index d3d65be..ffcaba2 100644 --- a/pkg/exploits/ecs_passrole_starttask/module.go +++ b/pkg/exploits/ecs_passrole_starttask/module.go @@ -107,7 +107,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "CLUSTER_NAME": return discoverClusters(context.Background(), config) case "CONTAINER_INSTANCE_ARN": @@ -177,6 +177,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] clusterName := options["CLUSTER_NAME"] @@ -303,6 +304,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskArn := aws.ToString(startResult.Tasks[0].TaskArn) fmt.Printf("Task started: %s\n", taskArn) + if logger != nil { + logger.LogAWSCall("ecs", "StartTask", region, + fmt.Sprintf("Started ECS task %s with task role override %s", taskArn, roleArn), + map[string]string{"task_arn": taskArn, "role_arn": roleArn}) + } // Track the running task for cleanup if tracker != nil { diff --git a/pkg/exploits/ecs_registertaskdefinition_runtask/module.go b/pkg/exploits/ecs_registertaskdefinition_runtask/module.go index 8ada132..d9da47a 100644 --- a/pkg/exploits/ecs_registertaskdefinition_runtask/module.go +++ b/pkg/exploits/ecs_registertaskdefinition_runtask/module.go @@ -103,7 +103,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "CLUSTER_NAME": return discoverClusters(context.Background(), config) case "CONTAINER_INSTANCE_ARN": @@ -170,6 +170,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] clusterName := options["CLUSTER_NAME"] @@ -271,6 +272,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskDefArn := aws.ToString(registerResult.TaskDefinition.TaskDefinitionArn) taskDefRevision := registerResult.TaskDefinition.Revision fmt.Printf("Task definition registered: %s (revision %d)\n", taskDefArn, taskDefRevision) + if logger != nil { + logger.LogAWSCall("ecs", "RegisterTaskDefinition", region, + fmt.Sprintf("Registered ECS task definition %s with role %s", taskFamily, roleArn), + map[string]string{"task_definition_arn": taskDefArn, "role_arn": roleArn}) + } // Track the task definition for cleanup if tracker != nil { @@ -327,6 +333,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskArn := aws.ToString(startResult.Tasks[0].TaskArn) fmt.Printf("Task started: %s\n", taskArn) + if logger != nil { + logger.LogAWSCall("ecs", "StartTask", region, + fmt.Sprintf("Started ECS task %s on cluster %s with role %s", taskArn, clusterName, roleArn), + map[string]string{"task_arn": taskArn, "cluster_name": clusterName, "role_arn": roleArn}) + } // Track the running task if tracker != nil { diff --git a/pkg/exploits/ecs_starttask_registercontainerinstance/module.go b/pkg/exploits/ecs_starttask_registercontainerinstance/module.go index a008980..e91633e 100644 --- a/pkg/exploits/ecs_starttask_registercontainerinstance/module.go +++ b/pkg/exploits/ecs_starttask_registercontainerinstance/module.go @@ -107,7 +107,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ecs-tasks.amazonaws.com", m.DiscoveryLogger) case "CLUSTER_NAME": return discoverClusters(context.Background(), config) case "TASK_DEFINITION": @@ -168,6 +168,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] clusterName := options["CLUSTER_NAME"] @@ -272,6 +273,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { taskArn := aws.ToString(startResult.Tasks[0].TaskArn) fmt.Printf("Task started: %s\n", taskArn) + if logger != nil { + logger.LogAWSCall("ecs", "StartTask", region, + fmt.Sprintf("Started ECS task %s on cluster %s with role %s", taskArn, clusterName, roleArn), + map[string]string{"task_arn": taskArn, "cluster_name": clusterName, "role_arn": roleArn}) + } // Track the running task for cleanup if tracker != nil { diff --git a/pkg/exploits/emr_passrole/module.go b/pkg/exploits/emr_passrole/module.go index 696b66f..23c8aaf 100644 --- a/pkg/exploits/emr_passrole/module.go +++ b/pkg/exploits/emr_passrole/module.go @@ -106,9 +106,9 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_PROFILE": - return discovery.DiscoverInstanceProfiles(context.Background(), getConfig()) + return discovery.DiscoverInstanceProfiles(context.Background(), getConfig(), m.DiscoveryLogger) case "SERVICE_ROLE": - return discovery.DiscoverRolesForService(context.Background(), getConfig(), "elasticmapreduce.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), getConfig(), "elasticmapreduce.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -174,6 +174,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger instanceProfile := options["INSTANCE_PROFILE"] serviceRole := options["SERVICE_ROLE"] @@ -288,6 +289,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { clusterID := aws.ToString(runResult.JobFlowId) fmt.Printf("EMR cluster created: %s\n", clusterID) + if logger != nil { + logger.LogAWSCall("elasticmapreduce", "RunJobFlow", region, + fmt.Sprintf("Created EMR cluster %s with instance profile %s", clusterID, instanceProfile), + map[string]string{"cluster_id": clusterID, "instance_profile": instanceProfile}) + } fmt.Println("Waiting for cluster to run step and auto-terminate (5-15 minutes typical, up to 20 minutes)...") fmt.Println("Polling every 30 seconds...") diff --git a/pkg/exploits/emrserverless_passrole/module.go b/pkg/exploits/emrserverless_passrole/module.go index a3da05e..4d1168f 100644 --- a/pkg/exploits/emrserverless_passrole/module.go +++ b/pkg/exploits/emrserverless_passrole/module.go @@ -199,7 +199,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "emr-serverless.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "emr-serverless.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -254,6 +254,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger executionRoleARN := options["EXECUTION_ROLE_ARN"] scriptBucket := options["BUCKET"] @@ -347,6 +348,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { applicationID := aws.ToString(createResult.ApplicationId) fmt.Printf("EMR Serverless application created: %s\n", applicationID) + if logger != nil { + logger.LogAWSCall("emr-serverless", "CreateApplication", region, + fmt.Sprintf("Created EMR Serverless application %s", appName), + map[string]string{"application_id": applicationID, "app_name": appName}) + } // Track the application for workspace cleanup. if tracker != nil { @@ -398,6 +404,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobRunID := aws.ToString(jobResult.JobRunId) fmt.Printf("Job run started: %s\n", jobRunID) + if logger != nil { + logger.LogAWSCall("emr-serverless", "StartJobRun", region, + fmt.Sprintf("Started EMR Serverless job run %s with execution role %s", jobRunID, executionRoleARN), + map[string]string{"job_run_id": jobRunID, "application_id": applicationID, "execution_role_arn": executionRoleARN}) + } // Step 4: Poll until the job run reaches SUCCESS. fmt.Printf("Waiting for job run to complete (polling every %s, timeout %s)...\n", diff --git a/pkg/exploits/gamelift_passrole/module.go b/pkg/exploits/gamelift_passrole/module.go index 4dd2b5a..d95d381 100644 --- a/pkg/exploits/gamelift_passrole/module.go +++ b/pkg/exploits/gamelift_passrole/module.go @@ -117,7 +117,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "ec2.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ec2.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -182,6 +182,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -261,6 +262,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { buildID := aws.ToString(buildResult.Build.BuildId) fmt.Printf("GameLift build created: %s\n", buildID) + if logger != nil { + logger.LogAWSCall("gamelift", "CreateBuild", region, + fmt.Sprintf("Created GameLift build %s for fleet-based privilege escalation", buildName), + map[string]string{"build_id": buildID, "build_name": buildName}) + } // Track the build for cleanup. if tracker != nil { @@ -389,6 +395,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fleetID := aws.ToString(fleetResult.FleetAttributes.FleetId) fmt.Printf("GameLift fleet created: %s\n", fleetID) + if logger != nil { + logger.LogAWSCall("gamelift", "CreateFleet", region, + fmt.Sprintf("Created GameLift fleet %s with instance role %s for privilege escalation", fleetName, roleArn), + map[string]string{"fleet_id": fleetID, "fleet_name": fleetName, "role_arn": roleArn}) + } fmt.Println("Fleet is provisioning EC2 instances. The game server will start automatically.") fmt.Println("Note: the fleet will enter ERROR state (game server does not call InitSDK) — the exploit runs before the timeout.") diff --git a/pkg/exploits/glue_passrole_createsession/module.go b/pkg/exploits/glue_passrole_createsession/module.go index 15998a8..6908b3f 100644 --- a/pkg/exploits/glue_passrole_createsession/module.go +++ b/pkg/exploits/glue_passrole_createsession/module.go @@ -105,7 +105,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -158,6 +158,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -239,6 +240,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create Glue Interactive Session: %v", err) } fmt.Printf("Glue Interactive Session created: %s\n", sessionID) + if logger != nil { + logger.LogAWSCall("glue", "CreateSession", region, + fmt.Sprintf("Created Glue Interactive Session %s with role %s", sessionID, roleArn), + map[string]string{"session_id": sessionID, "role_arn": roleArn}) + } // Track the session resource for workspace cleanup. if tracker != nil { @@ -280,6 +286,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { statementID := runResult.Id fmt.Printf("Statement submitted: ID=%d\n", statementID) + if logger != nil { + logger.LogAWSCall("glue", "RunStatement", region, + fmt.Sprintf("Ran statement in Glue session %s with role %s", sessionID, roleArn), + map[string]string{"session_id": sessionID, "statement_id": fmt.Sprintf("%d", statementID)}) + } // Step 4: Poll for AVAILABLE — interval 5 s, max 120 s (24 attempts). fmt.Println("Waiting for statement to complete (polling every 5 s, timeout 120 s)...") diff --git a/pkg/exploits/glue_passrole_devendpoint/module.go b/pkg/exploits/glue_passrole_devendpoint/module.go index 4905f62..c6132d1 100644 --- a/pkg/exploits/glue_passrole_devendpoint/module.go +++ b/pkg/exploits/glue_passrole_devendpoint/module.go @@ -113,7 +113,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -169,6 +169,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] endpointName := options["ENDPOINT_NAME"] @@ -239,6 +240,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create Glue dev endpoint: %v", err) } fmt.Printf("Glue dev endpoint created: %s\n", endpointName) + if logger != nil { + logger.LogAWSCall("glue", "CreateDevEndpoint", region, + fmt.Sprintf("Created Glue dev endpoint %s with role %s", endpointName, roleArn), + map[string]string{"endpoint_name": endpointName, "role_arn": roleArn}) + } // Track the endpoint for cleanup immediately after creation so it is captured // even if the module exits early (e.g., during the provisioning wait). diff --git a/pkg/exploits/glue_passrole_job/module.go b/pkg/exploits/glue_passrole_job/module.go index 40e7ac6..119332e 100644 --- a/pkg/exploits/glue_passrole_job/module.go +++ b/pkg/exploits/glue_passrole_job/module.go @@ -94,7 +94,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -142,6 +142,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -275,6 +276,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create Glue job: %v", err) } fmt.Printf("Glue job created: %s\n", jobName) + if logger != nil { + logger.LogAWSCall("glue", "CreateJob", region, + fmt.Sprintf("Created Glue Python Shell job %s with execution role %s", jobName, roleArn), + map[string]string{"job_name": jobName, "role_arn": roleArn, "script_uri": scriptS3URI, "payload": payloadType}) + } // Track the Glue job for cleanup if tracker != nil { @@ -307,6 +313,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobRunID := aws.ToString(startResult.JobRunId) fmt.Printf("Job run started: %s\n", jobRunID) + if logger != nil { + logger.LogAWSCall("glue", "StartJobRun", region, + fmt.Sprintf("Started Glue job run for job %s to execute payload under execution role permissions", jobName), + map[string]string{"job_name": jobName, "job_run_id": jobRunID}) + } // Poll for job completion fmt.Println("Waiting for Glue job to complete (polling every 5s, timeout 5m)...") diff --git a/pkg/exploits/glue_passrole_job_createtrigger/module.go b/pkg/exploits/glue_passrole_job_createtrigger/module.go index 5b8c041..fc1e0ce 100644 --- a/pkg/exploits/glue_passrole_job_createtrigger/module.go +++ b/pkg/exploits/glue_passrole_job_createtrigger/module.go @@ -106,7 +106,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -168,6 +168,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -309,6 +310,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create Glue job: %v", err) } fmt.Printf("Glue job created: %s\n", jobName) + if logger != nil { + logger.LogAWSCall("glue", "CreateJob", region, + fmt.Sprintf("Created Glue job %s with role %s", jobName, roleArn), + map[string]string{"job_name": jobName, "role_arn": roleArn}) + } // Track the Glue job for cleanup. if tracker != nil { @@ -348,6 +354,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create Glue trigger: %v", err) } fmt.Printf("Trigger created: %s\n", triggerName) + if logger != nil { + logger.LogAWSCall("glue", "CreateTrigger", region, + fmt.Sprintf("Created Glue scheduled trigger %s for job %s", triggerName, jobName), + map[string]string{"trigger_name": triggerName, "job_name": jobName}) + } // Track the trigger for cleanup (must stop before delete). if tracker != nil { diff --git a/pkg/exploits/glue_updatejob_createtrigger/module.go b/pkg/exploits/glue_updatejob_createtrigger/module.go index 7dd0994..32ed8e8 100644 --- a/pkg/exploits/glue_updatejob_createtrigger/module.go +++ b/pkg/exploits/glue_updatejob_createtrigger/module.go @@ -115,7 +115,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com", m.DiscoveryLogger) case "JOB_NAME": return discoverGlueJobs(context.Background(), config) default: @@ -179,6 +179,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger // Apply option defaults for values not explicitly set by the operator. // This mirrors the Default field from Options() that the UI displays but does not @@ -332,6 +333,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to update Glue job: %v", err) } fmt.Printf("Glue job '%s' updated with privileged role and malicious script.\n", jobName) + if logger != nil { + logger.LogAWSCall("glue", "UpdateJob", region, + fmt.Sprintf("Updated Glue job %s with privileged role %s and malicious script", jobName, roleArn), + map[string]string{"job_name": jobName, "role_arn": roleArn}) + } // Clean up any orphaned "pathrunner-trigger-*" triggers for this job from prior // runs that were not properly deleted. Each such trigger fires every minute and @@ -374,6 +380,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to create Glue trigger: %v", err) } fmt.Printf("Trigger created: %s\n", triggerName) + if logger != nil { + logger.LogAWSCall("glue", "CreateTrigger", region, + fmt.Sprintf("Created Glue scheduled trigger %s to run job %s with role %s", triggerName, jobName, roleArn), + map[string]string{"trigger_name": triggerName, "job_name": jobName, "role_arn": roleArn}) + } // Track the trigger for workspace cleanup (session.go already handles glue:trigger). if tracker != nil { @@ -482,6 +493,24 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { m.cleanupUploadedScript(ectx, codeCleanupPrefix) } + // Track the job modification when cleanup is disabled — the workspace report shows what needs manual restoration. + if options["CLEANUP"] != "true" { + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "glue:job-update", + Name: jobName, + Region: region, + CleanupMethod: "glue:UpdateJob", + ModuleID: "glue-006", + Metadata: map[string]string{ + "job_name": jobName, + "original_role_arn": originalRoleArn, + "original_script": originalScript, + }, + }) + } + } + // Resolve the job run ID so we can fetch its CloudWatch log stream. // waitForTriggerJobCompletion returns state but not run ID; query the most recent run. jobRunID := m.getMostRecentJobRunID(ctx, glueClient, jobName) diff --git a/pkg/exploits/glue_updatejob_startjobrun/module.go b/pkg/exploits/glue_updatejob_startjobrun/module.go index 355ab92..5ece4b8 100644 --- a/pkg/exploits/glue_updatejob_startjobrun/module.go +++ b/pkg/exploits/glue_updatejob_startjobrun/module.go @@ -109,9 +109,9 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "glue.amazonaws.com", m.DiscoveryLogger) case "JOB_NAME": - return discovery.DiscoverGlueJobs(context.Background(), config) + return discovery.DiscoverGlueJobs(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -180,6 +180,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger jobName := options["JOB_NAME"] roleArn := options["ROLE_ARN"] @@ -314,6 +315,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to update Glue job: %v", err) } fmt.Printf("Glue job '%s' updated with privileged role and malicious script\n", jobName) + if logger != nil { + logger.LogAWSCall("glue", "UpdateJob", region, + fmt.Sprintf("Updated Glue job %s with privileged role %s and malicious script", jobName, roleArn), + map[string]string{"job_name": jobName, "role_arn": roleArn}) + } // Step 8: Start the job run, retrying on ConcurrentRunsExceededException. // Glue jobs with MaxConcurrentRuns=1 briefly retain the "occupied" state after @@ -342,6 +348,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { jobRunID := aws.ToString(startResult.JobRunId) fmt.Printf("Job run started: %s\n", jobRunID) + if logger != nil { + logger.LogAWSCall("glue", "StartJobRun", region, + fmt.Sprintf("Started Glue job run %s for job %s with role %s", jobRunID, jobName, roleArn), + map[string]string{"job_run_id": jobRunID, "job_name": jobName, "role_arn": roleArn}) + } // Step 9: Poll for job completion (every 5s, 5 min timeout — from demo_attack.sh) fmt.Println("Waiting for Glue job to complete (polling every 5s, timeout 5m)...") @@ -410,6 +421,20 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { m.cleanupUploadedScript(ectx, codeCleanupPrefix) } else { fmt.Printf("Job '%s' left with modified config (cleanup disabled). Restore manually when done.\n", jobName) + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "glue:job-update", + Name: jobName, + Region: region, + CleanupMethod: "glue:UpdateJob", + ModuleID: "glue-005", + Metadata: map[string]string{ + "job_name": jobName, + "original_role_arn": origConfig.roleArn, + "original_script": origConfig.scriptLocation, + }, + }) + } } // Fetch job output from CloudWatch so payloads can process it (PATHFINDER_IDENTITY_DATA, role ARNs, etc.) diff --git a/pkg/exploits/iam_addusertogroup/module.go b/pkg/exploits/iam_addusertogroup/module.go index b307e3a..53e78db 100644 --- a/pkg/exploits/iam_addusertogroup/module.go +++ b/pkg/exploits/iam_addusertogroup/module.go @@ -90,7 +90,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMGroups(context.Background(), config) + return discovery.DiscoverIAMGroups(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -124,6 +124,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger groupName := options["GROUP_NAME"] if groupName == "" { return "", fmt.Errorf("GROUP_NAME is required") @@ -144,6 +145,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -167,6 +172,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to add user to group: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "AddUserToGroup", config.Region, + fmt.Sprintf("Added user %s to group %s", userName, groupName), + map[string]string{"user_name": userName, "group_name": groupName}) + } fmt.Printf("Successfully added user '%s' to group '%s'.\n", userName, groupName) diff --git a/pkg/exploits/iam_attachgrouppolicy/module.go b/pkg/exploits/iam_attachgrouppolicy/module.go index ace3981..c4e21fb 100644 --- a/pkg/exploits/iam_attachgrouppolicy/module.go +++ b/pkg/exploits/iam_attachgrouppolicy/module.go @@ -89,7 +89,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverCallerGroups(context.Background(), config) + return discovery.DiscoverCallerGroups(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -129,6 +129,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger groupName := options["GROUP_NAME"] if groupName == "" { return "", fmt.Errorf("GROUP_NAME is required") @@ -154,6 +155,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -173,6 +178,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to attach group policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "AttachGroupPolicy", config.Region, + fmt.Sprintf("Attached policy %s to group %s", policyArn, groupName), + map[string]string{"group_name": groupName, "policy_arn": policyArn}) + } fmt.Printf("Successfully attached policy to group '%s'.\n", groupName) diff --git a/pkg/exploits/iam_attachrolepolicy/module.go b/pkg/exploits/iam_attachrolepolicy/module.go index bd7e1d0..a173fb6 100644 --- a/pkg/exploits/iam_attachrolepolicy/module.go +++ b/pkg/exploits/iam_attachrolepolicy/module.go @@ -130,7 +130,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverIAMRoles(context.Background(), config) + return discovery.DiscoverIAMRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -141,6 +141,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger adminPolicyArn := options["ADMIN_POLICY_ARN"] if adminPolicyArn == "" { @@ -162,6 +163,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -204,6 +209,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if assumeErr != nil { return "", fmt.Errorf("failed to assume role '%s': %v", roleName, assumeErr) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s to perform self-escalation", roleName), + map[string]string{"role_arn": roleArn}) + } fmt.Printf("Successfully assumed role '%s'.\n", roleName) // Build a config using the assumed role credentials @@ -235,6 +245,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to attach role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "AttachRolePolicy", config.Region, + fmt.Sprintf("Attached policy %s to role %s", adminPolicyArn, roleName), + map[string]string{"role_name": roleName, "policy_arn": adminPolicyArn}) + } fmt.Printf("Successfully attached policy '%s' to role '%s'.\n", adminPolicyArn, roleName) @@ -277,6 +292,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if assumeErr2 != nil { return "", fmt.Errorf("failed to re-assume role '%s' after escalation: %v", roleName, assumeErr2) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Re-assumed role %s with escalated privileges", roleName), + map[string]string{"role_arn": roleArn}) + } newAccessKeyID := aws.ToString(assumeResult2.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult2.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_attachrolepolicy_assumerole/module.go b/pkg/exploits/iam_attachrolepolicy_assumerole/module.go index d79bcb0..8dc1c3a 100644 --- a/pkg/exploits/iam_attachrolepolicy_assumerole/module.go +++ b/pkg/exploits/iam_attachrolepolicy_assumerole/module.go @@ -82,7 +82,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverAssumableRoles(context.Background(), config) + return discovery.DiscoverAssumableRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -101,6 +101,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetRoleArn := options["TARGET_ROLE"] if targetRoleArn == "" { return "", fmt.Errorf("TARGET_ROLE is required") @@ -133,6 +134,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -150,6 +155,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to attach role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "AttachRolePolicy", config.Region, + fmt.Sprintf("Attached policy %s to role %s", policyArn, roleName), + map[string]string{"role_name": roleName, "policy_arn": policyArn}) + } fmt.Printf("Successfully attached policy to role '%s'.\n", roleName) tracker.TrackResource(modules.CreatedResource{ @@ -175,6 +185,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role '%s': %v", roleName, err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s after attaching admin policy", roleName), + map[string]string{"role_arn": targetRoleArn}) + } newAccessKeyID := aws.ToString(assumeResult.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_attachrolepolicy_updateassumerolepolicy/module.go b/pkg/exploits/iam_attachrolepolicy_updateassumerolepolicy/module.go index 820c1d6..404f403 100644 --- a/pkg/exploits/iam_attachrolepolicy_updateassumerolepolicy/module.go +++ b/pkg/exploits/iam_attachrolepolicy_updateassumerolepolicy/module.go @@ -83,7 +83,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMRoles(context.Background(), config) + return discovery.DiscoverIAMRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -104,6 +104,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetRole := options["TARGET_ROLE"] if targetRole == "" { return "", fmt.Errorf("TARGET_ROLE is required") @@ -129,6 +130,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) @@ -167,6 +172,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to attach role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "AttachRolePolicy", config.Region, + fmt.Sprintf("Attached policy %s to role %s", policyArn, targetRole), + map[string]string{"role_name": targetRole, "policy_arn": policyArn}) + } fmt.Printf("Successfully attached policy to role '%s'.\n", targetRole) tracker.TrackResource(modules.CreatedResource{ @@ -208,6 +218,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to update assume role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "UpdateAssumeRolePolicy", config.Region, + fmt.Sprintf("Updated trust policy for role %s to allow caller assumption", targetRole), + map[string]string{"role_name": targetRole, "trust_principal": trustPrincipal}) + } fmt.Println("Successfully updated trust policy!") tracker.TrackResource(modules.CreatedResource{ @@ -233,6 +248,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role '%s': %v", targetRole, err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s after attaching policy and updating trust policy", targetRole), + map[string]string{"role_arn": roleArn}) + } newAccessKeyID := aws.ToString(assumeResult.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_attachuserpolicy/module.go b/pkg/exploits/iam_attachuserpolicy/module.go index 26539d6..7c72afd 100644 --- a/pkg/exploits/iam_attachuserpolicy/module.go +++ b/pkg/exploits/iam_attachuserpolicy/module.go @@ -103,6 +103,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger policyArn := options["POLICY_ARN"] if policyArn == "" { policyArn = "arn:aws:iam::aws:policy/AdministratorAccess" @@ -123,6 +124,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -148,6 +153,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to attach user policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "AttachUserPolicy", config.Region, + fmt.Sprintf("Attached policy %s to user %s", policyArn, userName), + map[string]string{"user_name": userName, "policy_arn": policyArn}) + } fmt.Printf("Successfully attached policy to user '%s'.\n", userName) diff --git a/pkg/exploits/iam_attachuserpolicy_createaccesskey/module.go b/pkg/exploits/iam_attachuserpolicy_createaccesskey/module.go index 8b530d6..e969acf 100644 --- a/pkg/exploits/iam_attachuserpolicy_createaccesskey/module.go +++ b/pkg/exploits/iam_attachuserpolicy_createaccesskey/module.go @@ -83,7 +83,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMUsers(context.Background(), config) + return discovery.DiscoverIAMUsers(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -102,6 +102,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetUser := options["TARGET_USER"] if targetUser == "" { return "", fmt.Errorf("TARGET_USER is required") @@ -126,6 +127,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -144,6 +149,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to attach user policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "AttachUserPolicy", config.Region, + fmt.Sprintf("Attached policy %s to user %s", policyArn, targetUser), + map[string]string{"user_name": targetUser, "policy_arn": policyArn}) + } fmt.Printf("Successfully attached policy to user '%s'.\n", targetUser) tracker.TrackResource(modules.CreatedResource{ @@ -171,6 +181,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { newAccessKeyID := aws.ToString(createResult.AccessKey.AccessKeyId) newSecretKey := aws.ToString(createResult.AccessKey.SecretAccessKey) fmt.Printf("Created access key: %s\n", newAccessKeyID) + if logger != nil { + logger.LogAWSCall("iam", "CreateAccessKey", config.Region, + fmt.Sprintf("Created access key for user %s", targetUser), + map[string]string{"user_name": targetUser, "access_key_id": newAccessKeyID}) + } tracker.TrackResource(modules.CreatedResource{ Type: "iam:access-key", Name: newAccessKeyID, Region: config.Region, diff --git a/pkg/exploits/iam_create_policy_version/module.go b/pkg/exploits/iam_create_policy_version/module.go index c2b89a8..57ec36a 100644 --- a/pkg/exploits/iam_create_policy_version/module.go +++ b/pkg/exploits/iam_create_policy_version/module.go @@ -95,7 +95,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverCallerPolicies(context.Background(), config) + return discovery.DiscoverCallerPolicies(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -156,6 +156,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger policyArn := options["POLICY_ARN"] if policyArn == "" { return "", fmt.Errorf("POLICY_ARN is required") @@ -175,6 +176,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -223,6 +228,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if assumeErr != nil { return "", fmt.Errorf("failed to assume role '%s': %v", roleName, assumeErr) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s to perform self-escalation", roleName), + map[string]string{"role_arn": roleArn}) + } fmt.Printf("Successfully assumed role '%s'.\n", roleName) attackConfig = config.Copy() @@ -256,6 +266,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { versionID := aws.ToString(createResult.PolicyVersion.VersionId) fmt.Printf("Successfully created policy version: %s (set as default)\n", versionID) + if logger != nil { + logger.LogAWSCall("iam", "CreatePolicyVersion", config.Region, + fmt.Sprintf("Created admin policy version %s for policy %s (set as default)", versionID, policyArn), + map[string]string{"policy_arn": policyArn, "version_id": versionID}) + } // Step 5: Track the modification for cleanup tracker.TrackResource(modules.CreatedResource{ @@ -301,6 +316,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if assumeErr2 != nil { return "", fmt.Errorf("failed to re-assume role '%s' after escalation: %v", roleName, assumeErr2) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Re-assumed role %s with escalated policy version", roleName), + map[string]string{"role_arn": roleArn}) + } newAccessKeyID := aws.ToString(assumeResult2.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult2.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_createaccesskey/module.go b/pkg/exploits/iam_createaccesskey/module.go index 77b3e5b..203b80d 100644 --- a/pkg/exploits/iam_createaccesskey/module.go +++ b/pkg/exploits/iam_createaccesskey/module.go @@ -93,7 +93,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMUsers(context.Background(), config) + return discovery.DiscoverIAMUsers(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -127,6 +127,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetUser := options["TARGET_USER"] if targetUser == "" { return "", fmt.Errorf("TARGET_USER is required") @@ -146,6 +147,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } fmt.Printf("Current identity: %s\n", aws.ToString(callerResult.Arn)) fmt.Printf("Account: %s\n", aws.ToString(callerResult.Account)) @@ -169,6 +174,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { newSecretKey := aws.ToString(createResult.AccessKey.SecretAccessKey) fmt.Printf("Created access key: %s\n", newAccessKeyID) + if logger != nil { + logger.LogAWSCall("iam", "CreateAccessKey", config.Region, + fmt.Sprintf("Created access key for IAM user %s", targetUser), + map[string]string{"target_user": targetUser, "access_key_id": newAccessKeyID}) + } // Step 3: Track the created access key for cleanup tracker.TrackResource(modules.CreatedResource{ @@ -210,6 +220,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { newArn := aws.ToString(verifyResult.Arn) fmt.Printf("\nVerified new identity: %s\n", newArn) + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + fmt.Sprintf("Verified new credentials work for user %s", targetUser), + map[string]string{"identity_arn": newArn}) + } // Step 6: Build output with identity data for auto-import identityName := fmt.Sprintf("iam002_%s_%d", targetUser, time.Now().Unix()) diff --git a/pkg/exploits/iam_createloginprofile/module.go b/pkg/exploits/iam_createloginprofile/module.go index 1d472e1..66f29a7 100644 --- a/pkg/exploits/iam_createloginprofile/module.go +++ b/pkg/exploits/iam_createloginprofile/module.go @@ -92,7 +92,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMUsers(context.Background(), config) + return discovery.DiscoverIAMUsers(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -136,6 +136,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetUser := options["TARGET_USER"] if targetUser == "" { return "", fmt.Errorf("TARGET_USER is required") @@ -170,6 +171,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } fmt.Printf("Current identity: %s\n", aws.ToString(callerIdentity.Arn)) accountID := aws.ToString(callerIdentity.Account) callerIsUser := strings.Contains(aws.ToString(callerIdentity.Arn), ":user/") @@ -196,6 +201,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if assumeErr != nil { return "", fmt.Errorf("failed to assume role '%s': %v", roleName, assumeErr) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed admin role %s to perform CreateLoginProfile", roleName), + map[string]string{"role_arn": adminRoleArn}) + } fmt.Printf("Successfully assumed role '%s'.\n", roleName) iamConfig = config.Copy() @@ -249,6 +259,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to create login profile: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "CreateLoginProfile", config.Region, + fmt.Sprintf("Created console login profile for user %s", targetUser), + map[string]string{"user_name": targetUser}) + } fmt.Printf("Successfully created login profile for user '%s'!\n", targetUser) diff --git a/pkg/exploits/iam_createpolicyversion_assumerole/module.go b/pkg/exploits/iam_createpolicyversion_assumerole/module.go index 4e9dffd..e08f5f1 100644 --- a/pkg/exploits/iam_createpolicyversion_assumerole/module.go +++ b/pkg/exploits/iam_createpolicyversion_assumerole/module.go @@ -87,7 +87,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverAssumableRoles(context.Background(), config) + return discovery.DiscoverAssumableRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -106,6 +106,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger policyArn := options["POLICY_ARN"] if policyArn == "" { return "", fmt.Errorf("POLICY_ARN is required") @@ -135,6 +136,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -156,6 +161,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { versionID := aws.ToString(createResult.PolicyVersion.VersionId) fmt.Printf("Created policy version %s (set as default).\n", versionID) + if logger != nil { + logger.LogAWSCall("iam", "CreatePolicyVersion", config.Region, + fmt.Sprintf("Created admin policy version %s for policy %s (set as default)", versionID, policyArn), + map[string]string{"policy_arn": policyArn, "version_id": versionID}) + } tracker.TrackResource(modules.CreatedResource{ Type: "iam:policy-version", Name: fmt.Sprintf("policy-version-%s", versionID), @@ -181,6 +191,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role '%s': %v", roleName, err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s after creating admin policy version", roleName), + map[string]string{"role_arn": targetRoleArn}) + } newAccessKeyID := aws.ToString(assumeResult.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_createpolicyversion_updateassumerolepolicy/module.go b/pkg/exploits/iam_createpolicyversion_updateassumerolepolicy/module.go index 0d8257b..1937fed 100644 --- a/pkg/exploits/iam_createpolicyversion_updateassumerolepolicy/module.go +++ b/pkg/exploits/iam_createpolicyversion_updateassumerolepolicy/module.go @@ -89,7 +89,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMRoles(context.Background(), config) + return discovery.DiscoverIAMRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -110,6 +110,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger policyArn := options["POLICY_ARN"] if policyArn == "" { return "", fmt.Errorf("POLICY_ARN is required") @@ -135,6 +136,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) @@ -177,6 +182,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { versionID := aws.ToString(createResult.PolicyVersion.VersionId) fmt.Printf("Created policy version %s (set as default).\n", versionID) + if logger != nil { + logger.LogAWSCall("iam", "CreatePolicyVersion", config.Region, + fmt.Sprintf("Created admin policy version %s for policy %s (set as default)", versionID, policyArn), + map[string]string{"policy_arn": policyArn, "version_id": versionID}) + } tracker.TrackResource(modules.CreatedResource{ Type: "iam:policy-version", Name: fmt.Sprintf("policy-version-%s", versionID), @@ -219,6 +229,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to update assume role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "UpdateAssumeRolePolicy", config.Region, + fmt.Sprintf("Updated trust policy for role %s to allow caller assumption", targetRole), + map[string]string{"role_name": targetRole, "trust_principal": trustPrincipal}) + } fmt.Println("Successfully updated trust policy!") tracker.TrackResource(modules.CreatedResource{ @@ -244,6 +259,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role '%s': %v", targetRole, err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s after creating admin policy version and updating trust policy", targetRole), + map[string]string{"role_arn": roleArn}) + } newAccessKeyID := aws.ToString(assumeResult.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_deleteaccesskey_createaccesskey/module.go b/pkg/exploits/iam_deleteaccesskey_createaccesskey/module.go index 6c9962f..231c876 100644 --- a/pkg/exploits/iam_deleteaccesskey_createaccesskey/module.go +++ b/pkg/exploits/iam_deleteaccesskey_createaccesskey/module.go @@ -92,7 +92,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMUsers(context.Background(), config) + return discovery.DiscoverIAMUsers(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -131,6 +131,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetUser := options["TARGET_USER"] if targetUser == "" { return "", fmt.Errorf("TARGET_USER is required") @@ -154,6 +155,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } fmt.Printf("Current identity: %s\n", aws.ToString(callerIdentity.Arn)) iamClient := iam.NewFromConfig(config) @@ -170,6 +175,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to list access keys for user '%s': %v", targetUser, err) } + if logger != nil { + logger.LogAWSCall("iam", "ListAccessKeys", config.Region, + fmt.Sprintf("Listed access keys for user %s", targetUser), + map[string]string{"user_name": targetUser}) + } keyCount := len(listResult.AccessKeyMetadata) fmt.Printf("Found %d existing access key(s):\n", keyCount) @@ -199,6 +209,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to delete access key %s: %v", deleteKeyID, err) } + if logger != nil { + logger.LogAWSCall("iam", "DeleteAccessKey", config.Region, + fmt.Sprintf("Deleted access key %s for user %s to make room for new key", deleteKeyID, targetUser), + map[string]string{"user_name": targetUser, "access_key_id": deleteKeyID}) + } fmt.Printf("Successfully deleted access key: %s\n", deleteKeyID) } else if keyCount == 1 { fmt.Println("\nUser has only 1 key. No deletion needed.") @@ -223,6 +238,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { newSecretKey := aws.ToString(createResult.AccessKey.SecretAccessKey) fmt.Printf("Successfully created access key: %s\n", newAccessKeyID) + if logger != nil { + logger.LogAWSCall("iam", "CreateAccessKey", config.Region, + fmt.Sprintf("Created access key for user %s to gain their permissions", targetUser), + map[string]string{"user_name": targetUser, "access_key_id": newAccessKeyID}) + } // Track the created access key for cleanup tracker.TrackResource(modules.CreatedResource{ diff --git a/pkg/exploits/iam_putgrouppolicy/module.go b/pkg/exploits/iam_putgrouppolicy/module.go index 32c44e1..911795b 100644 --- a/pkg/exploits/iam_putgrouppolicy/module.go +++ b/pkg/exploits/iam_putgrouppolicy/module.go @@ -99,7 +99,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverCallerGroups(context.Background(), config) + return discovery.DiscoverCallerGroups(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -139,6 +139,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger groupName := options["GROUP_NAME"] if groupName == "" { return "", fmt.Errorf("GROUP_NAME is required") @@ -164,6 +165,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -184,6 +189,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to put group policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "PutGroupPolicy", config.Region, + fmt.Sprintf("Added inline admin policy %s to group %s", policyName, groupName), + map[string]string{"group_name": groupName, "policy_name": policyName}) + } fmt.Printf("Successfully added inline policy '%s' to group '%s'.\n", policyName, groupName) diff --git a/pkg/exploits/iam_putrolepolicy/module.go b/pkg/exploits/iam_putrolepolicy/module.go index 211d430..2ee60fc 100644 --- a/pkg/exploits/iam_putrolepolicy/module.go +++ b/pkg/exploits/iam_putrolepolicy/module.go @@ -142,7 +142,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverIAMRoles(context.Background(), config) + return discovery.DiscoverIAMRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -153,6 +153,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger policyName := options["POLICY_NAME"] if policyName == "" { policyName = "pathrunner-admin" @@ -173,6 +174,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -215,6 +220,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if assumeErr != nil { return "", fmt.Errorf("failed to assume role '%s': %v", roleName, assumeErr) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s to perform PutRolePolicy self-escalation", roleName), + map[string]string{"role_arn": roleArn}) + } fmt.Printf("Successfully assumed role '%s'.\n", roleName) // Build a config using the assumed role credentials @@ -247,6 +257,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to put role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "PutRolePolicy", config.Region, + fmt.Sprintf("Added inline admin policy %s to role %s", policyName, roleName), + map[string]string{"role_name": roleName, "policy_name": policyName}) + } fmt.Printf("Successfully added inline policy '%s' to role '%s'.\n", policyName, roleName) @@ -289,6 +304,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if assumeErr2 != nil { return "", fmt.Errorf("failed to re-assume role '%s' after escalation: %v", roleName, assumeErr2) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Re-assumed role %s with elevated inline admin policy", roleName), + map[string]string{"role_arn": roleArn}) + } newAccessKeyID := aws.ToString(assumeResult2.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult2.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_putrolepolicy_assumerole/module.go b/pkg/exploits/iam_putrolepolicy_assumerole/module.go index 4cb561a..75a9acf 100644 --- a/pkg/exploits/iam_putrolepolicy_assumerole/module.go +++ b/pkg/exploits/iam_putrolepolicy_assumerole/module.go @@ -85,7 +85,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverAssumableRoles(context.Background(), config) + return discovery.DiscoverAssumableRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -104,6 +104,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetRoleArn := options["TARGET_ROLE"] if targetRoleArn == "" { return "", fmt.Errorf("TARGET_ROLE is required") @@ -134,6 +135,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -152,6 +157,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to put role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "PutRolePolicy", config.Region, + fmt.Sprintf("Added inline admin policy %s to role %s", policyName, roleName), + map[string]string{"role_arn": targetRoleArn, "policy_name": policyName}) + } fmt.Printf("Successfully added inline policy to role '%s'.\n", roleName) tracker.TrackResource(modules.CreatedResource{ @@ -176,6 +186,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role '%s': %v", roleName, err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s after adding inline admin policy", roleName), + map[string]string{"role_arn": targetRoleArn}) + } newAccessKeyID := aws.ToString(assumeResult.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_putrolepolicy_updateassumerolepolicy/module.go b/pkg/exploits/iam_putrolepolicy_updateassumerolepolicy/module.go index 67600b8..a9b0af1 100644 --- a/pkg/exploits/iam_putrolepolicy_updateassumerolepolicy/module.go +++ b/pkg/exploits/iam_putrolepolicy_updateassumerolepolicy/module.go @@ -86,7 +86,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMRoles(context.Background(), config) + return discovery.DiscoverIAMRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -107,6 +107,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetRole := options["TARGET_ROLE"] if targetRole == "" { return "", fmt.Errorf("TARGET_ROLE is required") @@ -132,6 +133,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) callerAccount := aws.ToString(callerIdentity.Account) fmt.Printf("Current identity: %s\n", callerArn) @@ -171,6 +176,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to put role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "PutRolePolicy", config.Region, + fmt.Sprintf("Added inline admin policy %s to role %s", policyName, targetRole), + map[string]string{"role_name": targetRole, "policy_name": policyName}) + } fmt.Printf("Successfully added inline policy to role '%s'.\n", targetRole) tracker.TrackResource(modules.CreatedResource{ @@ -212,6 +222,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to update assume role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "UpdateAssumeRolePolicy", config.Region, + fmt.Sprintf("Updated trust policy for role %s to allow caller assumption", targetRole), + map[string]string{"role_name": targetRole, "trust_principal": trustPrincipal}) + } fmt.Println("Successfully updated trust policy!") tracker.TrackResource(modules.CreatedResource{ @@ -237,6 +252,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role '%s': %v", targetRole, err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s after adding inline admin policy and updating trust policy", targetRole), + map[string]string{"role_arn": roleArn}) + } newAccessKeyID := aws.ToString(assumeResult.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_putuserpolicy/module.go b/pkg/exploits/iam_putuserpolicy/module.go index f18cbd1..e904da6 100644 --- a/pkg/exploits/iam_putuserpolicy/module.go +++ b/pkg/exploits/iam_putuserpolicy/module.go @@ -114,6 +114,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger policyName := options["POLICY_NAME"] if policyName == "" { policyName = "pathrunner-admin" @@ -134,6 +135,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -160,6 +165,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to put user policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "PutUserPolicy", config.Region, + fmt.Sprintf("Added inline admin policy %s to user %s", policyName, userName), + map[string]string{"user_name": userName, "policy_name": policyName}) + } fmt.Printf("Successfully added inline policy '%s' to user '%s'.\n", policyName, userName) diff --git a/pkg/exploits/iam_putuserpolicy_createaccesskey/module.go b/pkg/exploits/iam_putuserpolicy_createaccesskey/module.go index ae655cf..eb7e6fe 100644 --- a/pkg/exploits/iam_putuserpolicy_createaccesskey/module.go +++ b/pkg/exploits/iam_putuserpolicy_createaccesskey/module.go @@ -87,7 +87,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMUsers(context.Background(), config) + return discovery.DiscoverIAMUsers(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -106,6 +106,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetUser := options["TARGET_USER"] if targetUser == "" { return "", fmt.Errorf("TARGET_USER is required") @@ -130,6 +131,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Current identity: %s\n", callerArn) @@ -149,6 +154,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to put user policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "PutUserPolicy", config.Region, + fmt.Sprintf("Added inline admin policy %s to user %s", policyName, targetUser), + map[string]string{"user_name": targetUser, "policy_name": policyName}) + } fmt.Printf("Successfully added inline policy to user '%s'.\n", targetUser) tracker.TrackResource(modules.CreatedResource{ @@ -176,6 +186,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { newAccessKeyID := aws.ToString(createResult.AccessKey.AccessKeyId) newSecretKey := aws.ToString(createResult.AccessKey.SecretAccessKey) fmt.Printf("Created access key: %s\n", newAccessKeyID) + if logger != nil { + logger.LogAWSCall("iam", "CreateAccessKey", config.Region, + fmt.Sprintf("Created access key for user %s to gain their elevated permissions", targetUser), + map[string]string{"user_name": targetUser, "access_key_id": newAccessKeyID}) + } tracker.TrackResource(modules.CreatedResource{ Type: "iam:access-key", Name: newAccessKeyID, Region: config.Region, diff --git a/pkg/exploits/iam_updateassumerolepolicy/module.go b/pkg/exploits/iam_updateassumerolepolicy/module.go index c035f8c..b7a0247 100644 --- a/pkg/exploits/iam_updateassumerolepolicy/module.go +++ b/pkg/exploits/iam_updateassumerolepolicy/module.go @@ -89,7 +89,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMRoles(context.Background(), config) + return discovery.DiscoverIAMRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -135,6 +135,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger targetRole := options["TARGET_ROLE"] if targetRole == "" { return "", fmt.Errorf("TARGET_ROLE is required") @@ -155,6 +156,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } callerArn := aws.ToString(callerIdentity.Arn) callerAccount := aws.ToString(callerIdentity.Account) @@ -208,6 +213,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to update assume role policy: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "UpdateAssumeRolePolicy", config.Region, + fmt.Sprintf("Updated trust policy for role %s to trust principal %s", targetRole, trustPrincipal), + map[string]string{"role_name": targetRole, "role_arn": roleArn, "trust_principal": trustPrincipal}) + } fmt.Println("Successfully updated trust policy!") @@ -264,6 +274,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role '%s': %v", targetRole, err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s after updating trust policy to allow assumption", targetRole), + map[string]string{"role_arn": roleArn}) + } newAccessKeyID := aws.ToString(assumeResult.Credentials.AccessKeyId) newSecretKey := aws.ToString(assumeResult.Credentials.SecretAccessKey) diff --git a/pkg/exploits/iam_updateloginprofile/module.go b/pkg/exploits/iam_updateloginprofile/module.go index cccb7ab..485248a 100644 --- a/pkg/exploits/iam_updateloginprofile/module.go +++ b/pkg/exploits/iam_updateloginprofile/module.go @@ -92,7 +92,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverIAMUsers(context.Background(), config) + return discovery.DiscoverIAMUsers(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -130,7 +130,8 @@ func (m *Module) Options() []modules.Option { func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options - _ = ectx.Tracker + tracker := ectx.Tracker + logger := ectx.Logger targetUser := options["TARGET_USER"] if targetUser == "" { return "", fmt.Errorf("TARGET_USER is required") @@ -165,6 +166,10 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to get caller identity: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Verified current caller identity", nil) + } fmt.Printf("Current identity: %s\n", aws.ToString(callerIdentity.Arn)) accountID := aws.ToString(callerIdentity.Account) @@ -204,6 +209,23 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to update login profile: %v", err) } + if logger != nil { + logger.LogAWSCall("iam", "UpdateLoginProfile", config.Region, + fmt.Sprintf("Updated console login profile password for user %s", targetUser), + map[string]string{"user_name": targetUser}) + } + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "iam:login-profile-update", + Name: targetUser, + Region: config.Region, + CleanupMethod: "iam:UpdateLoginProfile", + ModuleID: "iam-006", + Metadata: map[string]string{ + "username": targetUser, + }, + }) + } fmt.Printf("Successfully updated login profile for user '%s'!\n", targetUser) diff --git a/pkg/exploits/imagebuilder_passrole/module.go b/pkg/exploits/imagebuilder_passrole/module.go index 546c491..0e23962 100644 --- a/pkg/exploits/imagebuilder_passrole/module.go +++ b/pkg/exploits/imagebuilder_passrole/module.go @@ -119,11 +119,11 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_PROFILE": - return discovery.DiscoverInstanceProfiles(context.Background(), getConfig()) + return discovery.DiscoverInstanceProfiles(context.Background(), getConfig(), m.DiscoveryLogger) case "SUBNET_ID": - return discovery.DiscoverSubnets(context.Background(), getConfig()) + return discovery.DiscoverSubnets(context.Background(), getConfig(), m.DiscoveryLogger) case "SECURITY_GROUP_ID": - return discovery.DiscoverSecurityGroups(context.Background(), getConfig()) + return discovery.DiscoverSecurityGroups(context.Background(), getConfig(), m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -199,6 +199,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger instanceProfileRaw := options["INSTANCE_PROFILE"] // The Image Builder API requires the instance profile name, not its ARN. @@ -288,6 +289,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { componentArn := aws.ToString(componentOutput.ComponentBuildVersionArn) fmt.Printf("Component created: %s\n", componentArn) + if logger != nil { + logger.LogAWSCall("imagebuilder", "CreateComponent", region, + fmt.Sprintf("Created Image Builder component %s with malicious payload", componentName), + map[string]string{"component_arn": componentArn, "component_name": componentName}) + } // Track the component for cleanup if tracker != nil { @@ -380,6 +386,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { infraConfigArn := aws.ToString(infraOutput.InfrastructureConfigurationArn) fmt.Printf("Infrastructure configuration created (iam:PassRole executed): %s\n", infraConfigArn) + if logger != nil { + logger.LogAWSCall("imagebuilder", "CreateInfrastructureConfiguration", region, + fmt.Sprintf("Created Image Builder infra config %s with instance profile %s (iam:PassRole)", infraConfigName, instanceProfile), + map[string]string{"infra_config_arn": infraConfigArn, "instance_profile": instanceProfile}) + } if tracker != nil { tracker.TrackResource(modules.CreatedResource{ diff --git a/pkg/exploits/kinesisanalytics_passrole/module.go b/pkg/exploits/kinesisanalytics_passrole/module.go index 0095792..256c060 100644 --- a/pkg/exploits/kinesisanalytics_passrole/module.go +++ b/pkg/exploits/kinesisanalytics_passrole/module.go @@ -114,7 +114,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "kinesisanalytics.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "kinesisanalytics.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -174,6 +174,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger payloadType := options["PAYLOAD"] roleArn := options["ROLE_ARN"] @@ -353,6 +354,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { appDetail := createResult.ApplicationDetail createTimestamp := appDetail.CreateTimestamp fmt.Printf("Flink application created: %s\n", aws.ToString(appDetail.ApplicationARN)) + if logger != nil { + logger.LogAWSCall("kinesisanalyticsv2", "CreateApplication", region, + fmt.Sprintf("Created Kinesis Analytics Flink application %s with execution role %s", appName, roleArn), + map[string]string{"application_arn": aws.ToString(appDetail.ApplicationARN), "app_name": appName, "role_arn": roleArn}) + } if tracker != nil { tracker.TrackResource(modules.CreatedResource{ diff --git a/pkg/exploits/lambda_createfunction_addpermission/module.go b/pkg/exploits/lambda_createfunction_addpermission/module.go index 3645c4e..996dc5d 100644 --- a/pkg/exploits/lambda_createfunction_addpermission/module.go +++ b/pkg/exploits/lambda_createfunction_addpermission/module.go @@ -107,7 +107,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "lambda.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "lambda.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -176,6 +176,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] functionName := options["FUNCTION_NAME"] @@ -238,6 +239,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } callerArn := aws.ToString(callerIdentity.Arn) fmt.Printf("Caller: %s\n", callerArn) + if logger != nil { + logger.LogAWSCall("sts", "GetCallerIdentity", config.Region, + "Retrieved caller identity to use as principal for Lambda resource-based policy", + map[string]string{"caller_arn": callerArn}) + } // Step 2: Create deployment package fmt.Printf("Creating Lambda function '%s' with role '%s'...\n", functionName, roleArn) @@ -280,6 +286,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } fmt.Printf("Lambda function created: %s\n", aws.ToString(createResult.FunctionArn)) + if logger != nil { + logger.LogAWSCall("lambda", "CreateFunction", config.Region, + fmt.Sprintf("Created Lambda function %s with execution role %s", functionName, roleArn), + map[string]string{"function_arn": aws.ToString(createResult.FunctionArn), "role_arn": roleArn, "payload": payloadType}) + } // Track the created Lambda function resource if tracker != nil { @@ -325,6 +336,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to add invoke permission: %v", err) } fmt.Println("Invoke permission added") + if logger != nil { + logger.LogAWSCall("lambda", "AddPermission", config.Region, + fmt.Sprintf("Added resource-based policy to Lambda function %s granting invoke permission to caller (%s)", functionName, callerArn), + map[string]string{"function_name": functionName, "principal": callerArn, "statement_id": statementID}) + } // Step 5: Wait for Lambda function and permissions to propagate (15s from demo_attack.sh) fmt.Println("Waiting 15 seconds for Lambda function and permissions to propagate...") @@ -346,6 +362,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { result := string(invokeResult.Payload) fmt.Println("Function execution completed") + if logger != nil { + logger.LogAWSCall("lambda", "InvokeFunction", config.Region, + fmt.Sprintf("Invoked Lambda function %s to execute payload under role permissions", functionName), + map[string]string{"function_name": functionName, "payload": payloadType}) + } // Step 7: If action-based payload, wait for IAM propagation (15s from demo_attack.sh) if _, ok := payload.(payloads.SideEffectReporter); ok { diff --git a/pkg/exploits/lambda_passrole/module.go b/pkg/exploits/lambda_passrole/module.go index 1310d1a..8baff72 100644 --- a/pkg/exploits/lambda_passrole/module.go +++ b/pkg/exploits/lambda_passrole/module.go @@ -88,7 +88,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "lambda.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "lambda.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -149,6 +149,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] functionName := options["FUNCTION_NAME"] @@ -244,6 +245,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } fmt.Printf("Lambda function created: %s\n", aws.ToString(createResult.FunctionArn)) + if logger != nil { + logger.LogAWSCall("lambda", "CreateFunction", config.Region, + fmt.Sprintf("Created Lambda function %s with execution role %s", functionName, roleArn), + map[string]string{"function_arn": aws.ToString(createResult.FunctionArn), "role_arn": roleArn, "payload": payloadType}) + } // Track the created Lambda function resource if tracker != nil { @@ -290,6 +296,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { result := string(invokeResult.Payload) fmt.Printf("Function execution completed\n") + if logger != nil { + logger.LogAWSCall("lambda", "InvokeFunction", config.Region, + fmt.Sprintf("Invoked Lambda function %s to execute payload under role permissions", functionName), + map[string]string{"function_name": functionName, "payload": payloadType}) + } // Track payload side effects (e.g., policy attachments, created roles/users) if tracker != nil { diff --git a/pkg/exploits/lambda_passrole_esm/module.go b/pkg/exploits/lambda_passrole_esm/module.go index 1a829c5..4cedbee 100644 --- a/pkg/exploits/lambda_passrole_esm/module.go +++ b/pkg/exploits/lambda_passrole_esm/module.go @@ -108,10 +108,10 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "lambda.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "lambda.amazonaws.com", m.DiscoveryLogger) case "EVENT_SOURCE_ARN": - return discovery.DiscoverDynamoDBStreams(context.Background(), config) + return discovery.DiscoverDynamoDBStreams(context.Background(), config, m.DiscoveryLogger) case "TABLE_NAME": // If EVENT_SOURCE_ARN is already set, derive table name from it @@ -124,7 +124,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current }, nil } } - return discovery.DiscoverDynamoDBTableNames(context.Background(), config) + return discovery.DiscoverDynamoDBTableNames(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) @@ -204,6 +204,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] payloadType := options["PAYLOAD"] eventSourceArn := options["EVENT_SOURCE_ARN"] @@ -301,6 +302,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } fmt.Printf("[+] Lambda function created: %s\n", aws.ToString(createResult.FunctionArn)) + if logger != nil { + logger.LogAWSCall("lambda", "CreateFunction", config.Region, + fmt.Sprintf("Created Lambda function %s with role %s for event-source-mapping exploit", functionName, roleArn), + map[string]string{"function_arn": aws.ToString(createResult.FunctionArn), "role_arn": roleArn}) + } if tracker != nil { tracker.TrackResource(modules.CreatedResource{ @@ -341,6 +347,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { esmUUID := aws.ToString(esmResult.UUID) fmt.Printf("[+] Event source mapping created: %s\n", esmUUID) + if logger != nil { + logger.LogAWSCall("lambda", "CreateEventSourceMapping", config.Region, + fmt.Sprintf("Created event source mapping %s from %s to function %s", esmUUID, eventSourceArn, functionName), + map[string]string{"esm_uuid": esmUUID, "event_source_arn": eventSourceArn, "function_name": functionName}) + } if tracker != nil { tracker.TrackResource(modules.CreatedResource{ diff --git a/pkg/exploits/lambda_updatecode/module.go b/pkg/exploits/lambda_updatecode/module.go index 5a3f36c..f74ee2c 100644 --- a/pkg/exploits/lambda_updatecode/module.go +++ b/pkg/exploits/lambda_updatecode/module.go @@ -105,7 +105,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverLambdaFunctions(context.Background(), config) + return discovery.DiscoverLambdaFunctions(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -148,6 +148,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger functionName := options["FUNCTION_NAME"] payloadType := options["PAYLOAD"] cleanup := options["CLEANUP"] != "false" @@ -196,6 +197,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { fmt.Printf("Handler: %s\n", handler) fmt.Printf("Execution Role: %s\n", roleArn) fmt.Printf("Runtime: %s\n", actualRuntime) + if logger != nil { + logger.LogAWSCall("lambda", "GetFunction", config.Region, + fmt.Sprintf("Retrieved configuration for Lambda function %s (execution role: %s, runtime: %s)", functionName, roleArn, actualRuntime), + map[string]string{"function_name": functionName, "role_arn": roleArn, "runtime": actualRuntime}) + } // Persist detected runtime so the REPL can filter payloads on the next interaction options["FUNCTION_RUNTIME"] = actualRuntime @@ -263,6 +269,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to update function code: %v", err) } fmt.Println("Function code updated") + if logger != nil { + logger.LogAWSCall("lambda", "UpdateFunctionCode", config.Region, + fmt.Sprintf("Replaced code of Lambda function %s with exploit payload", functionName), + map[string]string{"function_name": functionName, "payload": payloadType, "role_arn": roleArn}) + } // Step 8: Wait for update to complete (15s from demo_attack.sh, plus polling) fmt.Println("Waiting for Lambda to deploy new code...") @@ -287,6 +298,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { result := string(invokeResult.Payload) fmt.Println("Function execution completed") + if logger != nil { + logger.LogAWSCall("lambda", "InvokeFunction", config.Region, + fmt.Sprintf("Invoked Lambda function %s to execute payload under function's execution role", functionName), + map[string]string{"function_name": functionName, "payload": payloadType}) + } // Step 10: If action-based payload, wait for IAM propagation (15s from demo_attack.sh) if _, ok := payload.(payloads.SideEffectReporter); ok { @@ -318,6 +334,19 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } else if !cleanup { fmt.Printf("Function '%s' left with modified code (cleanup disabled)\n", functionName) fmt.Println("Restore original code manually or via Terraform when done") + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "lambda:function-code", + Name: functionName, + Region: config.Region, + CleanupMethod: "lambda:UpdateFunctionCode", + ModuleID: "lambda-003", + Metadata: map[string]string{ + "function_name": functionName, + "role_arn": roleArn, + }, + }) + } } // Step 13: Process result diff --git a/pkg/exploits/lambda_updatecode_addpermission/module.go b/pkg/exploits/lambda_updatecode_addpermission/module.go index 69baa99..99f9e8a 100644 --- a/pkg/exploits/lambda_updatecode_addpermission/module.go +++ b/pkg/exploits/lambda_updatecode_addpermission/module.go @@ -110,7 +110,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverLambdaFunctions(context.Background(), config) + return discovery.DiscoverLambdaFunctions(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -164,6 +164,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger functionName := options["FUNCTION_NAME"] payloadType := options["PAYLOAD"] cleanup := options["CLEANUP"] != "false" @@ -288,6 +289,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to update function code: %v", err) } fmt.Println("Function code updated") + if logger != nil { + logger.LogAWSCall("lambda", "UpdateFunctionCode", config.Region, + fmt.Sprintf("Updated code of existing Lambda function %s with malicious payload", functionName), + map[string]string{"function_name": functionName}) + } // Step 9: Add resource-based permission to allow self-invocation statementID := fmt.Sprintf("AllowStartingUserInvoke-%d", time.Now().Unix()) @@ -307,6 +313,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to add invoke permission: %v", err) } fmt.Println("Invoke permission added") + if logger != nil { + logger.LogAWSCall("lambda", "AddPermission", config.Region, + fmt.Sprintf("Added invoke permission (statement %s) to Lambda function %s for caller %s", statementID, functionName, callerArn), + map[string]string{"function_name": functionName, "statement_id": statementID, "caller_arn": callerArn}) + } // Track the permission for cleanup if tracker != nil { @@ -393,6 +404,19 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } else { fmt.Printf("Function '%s' left with modified code and added permission (cleanup disabled)\n", functionName) fmt.Println("Restore original code and remove permission manually or via Terraform when done") + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "lambda:function-code", + Name: functionName, + Region: config.Region, + CleanupMethod: "lambda:UpdateFunctionCode", + ModuleID: "lambda-005", + Metadata: map[string]string{ + "function_name": functionName, + "role_arn": roleArn, + }, + }) + } } // Step 15: Process result diff --git a/pkg/exploits/lambda_updatecode_invoke/module.go b/pkg/exploits/lambda_updatecode_invoke/module.go index ec4de95..10f1253 100644 --- a/pkg/exploits/lambda_updatecode_invoke/module.go +++ b/pkg/exploits/lambda_updatecode_invoke/module.go @@ -108,7 +108,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverLambdaFunctions(context.Background(), config) + return discovery.DiscoverLambdaFunctions(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -161,6 +161,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger functionName := options["FUNCTION_NAME"] payloadType := options["PAYLOAD"] cleanup := options["CLEANUP"] != "false" @@ -276,6 +277,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("failed to update function code: %v", err) } fmt.Println("Function code updated") + if logger != nil { + logger.LogAWSCall("lambda", "UpdateFunctionCode", config.Region, + fmt.Sprintf("Updated code of existing Lambda function %s with malicious payload", functionName), + map[string]string{"function_name": functionName}) + } // Step 8: Wait for update to complete (15s from demo_attack.sh, plus polling) fmt.Println("Waiting for Lambda to deploy new code...") @@ -331,6 +337,19 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { } else if !cleanup { fmt.Printf("Function '%s' left with modified code (cleanup disabled)\n", functionName) fmt.Println("Restore original code manually or via Terraform when done") + if tracker != nil { + tracker.TrackResource(modules.CreatedResource{ + Type: "lambda:function-code", + Name: functionName, + Region: config.Region, + CleanupMethod: "lambda:UpdateFunctionCode", + ModuleID: "lambda-004", + Metadata: map[string]string{ + "function_name": functionName, + "role_arn": roleArn, + }, + }) + } } // Step 13: Process result diff --git a/pkg/exploits/omics_passrole/module.go b/pkg/exploits/omics_passrole/module.go index 25217d0..f041171 100644 --- a/pkg/exploits/omics_passrole/module.go +++ b/pkg/exploits/omics_passrole/module.go @@ -198,7 +198,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverRolesForService(context.Background(), config, "omics.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "omics.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -259,6 +259,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] ecrImageURI := options["CONTAINER_URI"] @@ -351,6 +352,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { workflowID := aws.ToString(createResult.Id) fmt.Printf("HealthOmics workflow created: %s\n", workflowID) + if logger != nil { + logger.LogAWSCall("omics", "CreateWorkflow", region, + fmt.Sprintf("Created HealthOmics workflow %s", workflowName), + map[string]string{"workflow_id": workflowID, "workflow_name": workflowName}) + } // Track the workflow for cleanup. if tracker != nil { @@ -408,6 +414,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { runID := aws.ToString(startResult.Id) fmt.Printf("Workflow run started: %s\n", runID) + if logger != nil { + logger.LogAWSCall("omics", "StartRun", region, + fmt.Sprintf("Started HealthOmics workflow run %s with role %s", runID, roleArn), + map[string]string{"run_id": runID, "workflow_id": workflowID, "role_arn": roleArn}) + } // Track the run for cleanup. if tracker != nil { diff --git a/pkg/exploits/ssm_passrole_automation/module.go b/pkg/exploits/ssm_passrole_automation/module.go index 433aad7..b37f18c 100644 --- a/pkg/exploits/ssm_passrole_automation/module.go +++ b/pkg/exploits/ssm_passrole_automation/module.go @@ -171,7 +171,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "ROLE_ARN": - return discovery.DiscoverRolesForService(context.Background(), config, "ssm.amazonaws.com") + return discovery.DiscoverRolesForService(context.Background(), config, "ssm.amazonaws.com", m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -247,6 +247,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger automationRoleARN := options["ROLE_ARN"] payloadType := options["PAYLOAD"] @@ -301,6 +302,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { return "", fmt.Errorf("ssm:CreateDocument failed: %w", err) } fmt.Printf("SSM Automation document created: %s\n", documentName) + if logger != nil { + logger.LogAWSCall("ssm", "CreateDocument", region, + fmt.Sprintf("Created SSM Automation document %s", documentName), + map[string]string{"document_name": documentName}) + } // Track the SSM document so workspace cleanup can delete it. if tracker != nil { @@ -334,6 +340,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { executionID := aws.ToString(execOutput.AutomationExecutionId) fmt.Printf("Automation execution started: %s\n", executionID) + if logger != nil { + logger.LogAWSCall("ssm", "StartAutomationExecution", region, + fmt.Sprintf("Started SSM Automation execution %s with role %s (iam:PassRole)", executionID, automationRoleARN), + map[string]string{"execution_id": executionID, "document_name": documentName, "role_arn": automationRoleARN}) + } // Track side effects reported by the payload (e.g., policy attachments). // These are tracked before polling so they appear in cleanup even if the poll times out. diff --git a/pkg/exploits/ssm_sendcommand/module.go b/pkg/exploits/ssm_sendcommand/module.go index 3057618..81be474 100644 --- a/pkg/exploits/ssm_sendcommand/module.go +++ b/pkg/exploits/ssm_sendcommand/module.go @@ -129,7 +129,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_ID": - choices, err := discovery.DiscoverEC2InstancesWithProfiles(context.Background(), config) + choices, err := discovery.DiscoverEC2InstancesWithProfiles(context.Background(), config, m.DiscoveryLogger) if err != nil { return nil, err } @@ -178,6 +178,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger instanceID := options["INSTANCE_ID"] payloadType := options["PAYLOAD"] @@ -245,6 +246,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { commandID := aws.ToString(commandOutput.Command.CommandId) fmt.Printf("Command sent. Command ID: %s\n", commandID) + if logger != nil { + logger.LogAWSCall("ssm", "SendCommand", config.Region, + fmt.Sprintf("Sent SSM command %s to instance %s with payload %s", commandID, instanceID, payloadType), + map[string]string{"command_id": commandID, "instance_id": instanceID, "payload": payloadType}) + } // Wait for the command to execute. fmt.Println("Waiting 15 seconds for command execution...") diff --git a/pkg/exploits/ssm_startsession/module.go b/pkg/exploits/ssm_startsession/module.go index 1e3bb4b..5afce85 100644 --- a/pkg/exploits/ssm_startsession/module.go +++ b/pkg/exploits/ssm_startsession/module.go @@ -129,7 +129,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current switch optionName { case "INSTANCE_ID": - choices, err := discovery.DiscoverEC2InstancesWithProfiles(context.Background(), config) + choices, err := discovery.DiscoverEC2InstancesWithProfiles(context.Background(), config, m.DiscoveryLogger) if err != nil { return nil, err } @@ -177,6 +177,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options tracker := ectx.Tracker + logger := ectx.Logger // Pre-flight: session-manager-plugin must be in PATH for aws ssm start-session to work. // Check this before any AWS calls so the failure is immediate and actionable. @@ -262,6 +263,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("SSM session failed: %v", err) } + if logger != nil { + logger.LogAWSCall("ssm", "StartSession", region, + fmt.Sprintf("Started SSM session on instance %s and executed payload %s", instanceID, payloadType), + map[string]string{"instance_id": instanceID, "payload": payloadType}) + } // Track side effects reported by the payload. if reporter, ok := pl.(payloads.SideEffectReporter); ok && tracker != nil { diff --git a/pkg/exploits/sts_assume_role/module.go b/pkg/exploits/sts_assume_role/module.go index 1e5ff5c..48ac7a6 100644 --- a/pkg/exploits/sts_assume_role/module.go +++ b/pkg/exploits/sts_assume_role/module.go @@ -77,7 +77,7 @@ func (m *Module) Discover(optionName string, identity *modules.Identity, current if region := currentOptions["REGION"]; region != "" { config.Region = region } - return discovery.DiscoverAssumableRoles(context.Background(), config) + return discovery.DiscoverAssumableRoles(context.Background(), config, m.DiscoveryLogger) default: return nil, fmt.Errorf("option '%s' does not support auto-discovery", optionName) } @@ -131,6 +131,7 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { identity := ectx.Identity options := ectx.Options _ = ectx.Tracker + logger := ectx.Logger roleArn := options["ROLE_ARN"] sessionName := options["SESSION_NAME"] if sessionName == "" { @@ -182,6 +183,11 @@ func (m *Module) Execute(ectx modules.ExecutionContext) (string, error) { if err != nil { return "", fmt.Errorf("failed to assume role: %v", err) } + if logger != nil { + logger.LogAWSCall("sts", "AssumeRole", config.Region, + fmt.Sprintf("Assumed role %s with session %s", roleArn, sessionName), + map[string]string{"role_arn": roleArn, "session_name": sessionName}) + } fmt.Printf("✓ Successfully assumed role!\n") fmt.Printf("Account: %s\n", aws.ToString(result.AssumedRoleUser.Arn)) diff --git a/pkg/modules/base.go b/pkg/modules/base.go index c691399..67025c4 100644 --- a/pkg/modules/base.go +++ b/pkg/modules/base.go @@ -8,7 +8,15 @@ package modules // Embed this in concrete modules to reduce boilerplate — modules only need // to implement Options() and Execute() (plus payload methods if applicable). type BaseModule struct { - Info PathInfo + Info PathInfo + DiscoveryLogger ActionLogger // injected by REPL before each Discover() call; nil in tests +} + +// SetDiscoveryLogger satisfies the DiscoverLogged interface. +// The REPL calls this before invoking Discover() so that discovery functions +// can record their AWS API calls in the CloudTrail events log. +func (b *BaseModule) SetDiscoveryLogger(logger ActionLogger) { + b.DiscoveryLogger = logger } // PathInfo returns the module's structured metadata. diff --git a/pkg/modules/interface.go b/pkg/modules/interface.go index d823918..9d042dc 100644 --- a/pkg/modules/interface.go +++ b/pkg/modules/interface.go @@ -174,7 +174,8 @@ type ExecutionContext struct { Identity *Identity Options map[string]string Tracker ResourceTracker - AttackerIdentity *Identity // nil when no attacker account is configured + AttackerIdentity *Identity // nil when no attacker account is configured + Logger ActionLogger // nil-safe; always set by the REPL, may be nil in tests } type Module interface { @@ -227,6 +228,33 @@ type ResourceTracker interface { TrackResource(resource CreatedResource) } +// CloudTrailEvent records one AWS API call made during module execution, +// for use in purple team detection-reference reports. +type CloudTrailEvent struct { + Timestamp time.Time `json:"timestamp"` + ModuleID string `json:"module_id"` + Service string `json:"service"` // e.g. "sts" + Operation string `json:"operation"` // e.g. "AssumeRole" + Region string `json:"region,omitempty"` + Description string `json:"description"` // human-readable context for blue team + Metadata map[string]string `json:"metadata,omitempty"` // key resource identifiers (ARNs, names) +} + +// ActionLogger records AWS API calls made during module execution. Modules must +// nil-check before calling — the REPL always supplies one, but unit tests that +// don't care about logging may pass nil. +type ActionLogger interface { + LogAWSCall(service, operation, region, description string, metadata map[string]string) +} + +// DiscoverLogged is an optional interface implemented automatically by modules +// that embed BaseModule. The REPL calls SetDiscoveryLogger before each Discover() +// invocation so discovery functions can record their AWS API calls in the +// CloudTrail events log. +type DiscoverLogged interface { + SetDiscoveryLogger(logger ActionLogger) +} + // Discoverable is an optional interface that modules can implement // to support auto-discovery of option values via AWS API calls. // When a user runs 'discover' or 'exploit' with missing options, diff --git a/pkg/report/cleanup.go b/pkg/report/cleanup.go new file mode 100644 index 0000000..6ba69e5 --- /dev/null +++ b/pkg/report/cleanup.go @@ -0,0 +1,416 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +package report + +import "fmt" + +// CleanupCommand returns the AWS CLI command(s) needed to clean up a resource. +// Returns an empty string for unknown types. Multiple commands are newline-separated. +func CleanupCommand(res Resource) string { + region := res.Region + if region == "" { + region = "us-east-1" + } + + switch res.Type { + case "lambda:function": + return fmt.Sprintf("aws lambda delete-function --function-name %s --region %s", res.Name, region) + case "lambda:event-source-mapping": + uuid := res.Metadata["uuid"] + if uuid == "" { + uuid = res.Name + } + return fmt.Sprintf("aws lambda delete-event-source-mapping --uuid %s --region %s", uuid, region) + case "lambda:permission": + funcName := res.Metadata["function_name"] + stmtID := res.Metadata["statement_id"] + return fmt.Sprintf("aws lambda remove-permission --function-name %s --statement-id %s --region %s", funcName, stmtID, region) + case "ec2:instance": + instanceID := res.Metadata["instance_id"] + if instanceID == "" { + instanceID = res.Name + } + return fmt.Sprintf("aws ec2 terminate-instances --instance-ids %s --region %s", instanceID, region) + case "ec2:spot-instance-request": + spotRequestID := res.Metadata["spot_request_id"] + if spotRequestID == "" { + spotRequestID = res.Name + } + return fmt.Sprintf("aws ec2 cancel-spot-instance-requests --spot-instance-request-ids %s --region %s", spotRequestID, region) + case "iam:attached-policy": + principalType := res.Metadata["principal_type"] + principalName := res.Metadata["principal_name"] + policyArn := res.Metadata["policy_arn"] + switch principalType { + case "role": + return fmt.Sprintf("aws iam detach-role-policy --role-name %s --policy-arn %s", principalName, policyArn) + case "group": + return fmt.Sprintf("aws iam detach-group-policy --group-name %s --policy-arn %s", principalName, policyArn) + default: + return fmt.Sprintf("aws iam detach-user-policy --user-name %s --policy-arn %s", principalName, policyArn) + } + case "iam:inline-policy": + principalType := res.Metadata["principal_type"] + principalName := res.Metadata["principal_name"] + policyName := res.Metadata["policy_name"] + switch principalType { + case "role": + return fmt.Sprintf("aws iam delete-role-policy --role-name %s --policy-name %s", principalName, policyName) + case "group": + return fmt.Sprintf("aws iam delete-group-policy --group-name %s --policy-name %s", principalName, policyName) + default: + return fmt.Sprintf("aws iam delete-user-policy --user-name %s --policy-name %s", principalName, policyName) + } + case "iam:group-membership": + userName := res.Metadata["user_name"] + groupName := res.Metadata["group_name"] + return fmt.Sprintf("aws iam remove-user-from-group --user-name %s --group-name %s", userName, groupName) + case "iam:trust-policy": + roleName := res.Metadata["role_name"] + return fmt.Sprintf("aws iam update-assume-role-policy --role-name %s --policy-document ''", roleName) + case "iam:policy-version": + policyArn := res.Metadata["policy_arn"] + versionID := res.Metadata["version_id"] + return fmt.Sprintf("aws iam delete-policy-version --policy-arn %s --version-id %s", policyArn, versionID) + case "iam:access-key": + username := res.Metadata["username"] + accessKeyID := res.Metadata["access_key_id"] + if accessKeyID == "" { + accessKeyID = res.Name + } + return fmt.Sprintf("aws iam delete-access-key --user-name %s --access-key-id %s", username, accessKeyID) + case "iam:login-profile": + username := res.Metadata["username"] + if username == "" { + username = res.Name + } + return fmt.Sprintf("aws iam delete-login-profile --user-name %s", username) + case "iam:role": + return fmt.Sprintf("aws iam delete-role --role-name %s", res.Name) + case "iam:user": + return fmt.Sprintf("aws iam delete-user --user-name %s", res.Name) + case "ecs:service": + cluster := res.Metadata["cluster"] + return fmt.Sprintf("aws ecs delete-service --cluster %s --service %s --force --region %s", cluster, res.Name, region) + case "ecs:cluster": + return fmt.Sprintf("aws ecs delete-cluster --cluster %s --region %s", res.Name, region) + case "s3_bucket": + return fmt.Sprintf("aws s3 rm s3://%s --recursive --region %s\naws s3api delete-bucket --bucket %s --region %s", res.Name, region, res.Name, region) + case "glue:dev-endpoint": + return fmt.Sprintf("aws glue delete-dev-endpoint --endpoint-name %s --region %s", res.Name, region) + case "glue:job": + return fmt.Sprintf("aws glue delete-job --job-name %s --region %s", res.Name, region) + case "glue:session": + return fmt.Sprintf("aws glue stop-session --id %s --region %s\naws glue delete-session --id %s --region %s", res.Name, region, res.Name, region) + case "glue:trigger": + return fmt.Sprintf("aws glue stop-trigger --name %s --region %s\naws glue delete-trigger --name %s --region %s", res.Name, region, res.Name, region) + case "imagebuilder:component": + componentArn := res.Metadata["component_arn"] + if componentArn == "" { + componentArn = res.ARN + } + return fmt.Sprintf("aws imagebuilder delete-component --component-build-version-arn %s --region %s", componentArn, region) + case "imagebuilder:recipe": + recipeArn := res.Metadata["recipe_arn"] + if recipeArn == "" { + recipeArn = res.ARN + } + return fmt.Sprintf("aws imagebuilder delete-image-recipe --image-recipe-arn %s --region %s", recipeArn, region) + case "imagebuilder:infra-config": + infraArn := res.Metadata["infra_config_arn"] + if infraArn == "" { + infraArn = res.ARN + } + return fmt.Sprintf("aws imagebuilder delete-infrastructure-configuration --infrastructure-configuration-arn %s --region %s", infraArn, region) + case "imagebuilder:image": + imageArn := res.Metadata["image_build_arn"] + if imageArn == "" { + imageArn = res.ARN + } + return fmt.Sprintf("aws imagebuilder cancel-image-creation --image-build-version-arn %s --region %s 2>/dev/null || true\naws imagebuilder delete-image --image-build-version-arn %s --region %s", imageArn, region, imageArn, region) + case "kinesisanalyticsv2:application": + createTimestamp := res.Metadata["create_timestamp"] + return fmt.Sprintf("# Stop the application first, then delete using its original CreateTimestamp\naws kinesisanalyticsv2 stop-application --application-name %s --force --region %s 2>/dev/null || true\naws kinesisanalyticsv2 delete-application --application-name %s --create-timestamp %s --region %s", res.Name, region, res.Name, createTimestamp, region) + case "local:file": + path := res.Metadata["path"] + if path == "" { + path = res.Name + } + return fmt.Sprintf("rm -f %s", path) + case "batch:job-definition": + jobDefArn := res.Metadata["job_definition_arn"] + if jobDefArn == "" { + jobDefArn = res.ARN + } + if jobDefArn == "" { + jobDefArn = res.Name + } + return fmt.Sprintf("aws batch deregister-job-definition --job-definition %s --region %s", jobDefArn, region) + case "batch:job-queue": + queueName := res.Metadata["job_queue_name"] + if queueName == "" { + queueName = res.Name + } + return fmt.Sprintf("aws batch update-job-queue --job-queue %s --state DISABLED --region %s\naws batch delete-job-queue --job-queue %s --region %s", queueName, region, queueName, region) + case "batch:compute-environment": + ceName := res.Metadata["compute_environment_name"] + if ceName == "" { + ceName = res.Name + } + return fmt.Sprintf("aws batch update-compute-environment --compute-environment %s --state DISABLED --region %s\naws batch delete-compute-environment --compute-environment %s --region %s", ceName, region, ceName, region) + case "bedrock-agentcore:code-interpreter": + interpreterID := res.Metadata["interpreter_id"] + if interpreterID == "" { + interpreterID = res.Name + } + return fmt.Sprintf("aws bedrock-agentcore-control delete-code-interpreter --code-interpreter-id %s --region %s", interpreterID, region) + case "bedrock-agentcore:agent-runtime": + runtimeID := res.Metadata["runtime_id"] + if runtimeID == "" { + runtimeID = res.Name + } + return fmt.Sprintf("aws bedrock-agentcore-control delete-agent-runtime --agent-runtime-id %s --region %s", runtimeID, region) + case "bedrock-agentcore:browser": + browserID := res.Metadata["browser_id"] + if browserID == "" { + browserID = res.Name + } + return fmt.Sprintf("aws bedrock-agentcore-control delete-browser --browser-id %s --region %s", browserID, region) + case "bedrock-agentcore:harness": + harnessID := res.Metadata["harness_id"] + if harnessID == "" { + harnessID = res.Name + } + return fmt.Sprintf("aws bedrock-agentcore-control delete-harness --harness-id %s --region %s", harnessID, region) + case "apprunner:service": + serviceArn := res.Metadata["service_arn"] + if serviceArn == "" { + serviceArn = res.ARN + } + if serviceArn == "" { + serviceArn = res.Name + } + return fmt.Sprintf("aws apprunner delete-service --service-arn %s --region %s", serviceArn, region) + case "braket:job": + jobArn := res.ARN + if jobArn == "" { + jobArn = res.Name + } + return fmt.Sprintf("aws braket cancel-job --job-arn %s --region %s", jobArn, region) + case "cloudformation:stack": + stackName := res.Metadata["stack_name"] + if stackName == "" { + stackName = res.Name + } + return fmt.Sprintf("aws cloudformation delete-stack --stack-name %s --region %s", stackName, region) + case "cloudformation:stack-update": + stackName := res.Metadata["stack_name"] + if stackName == "" { + stackName = res.Name + } + changeSetName := "pathrunner-revert-" + stackName + return fmt.Sprintf("# Revert the stack to its original template via a change set\n"+ + "aws cloudformation create-change-set --stack-name %s --change-set-name %s --change-set-type UPDATE --template-body '' --region %s\n"+ + "aws cloudformation execute-change-set --change-set-name %s --stack-name %s --region %s", + stackName, changeSetName, region, changeSetName, stackName, region) + case "cloudformation:stackset": + stackSetName := res.Metadata["stackset_name"] + if stackSetName == "" { + stackSetName = res.Name + } + accountID := res.Metadata["account_id"] + targetRegion := res.Metadata["target_region"] + if targetRegion == "" { + targetRegion = region + } + return fmt.Sprintf("# Delete stack instances first, then the stack set\n"+ + "aws cloudformation delete-stack-instances --stack-set-name %s --accounts %s --regions %s --no-retain-stacks --region %s\n"+ + "# Wait for the operation to complete, then:\n"+ + "aws cloudformation delete-stack-set --stack-set-name %s --region %s", + stackSetName, accountID, targetRegion, region, stackSetName, region) + case "cloudformation:stackset-update": + stackSetName := res.Metadata["stackset_name"] + if stackSetName == "" { + stackSetName = res.Name + } + return fmt.Sprintf("# Revert the stack set to its original template\n"+ + "aws cloudformation update-stack-set --stack-set-name %s --template-body '' --region %s", + stackSetName, region) + case "codebuild:project": + projectName := res.Metadata["project_name"] + if projectName == "" { + projectName = res.Name + } + return fmt.Sprintf("aws codebuild delete-project --name %s --region %s", projectName, region) + case "cognito-identity:identity-pool-roles": + identityPoolID := res.Metadata["identity_pool_id"] + if identityPoolID == "" { + identityPoolID = res.Name + } + return fmt.Sprintf("# Remove the malicious unauthenticated role binding from the identity pool\n"+ + "# First check the current roles:\n"+ + "aws cognito-identity get-identity-pool-roles --identity-pool-id %s --region %s\n"+ + "# Then restore or clear the unauthenticated role:\n"+ + "aws cognito-identity set-identity-pool-roles --identity-pool-id %s --roles '{}' --region %s", + identityPoolID, region, identityPoolID, region) + case "ec2:launch-template-version": + templateID := res.Metadata["template_id"] + if templateID == "" { + templateID = res.Name + } + versionNumber := res.Metadata["version_number"] + if versionNumber == "" { + versionNumber = "" + } + return fmt.Sprintf("aws ec2 delete-launch-template-versions --launch-template-id %s --versions %s --region %s", templateID, versionNumber, region) + case "ec2:launch-template-default": + templateID := res.Metadata["template_id"] + if templateID == "" { + templateID = res.Name + } + originalVersion := res.Metadata["original_version"] + if originalVersion == "" { + originalVersion = "" + } + return fmt.Sprintf("aws ec2 modify-launch-template --launch-template-id %s --default-version %s --region %s", templateID, originalVersion, region) + case "ec2:userdata": + instanceID := res.Metadata["instance_id"] + if instanceID == "" { + instanceID = res.Name + } + originalUserData := res.Metadata["original_userdata"] + if originalUserData == "" { + originalUserData = "" + } + return fmt.Sprintf("# Stop instance, restore original user-data, then restart\n"+ + "aws ec2 stop-instances --instance-ids %s --region %s\n"+ + "# Wait for stopped state:\n"+ + "aws ec2 wait instance-stopped --instance-ids %s --region %s\n"+ + "aws ec2 modify-instance-attribute --instance-id %s --user-data Value=%s --region %s\n"+ + "aws ec2 start-instances --instance-ids %s --region %s", + instanceID, region, instanceID, region, instanceID, originalUserData, region, instanceID, region) + case "ecs:task-definition": + taskDefArn := res.ARN + if taskDefArn == "" { + revision := res.Metadata["revision"] + if revision != "" { + taskDefArn = res.Name + ":" + revision + } else { + taskDefArn = res.Name + } + } + return fmt.Sprintf("aws ecs deregister-task-definition --task-definition %s --region %s\naws ecs delete-task-definitions --task-definitions %s --region %s", + taskDefArn, region, taskDefArn, region) + case "ecs:task": + cluster := res.Metadata["cluster"] + taskArn := res.ARN + if taskArn == "" { + taskArn = res.Name + } + return fmt.Sprintf("aws ecs stop-task --cluster %s --task %s --region %s", cluster, taskArn, region) + case "emr:cluster": + clusterID := res.Metadata["cluster_id"] + if clusterID == "" { + clusterID = res.Name + } + return fmt.Sprintf("aws emr terminate-clusters --cluster-ids %s --region %s", clusterID, region) + case "emrserverless:application": + applicationID := res.Metadata["application_id"] + if applicationID == "" { + applicationID = res.Name + } + return fmt.Sprintf("aws emr-serverless delete-application --application-id %s --region %s", applicationID, region) + case "gamelift:build": + buildID := res.Metadata["build_id"] + if buildID == "" { + buildID = res.ARN + } + if buildID == "" { + buildID = res.Name + } + return fmt.Sprintf("aws gamelift delete-build --build-id %s --region %s", buildID, region) + case "gamelift:fleet": + fleetID := res.Metadata["fleet_id"] + if fleetID == "" { + fleetID = res.ARN + } + if fleetID == "" { + fleetID = res.Name + } + return fmt.Sprintf("aws gamelift delete-fleet --fleet-id %s --region %s", fleetID, region) + case "omics:workflow": + workflowID := res.Metadata["workflow_id"] + if workflowID == "" { + workflowID = res.Name + } + return fmt.Sprintf("aws omics delete-workflow --id %s --region %s", workflowID, region) + case "omics:run": + runID := res.Metadata["run_id"] + if runID == "" { + runID = res.Name + } + return fmt.Sprintf("aws omics cancel-run --id %s --region %s 2>/dev/null || true\naws omics delete-run --id %s --region %s", runID, region, runID, region) + case "ssm:automation-document": + documentName := res.Metadata["document_name"] + if documentName == "" { + documentName = res.Name + } + return fmt.Sprintf("aws ssm delete-document --name %s --region %s", documentName, region) + case "lambda:function-code": + functionName := res.Metadata["function_name"] + if functionName == "" { + functionName = res.Name + } + return fmt.Sprintf( + "# Lambda function '%s' code was modified and not restored. Restore via Terraform or:\n"+ + "# aws lambda update-function-code --function-name %s --zip-file fileb:// --region %s", + functionName, functionName, region) + case "glue:job-update": + jobName := res.Metadata["job_name"] + if jobName == "" { + jobName = res.Name + } + originalRoleArn := res.Metadata["original_role_arn"] + if originalRoleArn == "" { + originalRoleArn = "" + } + originalScript := res.Metadata["original_script"] + if originalScript == "" { + originalScript = "" + } + return fmt.Sprintf( + "aws glue update-job --job-name %s --job-update '{\"Role\":\"%s\",\"Command\":{\"Name\":\"pythonshell\",\"ScriptLocation\":\"%s\",\"PythonVersion\":\"3.9\"}}' --region %s", + jobName, originalRoleArn, originalScript, region) + case "apprunner:service-update": + serviceArn := res.Metadata["service_arn"] + if serviceArn == "" { + serviceArn = res.ARN + } + if serviceArn == "" { + serviceArn = res.Name + } + serviceName := res.Metadata["service_name"] + if serviceName == "" { + serviceName = res.Name + } + return fmt.Sprintf( + "# App Runner service '%s' was updated with exploit config and not restored.\n"+ + "# Restore the original image/StartCommand via the console or:\n"+ + "aws apprunner update-service --service-arn %s --source-configuration '' --region %s", + serviceName, serviceArn, region) + case "iam:login-profile-update": + username := res.Metadata["username"] + if username == "" { + username = res.Name + } + return fmt.Sprintf( + "# IAM user '%s' login profile password was changed. The original password cannot be recovered.\n"+ + "# Reset it to a new known value or have the user change it:\n"+ + "aws iam update-login-profile --user-name %s --password '' --no-password-reset-required", + username, username) + default: + return fmt.Sprintf("# %s: %s (manual cleanup required)", res.Type, res.Name) + } +} diff --git a/pkg/report/data.go b/pkg/report/data.go new file mode 100644 index 0000000..2604664 --- /dev/null +++ b/pkg/report/data.go @@ -0,0 +1,41 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +// Package report renders workspace reports in different file formats (HTML, Markdown). +package report + +import "time" + +// ReportData holds all information needed to render a workspace report in any format. +type ReportData struct { + WorkspaceName string + GeneratedAt time.Time + ModuleFilter string // empty when no filter is applied + Created []Resource + Modified []Resource + Events []Event +} + +// Resource represents a created or modified AWS resource tracked during exploitation. +type Resource struct { + Type string + Name string + ARN string + Region string + ModuleID string + CleanupMethod string + Created string + Metadata map[string]string +} + +// Event represents a recorded CloudTrail API call for blue team detection reference. +type Event struct { + Timestamp string + ModuleID string + Service string + Operation string + Region string + Principal string + Description string +} diff --git a/pkg/report/html.go b/pkg/report/html.go new file mode 100644 index 0000000..ab2f8b0 --- /dev/null +++ b/pkg/report/html.go @@ -0,0 +1,488 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +package report + +import ( + "bytes" + "html/template" +) + +// RenderHTML produces a self-contained HTML workspace report with inline CSS and JS. +// All content is safely escaped via html/template to prevent XSS. +func RenderHTML(data ReportData) (string, error) { + // Pre-compute per-resource display values to keep the template simple. + type resourceWithCmd struct { + Resource + CleanupCmd string + DisplayName string // principal "name (type)" for modified resources, or just Name + PolicyARN string // for iam:attached-policy and similar + } + + enrichResource := func(res Resource) resourceWithCmd { + displayName := res.Name + if pn := res.Metadata["principal_name"]; pn != "" { + displayName = pn + " (" + res.Metadata["principal_type"] + ")" + } + return resourceWithCmd{ + Resource: res, + CleanupCmd: CleanupCommand(res), + DisplayName: displayName, + PolicyARN: res.Metadata["policy_arn"], + } + } + + createdWithCmds := make([]resourceWithCmd, len(data.Created)) + for i, res := range data.Created { + createdWithCmds[i] = enrichResource(res) + } + modifiedWithCmds := make([]resourceWithCmd, len(data.Modified)) + for i, res := range data.Modified { + modifiedWithCmds[i] = enrichResource(res) + } + + tmplData := struct { + ReportData + CreatedWithCmds []resourceWithCmd + ModifiedWithCmds []resourceWithCmd + GeneratedAtStr string + TotalResources int + }{ + ReportData: data, + CreatedWithCmds: createdWithCmds, + ModifiedWithCmds: modifiedWithCmds, + GeneratedAtStr: data.GeneratedAt.UTC().Format("2006-01-02 15:04:05 UTC"), + TotalResources: len(data.Created) + len(data.Modified), + } + + t, err := template.New("report").Parse(htmlTemplate) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := t.Execute(&buf, tmplData); err != nil { + return "", err + } + return buf.String(), nil +} + +const htmlTemplate = ` + + + + + + + +Pathrunner Report — {{.WorkspaceName}} + + + +
+ +
+

Pathrunner Workspace Report

+
+ Workspace: {{.WorkspaceName}} + Generated: {{.GeneratedAtStr}} + {{if .ModuleFilter}}Module filter: {{.ModuleFilter}}{{end}} +
+
+ +
+
+
Created Resources
+
{{len .Created}}
+
+
+
Modified Resources
+
{{len .Modified}}
+
+
+
CloudTrail Events
+
{{len .Events}}
+
+
+ +{{if .CreatedWithCmds}} +
+

+ Created Resources + delete to clean up +

+
+ {{range .CreatedWithCmds}} +
+
{{.Type}}
+
{{.DisplayName}}
+
+ {{if .ARN}}ARN{{.ARN}}{{end}} + {{if .Region}}Region{{.Region}}{{end}} + {{if .ModuleID}}Module{{.ModuleID}}{{end}} + Cleanup{{.CleanupMethod}} + {{if .Created}}Created{{.Created}}{{end}} +
+ {{if .CleanupCmd}}
Manual cleanup command
{{.CleanupCmd}}
{{end}} +
+ {{end}} +
+
+{{end}} + +{{if .ModifiedWithCmds}} +
+

+ Modified Resources + revert to clean up +

+
+ {{range .ModifiedWithCmds}} +
+
{{.Type}}
+
{{.DisplayName}}
+
+ {{if .PolicyARN}}Policy{{.PolicyARN}}{{end}} + {{if .Region}}Region{{.Region}}{{end}} + {{if .ModuleID}}Module{{.ModuleID}}{{end}} + Reversal{{.CleanupMethod}} +
+ {{if .CleanupCmd}}
Manual cleanup command
{{.CleanupCmd}}
{{end}} +
+ {{end}} +
+
+{{end}} + + +{{if .Events}} +
+

+ CloudTrail Events + blue team detection reference +

+
+
+ +
+
+ + + + + + + + + + + + + {{range .Events}} + + + + + + + + + {{end}} + +
Timestamp ↕Module ↕Service ↕Operation ↕Principal ↕Description ↕
{{.Timestamp}}{{.ModuleID}}{{.Service}}{{.Operation}}{{.Principal}}{{.Description}}
+
+
{{len .Events}} events
+
+
+{{end}} + +
+ + + + +` diff --git a/pkg/report/markdown.go b/pkg/report/markdown.go new file mode 100644 index 0000000..cbafb6e --- /dev/null +++ b/pkg/report/markdown.go @@ -0,0 +1,125 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +package report + +import ( + "fmt" + "strings" +) + +// RenderMarkdown produces a Markdown-formatted workspace report. +func RenderMarkdown(data ReportData) string { + var b strings.Builder + + // YAML frontmatter for tools that parse it (Obsidian, Jekyll, etc.) + b.WriteString("---\n") + fmt.Fprintf(&b, "workspace: %q\n", data.WorkspaceName) + fmt.Fprintf(&b, "generated: %q\n", data.GeneratedAt.UTC().Format("2006-01-02T15:04:05Z")) + if data.ModuleFilter != "" { + fmt.Fprintf(&b, "module_filter: %q\n", data.ModuleFilter) + } + fmt.Fprintf(&b, "created_resources: %d\n", len(data.Created)) + fmt.Fprintf(&b, "modified_resources: %d\n", len(data.Modified)) + fmt.Fprintf(&b, "cloudtrail_events: %d\n", len(data.Events)) + b.WriteString("---\n\n") + + b.WriteString("# Pathrunner Workspace Report\n\n") + + // Summary table + b.WriteString("## Summary\n\n") + b.WriteString("| Field | Value |\n") + b.WriteString("|-------|-------|\n") + fmt.Fprintf(&b, "| Workspace | %s |\n", data.WorkspaceName) + fmt.Fprintf(&b, "| Generated | %s |\n", data.GeneratedAt.UTC().Format("2006-01-02 15:04:05 UTC")) + if data.ModuleFilter != "" { + fmt.Fprintf(&b, "| Module filter | %s |\n", data.ModuleFilter) + } + fmt.Fprintf(&b, "| Created resources | %d |\n", len(data.Created)) + fmt.Fprintf(&b, "| Modified resources | %d |\n", len(data.Modified)) + fmt.Fprintf(&b, "| CloudTrail events | %d |\n", len(data.Events)) + b.WriteString("\n") + + if len(data.Created) > 0 { + b.WriteString("## Created Resources\n\n") + b.WriteString("These resources were created during exploitation and must be **deleted** to clean up.\n\n") + for _, res := range data.Created { + fmt.Fprintf(&b, "### `%s` — %s\n\n", res.Type, res.Name) + if res.ARN != "" { + fmt.Fprintf(&b, "- **ARN:** `%s`\n", res.ARN) + } + if res.Region != "" { + fmt.Fprintf(&b, "- **Region:** `%s`\n", res.Region) + } + if res.ModuleID != "" { + fmt.Fprintf(&b, "- **Module:** %s\n", res.ModuleID) + } + fmt.Fprintf(&b, "- **Cleanup:** %s\n", res.CleanupMethod) + if res.Created != "" { + fmt.Fprintf(&b, "- **Created:** %s\n", res.Created) + } + if cmd := CleanupCommand(res); cmd != "" { + b.WriteString("\n**Manual cleanup command:**\n\n```bash\n") + b.WriteString(cmd) + b.WriteString("\n```\n") + } + b.WriteString("\n") + } + } + + if len(data.Modified) > 0 { + b.WriteString("## Modified Resources\n\n") + b.WriteString("These existing resources were modified and must be **reverted** to clean up.\n\n") + for _, res := range data.Modified { + principalName := res.Metadata["principal_name"] + principalType := res.Metadata["principal_type"] + displayName := res.Name + if principalName != "" { + displayName = principalName + " (" + principalType + ")" + } + fmt.Fprintf(&b, "### `%s` — %s\n\n", res.Type, displayName) + if policyArn := res.Metadata["policy_arn"]; policyArn != "" { + fmt.Fprintf(&b, "- **Policy:** `%s`\n", policyArn) + } + if res.Region != "" { + fmt.Fprintf(&b, "- **Region:** `%s`\n", res.Region) + } + if res.ModuleID != "" { + fmt.Fprintf(&b, "- **Module:** %s\n", res.ModuleID) + } + fmt.Fprintf(&b, "- **Reversal:** %s\n", res.CleanupMethod) + if cmd := CleanupCommand(res); cmd != "" { + b.WriteString("\n**Manual cleanup command:**\n\n```bash\n") + b.WriteString(cmd) + b.WriteString("\n```\n") + } + b.WriteString("\n") + } + } + + if len(data.Events) > 0 { + b.WriteString("## CloudTrail Events\n\n") + b.WriteString("API calls recorded during exploitation for blue team detection reference.\n\n") + b.WriteString("| Timestamp | Module | Service | Operation | Principal | Description |\n") + b.WriteString("|-----------|--------|---------|-----------|-----------|-------------|\n") + for _, ev := range data.Events { + fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n", + mdCell(ev.Timestamp), + mdCell(ev.ModuleID), + mdCell(ev.Service), + mdCell(ev.Operation), + mdCell(ev.Principal), + mdCell(ev.Description), + ) + } + b.WriteString("\n") + } + + return b.String() +} + +// mdCell escapes pipe characters so they don't break Markdown table rendering. +func mdCell(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} diff --git a/testdata/module-status.json b/testdata/module-status.json index ba1ff4e..5c18599 100644 --- a/testdata/module-status.json +++ b/testdata/module-status.json @@ -969,7 +969,7 @@ }, "lambda-001": { "status": "tested", - "last_tested": "2026-07-23", + "last_tested": "2026-09-23", "tested_against": "lambda-001-to-admin", "notes": "", "payload_results": [ diff --git a/tests/integration/cleanup_integration_test.go b/tests/integration/cleanup_integration_test.go index 83a3c64..609d6be 100644 --- a/tests/integration/cleanup_integration_test.go +++ b/tests/integration/cleanup_integration_test.go @@ -311,3 +311,54 @@ func TestReportWorkspacesAlias(t *testing.T) { t.Errorf("Expected no error via alias, got: %v", err) } } + +// TestCleanupLogsCloudTrailEvents verifies that workspace cleanup records CloudTrail events +// for each resource that is attempted (failed deletions against fake AWS are still logged +// for the not-found path when the resource is removed from tracking). +func TestCleanupLogsCloudTrailEvents(t *testing.T) { + r, sm, im, cleanup := setupTest(t) + defer cleanup() + + identity := &modules.Identity{ + Name: "cleanup-test", + Type: "keys", + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + Region: "us-east-1", + } + im.SetCurrent(identity) + + sm.TrackResource(modules.CreatedResource{ + Type: "lambda:function", + Name: "cleanup-log-test-fn", + Region: "us-east-1", + CleanupMethod: "delete the Lambda function", + ModuleID: "lambda-001", + }) + + // Cleanup will fail with a "not found" or permission error against fake AWS. + // Because it's a not found error the resource gets removed from tracking and a + // CloudTrail event should be logged. In CI without real AWS creds it may also + // fail with a credential error — either way we just check that the command runs. + _ = r.ExecuteCommand("workspace cleanup --all --yes") + + // The session manager should now have CloudTrail events (from either the deletion + // attempt or the not-found removal path). + events := sm.GetCloudTrailEvents() + + // If cleanup produced events, verify they contain useful info. + if len(events) > 0 { + found := false + for _, ev := range events { + if strings.Contains(ev.Service, "lambda") || strings.Contains(ev.Operation, "Delete") { + found = true + break + } + } + if !found { + t.Errorf("expected at least one lambda/delete event in cleanup log, got: %+v", events) + } + } + // If no events were logged (e.g. permission error before not-found), that's also + // acceptable in this unit environment — the test still validates the command runs cleanly. +} diff --git a/tests/integration/cloudtrail_report_integration_test.go b/tests/integration/cloudtrail_report_integration_test.go new file mode 100644 index 0000000..9e17261 --- /dev/null +++ b/tests/integration/cloudtrail_report_integration_test.go @@ -0,0 +1,171 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +package integration + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/DataDog/pathrunner/pkg/modules" +) + +// captureOutput redirects stdout to capture printed output during a function call. +func captureOutput(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + _ = w.Close() + os.Stdout = old + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +// TestWorkspaceReportShowsCloudTrailSection verifies that workspace report +// includes the CloudTrail events section when events have been logged. +func TestWorkspaceReportShowsCloudTrailSection(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + // Log CloudTrail events into the session manager + sm.LogCloudTrailEvent("iam", "CreateAccessKey", "us-east-1", + "Created access key for IAM user alice", map[string]string{"target_user": "alice"}) + sm.LogCloudTrailEvent("sts", "GetCallerIdentity", "us-east-1", + "Verified new credentials work", nil) + + // Also add a resource so the report does not early-exit + sm.TrackResource(modules.CreatedResource{ + Type: "iam:access-key", + Name: "AKIA1234567890", + Region: "us-east-1", + Created: time.Now(), + CleanupMethod: "iam:DeleteAccessKey", + ModuleID: "iam-002", + }) + + output := captureOutput(func() { + _ = r.ExecuteCommand("workspace report") + }) + + if !strings.Contains(output, "CLOUDTRAIL EVENTS") { + t.Errorf("Expected 'CLOUDTRAIL EVENTS' section in report output, got:\n%s", output) + } + if !strings.Contains(output, "CreateAccessKey") { + t.Errorf("Expected 'CreateAccessKey' in report output, got:\n%s", output) + } + if !strings.Contains(output, "GetCallerIdentity") { + t.Errorf("Expected 'GetCallerIdentity' in report output, got:\n%s", output) + } + if !strings.Contains(output, "blue team") { + t.Errorf("Expected 'blue team' label in CloudTrail section header, got:\n%s", output) + } +} + +// TestWorkspaceReportCloudTrailOnlyNoResources verifies that the report shows +// CloudTrail events even when there are no created resources. +func TestWorkspaceReportCloudTrailOnlyNoResources(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + sm.LogCloudTrailEvent("sts", "AssumeRole", "us-east-1", + "Assumed target role arn:aws:iam::123456789012:role/Target", + map[string]string{"role_arn": "arn:aws:iam::123456789012:role/Target"}) + + output := captureOutput(func() { + _ = r.ExecuteCommand("workspace report") + }) + + if strings.Contains(output, "Nothing to report") { + t.Error("Expected report to show content when CloudTrail events exist, not 'Nothing to report'") + } + if !strings.Contains(output, "CLOUDTRAIL EVENTS") { + t.Errorf("Expected 'CLOUDTRAIL EVENTS' section, got:\n%s", output) + } + if !strings.Contains(output, "AssumeRole") { + t.Errorf("Expected 'AssumeRole' in output, got:\n%s", output) + } +} + +// TestWorkspaceReportNothingWhenEmpty verifies the early-exit message appears +// when there are no resources AND no CloudTrail events. +func TestWorkspaceReportNothingWhenEmpty(t *testing.T) { + r, _, _, cleanup := setupTest(t) + defer cleanup() + + output := captureOutput(func() { + err := r.ExecuteCommand("workspace report") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) + + if !strings.Contains(output, "Nothing to report") { + t.Errorf("Expected 'Nothing to report' when workspace is empty, got:\n%s", output) + } +} + +// TestWorkspaceReportModuleFilterIncludesEvents verifies that --module filter +// applies to CloudTrail events as well as resources. +func TestWorkspaceReportModuleFilterIncludesEvents(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + // Log event for iam-002 by setting CurrentModule before logging + session := sm.GetCurrentSession() + session.CurrentModule = "iam-002" + sm.LogCloudTrailEvent("iam", "CreateAccessKey", "us-east-1", + "Created access key for user alice", nil) + + // Log event for sts-001 + session.CurrentModule = "sts-001" + sm.LogCloudTrailEvent("sts", "AssumeRole", "us-east-1", + "Assumed target role", nil) + + sm.TrackResource(modules.CreatedResource{ + Type: "iam:access-key", + Name: "AKIA1234", + Region: "us-east-1", + Created: time.Now(), + CleanupMethod: "iam:DeleteAccessKey", + ModuleID: "iam-002", + }) + + output := captureOutput(func() { + _ = r.ExecuteCommand("workspace report --module iam-002") + }) + + if !strings.Contains(output, "CreateAccessKey") { + t.Errorf("Expected iam-002 event 'CreateAccessKey' in filtered report, got:\n%s", output) + } + if strings.Contains(output, "AssumeRole") { + t.Errorf("Expected sts-001 event 'AssumeRole' to be filtered out, but it appeared:\n%s", output) + } +} + +// TestWorkspaceReportDescriptionAppears verifies that the description field +// is rendered in the CloudTrail events table. +func TestWorkspaceReportDescriptionAppears(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + sm.LogCloudTrailEvent("lambda", "CreateFunction", "us-west-2", + "Created payload Lambda function for code execution", nil) + + output := captureOutput(func() { + _ = r.ExecuteCommand("workspace report") + }) + + if !strings.Contains(output, "Created payload Lambda function") { + t.Errorf("Expected description in report output, got:\n%s", output) + } +} diff --git a/tests/integration/report_export_integration_test.go b/tests/integration/report_export_integration_test.go new file mode 100644 index 0000000..6560b15 --- /dev/null +++ b/tests/integration/report_export_integration_test.go @@ -0,0 +1,194 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DataDog/pathrunner/pkg/modules" +) + +// addSampleData seeds a session with one created resource, one modified resource, and one CloudTrail event. +func addSampleData(sm interface { + TrackResource(modules.CreatedResource) + LogCloudTrailEvent(service, operation, region, description string, metadata map[string]string) +}) { + sm.TrackResource(modules.CreatedResource{ + Type: "lambda:function", + Name: "pathrunner-export-test-fn", + ARN: "arn:aws:lambda:us-east-1:123456789012:function:pathrunner-export-test-fn", + Region: "us-east-1", + CleanupMethod: "delete the Lambda function", + ModuleID: "lambda-001", + Created: time.Now(), + }) + sm.LogCloudTrailEvent("lambda", "CreateFunction", "us-east-1", + "Created exploit Lambda function", map[string]string{"function_name": "pathrunner-export-test-fn"}) +} + +// TestWorkspaceReport_HTMLExport verifies that --output writes a valid HTML report. +func TestWorkspaceReport_HTMLExport(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + addSampleData(sm) + + outputPath := filepath.Join(t.TempDir(), "report.html") + + output := captureOutput(func() { + _ = r.ExecuteCommand("workspace report --output " + outputPath) + }) + + if !strings.Contains(output, "Report written to") { + t.Errorf("expected confirmation message, got: %q", output) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("HTML report not written: %v", err) + } + + html := string(data) + if !strings.Contains(html, "") { + t.Error("expected HTML doctype") + } + if !strings.Contains(html, "pathrunner-export-test-fn") { + t.Error("expected resource name in HTML output") + } + if !strings.Contains(html, "CreateFunction") { + t.Error("expected CloudTrail event in HTML output") + } + if !strings.Contains(html, "aws lambda delete-function") { + t.Error("expected cleanup command in HTML output") + } +} + +// TestWorkspaceReport_MarkdownExport verifies that --output writes a valid Markdown report. +func TestWorkspaceReport_MarkdownExport(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + addSampleData(sm) + + outputPath := filepath.Join(t.TempDir(), "report.md") + + output := captureOutput(func() { + _ = r.ExecuteCommand("workspace report --output " + outputPath) + }) + + if !strings.Contains(output, "Report written to") { + t.Errorf("expected confirmation message, got: %q", output) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("Markdown report not written: %v", err) + } + + md := string(data) + if !strings.Contains(md, "# Pathrunner Workspace Report") { + t.Error("expected Markdown h1 heading") + } + if !strings.Contains(md, "pathrunner-export-test-fn") { + t.Error("expected resource name in Markdown output") + } + if !strings.Contains(md, "CreateFunction") { + t.Error("expected CloudTrail event in Markdown output") + } + if !strings.Contains(md, "aws lambda delete-function") { + t.Error("expected cleanup command in Markdown output") + } +} + +// TestWorkspaceReport_HTMLExportWithModuleFilter verifies that --module filtering works with --output. +func TestWorkspaceReport_HTMLExportWithModuleFilter(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + addSampleData(sm) + // Add a second resource with a different module ID + sm.TrackResource(modules.CreatedResource{ + Type: "iam:role", + Name: "other-module-role", + Region: "us-east-1", + CleanupMethod: "delete the IAM role", + ModuleID: "iam-001", + Created: time.Now(), + }) + + outputPath := filepath.Join(t.TempDir(), "filtered.html") + + captureOutput(func() { + _ = r.ExecuteCommand("workspace report --module lambda-001 --output " + outputPath) + }) + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("HTML report not written: %v", err) + } + + html := string(data) + // The lambda resource should be in the report + if !strings.Contains(html, "pathrunner-export-test-fn") { + t.Error("expected lambda-001 resource in filtered output") + } + // The iam-001 resource should NOT be in the report + if strings.Contains(html, "other-module-role") { + t.Error("expected iam-001 resource to be excluded by module filter") + } +} + +// TestWorkspaceReport_UnknownExtension verifies that unsupported extensions return an error. +func TestWorkspaceReport_UnknownExtension(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + addSampleData(sm) + + outputPath := filepath.Join(t.TempDir(), "report.csv") + + output := captureOutput(func() { + err := r.ExecuteCommand("workspace report --output " + outputPath) + if err == nil { + t.Error("expected error for unsupported extension, got nil") + } + }) + + // File should not have been created + if _, err := os.Stat(outputPath); !os.IsNotExist(err) { + t.Error("expected output file to not exist for unsupported format") + } + // Should not print a success message + if strings.Contains(output, "Report written to") { + t.Errorf("unexpected success message for unsupported format: %q", output) + } +} + +// TestWorkspaceReport_TerminalRenderingUnchanged verifies that plain `workspace report` still +// renders to the terminal when no --output flag is provided. +func TestWorkspaceReport_TerminalRenderingUnchanged(t *testing.T) { + r, sm, _, cleanup := setupTest(t) + defer cleanup() + + addSampleData(sm) + + output := captureOutput(func() { + _ = r.ExecuteCommand("workspace report") + }) + + if !strings.Contains(output, "CREATED RESOURCES") { + t.Error("expected terminal report to still contain CREATED RESOURCES section") + } + if !strings.Contains(output, "CLOUDTRAIL EVENTS") { + t.Error("expected terminal report to still contain CLOUDTRAIL EVENTS section") + } + if strings.Contains(output, "Report written to") { + t.Error("unexpected file-write message in terminal mode") + } +} diff --git a/tests/unit/cloudtrail_logger_test.go b/tests/unit/cloudtrail_logger_test.go new file mode 100644 index 0000000..b8ded01 --- /dev/null +++ b/tests/unit/cloudtrail_logger_test.go @@ -0,0 +1,165 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +package unit + +import ( + "os" + "testing" + + "github.com/DataDog/pathrunner/pkg/core" +) + +func TestLogCloudTrailEvent(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + defer func() { _ = os.Setenv("HOME", originalHome) }() + + sm := core.NewSessionManager() + + sm.LogCloudTrailEvent("iam", "CreateAccessKey", "us-east-1", + "Created access key for IAM user alice", + map[string]string{"target_user": "alice"}) + + events := sm.GetCloudTrailEvents() + if len(events) != 1 { + t.Fatalf("Expected 1 CloudTrail event, got %d", len(events)) + } + + ev := events[0] + if ev.Service != "iam" { + t.Errorf("Expected service 'iam', got '%s'", ev.Service) + } + if ev.Operation != "CreateAccessKey" { + t.Errorf("Expected operation 'CreateAccessKey', got '%s'", ev.Operation) + } + if ev.Region != "us-east-1" { + t.Errorf("Expected region 'us-east-1', got '%s'", ev.Region) + } + if ev.Description != "Created access key for IAM user alice" { + t.Errorf("Unexpected description: %s", ev.Description) + } + if ev.Metadata["target_user"] != "alice" { + t.Errorf("Expected metadata target_user 'alice', got '%s'", ev.Metadata["target_user"]) + } +} + +func TestLogCloudTrailEventSetsModuleID(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + defer func() { _ = os.Setenv("HOME", originalHome) }() + + sm := core.NewSessionManager() + + // Simulate the session having a current module set (as the REPL does before Execute()) + session := sm.GetCurrentSession() + session.CurrentModule = "iam-002" + + sm.LogCloudTrailEvent("iam", "CreateAccessKey", "us-east-1", + "Created access key", nil) + + events := sm.GetCloudTrailEvents() + if len(events) != 1 { + t.Fatalf("Expected 1 event, got %d", len(events)) + } + if events[0].ModuleID != "iam-002" { + t.Errorf("Expected module ID 'iam-002', got '%s'", events[0].ModuleID) + } +} + +func TestLogMultipleCloudTrailEvents(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + defer func() { _ = os.Setenv("HOME", originalHome) }() + + sm := core.NewSessionManager() + + sm.LogCloudTrailEvent("sts", "GetCallerIdentity", "us-east-1", "Verified caller", nil) + sm.LogCloudTrailEvent("iam", "AttachRolePolicy", "us-east-1", "Attached policy to role", nil) + sm.LogCloudTrailEvent("sts", "AssumeRole", "us-east-1", "Assumed target role", nil) + + events := sm.GetCloudTrailEvents() + if len(events) != 3 { + t.Fatalf("Expected 3 events, got %d", len(events)) + } + if events[0].Operation != "GetCallerIdentity" { + t.Errorf("Expected first event GetCallerIdentity, got %s", events[0].Operation) + } + if events[2].Operation != "AssumeRole" { + t.Errorf("Expected third event AssumeRole, got %s", events[2].Operation) + } +} + +func TestGetCloudTrailEventsEmptyByDefault(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + defer func() { _ = os.Setenv("HOME", originalHome) }() + + sm := core.NewSessionManager() + events := sm.GetCloudTrailEvents() + + // Should return empty slice (or nil), not panic + if len(events) != 0 { + t.Errorf("Expected no events initially, got %d", len(events)) + } +} + +func TestCloudTrailEventTimestamp(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + defer func() { _ = os.Setenv("HOME", originalHome) }() + + sm := core.NewSessionManager() + sm.LogCloudTrailEvent("iam", "CreateUser", "us-east-1", "Created IAM user", nil) + + events := sm.GetCloudTrailEvents() + if len(events) != 1 { + t.Fatalf("Expected 1 event, got %d", len(events)) + } + if events[0].Timestamp.IsZero() { + t.Error("Expected non-zero timestamp on CloudTrail event") + } +} + +func TestCloudTrailEventsWorkspaceIsolation(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + defer func() { _ = os.Setenv("HOME", originalHome) }() + + sm := core.NewSessionManager() + + // Log event in workspace A (default) + sm.LogCloudTrailEvent("iam", "CreateAccessKey", "us-east-1", "Event in default", nil) + + // Switch to workspace B + _ = sm.CreateSession("workspace-b") + _ = sm.SwitchSession("workspace-b") + + // Events in workspace B should be empty + eventsB := sm.GetCloudTrailEvents() + if len(eventsB) != 0 { + t.Errorf("Expected no events in workspace-b, got %d", len(eventsB)) + } + + // Log a different event in workspace B + sm.LogCloudTrailEvent("sts", "AssumeRole", "us-east-1", "Event in workspace-b", nil) + + // Switch back to default + _ = sm.SwitchSession("default") + + // Default workspace should still only have its original event + eventsDefault := sm.GetCloudTrailEvents() + if len(eventsDefault) != 1 { + t.Errorf("Expected 1 event in default workspace, got %d", len(eventsDefault)) + } + if eventsDefault[0].Operation != "CreateAccessKey" { + t.Errorf("Expected CreateAccessKey in default, got %s", eventsDefault[0].Operation) + } +} diff --git a/tests/unit/repl_test.go b/tests/unit/repl_test.go index 2fe98a8..8e80efa 100644 --- a/tests/unit/repl_test.go +++ b/tests/unit/repl_test.go @@ -126,6 +126,13 @@ func (m *MockSessionManager) GetCreatedResources() []repl.CreatedResource { func (m *MockSessionManager) TrackResource(resource modules.CreatedResource) { } +func (m *MockSessionManager) LogAWSCall(service, operation, region, description string, metadata map[string]string) { +} + +func (m *MockSessionManager) GetCloudTrailEvents() []repl.CloudTrailEvent { + return []repl.CloudTrailEvent{} +} + type MockSession struct{} func (m *MockSession) GetName() string { diff --git a/tests/unit/report_test.go b/tests/unit/report_test.go new file mode 100644 index 0000000..71f58fe --- /dev/null +++ b/tests/unit/report_test.go @@ -0,0 +1,634 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/) +// Copyright 2026 Datadog, Inc. + +package unit + +import ( + "strings" + "testing" + "time" + + "github.com/DataDog/pathrunner/pkg/report" +) + +func sampleReportData() report.ReportData { + return report.ReportData{ + WorkspaceName: "test-workspace", + GeneratedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), + Created: []report.Resource{ + { + Type: "lambda:function", + Name: "pathrunner-test-fn", + ARN: "arn:aws:lambda:us-east-1:123456789012:function:pathrunner-test-fn", + Region: "us-east-1", + ModuleID: "lambda-001", + CleanupMethod: "delete the Lambda function", + Created: "2026-01-15 10:00:00", + Metadata: map[string]string{}, + }, + }, + Modified: []report.Resource{ + { + Type: "iam:attached-policy", + Name: "test-user", + Region: "us-east-1", + ModuleID: "iam-001", + CleanupMethod: "detach the policy", + Metadata: map[string]string{ + "principal_type": "user", + "principal_name": "test-user", + "policy_arn": "arn:aws:iam::123456789012:policy/AdminPolicy", + }, + }, + }, + Events: []report.Event{ + { + Timestamp: "2026-01-15 10:00:01", + ModuleID: "lambda-001", + Service: "lambda", + Operation: "CreateFunction", + Region: "us-east-1", + Principal: "arn:aws:iam::123456789012:user/attacker", + Description: "created exploit Lambda function", + }, + }, + } +} + +// --- Markdown tests --- + +func TestRenderMarkdown_basicReport(t *testing.T) { + data := sampleReportData() + md := report.RenderMarkdown(data) + + checks := []string{ + "# Pathrunner Workspace Report", + "test-workspace", + "2026-01-15", + "## Summary", + "## Created Resources", + "pathrunner-test-fn", + "lambda:function", + "arn:aws:lambda:us-east-1:123456789012:function:pathrunner-test-fn", + // cleanup command now paired inline with the resource, not in a separate section + "aws lambda delete-function", + "## Modified Resources", + "iam:attached-policy", + "test-user (user)", + "arn:aws:iam::123456789012:policy/AdminPolicy", + "aws iam detach-user-policy", + "## CloudTrail Events", + "CreateFunction", + "| Timestamp | Module | Service | Operation | Principal | Description |", + } + + if strings.Contains(md, "## Manual Cleanup Commands") { + t.Error("standalone Manual Cleanup Commands section should no longer exist; commands are paired with resources") + } + + for _, want := range checks { + if !strings.Contains(md, want) { + t.Errorf("expected Markdown to contain %q", want) + } + } +} + +func TestRenderMarkdown_emptyEvents(t *testing.T) { + data := sampleReportData() + data.Events = nil + md := report.RenderMarkdown(data) + + if strings.Contains(md, "## CloudTrail Events") { + t.Error("CloudTrail section should be absent when there are no events") + } +} + +func TestRenderMarkdown_moduleFilter(t *testing.T) { + data := sampleReportData() + data.ModuleFilter = "lambda-001" + md := report.RenderMarkdown(data) + + if !strings.Contains(md, "module_filter") { + t.Error("expected frontmatter to include module_filter when set") + } + if !strings.Contains(md, "lambda-001") { + t.Error("expected module filter value to appear in Markdown") + } +} + +func TestRenderMarkdown_emptyCreated(t *testing.T) { + data := sampleReportData() + data.Created = nil + md := report.RenderMarkdown(data) + + if strings.Contains(md, "## Created Resources") { + t.Error("Created Resources section should be absent when no created resources") + } + // Modified section should still appear + if !strings.Contains(md, "## Modified Resources") { + t.Error("Modified Resources section should still appear") + } +} + +func TestRenderMarkdown_pipeEscaping(t *testing.T) { + data := sampleReportData() + // Pipe character in a description would break the Markdown table if unescaped. + data.Events[0].Description = "foo | bar | baz" + md := report.RenderMarkdown(data) + + if strings.Contains(md, "foo | bar | baz") { + t.Error("raw pipe in table cell must be escaped as \\|") + } + if !strings.Contains(md, "foo \\| bar \\| baz") { + t.Error("expected escaped pipe characters in CloudTrail table") + } +} + +// --- HTML tests --- + +func TestRenderHTML_basicReport(t *testing.T) { + data := sampleReportData() + html, err := report.RenderHTML(data) + if err != nil { + t.Fatalf("RenderHTML returned error: %v", err) + } + + checks := []string{ + "", + "test-workspace", + "pathrunner-test-fn", + "lambda:function", + "arn:aws:lambda:us-east-1:123456789012:function:pathrunner-test-fn", + "iam:attached-policy", + "CreateFunction", + "aws lambda delete-function", + } + + for _, want := range checks { + if !strings.Contains(html, want) { + t.Errorf("expected HTML to contain %q", want) + } + } +} + +func TestRenderHTML_escaping(t *testing.T) { + data := sampleReportData() + // Inject a script tag — html/template must escape it. + data.Created[0].Name = "" + data.Events[0].Description = "" + + html, err := report.RenderHTML(data) + if err != nil { + t.Fatalf("RenderHTML returned error: %v", err) + } + + // Raw tags must not appear in the output. + if strings.Contains(html, "