diff --git a/.gitignore b/.gitignore index 6fca79f..9dac55a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,8 @@ cloudfox *.json *.csv *.log -dist/ \ No newline at end of file +dist/ + +# graphvis files +*.gv +*.svg \ No newline at end of file diff --git a/README.md b/README.md index c644cf0..5c8baa6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ For the full documentation please refer to our [wiki](https://github.com/BishopF | - | - | | AWS | 34 | | Azure | 4 | -| GCP | Support Planned | +| GCP | 8 | | Kubernetes | Support Planned | @@ -111,6 +111,7 @@ Additional policy notes (as of 09/2022): | AWS | [access-keys](https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#access-keys) | Lists active access keys for all users. Useful for cross referencing a key you found with which in-scope account it belongs to. | | AWS | [api-gw](https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#api-gw) | Lists API gateway endpoints and gives you custom curl commands including API tokens if they are stored in metadata. | | AWS | [buckets](https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#filesystems) | Lists the buckets in the account and gives you handy commands for inspecting them further. | +| AWS | [cape](https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#cape) | Enumerates cross-account privilege escalation paths. Requires `pmapper` to be run first | | AWS | [cloudformation](https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#cloudformation) | Lists the cloudformation stacks in the account. Generates loot file with stack details, stack parameters, and stack output - look for secrets. | | AWS | [codebuild](https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#codebuild) | Enumerate CodeBuild projects | | AWS | [databases](https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#databases) | Enumerate RDS databases. Get a loot file with connection strings. | @@ -152,6 +153,21 @@ Additional policy notes (as of 09/2022): | Azure | [storage](https://github.com/BishopFox/cloudfox/wiki/Azure-Commands#storage) | The storage command is still under development. Currently it only displays limited data about the storage accounts | | Azure | [vms](https://github.com/BishopFox/cloudfox/wiki/Azure-Commands#vms) | Enumerates useful information for Compute instances in all available resource groups and subscriptions | + +# GCP Commands +| Provider | Command Name | Description +| - | - | - | +| GCP | [whoami](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#whoami) | Display the email address of the GCP authenticated user | +| GCP | [all-checks](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#all-checks) | Runs all available GCP commands | +| GCP | [artifact-registry](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#artifact-registry) | Display GCP artifact registry information | +| GCP | [bigquery](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#bigquery) | Display Bigquery datasets and tables information | +| GCP | [buckets](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#buckets) | Display GCP buckets information | +| GCP | [iam](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#iam) | Display GCP IAM information | +| GCP | [instances](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#instances) | Display GCP Compute Engine instances information | +| GCP | [secrets](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#secrets) | Display GCP secrets information | + + + # Authors * [Carlos Vendramini](https://github.com/carlosvendramini-bf) * [Seth Art (@sethsec](https://twitter.com/sethsec)) diff --git a/aws/cape-tui.go b/aws/cape-tui.go new file mode 100644 index 0000000..7b87aa8 --- /dev/null +++ b/aws/cape-tui.go @@ -0,0 +1,538 @@ +package aws + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return titleStyle.Copy().BorderStyle(b) + }() + magentaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("magenta")) +) + +type model struct { + preloadedData *AllAccountData + awsAccountsTable table.Model + awsAccountsViewport viewport.Model + + mainTable table.Model + mainTableViewport viewport.Model + mainTableData map[int][]table.Row + + detailsData map[int]string + detailsViewport viewport.Model // Use viewport for details view + + focusSelector int + defaultTableStyle table.Styles + + terminalWidth int + terminalHeight int // Add terminal height to manage viewport size + awsSelectedRow int + mainSelectedRow int // Track the currently selected row for detail view updates + keys keyMap + help help.Model + lastKey string + quitting bool +} + +type CapeJSON struct { + Account string `json:"account"` + Source string `json:"source"` + Summary string `json:"summary"` + Target string `json:"target"` + IsTargetAdmin string `json:"isTargetAdmin"` +} + +type PerAccountData struct { + FilePath string // Path to the JSON file + PrivescPaths []CapeJSON // All records contained in the file +} + +type AllAccountData struct { + Files map[string]*PerAccountData // Map of file paths to their records +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.terminalWidth = msg.Width + m.terminalHeight = msg.Height - 7 // Adjust as needed + + // Calculate heights based on the specified percentages + awsAccountsHeight := int(float32(m.terminalHeight) * 0.2) + halfHeight := int(float32(m.terminalHeight) * 0.4) // For the other two viewports + + // Update dimensions for all viewports + m.awsAccountsViewport.Width = m.terminalWidth - 4 + m.awsAccountsViewport.Height = awsAccountsHeight + + m.mainTableViewport.Width = m.terminalWidth - 4 + m.mainTableViewport.Height = halfHeight + m.awsAccountsTable.SetHeight(awsAccountsHeight - 2) + m.awsAccountsTable.SetWidth(m.terminalWidth - 4) + m.awsAccountsViewport.Width = m.terminalWidth - 4 + + m.detailsViewport.Width = m.terminalWidth - 4 + m.detailsViewport.Height = halfHeight + m.mainTable.SetHeight(halfHeight - 2) + + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Up): + m.lastKey = "↑" + case key.Matches(msg, m.keys.Down): + m.lastKey = "↓" + case key.Matches(msg, m.keys.Left): + m.lastKey = "←" + case key.Matches(msg, m.keys.Right): + m.lastKey = "→" + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + case key.Matches(msg, m.keys.Tab): + // Switch focus between the the three viewports by cycling focusSelector between 0, 1, and 2 + m.focusSelector = (m.focusSelector + 1) % 3 + //m.focusOnTable = !m.focusOnTable + // add a case for shift and tab at the same time to to cycle the focusSelector backwards between 0, 1, and 2 + case key.Matches(msg, m.keys.ShiftTab): + m.focusSelector = (m.focusSelector - 1) % 3 + + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + } + + //var detailsData map[int]string + //var mainTable table.Model + var err error + var awsCurrentRow int + var mainCurrentRow int + switch m.focusSelector { + case 0: + m.awsAccountsTable, cmd = m.awsAccountsTable.Update(msg) + m.awsAccountsViewport.SetContent(m.awsAccountsTable.View()) + awsCurrentRow = m.awsAccountsTable.Cursor() + if m.awsSelectedRow != awsCurrentRow { + m.awsSelectedRow = awsCurrentRow + // Update the viewport content based on the newly selected row + // Load the file for the selected account and show the file content in the main table + m.mainTable, m.detailsData, err = getRecordsForAccount(m.preloadedData.Files[m.awsAccountsTable.Rows()[awsCurrentRow][0]]) + if err != nil { + // Handle error + break + } + m.mainTable.SetStyles(s) + m.mainTableViewport.SetContent(m.mainTable.View()) + m.detailsViewport.SetContent(m.detailsData[0]) + + } + case 1: + m.mainTable, cmd = m.mainTable.Update(msg) + // Check if the selected row has changed + m.mainTableViewport.SetContent(m.mainTable.View()) + + mainCurrentRow = m.mainTable.Cursor() + if m.mainSelectedRow != mainCurrentRow { + m.mainSelectedRow = mainCurrentRow + // Update the viewport content based on the newly selected row + if detail, ok := m.detailsData[m.mainSelectedRow]; ok { + m.detailsViewport.SetContent(detail) + } else { + m.detailsViewport.SetContent(fmt.Sprintf("No details available for main row %d (aws row %d)", m.mainSelectedRow, m.awsSelectedRow)) + } + } + case 2: + m.detailsViewport, cmd = m.detailsViewport.Update(msg) + } + } + return m, cmd +} + +func (m model) View() string { + // Define styles for active and inactive viewports + activeBorderStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#00FFFF")). // Cyan for active + Padding(0) + activeBorderStyle.PaddingLeft(1) + activeBorderStyle.PaddingRight(1) + + inactiveBorderStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#808080")). // Gray for inactive + Padding(0) + inactiveBorderStyle.PaddingLeft(1) + inactiveBorderStyle.PaddingRight(1) + + // Conditionally apply border styles based on focus + awsTableViewStyle := activeBorderStyle + m.awsAccountsTable.SetStyles(m.defaultTableStyle) + tableViewStyle := inactiveBorderStyle + m.mainTable.SetStyles(m.defaultTableStyle) + detailsViewStyle := inactiveBorderStyle + + switch m.focusSelector { + case 0: + awsTableViewStyle = activeBorderStyle + m.awsAccountsTable.SetStyles(m.defaultTableStyle) + tableViewStyle = inactiveBorderStyle + detailsViewStyle = inactiveBorderStyle + case 1: + awsTableViewStyle = inactiveBorderStyle + tableViewStyle = activeBorderStyle + m.mainTable.SetStyles(m.defaultTableStyle) + detailsViewStyle = inactiveBorderStyle + case 2: + awsTableViewStyle = inactiveBorderStyle + tableViewStyle = inactiveBorderStyle + detailsViewStyle = activeBorderStyle + } + + // Render the table and details viewports with their styles (adjust based on focus) + awsAccountsView := awsTableViewStyle.Render(m.awsAccountsViewport.View()) + tableView := tableViewStyle.Render(m.mainTableViewport.View()) + detailsView := detailsViewStyle.Render(m.detailsViewport.View()) + + // Combine all views + fullView := lipgloss.JoinVertical(lipgloss.Top, awsAccountsView, tableView, detailsView) + + helpView := m.help.View(m.keys) + + return fmt.Sprintf("%s\n%s", fullView, helpView) +} + +func calculateMaxWidths(rows []table.Row) []int { + + var maxWidths []int + // make sure the length of the rows is greater than 0 + + if len(rows) > 0 { + maxWidths = make([]int, len(rows[0])) + } else { + maxWidths = []int{30, 30, 30, 30} + } + + for _, row := range rows { + for i, cell := range row { + if len(cell) > maxWidths[i] { + maxWidths[i] = len(cell) + } + } + } + + return maxWidths + +} + +func (m model) footerView() string { + info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.detailsViewport.ScrollPercent()*100)) + line := strings.Repeat("─", max(0, m.detailsViewport.Width-lipgloss.Width(info))) + return lipgloss.JoinHorizontal(lipgloss.Center, line, info) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func loadFileRecords(filePath string) (*PerAccountData, error) { + // Open the file + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + // Decode the JSON data + var records []CapeJSON + decoder := json.NewDecoder(file) + if err := decoder.Decode(&records); err != nil { + return nil, err + } + + // Return a new FileRecords instance + return &PerAccountData{ + FilePath: filePath, + PrivescPaths: records, + }, nil +} + +func preloadData(filePaths []string) (*AllAccountData, error) { + appData := &AllAccountData{ + Files: make(map[string]*PerAccountData), + } + + for _, filePath := range filePaths { + fileRecords, err := loadFileRecords(filePath) + if err != nil { + return nil, err + } + appData.Files[filePath] = fileRecords + } + + return appData, nil +} + +func getRecordsForAccount(preloadedData *PerAccountData) (table.Model, map[int]string, error) { + // lets load the records for the first file in the list + + records := preloadedData.PrivescPaths + if len(records) < 1 { + mainTable := table.New( + table.WithColumns([]table.Column{ + {Title: "Account", Width: 30}, + {Title: "Source", Width: 30}, + {Title: "Target", Width: 30}, + {Title: "isTargetAdmin", Width: 30}, + }), + table.WithRows([]table.Row{{"No records found", "", "", ""}}), + table.WithFocused(true), + ) + return mainTable, nil, nil + + } + + // Prepare rows for the table and data for the right view + rows := make([]table.Row, 0, len(records)-1) + detailsData := make(map[int]string) // Initialize the map for the fourth column's data + for i, record := range records[1:] { // Skip the header row + rows = append(rows, table.Row{record.Account, record.Source, record.Target, record.IsTargetAdmin}) + detailsData[i] = expandDetailsData(record.Summary) + } + + // Calculate max widths + maxWidths := calculateMaxWidths(rows) + + // Define columns with calculated widths + columns := make([]table.Column, len(maxWidths)) + colNames := []string{} + for _, header := range []string{"Account", "Source", "Target", "isTargetAdmin"} { + colNames = append(colNames, header) + } + + for i, width := range maxWidths { + colName := colNames[i] + // if column name width is greater than the calculated width, use the column name width + if len(colName) > width { + width = len(colName) + } + columns[i] = table.Column{ + Title: colName, + Width: width, + } + } + + mainTable := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + return mainTable, detailsData, nil +} + +func CapeTUI(outputFiles []string) { + + preloadData, err := preloadData(outputFiles) + if err != nil { + fmt.Printf("Error preloading data: %s\n", err) + fmt.Println("Either remove this profile from the list of profiles, or make sure cape can run successfully for this profile") + os.Exit(1) + } + + var awsAccountsRows []table.Row + for _, file := range preloadData.Files { + // Extract a user-friendly account name from the file path if needed + // For simplicity, here we use the file path itself + awsAccountsRows = append(awsAccountsRows, table.Row{file.FilePath}) + } + + mainTable, detailsData, err := getRecordsForAccount(preloadData.Files[outputFiles[0]]) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + + awsAccountsWidth := calculateMaxWidths(awsAccountsRows) + awsAccountsTable := table.New( + table.WithColumns([]table.Column{ + {Title: "AWS Accounts", Width: awsAccountsWidth[0]}, + }), + table.WithRows(awsAccountsRows), + table.WithFocused(true), // Initially unfocused + ) + + awsAccountsTable.SetStyles(s) + awsAccountsViewport := viewport.New(0, 0) // Initialize with 0 size; will be updated + awsAccountsViewport.SetContent(awsAccountsTable.View()) // Set initial content + + mainTable.SetStyles(s) + mainTableViewport := viewport.New(0, 0) // Initialize with 0 size; it will be updated + mainTableViewport.SetContent(mainTable.View()) // Set the initial content of the table viewport + + // Initialize viewport for details view + detailsViewportModel := viewport.New(0, 0) // Size will be set based on terminal size in Update + // show the first row's details by default + detailsViewportModel.SetContent(detailsData[0]) + + m := model{ + preloadedData: preloadData, + awsAccountsTable: awsAccountsTable, + awsAccountsViewport: awsAccountsViewport, + mainTable: mainTable, + mainTableViewport: mainTableViewport, + detailsData: detailsData, + detailsViewport: detailsViewportModel, + + //focusOnTable: true, + focusSelector: 0, + mainSelectedRow: 0, // Initialize selectedRow with an invalid index + awsSelectedRow: 0, + defaultTableStyle: s, + keys: keys, + help: help.New(), + } + + p := tea.NewProgram(m) + if err := p.Start(); err != nil { + fmt.Printf("Error starting program: %s\n", err) + os.Exit(1) + } +} + +// keyMap defines a set of keybindings. To work for help it must satisfy +// key.Map. It could also very easily be a map[string]key.Binding. +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Help key.Binding + Quit key.Binding + Tab key.Binding + ShiftTab key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Tab, k.ShiftTab, k.Up, k.Down, k.Help, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, // first column + {k.Tab, k.ShiftTab, k.Help, k.Quit}, // second column + } +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "move down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "move left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "move right"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "Switch window focus"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "Switch window focus"), + ), +} + +func expandDetailsData(input string) string { + output := "" + lines := strings.Split(input, "\n") + + for _, line := range lines { + var hop, option int + var rest string + + // Use Sscanf to extract hop and option values + n, err := fmt.Sscanf(line, "[Hop: %d] [Option: %d]", &hop, &option) + if err != nil || n != 2 { + fmt.Printf("Error parsing line, expected 2 got %d: %v\n", n, err) + continue + } + + // Extract the rest of the line manually + restIndex := strings.Index(line, "]") + 1 + restIndex = strings.Index(line[restIndex:], "]") + restIndex + 1 + if restIndex > 1 && restIndex < len(line) { + rest = line[restIndex+1:] // +1 to skip the space after the second ] + } + + // Construct the formatted output + formattedLine := fmt.Sprintf("[Hop: %d][Option: %d]\n\t%s\n\n", hop, option, rest) + output += formattedLine + } + + return output + +} diff --git a/aws/cape.go b/aws/cape.go new file mode 100644 index 0000000..5e9e502 --- /dev/null +++ b/aws/cape.go @@ -0,0 +1,981 @@ +package aws + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/BishopFox/cloudfox/internal" + "github.com/BishopFox/cloudfox/internal/aws/policy" + "github.com/BishopFox/cloudfox/internal/common" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/bishopfox/knownawsaccountslookup" + "github.com/dominikbraun/graph" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +type CapeCommand struct { + + // General configuration data + Cmd cobra.Command + Caller sts.GetCallerIdentityOutput + AWSRegions []string + Goroutines int + AWSProfile string + WrapTable bool + AWSOutputType string + AWSTableCols string + Verbosity int + AWSOutputDirectory string + AWSConfig aws.Config + Version string + SkipAdminCheck bool + GlobalGraph graph.Graph[string, string] + PmapperDataBasePath string + AnalyzedAccounts map[string]CapeJobInfo + CapeAdminOnly bool + AccountsNotAnalyzed []string + + output internal.OutputData2 + modLog *logrus.Entry +} + +type CapeJobInfo struct { + AccountID string + Profile string + AnalyzedSuccessfully bool + AdminOnlyAnalysis bool + Source string +} + +func (m *CapeCommand) RunCapeCommand() { + + // These struct values are used by the output module + m.output.Verbosity = m.Verbosity + m.output.Directory = m.AWSOutputDirectory + m.output.CallingModule = "cape" + m.modLog = internal.TxtLog.WithFields(logrus.Fields{ + "module": m.output.CallingModule, + }) + if m.AWSProfile == "" { + m.AWSProfile = internal.BuildAWSPath(m.Caller) + } + m.output.FilePath = filepath.Join(m.AWSOutputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account))) + + o := internal.OutputClient{ + Verbosity: m.Verbosity, + CallingModule: m.output.CallingModule, + Table: internal.TableClient{ + Wrap: m.WrapTable, + }, + } + + o.PrefixIdentifier = m.AWSProfile + o.Table.DirectoryName = filepath.Join(m.AWSOutputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account))) + + // Table #1: Inbound Privilege Escalation Paths + fmt.Printf("[%s][%s] Printing inbound privesc paths for account: %s\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account)) + if !m.CapeAdminOnly { + fmt.Printf("[%s][%s] This can take a really long time if the number of vertices/edges is in the thousands. Consider stopping here and re-running cape with --admin-only to speed this step up!\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) + } else { + fmt.Printf("[%s][%s] This can take a really long time if the number of vertices/edges is in the thousands.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) + } + + header, body, _ := m.generateInboundPrivEscTableData() + + var fileName string + if m.CapeAdminOnly { + fileName = "inbound-privesc-paths-admin-targets-only" + } else { + fileName = "inbound-privesc-paths-all-targets" + } + + o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{ + Header: header, + Body: body, + //TableCols: tableCols, + Name: fileName, + SkipPrintToScreen: false, + }) + + // // Table #2: Outbound Privilege Escalation Paths + // header, body, tableCols := m.generateOutBoundPrivEscTable() + // o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{ + // Header: header, + // Body: body, + // TableCols: tableCols, + // Name: "outbound-privesc-paths", + // SkipPrintToScreen: false, + // }) + + o.WriteFullOutput(o.Table.TableFiles, nil) + fmt.Println("The following accounts are trusted by this account, but were not analyzed as part of this run.") + fmt.Println("As a result, we cannot determine which principals in these accounts have permission to assume roles in this account.") + // for account := range m.AnalyzedAccounts { + // if m.AnalyzedAccounts[account].AnalyzedSuccessfully == false { + // fmt.Println("\t\t" + account) + // } + // } + for _, account := range m.AccountsNotAnalyzed { + fmt.Println("\t\t" + account) + } + +} + +func (m *CapeCommand) generateInboundPrivEscTableData() ([]string, [][]string, []string) { + var body [][]string + var tableCols []string + var header []string + header = []string{ + "Account", + "Source", + "Target", + "isTargetAdmin", + "Summary", + } + + // If the user specified table columns, use those. + // If the user specified -o wide, use the wide default cols for this module. + // Otherwise, use the hardcoded default cols for this module. + + // If the user specified table columns, use those. + if m.AWSTableCols != "" { + tableCols = strings.Split(m.AWSTableCols, ",") + // If the user specified wide as the output format, use these columns. + } else if m.AWSOutputType == "wide" { + tableCols = []string{ + "Account", + "Source", + "Target", + "isTargetAdmin", + "Summary", + } + // Otherwise, use these columns. + } else { + tableCols = []string{ + "Source", + "Target", + "isTargetAdmin", + "Summary", + } + } + + var privescPathsBody [][]string + + allGlobalNodes, _ := m.GlobalGraph.AdjacencyMap() + for destination := range allGlobalNodes { + d, destinationVertexWithProperties, _ := m.GlobalGraph.VertexWithProperties(destination) + + // if the user specified the CapeAdminOnly flag, then we only want to show paths to admin roles + if m.CapeAdminOnly { + // if the user specified the CapeAdminOnly flag, then we only want to show paths to admin roles + if destinationVertexWithProperties.Attributes["IsAdminString"] == "Yes" { + //for the destination vertex, we only want to deal with the ones that are in this account + if destinationVertexWithProperties.Attributes["AccountID"] == aws.ToString(m.Caller.Account) { + privescPathsBody = m.findPathsToThisDestination(allGlobalNodes, d, destinationVertexWithProperties) + body = append(body, privescPathsBody...) + } + } + } else { + //for the destination vertex, we only want to deal with the ones that are in this account + if destinationVertexWithProperties.Attributes["AccountID"] == aws.ToString(m.Caller.Account) { + privescPathsBody := m.findPathsToThisDestination(allGlobalNodes, d, destinationVertexWithProperties) + body = append(body, privescPathsBody...) + } + } + } + body = append(body, privescPathsBody...) + return header, body, tableCols + +} + +func (m *CapeCommand) findPathsToThisDestination(allGlobalNodes map[string]map[string]graph.Edge[string], d string, destinationVertexWithProperties graph.VertexProperties) [][]string { + var privescPathsBody [][]string + var paths string + // now let's look at every other vertex and see if it has a path to this destination + for source := range allGlobalNodes { + s, sourceVertexWithProperties, _ := m.GlobalGraph.VertexWithProperties(source) + //for the source vertex, we only want to deal with the ones that are NOT in this account + if sourceVertexWithProperties.Attributes["AccountID"] != aws.ToString(m.Caller.Account) { + // now let's see if there is a path from this source to our destination + path, _ := graph.ShortestPath(m.GlobalGraph, s, d) + // if we have a path, then lets document this source as having a path to our destination + if path != nil { + if s != d { + fmt.Printf("[%s][%s] Found a path from %s to %s\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), s, d) + + // check to see if the source account was analyzed. If not, lets add it to the list of accounts that were not analyzed + if strings.Contains(s, "Not analyzed/in-scope") { + // add it to the m.AccountsNotAnalyzed if it doesn't already exist + if !internal.Contains(s, m.AccountsNotAnalyzed) { + m.AccountsNotAnalyzed = append(m.AccountsNotAnalyzed, s) + } + + } + + paths = "" + // if we got here there's a path. Lets print the reason and the short reason for each edge in the path to the screen + // and then lets print the full path to the screen + for i := 0; i < len(path)-1; i++ { + thisEdge, _ := m.GlobalGraph.Edge(path[i], path[i+1]) + j := 0 + for _, value := range thisEdge.Properties.Attributes { + value = strings.ReplaceAll(value, ",", " and") + paths += fmt.Sprintf("[Hop: %d] [Option: %d] [%s] [%s] [%s]\n", i, j, thisEdge.Source, value, thisEdge.Target) + j++ + } + } + + //trim the last newline from csvPaths + paths = strings.TrimSuffix(paths, "\n") + if destinationVertexWithProperties.Attributes["IsAdminString"] == "Yes" { + privescPathsBody = append(privescPathsBody, []string{ + aws.ToString(m.Caller.Account), + s, + magenta(d), + magenta(destinationVertexWithProperties.Attributes["IsAdminString"]), + paths}) + } else { + privescPathsBody = append(privescPathsBody, []string{ + aws.ToString(m.Caller.Account), + s, + d, + destinationVertexWithProperties.Attributes["IsAdminString"], + paths}) + } + } + } + } + } + return privescPathsBody +} + +func ConvertIAMRoleToNode(role types.Role, vendors *knownawsaccountslookup.Vendors, analyzedAccounts map[string]CapeJobInfo) Node { + //var isAdmin, canPrivEscToAdmin string + + accountId := strings.Split(aws.ToString(role.Arn), ":")[4] + trustsdoc, err := policy.ParseRoleTrustPolicyDocument(role) + if err != nil { + internal.TxtLog.Error(err.Error()) + return Node{} + } + + var TrustedPrincipals []TrustedPrincipal + var TrustedServices []TrustedService + var TrustedFederatedProviders []TrustedFederatedProvider + //var TrustedFederatedSubjects string + var trustedProvider string + var trustedSubjects []string + var vendorName string + var isAnalyzedAccount bool + + for _, statement := range trustsdoc.Statement { + for _, principal := range statement.Principal.AWS { + if strings.Contains(principal, ":root") { + //check to see if the trustedRootAccountID is known + trustedRootAccountID := strings.Split(principal, ":")[4] + vendorName = vendors.GetVendorNameFromAccountID(trustedRootAccountID) + // check to see if trustedRootAccountID is in the m.AnalyzedAccounts map + if _, ok := analyzedAccounts[trustedRootAccountID]; ok { + isAnalyzedAccount = analyzedAccounts[trustedRootAccountID].AnalyzedSuccessfully + } else { + isAnalyzedAccount = false + } + + } + + TrustedPrincipals = append(TrustedPrincipals, TrustedPrincipal{ + TrustedPrincipal: principal, + ExternalID: statement.Condition.StringEquals.StsExternalID, + VendorName: vendorName, + //IsAdmin: false, + //CanPrivEscToAdmin: false, + AccountIsInAnalyzedAccountList: isAnalyzedAccount, + }) + + } + for _, service := range statement.Principal.Service { + TrustedServices = append(TrustedServices, TrustedService{ + TrustedService: service, + AccountID: accountId, + //IsAdmin: false, + //CanPrivEscToAdmin: false, + }) + + } + for _, federated := range statement.Principal.Federated { + // provider accountID + //accountId := strings.Split(federated, ":")[4] + + trustedProvider, trustedSubjects = parseFederatedTrustPolicy(statement) + TrustedFederatedProviders = append(TrustedFederatedProviders, TrustedFederatedProvider{ + TrustedFederatedProvider: federated, + ProviderShortName: trustedProvider, + //ProviderAccountId: accountId, + TrustedSubjects: trustedSubjects, + //IsAdmin: false, + //CanPrivEscToAdmin: false, + }) + } + } + + node := Node{ + Arn: aws.ToString(role.Arn), + Type: "Role", + AccountID: accountId, + Name: aws.ToString(role.RoleName), + TrustsDoc: trustsdoc, + TrustedPrincipals: TrustedPrincipals, + TrustedServices: TrustedServices, + TrustedFederatedProviders: TrustedFederatedProviders, + } + + return node +} + +func ConvertIAMUserToNode(user types.User) Node { + accountId := strings.Split(aws.ToString(user.Arn), ":")[4] + node := Node{ + Arn: aws.ToString(user.Arn), + Type: "User", + AccountID: accountId, + Name: aws.ToString(user.UserName), + } + + return node +} + +func FindVerticesInRoleTrust(a Node, vendors *knownawsaccountslookup.Vendors) []Node { + + var newNodes []Node + + // get thisAccount id from role arn + // var thisAccount string + // if len(a.Arn) >= 25 { + // thisAccount = a.Arn[13:25] + // } else { + // fmt.Sprintf("Could not get account number from this role arn%s", a.Arn) + // } + + for _, TrustedPrincipal := range a.TrustedPrincipals { + //get account id from the trusted principal arn + var trustedPrincipalAccount string + if len(TrustedPrincipal.TrustedPrincipal) >= 25 { + trustedPrincipalAccount = TrustedPrincipal.TrustedPrincipal[13:25] + } else { + fmt.Sprintf("Could not get account number from this TrustedPrincipal%s", TrustedPrincipal.TrustedPrincipal) + } + + // If the role trusts a principal in this account or another account using the :root notation, then we need to iterate over all of the rows in AllPermissionsRows to find the principals that have sts:AssumeRole permissions on this role + // if the role we are looking at trusts root in it's own account + + // if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", thisAccount)) { + + // newNodes = append(newNodes, Node{ + // Arn: a.Arn, + // Type: "Account", + // AccountID: a.AccountID, + // Name: a.Name, + // }) + + // } else + + if strings.Contains(TrustedPrincipal.TrustedPrincipal, ":root") && TrustedPrincipal.VendorName != "" { + // First lets take care of vendor accounts + newNodes = append(newNodes, Node{ + Arn: fmt.Sprintf("%s [%s]", TrustedPrincipal.VendorName, TrustedPrincipal.TrustedPrincipal), + //Arn: TrustedPrincipal.VendorName, + Type: "Account", + AccountID: trustedPrincipalAccount, + Name: TrustedPrincipal.VendorName, + VendorName: TrustedPrincipal.VendorName, + }) + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, ":root") && !TrustedPrincipal.AccountIsInAnalyzedAccountList { + // Next lets take care of accounts that are not in the analyzed account list and add the full :root as the node + + newNodes = append(newNodes, Node{ + Arn: fmt.Sprintf("%s [Not analyzed/in-scope]", TrustedPrincipal.TrustedPrincipal), + Type: "Account", + AccountID: trustedPrincipalAccount, + }) + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, ":root") && TrustedPrincipal.VendorName == "" { + // Now with those out of the way, lets take care of the accounts that are in the analyzed account list + newNodes = append(newNodes, Node{ + Arn: TrustedPrincipal.TrustedPrincipal, + Type: "Account", + AccountID: trustedPrincipalAccount, + }) + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf(":user")) { + newNodes = append(newNodes, Node{ + Arn: TrustedPrincipal.TrustedPrincipal, + Type: "User", + AccountID: trustedPrincipalAccount, + }) + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf(":role")) { + newNodes = append(newNodes, Node{ + Arn: TrustedPrincipal.TrustedPrincipal, + Type: "Role", + AccountID: trustedPrincipalAccount, + }) + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf(":group")) { + newNodes = append(newNodes, Node{ + Arn: TrustedPrincipal.TrustedPrincipal, + Type: "Group", + AccountID: trustedPrincipalAccount, + }) + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf(":assumed-role")) { + newNodes = append(newNodes, Node{ + Arn: TrustedPrincipal.TrustedPrincipal, + Type: "AssumedRole", + AccountID: trustedPrincipalAccount, + }) + } + } + // pmapper takes care of this part so commenting out for now - but leaving as a placeholder + // for _, TrustedService := range a.TrustedServices { + // // make relationship from trusted service to this role of type can assume + // // make relationship from this role to trusted service of type can be assumed by + // } + + for _, TrustedFederatedProvider := range a.TrustedFederatedProviders { + // make relationship from trusted federated provider to this role of type can assume + + var providerAndSubject string + for _, trustedSubject := range TrustedFederatedProvider.TrustedSubjects { + if trustedSubject == "Not applicable" { + providerAndSubject = TrustedFederatedProvider.ProviderShortName + } else { + //providerAndSubject = TrustedFederatedProvider.ProviderShortName + ":" + trustedSubject + providerAndSubject = fmt.Sprintf("%s [%s]", TrustedFederatedProvider.ProviderShortName, trustedSubject) + } + //fmt.Println("TrustedFederatedProvider: ", TrustedFederatedProvider.TrustedFederatedProvider) + // if the TrustedFederatedProvider.TrustedFederatedProvider is an arn (check to see if it has at least 4 semicolons), grab the account id. Otherwise, use a.AccountID + var accountID string + if strings.Count(TrustedFederatedProvider.TrustedFederatedProvider, ":") >= 4 { + accountID = strings.Split(TrustedFederatedProvider.TrustedFederatedProvider, ":")[4] + } else { + accountID = a.AccountID + } + + newNodes = append(newNodes, Node{ + Arn: providerAndSubject, + Name: TrustedFederatedProvider.ProviderShortName, + Type: "FederatedIdentity", + AccountID: accountID, + }) + } + + } + + return newNodes +} + +func MergeNodes(nodes []Node) []Node { + nodeMap := make(map[string]Node) + + for _, node := range nodes { + existingNode, exists := nodeMap[node.Arn] + if exists { + // fmt.Println("Found a duplicate node: %s, merging", node.Arn) + // fmt.Println("Existing node: %v", existingNode) + // fmt.Println("New node: %v", node) + + mergedNode := mergeNodeData(existingNode, node) + nodeMap[node.Arn] = mergedNode + //fmt.Println("Merged node: %v", mergedNode) + } else { + nodeMap[node.Arn] = node + } + } + + var mergedNodes []Node + for _, node := range nodeMap { + mergedNodes = append(mergedNodes, node) + } + + return mergedNodes +} + +func mergeNodeData(existingNode Node, newNode Node) Node { + if existingNode.Arn == "" { + existingNode.Arn = newNode.Arn + } else { + existingNode.Arn = existingNode.Arn + } + if existingNode.Name == "" { + existingNode.Name = newNode.Name + } else { + existingNode.Name = existingNode.Name + } + if existingNode.Type == "" { + existingNode.Type = newNode.Type + } else { + existingNode.Type = existingNode.Type + } + if existingNode.AccountID == "" { + existingNode.AccountID = newNode.AccountID + } else { + existingNode.AccountID = existingNode.AccountID + } + if existingNode.CanPrivEscToAdminString == "" { + existingNode.CanPrivEscToAdminString = newNode.CanPrivEscToAdminString + } else { + existingNode.CanPrivEscToAdminString = existingNode.CanPrivEscToAdminString + } + if existingNode.IsAdminString == "" { + existingNode.IsAdminString = newNode.IsAdminString + } else { + existingNode.IsAdminString = existingNode.IsAdminString + } + if existingNode.VendorName == "" { + existingNode.VendorName = newNode.VendorName + } else { + existingNode.VendorName = existingNode.VendorName + } + if existingNode.AccessKeys == 0 { + existingNode.AccessKeys = newNode.AccessKeys + } else { + existingNode.AccessKeys = existingNode.AccessKeys + } + if existingNode.ActivePassword == false { + existingNode.ActivePassword = newNode.ActivePassword + } else { + existingNode.ActivePassword = existingNode.ActivePassword + } + if existingNode.HasMfa == false { + existingNode.HasMfa = newNode.HasMfa + } else { + existingNode.HasMfa = existingNode.HasMfa + } + if existingNode.PathToAdmin == false { + existingNode.PathToAdmin = newNode.PathToAdmin + } else { + existingNode.PathToAdmin = existingNode.PathToAdmin + } + if existingNode.AttachedPolicies == nil { + existingNode.AttachedPolicies = newNode.AttachedPolicies + } else { + existingNode.AttachedPolicies = existingNode.AttachedPolicies + } + if existingNode.TrustedFederatedProviders == nil { + existingNode.TrustedFederatedProviders = newNode.TrustedFederatedProviders + } else { + existingNode.TrustedFederatedProviders = existingNode.TrustedFederatedProviders + } + if existingNode.TrustedPrincipals == nil { + existingNode.TrustedPrincipals = newNode.TrustedPrincipals + } else { + existingNode.TrustedPrincipals = existingNode.TrustedPrincipals + } + if existingNode.TrustedServices == nil { + existingNode.TrustedServices = newNode.TrustedServices + } else { + existingNode.TrustedServices = existingNode.TrustedServices + } + // if existingNode.TrustsDoc.Statement == nil { + // existingNode.TrustsDoc = newNode.TrustsDoc + // } else { + // } + return existingNode +} + +func (a *Node) MakeRoleEdges(GlobalGraph graph.Graph[string, string]) { + + // get thisAccount id from role arn + var thisAccount string + if len(a.Arn) >= 25 { + thisAccount = a.Arn[13:25] + } else { + fmt.Sprintf("Could not get account number from this role arn%s", a.Arn) + } + + for _, TrustedPrincipal := range a.TrustedPrincipals { + //get account id from the trusted principal arn + var trustedPrincipalAccount string + if len(TrustedPrincipal.TrustedPrincipal) >= 25 { + trustedPrincipalAccount = TrustedPrincipal.TrustedPrincipal[13:25] + } else { + fmt.Sprintf("Could not get account number from this TrustedPrincipal%s", TrustedPrincipal.TrustedPrincipal) + } + var PermissionsRowAccount string + + // if the role trusts a principal in this same account explicitly, then the principal can assume the role + if thisAccount == trustedPrincipalAccount { + // make a CAN_ASSUME relationship between the trusted principal and this role + + err := GlobalGraph.AddEdge( + TrustedPrincipal.TrustedPrincipal, + a.Arn, + //graph.EdgeAttribute("AssumeRole", "Same account explicit trust"), + graph.EdgeAttribute("AssumeRole", "can assume (because of an explicit same account trust) "), + ) + if err != nil { + //fmt.Println(err) + //fmt.Println(TrustedPrincipal.TrustedPrincipal + a.Arn + "Same account explicit trust") + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + //fmt.Println("Edge already exists") + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(TrustedPrincipal.TrustedPrincipal, a.Arn) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes["AssumeRole"] = "can assume (because of an explicit same account trust) " + err = GlobalGraph.UpdateEdge( + TrustedPrincipal.TrustedPrincipal, + a.Arn, + graph.EdgeAttributes(existingProperties.Attributes), + ) + if err != nil { + fmt.Println(err) + } + } + + } + } + + // If the role trusts a principal in this account or another account using the :root notation, then we need to iterate over all of the rows in AllPermissionsRows to find the principals that have sts:AssumeRole permissions on this role + // if the role we are looking at trusts root in it's own account + + if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", thisAccount)) { + // iterate over all rows in AllPermissionsRows + for _, PermissionsRow := range common.PermissionRowsFromAllProfiles { + // but we only care about the rows that have arns that are in this account + + if len(PermissionsRow.Arn) >= 25 { + PermissionsRowAccount = PermissionsRow.Arn[13:25] + } else { + fmt.Sprintf("Could not get account number from this PermissionsRow%s", PermissionsRow.Arn) + } + + if PermissionsRowAccount == thisAccount { + // lets only look for rows that have sts:AssumeRole permissions + if policy.MatchesAfterExpansion(PermissionsRow.Action, "sts:AssumeRole") { + // lets only focus on rows that have an effect of Allow + if strings.EqualFold(PermissionsRow.Effect, "Allow") { + // if the resource is * or the resource is this role arn, then this principal can assume this role + if PermissionsRow.Resource == "*" || strings.Contains(PermissionsRow.Resource, a.Arn) { + // make a CAN_ASSUME relationship between the trusted principal and this role + //evaluate if the principal is a user or a role and set a variable accordingly + //var principalType schema.NodeLabel + if strings.EqualFold(PermissionsRow.Type, "User") || strings.EqualFold(PermissionsRow.Type, "Role") { + err := GlobalGraph.AddEdge( + PermissionsRow.Arn, + a.Arn, + //graph.EdgeAttribute("AssumeRole", "Same account root trust and trusted principal has permission to assume role"), + graph.EdgeAttribute("AssumeRole", "can assume (because of a same account root trust and trusted principal has permission to assume role) "), + ) + if err != nil { + // fmt.Println(err) + // fmt.Println(PermissionsRow.Arn + a.Arn + "Same account root trust and trusted principal has permission to assume role") + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(PermissionsRow.Arn, a.Arn) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes["AssumeRole"] = "can assume (because of a same account root trust and trusted principal has permission to assume role) " + err = GlobalGraph.UpdateEdge( + PermissionsRow.Arn, + a.Arn, + graph.EdgeAttributes(existingProperties.Attributes), + ) + if err != nil { + fmt.Println(err) + } + } + + } + } + } + } + if strings.EqualFold(PermissionsRow.Effect, "Deny") { + // if the action is deny, we need to remove any edges between PermissionsRow.Arn and a.Arn + // if the edge exists, remove it + err := GlobalGraph.RemoveEdge(PermissionsRow.Arn, a.Arn) + if err != nil { + fmt.Println(err) + } + + } + } + } + } + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, ":root") && TrustedPrincipal.VendorName != "" { + + // If the role trusts :root in another account and the trusted principal is a vendor, we will make a relationship between our role and a vendor node instead of a principal node + + err := GlobalGraph.AddEdge( + //TrustedPrincipal.TrustedPrincipal, + //TrustedPrincipal.VendorName, + fmt.Sprintf("%s [%s]", TrustedPrincipal.VendorName, TrustedPrincipal.TrustedPrincipal), + a.Arn, + //graph.EdgeAttribute("VendorAssumeRole", "Cross account root trust and trusted principal is a vendor"), + graph.EdgeAttribute("VendorAssumeRole", "can assume (because of a cross account root trust and trusted principal is a vendor) "), + ) + if err != nil { + // fmt.Println(err) + // fmt.Println(TrustedPrincipal.VendorName + a.Arn + "Cross account root trust and trusted principal is a vendor") + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(TrustedPrincipal.VendorName, a.Arn) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes["VendorAssumeRole"] = "can assume (because of a cross account root trust and trusted principal is a vendor) " + err := GlobalGraph.UpdateEdge( + //fmt.Sprintf("%s-%s", a.Arn, TrustedPrincipal.VendorName), + TrustedPrincipal.TrustedPrincipal, + a.Arn, + graph.EdgeAttributes(existingProperties.Attributes), + ) + if err != nil { + fmt.Println(err) + } + } + + } + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", trustedPrincipalAccount)) && !TrustedPrincipal.AccountIsInAnalyzedAccountList { + // first lets check to see if the trustedRootAccountID is in the map of analzyeddAccounts + // if it is not, we can't iterate over the permissions, so we will just have to create an edge :root princpal and this role + + err := GlobalGraph.AddEdge( + //TrustedPrincipal.TrustedPrincipal, + fmt.Sprintf("%s [Not analyzed/in-scope]", TrustedPrincipal.TrustedPrincipal), + a.Arn, + //graph.EdgeAttribute("CrossAccountRootTrust", "Cross account root trust and trusted principal is not in the analyzed account list"), + graph.EdgeAttribute("CrossAccountRootTrust", "can assume (because of a cross account root trust and trusted principal is not in the analyzed account list) "), + ) + if err != nil { + // fmt.Println(err) + // fmt.Println(TrustedPrincipal.TrustedPrincipal + a.Arn + "Cross account root trust and trusted principal is not in the analyzed account list") + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(TrustedPrincipal.TrustedPrincipal, a.Arn) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes["CrossAccountRootTrust"] = "can assume (because of a cross account root trust and trusted principal is not in the analyzed account list) " + err := GlobalGraph.UpdateEdge( + TrustedPrincipal.TrustedPrincipal, + a.Arn, + graph.EdgeAttributes(existingProperties.Attributes), + ) + if err != nil { + fmt.Println(err) + } + } + + } + + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", trustedPrincipalAccount)) { + + // iterate over all rows in AllPermissionsRows + for _, PermissionsRow := range common.PermissionRowsFromAllProfiles { + // but we only care about the rows that have arns that are in this other account + if len(PermissionsRow.Arn) >= 25 { + PermissionsRowAccount = PermissionsRow.Arn[13:25] + } else { + fmt.Sprintf("Could not get account number from this PermissionsRow%s", PermissionsRow.Arn) + } + if PermissionsRowAccount == trustedPrincipalAccount { + // lets only look for rows that have sts:AssumeRole permissions + if policy.MatchesAfterExpansion(PermissionsRow.Action, "sts:AssumeRole") { + // if strings.EqualFold(PermissionsRow.Action, "sts:AssumeRole") || + // strings.EqualFold(PermissionsRow.Action, "*") || + // strings.EqualFold(PermissionsRow.Action, "sts:Assume*") || + // strings.EqualFold(PermissionsRow.Action, "sts:*") { + // lets only focus on rows that have an effect of Allow + if strings.EqualFold(PermissionsRow.Effect, "Allow") { + // if the resource is * or the resource is this role arn, then this principal can assume this role + if PermissionsRow.Resource == "*" || strings.Contains(PermissionsRow.Resource, a.Arn) { + // make a CAN_ASSUME relationship between the trusted principal and this role + + if strings.EqualFold(PermissionsRow.Type, "User") { + err := GlobalGraph.AddEdge( + PermissionsRow.Arn, + a.Arn, + //graph.EdgeAttribute("CrossAccountAssumeRole", "Cross account root trust and trusted principal has permission to assume role"), + graph.EdgeAttribute("CrossAccountAssumeRole", "can assume (because of a cross account root trust and trusted principal has permission to assume role) "), + ) + if err != nil { + //fmt.Println(err) + //fmt.Println(PermissionsRow.Arn + a.Arn + "Cross account root trust and trusted principal has permission to assume role") + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(PermissionsRow.Arn, a.Arn) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes["CrossAccountAssumeRole"] = "can assume (because of a cross account root trust and trusted principal has permission to assume role) " + err = GlobalGraph.UpdateEdge( + PermissionsRow.Arn, + a.Arn, + graph.EdgeAttributes(existingProperties.Attributes), + ) + if err != nil { + fmt.Println(err) + } + } + } + + } else if strings.EqualFold(PermissionsRow.Type, "Role") { + err := GlobalGraph.AddEdge( + PermissionsRow.Arn, + a.Arn, + //graph.EdgeAttribute("CrossAccountAssumeRole", "Cross account root trust and trusted principal has permission to assume role"), + graph.EdgeAttribute("CrossAccountAssumeRole", "can assume (because of a cross account root trust and trusted principal has permission to assume role) "), + ) + if err != nil { + //fmt.Println(err) + //fmt.Println(PermissionsRow.Arn + a.Arn + "Cross account root trust and trusted principal has permission to assume role") + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(PermissionsRow.Arn, a.Arn) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes["CrossAccountAssumeRole"] = "can assume (because of a cross account root trust and trusted principal has permission to assume role) " + err = GlobalGraph.UpdateEdge( + PermissionsRow.Arn, + a.Arn, + graph.EdgeAttributes(existingProperties.Attributes), + ) + if err != nil { + fmt.Println(err) + } + } + } + } + } + } + if strings.EqualFold(PermissionsRow.Effect, "Deny") { + // if the action is deny, we need to remove any edges between PermissionsRow.Arn and a.Arn + // if the edge exists, remove it + err := GlobalGraph.RemoveEdge(PermissionsRow.Arn, a.Arn) + if err != nil { + fmt.Println(err) + } + } + } + + } + } + + } + } + // pmapper takes care of this part so commenting out for now - but leaving as a placeholder + + // for _, TrustedService := range a.TrustedServices { + // // make relationship from trusted service to this role of type can assume + // // make relationship from this role to trusted service of type can be assumed by + // } + + for _, TrustedFederatedProvider := range a.TrustedFederatedProviders { + // make relationship from trusted federated provider to this role of type can assume + + var providerAndSubject string + for _, trustedSubject := range TrustedFederatedProvider.TrustedSubjects { + if trustedSubject == "Not applicable" { + providerAndSubject = TrustedFederatedProvider.ProviderShortName + } else { + //providerAndSubject = TrustedFederatedProvider.ProviderShortName + ":" + trustedSubject + providerAndSubject = fmt.Sprintf("%s [%s]", TrustedFederatedProvider.ProviderShortName, trustedSubject) + } + + err := GlobalGraph.AddEdge( + providerAndSubject, + a.Arn, + //graph.EdgeAttribute("FederatedAssumeRole", "Trusted federated provider"), + graph.EdgeAttribute("FederatedAssumeRole", "can assume (because of a trusted federated provider) "), + ) + if err != nil { + //fmt.Println(err) + //fmt.Println(TrustedFederatedProvider.TrustedFederatedProvider + a.Arn + "Trusted federated provider") + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(TrustedFederatedProvider.TrustedFederatedProvider, a.Arn) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes["FederatedAssumeRole"] = "can assume (because of a trusted federated provider) " + err = GlobalGraph.UpdateEdge( + providerAndSubject, + a.Arn, + graph.EdgeAttributes(existingProperties.Attributes), + ) + if err != nil { + fmt.Println(err) + } + } + } + + } + } + +} diff --git a/aws/client-initializers.go b/aws/client-initializers.go index a04a2f4..8dda581 100644 --- a/aws/client-initializers.go +++ b/aws/client-initializers.go @@ -3,11 +3,13 @@ package aws import ( "github.com/BishopFox/cloudfox/aws/sdk" "github.com/BishopFox/cloudfox/internal" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/codebuild" "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/aws/aws-sdk-go-v2/service/efs" "github.com/aws/aws-sdk-go-v2/service/fsx" "github.com/aws/aws-sdk-go-v2/service/glue" + "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/organizations" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -17,7 +19,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts" ) -func initIAMSimClient(iamSimPPClient sdk.AWSIAMClientInterface, caller sts.GetCallerIdentityOutput, AWSProfile string, Goroutines int) IamSimulatorModule { +func InitIamCommandClient(iamSimPPClient sdk.AWSIAMClientInterface, caller sts.GetCallerIdentityOutput, AWSProfile string, Goroutines int) IamSimulatorModule { iamSimMod := IamSimulatorModule{ IAMClient: iamSimPPClient, @@ -116,7 +118,7 @@ func InitFileSystemsClient(caller sts.GetCallerIdentityOutput, AWSProfile string return fileSystemsClient } -func InitOrgClient(caller sts.GetCallerIdentityOutput, AWSProfile string, cfVersion string, Goroutines int, AWSMFAToken string) OrgModule { +func InitOrgsClient(caller sts.GetCallerIdentityOutput, AWSProfile string, cfVersion string, Goroutines int, AWSMFAToken string) OrgModule { var AWSConfig = internal.AWSConfigFileLoader(AWSProfile, cfVersion, AWSMFAToken) orgClient := OrgModule{ OrganizationsClient: organizations.NewFromConfig(AWSConfig), @@ -127,6 +129,17 @@ func InitOrgClient(caller sts.GetCallerIdentityOutput, AWSProfile string, cfVers return orgClient } +func InitPermissionsClient(caller sts.GetCallerIdentityOutput, AWSProfile string, cfVersion string, Goroutines int, AWSMFAToken string) IamPermissionsModule { + var AWSConfig = internal.AWSConfigFileLoader(AWSProfile, cfVersion, AWSMFAToken) + permissionsClient := IamPermissionsModule{ + IAMClient: iam.NewFromConfig(AWSConfig), + Caller: caller, + AWSProfile: AWSProfile, + AWSRegions: internal.GetEnabledRegions(AWSProfile, cfVersion, AWSMFAToken), + } + return permissionsClient +} + func InitSecretsManagerClient(caller sts.GetCallerIdentityOutput, AWSProfile string, cfVersion string, Goroutines int, AWSMFAToken string) *secretsmanager.Client { var AWSConfig = internal.AWSConfigFileLoader(AWSProfile, cfVersion, AWSMFAToken) return secretsmanager.NewFromConfig(AWSConfig) @@ -136,3 +149,11 @@ func InitGlueClient(caller sts.GetCallerIdentityOutput, AWSProfile string, cfVer var AWSConfig = internal.AWSConfigFileLoader(AWSProfile, cfVersion, AWSMFAToken) return glue.NewFromConfig(AWSConfig) } + +func InitOrgClient(AWSConfig aws.Config) *organizations.Client { + return organizations.NewFromConfig(AWSConfig) +} + +func InitIAMClient(AWSConfig aws.Config) *iam.Client { + return iam.NewFromConfig(AWSConfig) +} diff --git a/aws/codebuild.go b/aws/codebuild.go index 5bb7697..458bd7a 100644 --- a/aws/codebuild.go +++ b/aws/codebuild.go @@ -19,10 +19,11 @@ type CodeBuildModule struct { CodeBuildClient sdk.CodeBuildClientInterface IAMClient sdk.AWSIAMClientInterface - Caller sts.GetCallerIdentityOutput - AWSRegions []string - AWSOutputType string - AWSTableCols string + Caller sts.GetCallerIdentityOutput + AWSRegions []string + AWSOutputType string + AWSTableCols string + PmapperDataBasePath string Goroutines int AWSProfile string @@ -64,8 +65,8 @@ func (m *CodeBuildModule) PrintCodeBuildProjects(outputDirectory string, verbosi } fmt.Printf("[%s][%s] Enumerating CodeBuild projects for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account)) - m.pmapperMod, m.pmapperError = initPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines) - m.iamSimClient = initIAMSimClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) wg := new(sync.WaitGroup) semaphore := make(chan struct{}, m.Goroutines) diff --git a/aws/ecs-tasks.go b/aws/ecs-tasks.go index f583cf0..49c3fa3 100644 --- a/aws/ecs-tasks.go +++ b/aws/ecs-tasks.go @@ -25,10 +25,11 @@ type ECSTasksModule struct { EC2Client sdk.AWSEC2ClientInterface IAMClient sdk.AWSIAMClientInterface - Caller sts.GetCallerIdentityOutput - AWSRegions []string - AWSOutputType string - AWSTableCols string + Caller sts.GetCallerIdentityOutput + AWSRegions []string + AWSOutputType string + AWSTableCols string + PmapperDataBasePath string AWSProfile string Goroutines int @@ -73,8 +74,8 @@ func (m *ECSTasksModule) ECSTasks(outputDirectory string, verbosity int) { fmt.Printf("[%s][%s] Enumerating ECS tasks in all regions for account %s\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account)) // Initialized the tools we'll need to check if any workload roles are admin or can privesc to admin //fmt.Printf("[%s][%s] Attempting to build a PrivEsc graph in memory using local pmapper data if it exists on the filesystem.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) - m.pmapperMod, m.pmapperError = initPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines) - m.iamSimClient = initIAMSimClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) // if m.pmapperError != nil { // fmt.Printf("[%s][%s] No pmapper data found for this account. Using cloudfox's iam-simulator for role analysis.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) diff --git a/aws/eks.go b/aws/eks.go index 61563a7..b4fe963 100644 --- a/aws/eks.go +++ b/aws/eks.go @@ -22,10 +22,11 @@ type EKSModule struct { EKSClient sdk.EKSClientInterface IAMClient sdk.AWSIAMClientInterface - Caller sts.GetCallerIdentityOutput - AWSRegions []string - AWSOutputType string - AWSTableCols string + Caller sts.GetCallerIdentityOutput + AWSRegions []string + AWSOutputType string + AWSTableCols string + PmapperDataBasePath string Goroutines int AWSProfile string @@ -72,8 +73,8 @@ func (m *EKSModule) EKS(outputDirectory string, verbosity int) { fmt.Printf("[%s][%s] Enumerating EKS clusters for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account)) // Initialized the tools we'll need to check if any workload roles are admin or can privesc to admin //fmt.Printf("[%s][%s] Attempting to build a PrivEsc graph in memory using local pmapper data if it exists on the filesystem.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) - m.pmapperMod, m.pmapperError = initPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines) - m.iamSimClient = initIAMSimClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) // if m.pmapperError != nil { // fmt.Printf("[%s][%s] No pmapper data found for this account. Using cloudfox's iam-simulator for role analysis.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) diff --git a/aws/graph.go b/aws/graph.go new file mode 100644 index 0000000..bf5a2b6 --- /dev/null +++ b/aws/graph.go @@ -0,0 +1,418 @@ +package aws + +import ( + "context" + "fmt" + "os" + "strings" + + ingestor "github.com/BishopFox/cloudfox/aws/graph/ingester" + "github.com/BishopFox/cloudfox/aws/graph/ingester/schema/models" + "github.com/BishopFox/cloudfox/aws/sdk" + "github.com/BishopFox/cloudfox/internal" + "github.com/BishopFox/cloudfox/internal/aws/policy" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/bishopfox/knownawsaccountslookup" + "github.com/sirupsen/logrus" +) + +type GraphCommand struct { + + // General configuration data + Caller sts.GetCallerIdentityOutput + AWSRegions []string + Goroutines int + AWSProfile string + WrapTable bool + AWSOutputType string + AWSTableCols string + Verbosity int + AWSOutputDirectory string + AWSConfig aws.Config + Version string + SkipAdminCheck bool + PmapperDataBasePath string + + pmapperMod PmapperModule + pmapperError error + + vendors *knownawsaccountslookup.Vendors + + // Main module data + // Used to store output data for pretty printing + output internal.OutputData2 + + modLog *logrus.Entry +} + +func (m *GraphCommand) RunGraphCommand() { + + // These struct values are used by the output module + m.output.Verbosity = m.Verbosity + m.output.Directory = m.AWSOutputDirectory + m.output.CallingModule = "graph" + m.modLog = internal.TxtLog.WithFields(logrus.Fields{ + "module": m.output.CallingModule, + }) + if m.AWSProfile == "" { + m.AWSProfile = internal.BuildAWSPath(m.Caller) + } + + m.vendors = knownawsaccountslookup.NewVendorMap() + m.vendors.PopulateKnownAWSAccounts() + + m.modLog.Info("Collecting data for graph ingestor...") + + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + + //////////////// + // Accounts + //////////////// + + accounts := m.collectAccountDataForGraph() + // write data to jsonl file for ingestor to read + fileName := fmt.Sprintf("%s/graph/%s/%s.jsonl", m.output.Directory, aws.ToString(m.Caller.Account), "accounts") + // create file and directory if it doesn't exist + if err := os.MkdirAll(fmt.Sprintf("%s/graph/%s", m.output.Directory, aws.ToString(m.Caller.Account)), 0755); err != nil { + m.modLog.Error(err) + return + } + + outputFile, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + m.modLog.Error(err) + return + } + defer outputFile.Close() + + for _, account := range accounts { + if err := internal.WriteJsonlFile(outputFile, account); err != nil { + m.modLog.Error(err) + return + } + } + + //////////////// + // Users + //////////////// + + // users := m.collectUserDataForGraph() + // // write data to jsonl file for ingestor to read + // fileName = fmt.Sprintf("%s/graph/%s/%s.jsonl", m.output.Directory, aws.ToString(m.Caller.Account), "users") + // // create file and directory if it doesn't exist + // if err := os.MkdirAll(fmt.Sprintf("%s/graph/%s", m.output.Directory, aws.ToString(m.Caller.Account)), 0755); err != nil { + // m.modLog.Error(err) + // return + // } + + // outputFile, err = os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + // if err != nil { + // m.modLog.Error(err) + // return + // } + // defer outputFile.Close() + + // for _, user := range users { + // if err := internal.WriteJsonlFile(outputFile, user); err != nil { + // m.modLog.Error(err) + // return + // } + // } + + //////////////// + // Roles + //////////////// + + roles := m.collectRoleDataForGraph() + // write data to jsonl file for ingestor to read + fileName = fmt.Sprintf("%s/graph/%s/%s.jsonl", m.output.Directory, aws.ToString(m.Caller.Account), "roles") + // create file and directory if it doesn't exist + if err := os.MkdirAll(fmt.Sprintf("%s/graph/%s", m.output.Directory, aws.ToString(m.Caller.Account)), 0755); err != nil { + m.modLog.Error(err) + return + } + + outputFile, err = os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + m.modLog.Error(err) + return + } + defer outputFile.Close() + + for _, role := range roles { + if err := internal.WriteJsonlFile(outputFile, role); err != nil { + m.modLog.Error(err) + return + } + } + + ingestor, err := ingestor.NewCloudFoxIngestor() + if err != nil { + return + } + + // Pmapper hack + + goCtx := context.Background() + sharedLogger.Infof("Verifying connectivity to Neo4J at %s", ingestor.Uri) + if err := ingestor.Driver.VerifyConnectivity(goCtx); err != nil { + sharedLogger.Error(err) + } + defer ingestor.Driver.Close(goCtx) + m.pmapperMod.GenerateCypherStatements(goCtx, ingestor.Driver) + + // back to regular stuff + + ingestor.Run(fmt.Sprintf("%s/graph/%s", m.output.Directory, aws.ToString(m.Caller.Account))) + +} + +func (m *GraphCommand) collectAccountDataForGraph() []models.Account { + //OrganizationsCommandClient := InitOrgClient(m.AWSConfig) + var accounts []models.Account + OrganizationsCommandClient := InitOrgClient(m.AWSConfig) + DescribeOrgOutput, err := sdk.CachedOrganizationsDescribeOrganization(OrganizationsCommandClient, aws.ToString(m.Caller.Account)) + if err != nil { + m.modLog.Fatal(err) + } + if DescribeOrgOutput.MasterAccountId == nil { + m.modLog.Error("Organization is not configured") + } + // If the account is not the org mgmt account, it can only see some info about itself and some info about the org mgmt account. + // populate both of them here. + if aws.ToString(DescribeOrgOutput.MasterAccountId) != aws.ToString(m.Caller.Account) { + + //create new object of type models.Account for this account + thisAccount := models.Account{ + Id: aws.ToString(m.Caller.Account), + Arn: fmt.Sprintf("arn:aws:iam::%s:root", aws.ToString(m.Caller.Account)), + //Name: aws.ToString(m.Caller.Account), + IsOrgMgmt: false, + IsChildAccount: true, + OrgMgmtAccountID: aws.ToString(DescribeOrgOutput.MasterAccountId), + OrganizationID: aws.ToString(DescribeOrgOutput.Id), + } + accounts = append(accounts, thisAccount) + + //create new object of type models.Account for the mgmt account + mgmtAccount := models.Account{ + Id: aws.ToString(DescribeOrgOutput.MasterAccountId), + Arn: aws.ToString(DescribeOrgOutput.MasterAccountArn), + Email: aws.ToString(DescribeOrgOutput.MasterAccountEmail), + //Status: string(account.Status), + //JoinedMethod: string(account.JoinedMethod), + //JoinedTimestamp: account.JoinedTimestamp.String(), + IsOrgMgmt: true, + IsChildAccount: false, + OrgMgmtAccountID: aws.ToString(DescribeOrgOutput.MasterAccountId), + OrganizationID: aws.ToString(DescribeOrgOutput.Id), + } + accounts = append(accounts, mgmtAccount) + return accounts + + } else { + // In this case we are the org mgmt account, so we can see all the accounts in the org. + ListAccounts, err := sdk.CachedOrganizationsListAccounts(OrganizationsCommandClient, aws.ToString(DescribeOrgOutput.MasterAccountId)) + if err != nil { + m.modLog.Fatal(err) + } + for _, account := range ListAccounts { + var isMgmtAccount bool + var isChildAccount bool + m.modLog.Info("Account: ", aws.ToString(account.Name)) + if aws.ToString(DescribeOrgOutput.MasterAccountId) == aws.ToString(account.Id) { + // this is the org mgmt account + isMgmtAccount = true + isChildAccount = false + } else if DescribeOrgOutput.MasterAccountId == nil { + // this is a standalone account + isChildAccount = false + isMgmtAccount = false + } else { + // this is a child account + isChildAccount = true + isMgmtAccount = false + } + + //create new object of type models.Account + account := models.Account{ + Id: aws.ToString(account.Id), + Arn: aws.ToString(account.Arn), + Name: aws.ToString(account.Name), + Email: aws.ToString(account.Email), + Status: string(account.Status), + JoinedMethod: string(account.JoinedMethod), + JoinedTimestamp: account.JoinedTimestamp.String(), + IsOrgMgmt: isMgmtAccount, + IsChildAccount: isChildAccount, + OrgMgmtAccountID: aws.ToString(DescribeOrgOutput.MasterAccountId), + OrganizationID: aws.ToString(DescribeOrgOutput.Id), + } + accounts = append(accounts, account) + } + return accounts + } +} + +func (m *GraphCommand) collectRoleDataForGraph() []models.Role { + var isAdmin, canPrivEscToAdmin string + + // iamClient := InitIAMClient(m.AWSConfig) + // iamSimClient := InitIamCommandClient(iamClient, m.Caller, m.AWSProfile, m.Goroutines) + // localAdminMap := make(map[string]bool) + + var roles []models.Role + IAMCommandClient := InitIAMClient(m.AWSConfig) + ListRolesOutput, err := sdk.CachedIamListRoles(IAMCommandClient, aws.ToString(m.Caller.Account)) + if err != nil { + m.modLog.Error(err) + } + + for _, role := range ListRolesOutput { + accountId := strings.Split(aws.ToString(role.Arn), ":")[4] + trustsdoc, err := policy.ParseRoleTrustPolicyDocument(role) + if err != nil { + m.modLog.Error(err.Error()) + break + } + + // if m.pmapperError == nil { + // isAdmin, canPrivEscToAdmin = GetPmapperResults(m.SkipAdminCheck, m.pmapperMod, role.Arn) + // } else { + // isAdmin, canPrivEscToAdmin = GetIamSimResult(m.SkipAdminCheck, role.Arn, iamSimClient, localAdminMap) + // } + + // for _, row := range m.PermissionRowsFromAllProfiles { + // if row.Arn == aws.ToString(role.Arn) { + + // // look for cases where there is a permission that allows sts:assumeRole or * + // // lowercase the action and compare it against the list of checks below + // if strings.EqualFold(row.Action, "sts:AssumeRole") || + // strings.EqualFold(row.Action, "*") || + // strings.EqualFold(row.Action, "sts:Assume*") || + // strings.EqualFold(row.Action, "sts:*") { + + // if row.Effect == "Allow" { + // //PrivEscPermissions = append(PrivEscPermissions, "sts:AssumeRole") + // if row.Resource == "*" { + // PrivEscPermissions = append(PrivEscPermissions, "sts:AssumeRole") + // } else if strings.EqualFold(row.Resource, aws.ToString(role.Arn)) { + // PrivEscPermissions = append(PrivEscPermissions, "sts:AssumeRole") + // } + // } + // } + // if row.Effect == "Deny" { + // // Remove the string sts:AssumeRole from the PrivEscPermissions slice + // for i, v := range PrivEscPermissions { + // if v == "sts:AssumeRole" { + // PrivEscPermissions = append(PrivEscPermissions[:i], PrivEscPermissions[i+1:]...) + // } + // } + // } + // } + // } + + var TrustedPrincipals []models.TrustedPrincipal + var TrustedServices []models.TrustedService + var TrustedFederatedProviders []models.TrustedFederatedProvider + //var TrustedFederatedSubjects string + var trustedProvider string + var trustedSubjects string + var vendorName string + + for _, statement := range trustsdoc.Statement { + for _, principal := range statement.Principal.AWS { + if strings.Contains(principal, ":root") { + //check to see if the accountID is known + accountID := strings.Split(principal, ":")[4] + vendorName = m.vendors.GetVendorNameFromAccountID(accountID) + } + + TrustedPrincipals = append(TrustedPrincipals, models.TrustedPrincipal{ + TrustedPrincipal: principal, + ExternalID: statement.Condition.StringEquals.StsExternalID, + VendorName: vendorName, + //IsAdmin: false, + //CanPrivEscToAdmin: false, + }) + + } + for _, service := range statement.Principal.Service { + TrustedServices = append(TrustedServices, models.TrustedService{ + TrustedService: service, + AccountID: accountId, + //IsAdmin: false, + //CanPrivEscToAdmin: false, + }) + + } + for _, federated := range statement.Principal.Federated { + if statement.Condition.StringLike.TokenActionsGithubusercontentComAud != "" || len(statement.Condition.StringLike.TokenActionsGithubusercontentComSub) > 0 { + trustedProvider = "GitHub" + trustedSubjects := strings.Join(statement.Condition.StringLike.TokenActionsGithubusercontentComSub, ",") + if trustedSubjects == "" { + trustedSubjects = "ALL REPOS!!!" + } else { + trustedSubjects = "Repos: " + trustedSubjects + } + + } else if statement.Condition.StringEquals.SAMLAud == "https://signin.aws.amazon.com/saml" { + if strings.Contains(statement.Principal.Federated[0], "AWSSSO") { + trustedProvider = "AWS SSO" // (" + statement.Principal.Federated[0] + ")" + } else if strings.Contains(statement.Principal.Federated[0], "Okta") { + trustedProvider = "Okta" // (" + statement.Principal.Federated[0] + ")" + } + trustedSubjects = "Not applicable" + } else if statement.Condition.StringEquals.OidcEksAud != "" || statement.Condition.StringEquals.OidcEksSub != nil || statement.Condition.StringLike.OidcEksAud != "" || statement.Condition.StringLike.OidcEksSub != nil { + trustedProvider = "EKS" // (" + statement.Principal.Federated[0] + ")" + // if statement.Condition.StringEquals.OidcEksSub != "" { + // trustedSubjects = statement.Condition.StringEquals.OidcEksSub + // } else if statement.Condition.StringLike.OidcEksSub != "" { + // trustedSubjects = statement.Condition.StringLike.OidcEksSub + // } else { + // trustedSubjects = "ALL SERVICE ACCOUNTS!" + // } + trustedSubjects = "ALL SERVICE ACCOUNTS!" + } else if statement.Principal.Federated[0] == "cognito-identity.amazonaws.com" { + trustedProvider = "Cognito" // (" + statement.Principal.Federated[0] + ")" + if statement.Condition.ForAnyValueStringLike.CognitoAMR != "" { + trustedSubjects = statement.Condition.ForAnyValueStringLike.CognitoAMR + } + } else { + if trustedProvider == "" && strings.Contains(statement.Principal.Federated[0], "oidc.eks") { + trustedProvider = "EKS" // (" + statement.Principal.Federated[0] + ")" + trustedSubjects = "ALL SERVICE ACCOUNTS!" + } else if trustedProvider == "" && strings.Contains(statement.Principal.Federated[0], "AWSSSO") { + trustedProvider = "AWS SSO" // (" + statement.Principal.Federated[0] + ")" + } + trustedSubjects = "Not applicable" + } + + TrustedFederatedProviders = append(TrustedFederatedProviders, models.TrustedFederatedProvider{ + TrustedFederatedProvider: federated, + ProviderShortName: trustedProvider, + TrustedSubjects: trustedSubjects, + //IsAdmin: false, + //CanPrivEscToAdmin: false, + }) + } + } + + //create new object of type models.Role + role := models.Role{ + Id: aws.ToString(role.Arn), + AccountID: accountId, + ARN: aws.ToString(role.Arn), + Name: aws.ToString(role.RoleName), + TrustsDoc: trustsdoc, + TrustedPrincipals: TrustedPrincipals, + TrustedServices: TrustedServices, + TrustedFederatedProviders: TrustedFederatedProviders, + CanPrivEscToAdmin: canPrivEscToAdmin, + IsAdmin: isAdmin, + } + roles = append(roles, role) + } + return roles +} diff --git a/aws/graph/collectors/accounts.go b/aws/graph/collectors/accounts.go new file mode 100644 index 0000000..9f1676b --- /dev/null +++ b/aws/graph/collectors/accounts.go @@ -0,0 +1 @@ +package collectors diff --git a/aws/graph/ingester/ingestor.go b/aws/graph/ingester/ingestor.go new file mode 100644 index 0000000..5fb932a --- /dev/null +++ b/aws/graph/ingester/ingestor.go @@ -0,0 +1,283 @@ +package ingestor + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/BishopFox/cloudfox/aws/graph/ingester/schema" + "github.com/BishopFox/cloudfox/aws/graph/ingester/schema/models" + "github.com/neo4j/neo4j-go-driver/v5/neo4j" + log "github.com/sirupsen/logrus" +) + +const ( + // Neo4j + MergeNodeQueryTemplate = `CALL apoc.merge.node([$labels[0]], {Id: $Id}, $properties, $properties) YIELD node as obj + CALL apoc.create.setLabels(obj, $labels) YIELD node as labeledObj + RETURN labeledObj` + + MergeRelationQueryTemplate = `UNWIND $batch as row + CALL apoc.merge.node([row.sourceLabel], apoc.map.fromValues([row.sourceProperty, row.sourceNodeId])) YIELD node as from + CALL apoc.merge.node([row.targetLabel], apoc.map.fromValues([row.targetProperty, row.targetNodeId])) YIELD node as to + CALL apoc.merge.relationship(from, row.relationshipType, {}, row.properties, to) YIELD rel + RETURN rel` + + // Using sprintf to insert the label name since the driver doesn't support parameters for labels here + // %[1]s is a nice way to say "insert the first parameter here" + CreateConstraintQueryTemplate = "CREATE CONSTRAINT IF NOT EXISTS FOR (n: %s) REQUIRE n.Id IS UNIQUE" + CreateIndexQueryTemplate = "CREATE INDEX %[1]s_Id IF NOT EXISTS FOR (n: %[1]s) ON (n.Id)" + + PostProcessMergeQueryTemplate = `MATCH (n) + WITH n.Id AS Id, COLLECT(n) AS nodesToMerge + WHERE size(nodesToMerge) > 1 + CALL apoc.refactor.mergeNodes(nodesToMerge, {properties: 'combine', mergeRels:true}) + YIELD node + RETURN count(*);` +) + +type Neo4jConfig struct { + Uri string + Username string + Password string +} + +type CloudFoxIngestor struct { + Neo4jConfig + //ResultsFile string + Driver neo4j.DriverWithContext + TmpDir string +} + +func NewCloudFoxIngestor() (*CloudFoxIngestor, error) { + config := Neo4jConfig{ + Uri: "neo4j://localhost:7687", + Username: "neo4j", + Password: "cloudfox", + } + driver, err := neo4j.NewDriverWithContext(config.Uri, neo4j.BasicAuth(config.Username, config.Password, "")) + if err != nil { + return nil, err + } + return &CloudFoxIngestor{ + Neo4jConfig: config, + //ResultsFile: resultsFile, + Driver: driver, + }, nil +} + +// func unzipToTemp(zipFilePath string) (string, error) { +// // Create a temporary directory to extract the zip file to +// tempDir, err := os.MkdirTemp("", "cloudfox-graph") +// if err != nil { +// return "", err +// } + +// // Open the zip file and extract to a temporary directory +// zipfile, err := zip.OpenReader(zipFilePath) +// if err != nil { +// return "", err +// } +// defer zipfile.Close() + +// for _, file := range zipfile.File { +// path := filepath.Join(tempDir, file.Name) +// log.Debugf("Extracting file: %s", path) + +// fileData, err := file.Open() +// if err != nil { +// return "", err +// } +// defer fileData.Close() + +// newFile, err := os.Create(path) +// if err != nil { +// return "", err +// } +// defer newFile.Close() + +// if _, err := io.Copy(newFile, fileData); err != nil { +// return "", err +// } +// } +// return tempDir, nil +// } + +func (i *CloudFoxIngestor) ProcessFile(path string, info os.FileInfo) error { + log.Infof("Processing file: %s", info.Name()) + + switch info.Name() { + case "accounts.jsonl": + return i.ProcessFileObjects(path, schema.Account, schema.Account) + case "roles.jsonl": + return i.ProcessFileObjects(path, schema.Role, schema.Role) + // case "servicePrincipals.jsonl": + // return i.ProcessFileObjects(path, schema.GraphServicePrincipal, schema.GraphObject) + // case "applications.jsonl": + // return i.ProcessFileObjects(path, schema.GraphApplication, schema.GraphObject) + // case "devices.jsonl": + // return i.ProcessFileObjects(path, schema.GraphDevice, schema.GraphObject) + // case "directoryRoles.jsonl": + // return i.ProcessFileObjects(path, schema.GraphRole, schema.GraphObject) + // case "subscriptions.jsonl": + // return i.ProcessFileObjects(path, schema.Subscription, schema.ArmResource) + // case "tenants.jsonl": + // return i.ProcessFileObjects(path, schema.Tenant, schema.ArmResource) + // case "rbac.jsonl": + // return i.ProcessFileObjects(path, schema.AzureRbac, "") + default: + return nil + } +} + +func (i *CloudFoxIngestor) ProcessFileObjects(path string, objectType schema.NodeLabel, generalType schema.NodeLabel) error { + + var object = models.NodeLabelToNodeMap[objectType] + + // Open the file + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + // Read the file line by line + scanner := bufio.NewScanner(file) + + //Iterate over the lines and create the nodes + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\n") + + // Skip empty lines + if len(line) > 0 { + if err := json.Unmarshal([]byte(line), &object); err != nil { + log.Errorf("%s : %s", err, line) + continue + } + } + relationships := object.MakeRelationships() + if err := i.InsertDBObjects(object, relationships, []schema.NodeLabel{generalType, objectType}); err != nil { + log.Error(err) + continue + } + + } + return nil +} + +func (i *CloudFoxIngestor) InsertDBObjects(object schema.Node, relationships []schema.Relationship, labels []schema.NodeLabel) error { + goCtx := context.Background() + var err error + + // Insert the node + if object != nil { + nodeMap, err := schema.ConvertCustomTypesToNeo4j(&object) + if err != nil { + log.Errorf("Error converting custom types to neo4j: %s -- %v", err, object) + return err + } + + //nodeMap := schema.AsNeo4j(&object) + nodeQueryParams := map[string]interface{}{ + "Id": nodeMap["Id"], + "labels": labels, + "properties": nodeMap, + } + _, err = neo4j.ExecuteQuery(goCtx, i.Driver, MergeNodeQueryTemplate, nodeQueryParams, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + if err != nil { + log.Errorf("Error inserting node: %s -- %v", err, nodeQueryParams) + return err + } + } + + // Insert the relationships + if len(relationships) > 0 { + var relationshipInterface []map[string]interface{} + + // Check the default SourceProperty and TargetProperty values + for _, relationship := range relationships { + var currentRelationship map[string]interface{} + + if relationship.SourceProperty == "" { + relationship.SourceProperty = "Id" + } + if relationship.TargetProperty == "" { + relationship.TargetProperty = "Id" + } + relationshipBytes, err := json.Marshal(relationship) + if err != nil { + return err + } + if err := json.Unmarshal(relationshipBytes, ¤tRelationship); err != nil { + return err + } + relationshipInterface = append(relationshipInterface, currentRelationship) + } + + _, err = neo4j.ExecuteQuery(goCtx, i.Driver, MergeRelationQueryTemplate, map[string]interface{}{"batch": relationshipInterface}, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + if err != nil { + log.Errorf("Error inserting relationships: %s -- %v", err, relationshipInterface) + return err + } + } + + return nil +} + +func (i *CloudFoxIngestor) Run(graphOutputDir string) error { + goCtx := context.Background() + log.Infof("Verifying connectivity to Neo4J at %s", i.Uri) + if err := i.Driver.VerifyConnectivity(goCtx); err != nil { + return err + } + defer i.Driver.Close(goCtx) + var err error + + // Get the label to model map + + // Create constraints and indexes + // log.Info("Creating constraints and indexes for labels") + // for label := range models.NodeLabelToNodeMap { + // for _, query := range []string{CreateConstraintQueryTemplate, CreateIndexQueryTemplate} { + // _, err := neo4j.ExecuteQuery(goCtx, i.Driver, fmt.Sprintf(query, label), nil, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + // if err != nil { + // log.Error(err) + // continue + // } + // } + // } + + // Process the files in the output directory + fileWg := new(sync.WaitGroup) + filepath.Walk(graphOutputDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if graphOutputDir == path { + return nil + } + + fileWg.Add(1) + go func(path string) { + defer fileWg.Done() + i.ProcessFile(path, info) + log.Infof("Finished processing file: %s", info.Name()) + }(path) + return nil + }) + fileWg.Wait() + log.Info("Finished processing files") + + // Run the post processing merge query + log.Info("Running post processing merge query") + _, err = neo4j.ExecuteQuery(goCtx, i.Driver, PostProcessMergeQueryTemplate, nil, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + if err != nil { + log.Error(err) + return err + } + return nil +} diff --git a/aws/graph/ingester/schema/models/account.go b/aws/graph/ingester/schema/models/account.go new file mode 100644 index 0000000..07b76d6 --- /dev/null +++ b/aws/graph/ingester/schema/models/account.go @@ -0,0 +1,44 @@ +package models + +import "github.com/BishopFox/cloudfox/aws/graph/ingester/schema" + +type Account struct { + Id string + Arn string + Email string + Name string + Status string + JoinedMethod string + JoinedTimestamp string + IsOrgMgmt bool + IsChildAccount bool + OrgMgmtAccountID string + OrganizationID string +} + +func (a *Account) MakeRelationships() []schema.Relationship { + var relationships []schema.Relationship + + // make relationship from children accounts to parent org + if a.IsChildAccount { + // make relationship from child to org mgmt account + relationships = append(relationships, schema.Relationship{ + SourceNodeID: a.OrganizationID, + TargetNodeID: a.Id, + SourceLabel: schema.Organization, + TargetLabel: schema.Account, + RelationshipType: schema.Manages, + }) + // make relationship from parent org mgmt account to child account + relationships = append(relationships, schema.Relationship{ + SourceNodeID: a.Id, + TargetNodeID: a.OrganizationID, + SourceLabel: schema.Account, + TargetLabel: schema.Organization, + RelationshipType: schema.MemberOf, + }) + + } + + return relationships +} diff --git a/aws/graph/ingester/schema/models/constants.go b/aws/graph/ingester/schema/models/constants.go new file mode 100644 index 0000000..377c48c --- /dev/null +++ b/aws/graph/ingester/schema/models/constants.go @@ -0,0 +1,11 @@ +package models + +import ( + "github.com/BishopFox/cloudfox/aws/graph/ingester/schema" +) + +var NodeLabelToNodeMap = map[schema.NodeLabel]schema.Node{ + schema.Organization: &Organization{}, + schema.Account: &Account{}, + schema.Role: &Role{}, +} diff --git a/aws/graph/ingester/schema/models/org.go b/aws/graph/ingester/schema/models/org.go new file mode 100644 index 0000000..589c25a --- /dev/null +++ b/aws/graph/ingester/schema/models/org.go @@ -0,0 +1,20 @@ +package models + +import ( + "github.com/BishopFox/cloudfox/aws/graph/ingester/schema" +) + +type Organization struct { + Id string + OrgId string + Arn string + MasterAccountArn string + MasterAccountId string + MasterAccountEmail string + ChildAccounts []Account + MgmtAccount Account +} + +func (o *Organization) MakeRelationships() []schema.Relationship { + return []schema.Relationship{} +} diff --git a/aws/graph/ingester/schema/models/resource.go b/aws/graph/ingester/schema/models/resource.go new file mode 100644 index 0000000..db5af6a --- /dev/null +++ b/aws/graph/ingester/schema/models/resource.go @@ -0,0 +1,5 @@ +package models + +type Resource struct { + ARN string +} diff --git a/aws/graph/ingester/schema/models/roles.go b/aws/graph/ingester/schema/models/roles.go new file mode 100644 index 0000000..57ae324 --- /dev/null +++ b/aws/graph/ingester/schema/models/roles.go @@ -0,0 +1,647 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/BishopFox/cloudfox/aws/graph/ingester/schema" + "github.com/BishopFox/cloudfox/internal/aws/policy" + "github.com/BishopFox/cloudfox/internal/common" + "github.com/dominikbraun/graph" +) + +type Role struct { + Id string + AccountID string + ARN string + Name string + TrustsDoc policy.TrustPolicyDocument + TrustedPrincipals []TrustedPrincipal + TrustedServices []TrustedService + TrustedFederatedProviders []TrustedFederatedProvider + CanPrivEscToAdmin string + IsAdmin string + IdValue string + IsAdminP bool + PathToAdminSameAccount bool + PathToAdminCrossAccount bool +} + +type TrustedPrincipal struct { + TrustedPrincipal string + ExternalID string + VendorName string + //IsAdmin bool + //CanPrivEscToAdmin bool +} + +type TrustedService struct { + TrustedService string + AccountID string + //IsAdmin bool + //CanPrivEscToAdmin bool +} + +type TrustedFederatedProvider struct { + TrustedFederatedProvider string + ProviderShortName string + TrustedSubjects string + //IsAdmin bool + //CanPrivEscToAdmin bool +} + +func (a *Role) MakeRelationships() []schema.Relationship { + var relationships []schema.Relationship + //instance := singleton.GetInstance() + + // get thisAccount id from role arn + var thisAccount string + if len(a.ARN) >= 25 { + thisAccount = a.ARN[13:25] + } else { + fmt.Sprintf("Could not get account number from this role arn%s", a.ARN) + } + + // make a relationship between each role and the account it belongs to + relationships = append(relationships, schema.Relationship{ + SourceNodeID: a.Id, + TargetNodeID: thisAccount, + SourceLabel: schema.Role, + TargetLabel: schema.Account, + RelationshipType: schema.MemberOf, + }) + + for _, TrustedPrincipal := range a.TrustedPrincipals { + //get account id from the trusted principal arn + var trustedPrincipalAccount string + if len(TrustedPrincipal.TrustedPrincipal) >= 25 { + trustedPrincipalAccount = TrustedPrincipal.TrustedPrincipal[13:25] + } else { + fmt.Sprintf("Could not get account number from this TrustedPrincipal%s", TrustedPrincipal.TrustedPrincipal) + } + var PermissionsRowAccount string + // make a TRUSTED_BY relationship between the role and the trusted principal. This does not mean the principal can assume this role, we need more logic to determine that (see below) + // relationships = append(relationships, schema.Relationship{ + // SourceNodeID: TrustedPrincipal.TrustedPrincipal, + // TargetNodeID: a.Id, + // SourceLabel: schema.Principal, + // TargetLabel: schema.Role, + // RelationshipType: schema.IsTrustedBy, + // }) + // // make a TRUSTS relationship between the trusted principal and this role. This does not mean the principal can assume this role, we need more logic to determine that (see below) + // relationships = append(relationships, schema.Relationship{ + // SourceNodeID: a.Id, + // TargetNodeID: TrustedPrincipal.TrustedPrincipal, + // SourceLabel: schema.Role, + // TargetLabel: schema.Principal, + // RelationshipType: schema.Trusts, + // }) + // // make a MEMBER_OF relationship between the role and the account + // relationships = append(relationships, schema.Relationship{ + // SourceNodeID: TrustedPrincipal.TrustedPrincipal, + // TargetNodeID: trustedPrincipalAccount, + // SourceLabel: schema.Principal, + // TargetLabel: schema.Account, + // RelationshipType: schema.MemberOf, + // }) + + // if the role trusts a principal in this same account explicitly, then the principal can assume the role + if thisAccount == trustedPrincipalAccount { + // make a CAN_ASSUME relationship between the trusted principal and this role + relationships = append(relationships, schema.Relationship{ + SourceNodeID: TrustedPrincipal.TrustedPrincipal, + TargetNodeID: a.Id, + SourceLabel: schema.Principal, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssume, + }) + + // make a CAN_BE_ASSUMED_BY relationship between this role and the trusted principal + // relationships = append(relationships, schema.Relationship{ + // SourceNodeID: a.Id, + // TargetNodeID: TrustedPrincipal.TrustedPrincipal, + // SourceLabel: schema.Role, + // TargetLabel: schema.Principal, + // RelationshipType: schema.CanBeAssumedBy, + // }) + } + + // If the role trusts a principal in this account or another account using the :root notation, then we need to iterate over all of the rows in AllPermissionsRows to find the principals that have sts:AssumeRole permissions on this role + // if the role we are looking at trusts root in it's own account + + if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", thisAccount)) { + // iterate over all rows in AllPermissionsRows + for _, PermissionsRow := range common.PermissionRowsFromAllProfiles { + // but we only care about the rows that have arns that are in this account + + if len(PermissionsRow.Arn) >= 25 { + PermissionsRowAccount = PermissionsRow.Arn[13:25] + } else { + fmt.Sprintf("Could not get account number from this PermissionsRow%s", PermissionsRow.Arn) + } + + if PermissionsRowAccount == thisAccount { + // lets only look for rows that have sts:AssumeRole permissions + if strings.EqualFold(PermissionsRow.Action, "sts:AssumeRole") || + strings.EqualFold(PermissionsRow.Action, "*") || + strings.EqualFold(PermissionsRow.Action, "sts:Assume*") || + strings.EqualFold(PermissionsRow.Action, "sts:*") { + // lets only focus on rows that have an effect of Allow + if strings.EqualFold(PermissionsRow.Effect, "Allow") { + // if the resource is * or the resource is this role arn, then this principal can assume this role + if PermissionsRow.Resource == "*" || strings.Contains(PermissionsRow.Resource, a.ARN) { + // make a CAN_ASSUME relationship between the trusted principal and this role + //evaluate if the princiapl is a user or a role and set a variable accordingly + //var principalType schema.NodeLabel + if strings.EqualFold(PermissionsRow.Type, "User") { + relationships = append(relationships, schema.Relationship{ + SourceNodeID: PermissionsRow.Arn, + TargetNodeID: a.Id, + SourceLabel: schema.User, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssume, + }) + } else if strings.EqualFold(PermissionsRow.Type, "Role") { + relationships = append(relationships, schema.Relationship{ + SourceNodeID: PermissionsRow.Arn, + TargetNodeID: a.Id, + SourceLabel: schema.Role, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssume, + }) + } + + // relationships = append(relationships, schema.Relationship{ + // SourceNodeID: PermissionsRow.Arn, + // TargetNodeID: a.Id, + // SourceLabel: principalType, + // TargetLabel: schema.Role, + // RelationshipType: schema.CanAssumeTest, + // }) + // make a CAN_BE_ASSUMED_BY relationship between this role and the trusted principal + // relationships = append(relationships, schema.Relationship{ + // SourceNodeID: a.Id, + // TargetNodeID: PermissionsRow.Arn, + // SourceLabel: schema.Role, + // TargetLabel: schema.Principal, + // RelationshipType: schema.CanBeAssumedByTest, + // }) + + } + } + } + + } + } + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", trustedPrincipalAccount)) { + // iterate over all rows in AllPermissionsRows + for _, PermissionsRow := range common.PermissionRowsFromAllProfiles { + // but we only care about the rows that have arns that are in this other account + if len(PermissionsRow.Arn) >= 25 { + PermissionsRowAccount = PermissionsRow.Arn[13:25] + } else { + fmt.Sprintf("Could not get account number from this PermissionsRow%s", PermissionsRow.Arn) + } + if PermissionsRowAccount == trustedPrincipalAccount { + // lets only look for rows that have sts:AssumeRole permissions + if strings.EqualFold(PermissionsRow.Action, "sts:AssumeRole") || + strings.EqualFold(PermissionsRow.Action, "*") || + strings.EqualFold(PermissionsRow.Action, "sts:Assume*") || + strings.EqualFold(PermissionsRow.Action, "sts:*") { + // lets only focus on rows that have an effect of Allow + if strings.EqualFold(PermissionsRow.Effect, "Allow") { + // if the resource is * or the resource is this role arn, then this principal can assume this role + if PermissionsRow.Resource == "*" || strings.Contains(PermissionsRow.Resource, a.ARN) { + // make a CAN_ASSUME relationship between the trusted principal and this role + + if strings.EqualFold(PermissionsRow.Type, "User") { + relationships = append(relationships, schema.Relationship{ + SourceNodeID: PermissionsRow.Arn, + TargetNodeID: a.Id, + SourceLabel: schema.User, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssumeCrossAccount, + }) + relationships = append(relationships, schema.Relationship{ + SourceNodeID: PermissionsRow.Arn, + TargetNodeID: a.Id, + SourceLabel: schema.User, + TargetLabel: schema.Role, + RelationshipType: schema.CanAccess, + }) + } else if strings.EqualFold(PermissionsRow.Type, "Role") { + relationships = append(relationships, schema.Relationship{ + SourceNodeID: PermissionsRow.Arn, + TargetNodeID: a.Id, + SourceLabel: schema.Role, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssumeCrossAccount, + }) + relationships = append(relationships, schema.Relationship{ + SourceNodeID: PermissionsRow.Arn, + TargetNodeID: a.Id, + SourceLabel: schema.Role, + TargetLabel: schema.Role, + RelationshipType: schema.CanAccess, + }) + } + // // make a CAN_BE_ASSUMED_BY relationship between this role and the trusted principal + // relationships = append(relationships, schema.Relationship{ + // SourceNodeID: a.Id, + // TargetNodeID: PermissionsRow.Arn, + // SourceLabel: schema.Role, + // TargetLabel: schema.Principal, + // RelationshipType: schema.CanBeAssumedByTest, + // }) + + } + } + } + + } + } + // If the role trusts :root in another account and the trusted principal is a vendor, we will make a relationship between our role and a vendor node instead of a principal node + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, ":root") && TrustedPrincipal.VendorName != "" { + relationships = append(relationships, schema.Relationship{ + SourceNodeID: TrustedPrincipal.TrustedPrincipal, + TargetNodeID: a.Id, + SourceLabel: schema.Vendor, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssume, + }) + } + + } + + for _, TrustedService := range a.TrustedServices { + // make relationship from trusted service to this role of type can assume + relationships = append(relationships, schema.Relationship{ + SourceNodeID: TrustedService.TrustedService + "_" + TrustedService.AccountID, + TargetNodeID: a.Id, + SourceLabel: schema.Service, + TargetLabel: schema.Role, + RelationshipType: schema.IsTrustedBy, + }) + // make relationship from this role to trusted service of type can be assumed by + relationships = append(relationships, schema.Relationship{ + SourceNodeID: a.Id, + TargetNodeID: TrustedService.TrustedService + "_" + TrustedService.AccountID, + SourceLabel: schema.Role, + TargetLabel: schema.Service, + RelationshipType: schema.Trusts, + }) + } + + for _, TrustedFederatedProvider := range a.TrustedFederatedProviders { + // make relationship from trusted federated provider to this role of type can assume + relationships = append(relationships, schema.Relationship{ + SourceNodeID: TrustedFederatedProvider.TrustedFederatedProvider, + TargetNodeID: a.Id, + SourceLabel: schema.FederatedIdentity, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssume, + }) + // make relationship from this role to trusted federated provider of type can be assumed by + relationships = append(relationships, schema.Relationship{ + SourceNodeID: a.Id, + TargetNodeID: TrustedFederatedProvider.TrustedFederatedProvider, + SourceLabel: schema.Role, + TargetLabel: schema.FederatedIdentity, + RelationshipType: schema.CanBeAssumedBy, + }) + // make relationship from trusted federated provider to this role of type can assume + relationships = append(relationships, schema.Relationship{ + SourceNodeID: TrustedFederatedProvider.TrustedFederatedProvider, + TargetNodeID: a.Id, + SourceLabel: schema.FederatedIdentity, + TargetLabel: schema.Role, + RelationshipType: schema.IsTrustedBy, + }) + // make relationship from this role to trusted federated provider of type can be assumed by + relationships = append(relationships, schema.Relationship{ + SourceNodeID: a.Id, + TargetNodeID: TrustedFederatedProvider.TrustedFederatedProvider, + SourceLabel: schema.Role, + TargetLabel: schema.FederatedIdentity, + RelationshipType: schema.Trusts, + }) + } + + return relationships +} + +// func (a *Node) GenerateAttributes() map[string]string { +// attributes := make(map[string]string) +// attributes["Id"] = a.Id +// attributes["Name"] = a.Name +// attributes["Type"] = "Role" +// attributes["AccountID"] = a.AccountID +// attributes["ARN"] = a.ARN +// attributes["CanPrivEscToAdmin"] = a.CanPrivEscToAdmin +// attributes["IsAdmin"] = a.IsAdmin +// attributes["IdValue"] = a.IdValue +// return attributes +// } + +// func (a *Role) MergeAttributes(newAttributes map[string]string) { +// if a.Id == "" { +// a.Id = newAttributes["Id"] +// } +// if a.Name == "" { +// a.Name = newAttributes["Name"] +// } +// if a.Type == "" { +// a.Type = newAttributes["Type"] +// } +// if a.AccountID == "" { +// a.AccountID = newAttributes["AccountID"] +// } +// if a.ARN == "" { +// a.ARN = newAttributes["ARN"] +// } +// if a.CanPrivEscToAdmin == "" { +// a.CanPrivEscToAdmin = newAttributes["CanPrivEscToAdmin"] +// } +// if a.IsAdmin == "" { +// a.IsAdmin = newAttributes["IsAdmin"] +// } +// if a.IdValue == "" { +// a.IdValue = newAttributes["IdValue"] +// } +// } + +func (a *Role) MakeVertices(GlobalGraph graph.Graph[string, string]) { + + // make a vertex for this role as populate all of the data in the Role struct as attributes + err := GlobalGraph.AddVertex( + a.Id, + graph.VertexAttribute("Name", a.Name), + graph.VertexAttribute("Type", "Role"), + graph.VertexAttribute("AccountID", a.AccountID), + graph.VertexAttribute("ARN", a.ARN), + graph.VertexAttribute("CanPrivEscToAdmin", a.CanPrivEscToAdmin), + graph.VertexAttribute("IsAdmin", a.IsAdmin), + graph.VertexAttribute("IdValue", a.IdValue), + ) + if err != nil { + if err == graph.ErrVertexAlreadyExists { + fmt.Println(a.Id + " already exists") + } + } + +} + +// func MakeAllVertices(GlobalRoles []Role, GlobalPmapperGraph aws.PmapperModule) (GlobalGraph graph.Graph[string, string]) { + +// // for all nodes in the GlobalPmapperGraph, check to see if they exist in the GlobalRoles slice. If they do, then update the node with the new data. If they don't, then add the node to the GlobalRoles slice +// for _, node := range GlobalPmapperGraph.Nodes { +// // check to see if the node exists in the GlobalRoles slice +// var nodeExists bool +// for _, role := range GlobalRoles { +// if node.Id == role.Id { +// nodeExists = true +// } +// } + +// // // make a vertex for this role as populate all of the data in the Role struct as attributes +// // err := GlobalGraph.AddVertex( +// // a.Id, +// // graph.VertexAttribute("Name", a.Name), +// // graph.VertexAttribute("Type", "Role"), +// // graph.VertexAttribute("AccountID", a.AccountID), +// // graph.VertexAttribute("ARN", a.ARN), +// // graph.VertexAttribute("CanPrivEscToAdmin", a.CanPrivEscToAdmin), +// // graph.VertexAttribute("IsAdmin", a.IsAdmin), +// // graph.VertexAttribute("IdValue", a.IdValue), +// // ) +// // if err != nil { +// // if err == graph.ErrVertexAlreadyExists { +// // fmt.Println(a.Id + " already exists") +// // } +// // } + +// } + +func (a *Role) MakeEdges(GlobalGraph graph.Graph[string, string]) []schema.Relationship { + var relationships []schema.Relationship + + // get thisAccount id from role arn + var thisAccount string + if len(a.ARN) >= 25 { + thisAccount = a.ARN[13:25] + } else { + fmt.Sprintf("Could not get account number from this role arn%s", a.ARN) + } + + for _, TrustedPrincipal := range a.TrustedPrincipals { + //get account id from the trusted principal arn + var trustedPrincipalAccount string + if len(TrustedPrincipal.TrustedPrincipal) >= 25 { + trustedPrincipalAccount = TrustedPrincipal.TrustedPrincipal[13:25] + } else { + fmt.Sprintf("Could not get account number from this TrustedPrincipal%s", TrustedPrincipal.TrustedPrincipal) + } + var PermissionsRowAccount string + + // if the role trusts a principal in this same account explicitly, then the principal can assume the role + if thisAccount == trustedPrincipalAccount { + // make a CAN_ASSUME relationship between the trusted principal and this role + + err := GlobalGraph.AddEdge( + TrustedPrincipal.TrustedPrincipal, + a.Id, + graph.EdgeAttribute("AssumeRole", "Same account explicit trust"), + ) + if err != nil { + fmt.Println(err) + fmt.Println(TrustedPrincipal.TrustedPrincipal + a.Id + "Same account explicit trust") + } + } + + // If the role trusts a principal in this account or another account using the :root notation, then we need to iterate over all of the rows in AllPermissionsRows to find the principals that have sts:AssumeRole permissions on this role + // if the role we are looking at trusts root in it's own account + + if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", thisAccount)) { + err := GlobalGraph.AddVertex( + a.Id, + graph.VertexAttribute("Name", a.Name), + graph.VertexAttribute("Type", "Account"), + graph.VertexAttribute("AccountID", a.AccountID), + ) + if err != nil { + if err == graph.ErrVertexAlreadyExists { + fmt.Println(a.Id + " already exists") + } + } + // iterate over all rows in AllPermissionsRows + for _, PermissionsRow := range common.PermissionRowsFromAllProfiles { + // but we only care about the rows that have arns that are in this account + + if len(PermissionsRow.Arn) >= 25 { + PermissionsRowAccount = PermissionsRow.Arn[13:25] + } else { + fmt.Sprintf("Could not get account number from this PermissionsRow%s", PermissionsRow.Arn) + } + + if PermissionsRowAccount == thisAccount { + // lets only look for rows that have sts:AssumeRole permissions + if strings.EqualFold(PermissionsRow.Action, "sts:AssumeRole") || + strings.EqualFold(PermissionsRow.Action, "*") || + strings.EqualFold(PermissionsRow.Action, "sts:Assume*") || + strings.EqualFold(PermissionsRow.Action, "sts:*") { + // lets only focus on rows that have an effect of Allow + if strings.EqualFold(PermissionsRow.Effect, "Allow") { + // if the resource is * or the resource is this role arn, then this principal can assume this role + if PermissionsRow.Resource == "*" || strings.Contains(PermissionsRow.Resource, a.ARN) { + // make a CAN_ASSUME relationship between the trusted principal and this role + //evaluate if the princiapl is a user or a role and set a variable accordingly + //var principalType schema.NodeLabel + if strings.EqualFold(PermissionsRow.Type, "User") { + err := GlobalGraph.AddEdge( + PermissionsRow.Arn, + a.Id, + graph.EdgeAttribute("AssumeRole", "Same account root trust and trusted principal has permission to assume role"), + ) + if err != nil { + fmt.Println(err) + fmt.Println(PermissionsRow.Arn + a.Id + "Same account root trust and trusted principal has permission to assume role") + } + } else if strings.EqualFold(PermissionsRow.Type, "Role") { + err := GlobalGraph.AddEdge( + PermissionsRow.Arn, + a.Id, + graph.EdgeAttribute("AssumeRole", "Same account root trust and trusted principal has permission to assume role"), + ) + if err != nil { + fmt.Println(err) + fmt.Println(PermissionsRow.Arn + a.Id + "Same account root trust and trusted principal has permission to assume role") + } + } + } + } + } + } + } + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, ":root") && TrustedPrincipal.VendorName != "" { + err := GlobalGraph.AddVertex( + a.Id, + graph.VertexAttribute("Name", a.Name), + graph.VertexAttribute("Type", "Account"), + graph.VertexAttribute("AccountID", a.AccountID), + graph.VertexAttribute("VendorName", TrustedPrincipal.VendorName), + ) + if err != nil { + if err == graph.ErrVertexAlreadyExists { + fmt.Println(a.Id + " already exists") + } + } + // If the role trusts :root in another account and the trusted principal is a vendor, we will make a relationship between our role and a vendor node instead of a principal node + relationships = append(relationships, schema.Relationship{ + SourceNodeID: TrustedPrincipal.TrustedPrincipal, + TargetNodeID: a.Id, + SourceLabel: schema.Vendor, + TargetLabel: schema.Role, + RelationshipType: schema.CanAssume, + }) + err = GlobalGraph.AddEdge( + //TrustedPrincipal.TrustedPrincipal, + TrustedPrincipal.VendorName, + a.Id, + graph.EdgeAttribute("VendorAssumeRole", "Cross account root trust and trusted principal is a vendor"), + ) + if err != nil { + fmt.Println(err) + fmt.Println(TrustedPrincipal.VendorName + a.Id + "Cross account root trust and trusted principal is a vendor") + + } + } else if strings.Contains(TrustedPrincipal.TrustedPrincipal, fmt.Sprintf("%s:root", trustedPrincipalAccount)) { + err := GlobalGraph.AddVertex( + a.Id, + graph.VertexAttribute("Name", a.Name), + graph.VertexAttribute("Type", "Account"), + graph.VertexAttribute("AccountID", a.AccountID), + ) + if err != nil { + if err == graph.ErrVertexAlreadyExists { + fmt.Println(a.Id + " already exists") + } + } + // iterate over all rows in AllPermissionsRows + for _, PermissionsRow := range common.PermissionRowsFromAllProfiles { + // but we only care about the rows that have arns that are in this other account + if len(PermissionsRow.Arn) >= 25 { + PermissionsRowAccount = PermissionsRow.Arn[13:25] + } else { + fmt.Sprintf("Could not get account number from this PermissionsRow%s", PermissionsRow.Arn) + } + if PermissionsRowAccount == trustedPrincipalAccount { + // lets only look for rows that have sts:AssumeRole permissions + if strings.EqualFold(PermissionsRow.Action, "sts:AssumeRole") || + strings.EqualFold(PermissionsRow.Action, "*") || + strings.EqualFold(PermissionsRow.Action, "sts:Assume*") || + strings.EqualFold(PermissionsRow.Action, "sts:*") { + // lets only focus on rows that have an effect of Allow + if strings.EqualFold(PermissionsRow.Effect, "Allow") { + // if the resource is * or the resource is this role arn, then this principal can assume this role + if PermissionsRow.Resource == "*" || strings.Contains(PermissionsRow.Resource, a.ARN) { + // make a CAN_ASSUME relationship between the trusted principal and this role + + if strings.EqualFold(PermissionsRow.Type, "User") { + err := GlobalGraph.AddEdge( + PermissionsRow.Arn, + a.Id, + graph.EdgeAttribute("CrossAccountAssumeRole", "Cross account root trust and trusted principal has permission to assume role"), + ) + if err != nil { + fmt.Println(err) + fmt.Println(PermissionsRow.Arn + a.Id + "Cross account root trust and trusted principal has permission to assume role") + } + + } else if strings.EqualFold(PermissionsRow.Type, "Role") { + err := GlobalGraph.AddEdge( + PermissionsRow.Arn, + a.Id, + graph.EdgeAttribute("CrossAccountAssumeRole", "Cross account root trust and trusted principal has permission to assume role"), + ) + if err != nil { + fmt.Println(err) + fmt.Println(PermissionsRow.Arn + a.Id + "Cross account root trust and trusted principal has permission to assume role") + } + } + } + } + } + + } + } + + } + } + // pmapper takes care of this part so commenting out for now - but leaving as a placeholder + + // for _, TrustedService := range a.TrustedServices { + // // make relationship from trusted service to this role of type can assume + // // make relationship from this role to trusted service of type can be assumed by + // } + + for _, TrustedFederatedProvider := range a.TrustedFederatedProviders { + // make relationship from trusted federated provider to this role of type can assume + + GlobalGraph.AddVertex( + TrustedFederatedProvider.TrustedFederatedProvider, + graph.VertexAttribute("Type", "FederatedIdentity"), + ) + err := GlobalGraph.AddEdge( + TrustedFederatedProvider.TrustedFederatedProvider, + a.Id, + graph.EdgeAttribute("FederatedAssumeRole", "Trusted federated provider"), + ) + if err != nil { + fmt.Println(err) + fmt.Println(TrustedFederatedProvider.TrustedFederatedProvider + a.Id + "Trusted federated provider") + } + + } + + return relationships +} diff --git a/aws/graph/ingester/schema/models/users.go b/aws/graph/ingester/schema/models/users.go new file mode 100644 index 0000000..2ed3f40 --- /dev/null +++ b/aws/graph/ingester/schema/models/users.go @@ -0,0 +1,71 @@ +package models + +import ( + "fmt" + + "github.com/BishopFox/cloudfox/aws/graph/ingester/schema" +) + +type User struct { + Id string + ARN string + Name string + IsAdmin string + CanPrivEscToAdmin string + IdValue string +} + +func (a *User) MakeRelationships() []schema.Relationship { + // make a relationship between each role and the account it belongs to + relationships := []schema.Relationship{} + + // get thisAccount id from user arn + var thisAccount string + if len(a.ARN) >= 25 { + thisAccount = a.ARN[13:25] + } else { + fmt.Sprintf("Could not get account number from this user arn%s", a.ARN) + } + + relationships = append(relationships, schema.Relationship{ + SourceNodeID: a.Id, + TargetNodeID: thisAccount, + SourceLabel: schema.User, + TargetLabel: schema.Account, + RelationshipType: schema.MemberOf, + Properties: map[string]interface{}{}, + }) + return relationships +} + +func (a *User) GenerateAttributes() map[string]string { + return map[string]string{ + "Id": a.Id, + "ARN": a.ARN, + "Name": a.Name, + "IsAdmin": a.IsAdmin, + "CanPrivEscToAdmin": a.CanPrivEscToAdmin, + "IdValue": a.IdValue, + } +} + +func (a *User) MergeAttributes(other map[string]string) { + if other["Id"] != "" { + a.Id = other["Id"] + } + if other["ARN"] != "" { + a.ARN = other["ARN"] + } + if other["Name"] != "" { + a.Name = other["Name"] + } + if other["IsAdmin"] != "" { + a.IsAdmin = other["IsAdmin"] + } + if other["CanPrivEscToAdmin"] != "" { + a.CanPrivEscToAdmin = other["CanPrivEscToAdmin"] + } + if other["IdValue"] != "" { + a.IdValue = other["IdValue"] + } +} diff --git a/aws/graph/ingester/schema/schema.go b/aws/graph/ingester/schema/schema.go new file mode 100644 index 0000000..4de803d --- /dev/null +++ b/aws/graph/ingester/schema/schema.go @@ -0,0 +1,148 @@ +package schema + +import ( + "fmt" + "reflect" + + "github.com/goccy/go-json" + "golang.org/x/exp/slices" +) + +type RelationshipType string +type NodeLabel string + +type Node interface { + MakeRelationships() []Relationship +} + +type Relationship struct { + SourceNodeID string `json:"sourceNodeId"` + TargetNodeID string `json:"targetNodeId"` + SourceLabel NodeLabel `json:"sourceLabel"` + TargetLabel NodeLabel `json:"targetLabel"` + RelationshipType RelationshipType `json:"relationshipType"` + Properties map[string]interface{} `json:"properties"` + SourceProperty string `json:"sourceProperty"` + TargetProperty string `json:"targetProperty"` +} + +const ( + // Relationships + + AssociatedTo RelationshipType = "AssociatedTo" + AttachedTo RelationshipType = "AttachedTo" + Authenticates RelationshipType = "Authenticates" + ConnectedTo RelationshipType = "ConnectedTo" + Contains RelationshipType = "Contains" + Exposes RelationshipType = "Exposes" + HasAccess RelationshipType = "HasAccess" + HasConfig RelationshipType = "HasConfig" + HasDisk RelationshipType = "HasDisk" + HasInstance RelationshipType = "HasInstance" + HasRbac RelationshipType = "HasRbac" + HasRole RelationshipType = "HasRole" + Manages RelationshipType = "Manages" + MemberOf RelationshipType = "MemberOf" + Owns RelationshipType = "Owns" + Represents RelationshipType = "Represents" + Trusts RelationshipType = "Trusts" + IsTrustedBy RelationshipType = "IsTrustedBy" + CanAssume RelationshipType = "CanAssume" + CanAssumeCrossAccount RelationshipType = "CanAssumeCrossAccount" + CanBeAssumedBy RelationshipType = "CanBeAssumedBy" + CanBeAssumedByTest RelationshipType = "CanBeAssumedByTest" + CanAssumeTest RelationshipType = "CanAssumeTest" + CanAccess RelationshipType = "CAN_ACCESS" +) + +const ( + // Node labels + Resource NodeLabel = "Resource" + Account NodeLabel = "Account" + Organization NodeLabel = "Org" + Service NodeLabel = "Service" + Principal NodeLabel = "Principal" + Vendor NodeLabel = "Vendor" + Role NodeLabel = "Role" + Group NodeLabel = "Group" + User NodeLabel = "User" + FederatedIdentity NodeLabel = "FederatedIdentity" +) + +func AsNeo4j(object *Node) map[string]interface{} { + + // We don't want to include these fields in the map + fieldsToExclude := []string{"members", "owners", "appRoles", "registeredUsers"} + objectMap, err := json.Marshal(object) + if err != nil { + return nil + } + + var objectMapInterface map[string]interface{} + json.Unmarshal(objectMap, &objectMapInterface) + for _, field := range fieldsToExclude { + delete(objectMapInterface, field) + } + + // We need to convert flatten maps to an array + // We'll want to keep order of the keys for things like extensionAttributes + for key, value := range objectMapInterface { + _, isMap := value.(map[string]interface{}) + if isMap { + var valueArray []string + var keys []string + + for k := range value.(map[string]interface{}) { + keys = append(keys, k) + } + slices.Sort(keys) + + for _, k := range keys { + valueString := value.(map[string]interface{})[k].(string) + if valueString != "" { + valueArray = append(valueArray, k, valueString) + } + } + objectMapInterface[key] = valueArray + } + } + return objectMapInterface +} + +func ConvertCustomTypesToNeo4j(node interface{}) (map[string]interface{}, error) { + result := make(map[string]interface{}) + val := reflect.ValueOf(node) + + // Handling pointers to structs or interfaces + for val.Kind() == reflect.Ptr || val.Kind() == reflect.Interface { + val = val.Elem() + } + + // Check if the value is valid and if it's a struct + if !val.IsValid() || val.Kind() != reflect.Struct { + return nil, fmt.Errorf("invalid input: not a struct or a pointer to a struct") + } + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := val.Type().Field(i) // Get the StructField + + // Check if the field is a struct or slice of structs and not one of the basic types + if (fieldType.Type.Kind() == reflect.Struct || + (fieldType.Type.Kind() == reflect.Slice && fieldType.Type.Elem().Kind() == reflect.Struct)) && + fieldType.Type != reflect.TypeOf([]string{}) && + fieldType.Type != reflect.TypeOf([]int{}) && + fieldType.Type != reflect.TypeOf([]bool{}) { + // Convert complex field to JSON string + jsonStr, err := json.Marshal(field.Interface()) + if err != nil { + return nil, err + } + result[fieldType.Name] = string(jsonStr) + } else { + // Directly use the field for primitive types + result[fieldType.Name] = field.Interface() + } + } + return result, nil +} diff --git a/aws/instances.go b/aws/instances.go index f607390..52b54fe 100644 --- a/aws/instances.go +++ b/aws/instances.go @@ -24,12 +24,13 @@ import ( type InstancesModule struct { // General configuration data - EC2Client sdk.AWSEC2ClientInterface - IAMClient sdk.AWSIAMClientInterface - Caller sts.GetCallerIdentityOutput - AWSRegions []string - AWSOutputType string - AWSTableCols string + EC2Client sdk.AWSEC2ClientInterface + IAMClient sdk.AWSIAMClientInterface + Caller sts.GetCallerIdentityOutput + AWSRegions []string + AWSOutputType string + AWSTableCols string + PmapperDataBasePath string Goroutines int UserDataAttributesOnly bool @@ -94,8 +95,8 @@ func (m *InstancesModule) Instances(filter string, outputDirectory string, verbo // Initialized the tools we'll need to check if any workload roles are admin or can privesc to admin //fmt.Printf("[%s][%s] Attempting to build a PrivEsc graph in memory using local pmapper data if it exists on the filesystem.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) - m.pmapperMod, m.pmapperError = initPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines) - m.iamSimClient = initIAMSimClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) // if m.pmapperError != nil { // fmt.Printf("[%s][%s] No pmapper data found for this account. Using cloudfox's iam-simulator for role analysis.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) diff --git a/aws/lambda.go b/aws/lambda.go index e3e590e..366a548 100644 --- a/aws/lambda.go +++ b/aws/lambda.go @@ -31,13 +31,15 @@ type LambdasModule struct { AWSOutputType string AWSTableCols string - Goroutines int - AWSProfile string - SkipAdminCheck bool - WrapTable bool - pmapperMod PmapperModule - pmapperError error - iamSimClient IamSimulatorModule + Goroutines int + AWSProfile string + SkipAdminCheck bool + WrapTable bool + pmapperMod PmapperModule + pmapperError error + PmapperDataBasePath string + + iamSimClient IamSimulatorModule // Main module data Lambdas []Lambda @@ -75,8 +77,8 @@ func (m *LambdasModule) PrintLambdas(outputDirectory string, verbosity int) { fmt.Printf("[%s][%s] Enumerating lambdas for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account)) //fmt.Printf("[%s][%s] Attempting to build a PrivEsc graph in memory using local pmapper data if it exists on the filesystem.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) - m.pmapperMod, m.pmapperError = initPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines) - m.iamSimClient = initIAMSimClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) // if m.pmapperError != nil { // fmt.Printf("[%s][%s] No pmapper data found for this account. Using cloudfox's iam-simulator for role analysis.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) diff --git a/aws/outbound-assumed-roles.go b/aws/outbound-assumed-roles.go index a65009f..d6acebd 100644 --- a/aws/outbound-assumed-roles.go +++ b/aws/outbound-assumed-roles.go @@ -50,6 +50,7 @@ type OutboundAssumeRoleEntry struct { SourcePrincipal string DestinationAccount string DestinationPrincipal string + Action string LogTimestamp string } @@ -117,6 +118,24 @@ type CloudTrailEvent struct { } `json:"tlsDetails"` } +var interestingCrossAccountEventNames = []string{ + "AssumeRole", + "AssumeRoleWithSAML", + "AssumeRoleWithWebIdentity", + "GetObject", + "ListBuckets", + "BatchGetImage", + "GetDownloadUrlForLayer", + "SendMessage", + "GetQueueUrl", + "Invoke20150331", + "RunInstances", + "RunTask", + "StartTask", + "CreateTask", + "CreateTaskSet", +} + func (m *OutboundAssumedRolesModule) PrintOutboundRoleTrusts(days int, outputDirectory string, verbosity int) { // These struct values are used by the output module m.output.Verbosity = verbosity @@ -171,6 +190,7 @@ func (m *OutboundAssumedRolesModule) PrintOutboundRoleTrusts(days int, outputDir "Source Principal", //"Destination Account", "Destination Principal", + "Action", "Log Entry Timestamp", } @@ -194,6 +214,7 @@ func (m *OutboundAssumedRolesModule) PrintOutboundRoleTrusts(days int, outputDir "Source Principal", //"Destination Account", "Destination Principal", + "Action", "Log Entry Timestamp", } // Otherwise, use the default columns. @@ -206,6 +227,7 @@ func (m *OutboundAssumedRolesModule) PrintOutboundRoleTrusts(days int, outputDir "Source Principal", //"Destination Account", "Destination Principal", + "Action", "Log Entry Timestamp", } } @@ -222,6 +244,7 @@ func (m *OutboundAssumedRolesModule) PrintOutboundRoleTrusts(days int, outputDir m.OutboundAssumeRoleEntries[i].SourcePrincipal, //m.OutboundAssumeRoleEntries[i].DestinationAccount, m.OutboundAssumeRoleEntries[i].DestinationPrincipal, + m.OutboundAssumeRoleEntries[i].Action, m.OutboundAssumeRoleEntries[i].LogTimestamp, }, ) @@ -279,9 +302,12 @@ func (m *OutboundAssumedRolesModule) executeChecks(r string, wg *sync.WaitGroup, m.modLog.Error(err) } if res { + // wg.Add(1) + // m.CommandCounter.Total++ + // m.getAssumeRoleLogEntriesPerRegion(r, wg, semaphore, dataReceiver) wg.Add(1) m.CommandCounter.Total++ - m.getAssumeRoleLogEntriesPerRegion(r, wg, semaphore, dataReceiver) + m.getCrossAccountBatchGetImageEntriesPerRegion(r, wg, semaphore, dataReceiver) } } @@ -359,6 +385,7 @@ func (m *OutboundAssumedRolesModule) getAssumeRoleLogEntriesPerRegion(r string, SourcePrincipal: sourcePrincipal, DestinationAccount: destinationAccount, DestinationPrincipal: destinationPrincipal, + Action: "sts:AssumeRole", LogTimestamp: logTimestamp, } } @@ -376,3 +403,107 @@ func (m *OutboundAssumedRolesModule) getAssumeRoleLogEntriesPerRegion(r string, } } + +// get cross account batch get image entries +func (m *OutboundAssumedRolesModule) getCrossAccountBatchGetImageEntriesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan OutboundAssumeRoleEntry) { + defer func() { + m.CommandCounter.Executing-- + m.CommandCounter.Complete++ + wg.Done() + + }() + semaphore <- struct{}{} + defer func() { + <-semaphore + }() + // "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages. + var PaginationControl *string + //var LookupAttributes []types.LookupAttributes + //var LookupAttribute types.LookupAttribute + var pages int + + days := 0 - m.Days + endTime := aws.Time(time.Now()) + startTime := endTime.AddDate(0, 0, days) + for { + LookupEvents, err := m.CloudTrailClient.LookupEvents( + context.TODO(), + &cloudtrail.LookupEventsInput{ + EndTime: endTime, + StartTime: &startTime, + // LookupAttributes: []cloudtrailTypes.LookupAttribute{ + // { + // AttributeKey: cloudtrailTypes.LookupAttributeKeyEventName, + // AttributeValue: aws.String("BatchGetImage"), + // }, + // }, + NextToken: PaginationControl, + }, + + func(o *cloudtrail.Options) { + o.Region = r + }, + ) + if err != nil { + m.modLog.Error(err.Error()) + m.CommandCounter.Error++ + break + } + + for _, event := range LookupEvents.Events { + //eventData := *event.CloudTrailEvent + //fmt.Println(eventData) + var sourceAccount, sourcePrincipal, destinationAccount, destinationPrincipal, userType string + cloudtrailEvent := CloudTrailEvent{} + json.Unmarshal([]byte(*event.CloudTrailEvent), &cloudtrailEvent) + + for _, eventName := range interestingCrossAccountEventNames { + if aws.ToString(event.EventName) == eventName { + + // extract the source account and principal + if cloudtrailEvent.UserIdentity.Type == "AssumedRole" || cloudtrailEvent.UserIdentity.Type == "IAMUser" || cloudtrailEvent.UserIdentity.Type == "Role" { + if cloudtrailEvent.UserIdentity.Type == "AssumedRole" { + sourcePrincipal = cloudtrailEvent.UserIdentity.SessionContext.SessionIssuer.Arn + } else { + sourcePrincipal = cloudtrailEvent.UserIdentity.Arn + } + userType = cloudtrailEvent.UserIdentity.Type + sourceAccount = cloudtrailEvent.UserIdentity.AccountID + + //fmt.Printf("%s,%s,%s,%s\n", sourceAccount, sourcePrincipal, destinationAccount, destinationPrincipal) + + if cloudtrailEvent.Resources != nil { + destinationAccount = cloudtrailEvent.Resources[0].AccountID + destinationPrincipal = cloudtrailEvent.Resources[0].Arn + if sourceAccount != destinationAccount { + logTimestamp := cloudtrailEvent.EventTime.Format("2006-01-02 15:04:05") + dataReceiver <- OutboundAssumeRoleEntry{ + AWSService: "CloudTrail", + Region: r, + Type: userType, + SourceAccount: sourceAccount, + SourcePrincipal: sourcePrincipal, + DestinationAccount: destinationAccount, + DestinationPrincipal: destinationPrincipal, + Action: aws.ToString(event.EventName), + LogTimestamp: logTimestamp, + } + } + } + + } + } + } + } + + // The "NextToken" value is nil when there's no more data to return. + if LookupEvents.NextToken != nil { + PaginationControl = LookupEvents.NextToken + pages++ + } else { + PaginationControl = nil + break + } + } + +} diff --git a/aws/permissions.go b/aws/permissions.go index d9f6ad4..0364835 100644 --- a/aws/permissions.go +++ b/aws/permissions.go @@ -11,6 +11,7 @@ import ( "github.com/BishopFox/cloudfox/aws/sdk" "github.com/BishopFox/cloudfox/internal" "github.com/BishopFox/cloudfox/internal/aws/policy" + "github.com/BishopFox/cloudfox/internal/common" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -36,7 +37,7 @@ type IamPermissionsModule struct { Users []GAADUser Roles []GAADRole Groups []GAADGroup - Rows []PermissionsRow + Rows []common.PermissionsRow CommandCounter internal.CommandCounter // Used to store output data for pretty printing output internal.OutputData2 @@ -71,20 +72,6 @@ type GAADGroup struct { InlinePolicies []types.PolicyDetail } -type PermissionsRow struct { - AWSService string - Type string - Name string - Arn string - PolicyType string - PolicyName string - PolicyArn string - Effect string - Action string - Resource string - Condition string -} - func (m *IamPermissionsModule) PrintIamPermissions(outputDirectory string, verbosity int, principal string) { // These struct values are used by the output module m.output.Verbosity = verbosity @@ -104,8 +91,8 @@ func (m *IamPermissionsModule) PrintIamPermissions(outputDirectory string, verbo filename = filepath.Join(fmt.Sprintf("%s-custom-%s", m.output.CallingModule, strconv.FormatInt((time.Now().Unix()), 10))) } - m.getGAAD() - m.parsePermissions(principal) + m.GetGAAD() + m.ParsePermissions(principal) m.output.Headers = []string{ "Account", @@ -206,10 +193,10 @@ func (m *IamPermissionsModule) PrintIamPermissions(outputDirectory string, verbo fmt.Printf("[%s][%s] For context and next steps: https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#%s\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), m.output.CallingModule) } -func (m *IamPermissionsModule) getGAAD() { +func (m *IamPermissionsModule) GetGAAD() { GAAD, err := sdk.CachedIAMGetAccountAuthorizationDetails(m.IAMClient, aws.ToString(m.Caller.Account)) if err != nil { - m.modLog.Error(err.Error()) + TxtLogger.Error(err.Error()) m.CommandCounter.Error++ return } @@ -297,7 +284,7 @@ func (m *IamPermissionsModule) getPrincipalArn(principal string) string { return arn } -func (m *IamPermissionsModule) parsePermissions(principal string) { +func (m *IamPermissionsModule) ParsePermissions(principal string) { var inputArn string for i := range m.Roles { if principal == "" { @@ -422,6 +409,10 @@ func (m *IamPermissionsModule) getPermissionsFromAttachedPolicy(arn string, atta //parsedPolicyDocument, _ := parsePolicyDocument(d.Document) document, _ := url.QueryUnescape(aws.ToString(d.Document)) parsedPolicyDocument, _ := policy.ParseJSONPolicy([]byte(document)) + + // hasStsAssumeRole := parsedPolicyDocument.DoesPolicyHaveMatchingStatement("Allow", "sts:AssumeRole", "*") + // fmt.Println(hasStsAssumeRole) + for _, s = range parsedPolicyDocument.Statement { //version := parsedPolicyDocument.Version effect := s.Effect @@ -435,7 +426,7 @@ func (m *IamPermissionsModule) getPermissionsFromAttachedPolicy(arn string, atta } m.Rows = append( m.Rows, - PermissionsRow{ + common.PermissionsRow{ AWSService: AWSService, Arn: arn, Name: name, @@ -462,7 +453,7 @@ func (m *IamPermissionsModule) getPermissionsFromAttachedPolicy(arn string, atta } m.Rows = append( m.Rows, - PermissionsRow{ + common.PermissionsRow{ AWSService: AWSService, Arn: arn, Name: name, @@ -508,7 +499,7 @@ func (m *IamPermissionsModule) getPermissionsFromInlinePolicy(arn string, inline } m.Rows = append( m.Rows, - PermissionsRow{ + common.PermissionsRow{ AWSService: AWSService, Arn: arn, Name: name, @@ -534,7 +525,7 @@ func (m *IamPermissionsModule) getPermissionsFromInlinePolicy(arn string, inline } m.Rows = append( m.Rows, - PermissionsRow{ + common.PermissionsRow{ AWSService: AWSService, Arn: arn, Name: name, diff --git a/aws/pmapper.go b/aws/pmapper.go index 34302fd..3368481 100644 --- a/aws/pmapper.go +++ b/aws/pmapper.go @@ -1,6 +1,7 @@ package aws import ( + "context" "encoding/json" "fmt" "io/ioutil" @@ -10,9 +11,11 @@ import ( "strings" "github.com/BishopFox/cloudfox/internal" + "github.com/BishopFox/cloudfox/internal/aws/policy" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/dominikbraun/graph" + "github.com/neo4j/neo4j-go-driver/v5/neo4j" "github.com/sirupsen/logrus" ) @@ -28,15 +31,22 @@ type PmapperModule struct { WrapTable bool // Main module data - pmapperGraph graph.Graph[string, string] - Nodes []Node - Edges []Edge - CommandCounter internal.CommandCounter + PmapperDataBasePath string + pmapperGraph graph.Graph[string, string] + Nodes []Node + Edges []Edge + CommandCounter internal.CommandCounter // Used to store output data for pretty printing output internal.OutputData2 modLog *logrus.Entry } +type PmapperOutputRow struct { + Start string + End string + Paths []string +} + type Edge struct { Source string `json:"source"` Destination string `json:"destination"` @@ -45,19 +55,54 @@ type Edge struct { } type Node struct { - Arn string `json:"arn"` - IDValue string `json:"id_value"` - AttachedPolicies []AttachedPolicies `json:"attached_policies"` - GroupMemberships []interface{} `json:"group_memberships"` - TrustPolicy interface{} `json:"trust_policy"` - InstanceProfile interface{} `json:"instance_profile"` - ActivePassword bool `json:"active_password"` - AccessKeys int `json:"access_keys"` - IsAdmin bool `json:"is_admin"` - PermissionsBoundary interface{} `json:"permissions_boundary"` - HasMfa bool `json:"has_mfa"` - Tags Tags `json:"tags"` - PathToAdmin bool + Arn string `json:"arn"` + Type string + AccountID string + Name string + IDValue string `json:"id_value"` + AttachedPolicies []AttachedPolicies `json:"attached_policies"` + GroupMemberships []interface{} `json:"group_memberships"` + TrustPolicy interface{} `json:"trust_policy"` + TrustsDoc policy.TrustPolicyDocument + TrustedPrincipals []TrustedPrincipal + TrustedServices []TrustedService + TrustedFederatedProviders []TrustedFederatedProvider + InstanceProfile interface{} `json:"instance_profile"` + ActivePassword bool `json:"active_password"` + AccessKeys int `json:"access_keys"` + IsAdmin bool `json:"is_admin"` + PathToAdmin bool + PermissionsBoundary interface{} `json:"permissions_boundary"` + HasMfa bool `json:"has_mfa"` + Tags Tags `json:"tags"` + CanPrivEscToAdminString string + IsAdminString string + VendorName string +} + +type TrustedPrincipal struct { + TrustedPrincipal string + ExternalID string + VendorName string + AccountIsInAnalyzedAccountList bool + //IsAdmin bool + //CanPrivEscToAdmin bool +} + +type TrustedService struct { + TrustedService string + AccountID string + //IsAdmin bool + //CanPrivEscToAdmin bool +} + +type TrustedFederatedProvider struct { + TrustedFederatedProvider string + ProviderAccountId string + ProviderShortName string + TrustedSubjects []string + //IsAdmin bool + //CanPrivEscToAdmin bool } type AttachedPolicies struct { @@ -79,9 +124,16 @@ func (m *PmapperModule) initPmapperGraph() error { for i := range m.Nodes { if m.doesNodeHavePathToAdmin(m.Nodes[i]) { m.Nodes[i].PathToAdmin = true + m.Nodes[i].CanPrivEscToAdminString = "Yes" //fmt.Println(m.Nodes[i].Arn, m.Nodes[i].IsAdmin, m.Nodes[i].PathToAdmin) } else { m.Nodes[i].PathToAdmin = false + m.Nodes[i].CanPrivEscToAdminString = "No" + } + if m.Nodes[i].IsAdmin { + m.Nodes[i].IsAdminString = "Yes" + } else { + m.Nodes[i].IsAdminString = "No" } } @@ -107,7 +159,39 @@ func (m *PmapperModule) createAndPopulateGraph() graph.Graph[string, string] { } for _, edge := range m.Edges { - _ = pmapperGraph.AddEdge(edge.Source, edge.Destination) + err := pmapperGraph.AddEdge( + edge.Source, + edge.Destination, + graph.EdgeAttribute(edge.ShortReason, edge.Reason), + ) + if err != nil { + if err == graph.ErrEdgeAlreadyExists { + // update the edge by copying the existing graph.Edge with attributes and add the new attributes + //fmt.Println("Edge already exists, but adding a new one!") + + // get the existing edge + existingEdge, _ := pmapperGraph.Edge(edge.Source, edge.Destination) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes[edge.ShortReason] = edge.Reason + //Update the edge + pmapperGraph.UpdateEdge( + edge.Source, + edge.Destination, + graph.EdgeAttributes(existingProperties.Attributes), + ) + + } + //fmt.Println(edge.Reason) + } + } //internal.Cache.Set(cacheKey, pmapperGraph, cache.DefaultExpiration) @@ -119,7 +203,11 @@ func (m *PmapperModule) DoesPrincipalHavePathToAdmin(principal string) bool { for i := range m.Nodes { if m.Nodes[i].Arn == principal { if m.Nodes[i].PathToAdmin { + m.Nodes[i].CanPrivEscToAdminString = "Yes" return true + } else { + m.Nodes[i].CanPrivEscToAdminString = "No" + return false } } @@ -131,7 +219,11 @@ func (m *PmapperModule) DoesPrincipalHaveAdmin(principal string) bool { for i := range m.Nodes { if m.Nodes[i].Arn == principal { if m.Nodes[i].IsAdmin { + m.Nodes[i].IsAdminString = "Yes" return true + } else { + m.Nodes[i].IsAdminString = "No" + return false } } @@ -234,6 +326,9 @@ func (m *PmapperModule) PrintPmapperData(outputDirectory string, verbosity int) Table: internal.TableClient{ Wrap: m.WrapTable, }, + Loot: internal.LootClient{ + DirectoryName: m.output.FilePath, + }, } o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{ Header: m.output.Headers, @@ -243,7 +338,21 @@ func (m *PmapperModule) PrintPmapperData(outputDirectory string, verbosity int) }) o.PrefixIdentifier = m.AWSProfile o.Table.DirectoryName = filepath.Join(outputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account))) - o.WriteFullOutput(o.Table.TableFiles, nil) + + header, body := m.createPmapperTableData(outputDirectory) + o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{ + Header: header, + Body: body, + Name: "pmapper-privesc-paths-enhanced", + SkipPrintToScreen: true, + }) + + loot := m.writeLoot(o.Table.DirectoryName, verbosity) + o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{ + Name: m.output.CallingModule, + Contents: loot, + }) + o.WriteFullOutput(o.Table.TableFiles, o.Loot.LootFiles) //m.writeLoot(o.Table.DirectoryName, verbosity) fmt.Printf("[%s][%s] %s principals who are admin or have a path to admin identified.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), strconv.Itoa(len(m.output.Body))) @@ -264,9 +373,6 @@ func (m *PmapperModule) doesNodeHavePathToAdmin(startNode Node) bool { for _, p := range path { if p != "" { if startNode.Arn != destNode.Arn { - // if we got here there is a path - //fmt.Printf("%s has a path %s who is an admin.\n", startNode.Arn, destNode.Arn) - //fmt.Println(path) return true } @@ -278,9 +384,136 @@ func (m *PmapperModule) doesNodeHavePathToAdmin(startNode Node) bool { return false } +func (m *PmapperModule) createPmapperTableData(outputDirectory string) ([]string, [][]string) { + var header []string + var body [][]string + + header = []string{ + "Start", + "End", + "Path(s)", + } + + var paths string + var admins, privescPathsBody [][]string + + for _, startNode := range m.Nodes { + if startNode.IsAdmin { + admins = append(admins, []string{startNode.Arn, "", "ADMIN"}) + + } else { + for _, destNode := range m.Nodes { + if destNode.IsAdmin { + path, _ := graph.ShortestPath(m.pmapperGraph, startNode.Arn, destNode.Arn) + // if we have a path, + + if len(path) > 0 { + if startNode.Arn != destNode.Arn { + paths = "" + // if we got herethere's a path. Lets print the reason and the short reason for each edge in the path to the screen + for i := 0; i < len(path)-1; i++ { + for _, edge := range m.Edges { + if edge.Source == path[i] && edge.Destination == path[i+1] { + + //Some pmapper reasons have commas in them so lets get rid of them in the csvOutputdata + edge.Reason = strings.ReplaceAll(edge.Reason, ",", " and") + paths += fmt.Sprintf("%s %s %s\n", path[i], edge.Reason, path[i+1]) + } + + } + } + //trim the last newline from csvPaths + paths = strings.TrimSuffix(paths, "\n") + privescPathsBody = append(privescPathsBody, []string{startNode.Arn, destNode.Arn, paths}) + + } + + } + } + } + } + } + + // create body by first adding the admins and then the privesc paths + body = append(body, admins...) + body = append(body, privescPathsBody...) + return header, body + +} + +func (m *PmapperModule) writeLoot(outputDirectory string, verbosity int) string { + path := filepath.Join(outputDirectory, "loot") + err := os.MkdirAll(path, os.ModePerm) + if err != nil { + m.modLog.Error(err.Error()) + m.CommandCounter.Error++ + panic(err.Error()) + } + lootFilePath := filepath.Join(path, "pmapper.txt") + + var admins, out string + + for _, startNode := range m.Nodes { + if startNode.IsAdmin { + admins += fmt.Sprintf("ADMIN FOUND: %s\n", startNode.Arn) + } else { + for _, destNode := range m.Nodes { + if destNode.IsAdmin { + path, _ := graph.ShortestPath(m.pmapperGraph, startNode.Arn, destNode.Arn) + // if we have a path, + + if len(path) > 0 { + if startNode.Arn != destNode.Arn { + // if we got here there is a path + out += fmt.Sprintf("PATH TO ADMIN FOUND\n Start: %s\n End: %s\n Path(s):\n", startNode.Arn, destNode.Arn) + //fmt.Println(path) + // if we got herethere's a path. Lets print the reason and the short reason for each edge in the path to the screen + for i := 0; i < len(path)-1; i++ { + for _, edge := range m.Edges { + if edge.Source == path[i] && edge.Destination == path[i+1] { + // print it like this: [start node] [reason] [end node] + out += fmt.Sprintf(" %s %s %s\n", path[i], edge.Reason, path[i+1]) + } + // shortest path only finds the shortest path. We want to find all paths. So we need to find all paths that have the same start and end nodes from the path, but going back to the main edges slice + //for _, edge := range GlobalPmapperEdges { + // if edge.Source == path[i] && edge.Destination == path[i+1] { + // // print it like this: [start node] [reason] [end node] + // out += fmt.Sprintf(" %s %s %s\n", path[i], edge.Reason, path[i+1]) + // } + // } + } + } + out += fmt.Sprintf("\n") + + } + + } + } + } + } + } + out = admins + "\n\n" + out + + if verbosity > 2 { + fmt.Println() + fmt.Printf("[%s][%s] %s \n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green("Beginning of loot file")) + fmt.Print(out) + fmt.Printf("[%s][%s] %s \n\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green("End of loot file")) + } + fmt.Printf("[%s][%s] %s \n", cyan(m.output.CallingModule), cyan(m.AWSProfile), magenta(fmt.Sprintf("Loot file with ALL potential paths written to: [%s]", lootFilePath))) + return out + +} + func (m *PmapperModule) readPmapperData(accountID *string) error { - e, n := generatePmapperDataBasePaths(accountID) + var e, n string + if m.PmapperDataBasePath == "" { + e, n = generatePmapperDataBasePaths(accountID) + } else { + e = filepath.Join(m.PmapperDataBasePath, aws.ToString(accountID), "graph", "edges.json") + n = filepath.Join(m.PmapperDataBasePath, aws.ToString(accountID), "graph", "nodes.json") + } nodesFile, err := os.Open(n) if err != nil { @@ -301,3 +534,101 @@ func (m *PmapperModule) readPmapperData(accountID *string) error { return nil } + +func (m *PmapperModule) GenerateCypherStatements(goCtx context.Context, driver neo4j.DriverWithContext) error { + // Insert nodes + for i, node := range m.Nodes { + query, params := m.generateNodeCreateStatement(node, i) + if err := m.executeCypherQuery(goCtx, driver, query, params); err != nil { + return err + } + } + + // Insert edges + for i, edge := range m.Edges { + query, params := m.generateEdgeCreateStatement(edge, i) + if err := m.executeCypherQuery(goCtx, driver, query, params); err != nil { + return err + } + } + + return nil +} + +func (m *PmapperModule) generateNodeCreateStatement(node Node, i int) (string, map[string]interface{}) { + var ptype, label, query string + var params map[string]any + + if strings.Contains(node.Arn, "role") { + label = GetResourceNameFromArn(node.Arn) + ptype = "Role" + params = map[string]any{ + "Id": node.Arn, + "ARN": node.Arn, + "Name": GetResourceNameFromArn(node.Arn), + "IdValue": node.IDValue, + "IsAdminP": node.IsAdmin, + "PathToAdmin": node.PathToAdmin, + } + + } else if strings.Contains(node.Arn, "user") { + label = GetResourceNameFromArn(node.Arn) + ptype = "User" + //node.TrustPolicy = "" + params = map[string]any{ + "Id": node.Arn, + "ARN": node.Arn, + "Name": GetResourceNameFromArn(node.Arn), + "IdValue": node.IDValue, + "IsAdminP": node.IsAdmin, + "PathToAdmin": node.PathToAdmin, + } + + } else if strings.Contains(node.Arn, "group") { + label = GetResourceNameFromArn(node.Arn) + ptype = "Group" + } + label = strings.ReplaceAll(label, "-", "_") + label = strings.ReplaceAll(label, ".", "_") + + query = `MERGE (%s:%s {Id: $Id, ARN: $ARN, Name: $Name, IdValue: $IdValue, IsAdminP: $IsAdminP, PathToAdmin: $PathToAdmin})` + + //sanitizedArn := sanitizeArnForNeo4jLabel(node.Arn) + //id := fmt.Sprintf("%s_%s", sanitizedArn, ptype) + + fmt.Println(fmt.Sprintf(query, label, ptype), params) + return fmt.Sprintf(query, label, ptype), params +} + +func (m *PmapperModule) generateEdgeCreateStatement(edge Edge, i int) (string, map[string]interface{}) { + // Sanitize ARNs for matching nodes + //srcArnSanitized := sanitizeArnForNeo4jLabel(edge.Source) + //destArnSanitized := sanitizeArnForNeo4jLabel(edge.Destination) + + query := `MATCH (a {ARN: $srcArn}), (b {ARN: $destArn}) CREATE (a)-[:CAN_ACCESS {reason: $reason, shortReason: $shortReason}]->(b)` + params := map[string]any{ + "srcArn": edge.Source, + "destArn": edge.Destination, + "reason": edge.Reason, + "shortReason": edge.ShortReason, + } + fmt.Println(query, params) + return query, params +} + +func (m *PmapperModule) executeCypherQuery(ctx context.Context, driver neo4j.DriverWithContext, query string, params map[string]interface{}) error { + _, err := neo4j.ExecuteQuery(ctx, driver, query, params, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + if err != nil { + sharedLogger.Errorf("Error executing query: %s -- %v", err, params) + return err + } + return nil +} + +func sanitizeArnForNeo4jLabel(arn string) string { + // Replace non-allowed characters with underscores or other allowed characters + sanitized := strings.ReplaceAll(arn, ":", "_") + sanitized = strings.ReplaceAll(sanitized, "-", "_") + // Add more replacements if needed + return sanitized +} diff --git a/aws/role-trusts.go b/aws/role-trusts.go index 47ae481..85b351d 100644 --- a/aws/role-trusts.go +++ b/aws/role-trusts.go @@ -31,8 +31,10 @@ type RoleTrustsModule struct { AWSOutputType string AWSTableCols string - pmapperMod PmapperModule - pmapperError error + pmapperMod PmapperModule + pmapperError error + PmapperDataBasePath string + iamSimClient IamSimulatorModule // Main module data @@ -83,8 +85,8 @@ func (m *RoleTrustsModule) PrintRoleTrusts(outputDirectory string, verbosity int fmt.Printf("[%s][%s] Enumerating role trusts for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account)) //fmt.Printf("[%s][%s] Looking for pmapper data for this account and building a PrivEsc graph in golang if it exists.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) - m.pmapperMod, m.pmapperError = initPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines) - m.iamSimClient = initIAMSimClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) // if m.pmapperError != nil { // fmt.Printf("[%s][%s] No pmapper data found for this account. Using cloudfox's iam-simulator for role analysis\n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) // } else { @@ -467,7 +469,11 @@ func parseFederatedTrustPolicy(statement policy.RoleTrustStatementEntry) (string subjects = append(subjects, "ALL REPOS!!!") } case strings.Contains(statement.Principal.Federated[0], "oidc.eks"): - provider = "EKS" + // extract accountId from statement.Principal.Federated[0] + accountId := strings.Split(statement.Principal.Federated[0], ":")[4] + provider = fmt.Sprintf("EKS-%s", accountId) + //provider = "EKS" + //provider = statement.Principal.Federated[0] if len(statement.Condition.StringLike.OidcEksSub) > 0 { subjects = append(subjects, statement.Condition.StringLike.OidcEksSub...) } else if len(statement.Condition.StringEquals.OidcEksSub) > 0 { @@ -496,9 +502,11 @@ func parseFederatedTrustPolicy(statement policy.RoleTrustStatementEntry) (string subjects = append(subjects, "ALL ISSUERS") } - // AWS SSO case + ///AWS SSO case case strings.Contains(statement.Principal.Federated[0], "AWSSSO"): - provider = "AWS SSO" + //provider = "AWS SSO" + accountId := strings.Split(statement.Principal.Federated[0], ":")[4] + provider = fmt.Sprintf("AWSSSO-%s", accountId) subjects = append(subjects, "Not applicable") // okta case @@ -553,6 +561,10 @@ func parseFederatedTrustPolicy(statement policy.RoleTrustStatementEntry) (string } else { subjects = append(subjects, "ALL PROJECTS") } + case strings.Contains(statement.Principal.Federated[0], "saml-provider"): + // the provider name is the last part of the ARN + provider = strings.Split(statement.Principal.Federated[0], ":saml-provider/")[1] + subjects = append(subjects, "Not applicable") default: provider = "Unknown Federated Provider" diff --git a/aws/sdk/codebuild.go b/aws/sdk/codebuild.go index 8395155..f27383d 100644 --- a/aws/sdk/codebuild.go +++ b/aws/sdk/codebuild.go @@ -20,6 +20,7 @@ type CodeBuildClientInterface interface { func init() { gob.Register(codeBuildTypes.Project{}) + gob.Register([]codeBuildTypes.Project{}) } diff --git a/aws/shared.go b/aws/shared.go index d0f16f7..20fffab 100644 --- a/aws/shared.go +++ b/aws/shared.go @@ -77,11 +77,12 @@ func isRoleAdmin(iamSimMod IamSimulatorModule, principal *string) bool { } -func initPmapperGraph(Caller sts.GetCallerIdentityOutput, AWSProfile string, Goroutines int) (PmapperModule, error) { +func InitPmapperGraph(Caller sts.GetCallerIdentityOutput, AWSProfile string, Goroutines int, PmapperDataBasePath string) (PmapperModule, error) { pmapperMod := PmapperModule{ - Caller: Caller, - AWSProfile: AWSProfile, - Goroutines: Goroutines, + Caller: Caller, + AWSProfile: AWSProfile, + Goroutines: Goroutines, + PmapperDataBasePath: PmapperDataBasePath, } err := pmapperMod.initPmapperGraph() if err != nil { diff --git a/aws/workloads.go b/aws/workloads.go index e07aa20..ce3edaf 100644 --- a/aws/workloads.go +++ b/aws/workloads.go @@ -40,8 +40,10 @@ type WorkloadsModule struct { //LightsailClient sdk.MockedLightsailClient //SagemakerClient *sagemaker.Client - pmapperMod PmapperModule - pmapperError error + pmapperMod PmapperModule + pmapperError error + PmapperDataBasePath string + iamSimClient IamSimulatorModule InstanceProfileToRolesMap map[string][]iamTypes.Role @@ -82,8 +84,8 @@ func (m *WorkloadsModule) PrintWorkloads(outputDirectory string, verbosity int) fmt.Printf("[%s][%s] Enumerating compute workloads in all regions for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account)) fmt.Printf("[%s][%s] Supported Services: App Runner, EC2, ECS, Lambda \n", cyan(m.output.CallingModule), cyan(m.AWSProfile)) - m.pmapperMod, m.pmapperError = initPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines) - m.iamSimClient = initIAMSimClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) + m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath) + m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines) wg := new(sync.WaitGroup) semaphore := make(chan struct{}, m.Goroutines) diff --git a/cli/aws.go b/cli/aws.go index 96c0a69..f75990b 100644 --- a/cli/aws.go +++ b/cli/aws.go @@ -10,6 +10,7 @@ import ( "github.com/BishopFox/cloudfox/aws" "github.com/BishopFox/cloudfox/aws/sdk" "github.com/BishopFox/cloudfox/internal" + "github.com/BishopFox/cloudfox/internal/common" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigatewayv2" "github.com/aws/aws-sdk-go-v2/service/apprunner" @@ -23,8 +24,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/codecommit" "github.com/aws/aws-sdk-go-v2/service/codedeploy" "github.com/aws/aws-sdk-go-v2/service/datapipeline" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/directoryservice" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/aws/aws-sdk-go-v2/service/ecs" @@ -59,6 +60,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/smithy-go/ptr" + "github.com/bishopfox/knownawsaccountslookup" + "github.com/dominikbraun/graph" "github.com/fatih/color" "github.com/kyokomi/emoji" "github.com/spf13/cobra" @@ -68,15 +71,17 @@ var ( cyan = color.New(color.FgCyan).SprintFunc() green = color.New(color.FgGreen).SprintFunc() red = color.New(color.FgRed).SprintFunc() + magenta = color.New(color.FgMagenta).SprintFunc() defaultOutputDir = ptr.ToString(internal.GetLogDirPath()) - AWSProfile string - AWSProfilesList string - AWSAllProfiles bool - AWSProfiles []string - AWSConfirm bool - AWSOutputType string - AWSTableCols string + AWSProfile string + AWSProfilesList string + AWSAllProfiles bool + AWSProfiles []string + AWSConfirm bool + AWSOutputType string + AWSTableCols string + PmapperDataBasePath string AWSOutputDirectory string AWSSkipAdminCheck bool @@ -84,8 +89,9 @@ var ( AWSUseCache bool AWSMFAToken string - Goroutines int - Verbosity int + Goroutines int + Verbosity int + AWSCommands = &cobra.Command{ Use: "aws", Short: "See \"Available Commands\" for AWS Modules", @@ -132,6 +138,20 @@ var ( PostRun: awsPostRun, } + CapeAdminOnly bool + CapeJobName string + CapeCommand = &cobra.Command{ + Use: "cape", + Aliases: []string{"CAPE"}, + Short: "Cross-Account Privilege Escalation Route finder. Needs to be run with multiple profiles using -l or -a flag. Needs pmapper data to be present", + Long: "\nUse case examples:\n" + + os.Args[0] + " aws cape -l file_with_profile_names.txt --admin-only" + + os.Args[0] + " aws cape -l file_with_profile_names.txt # This default mode shows all inbound paths but is very slow when there are many accounts)", + PreRun: awsPreRun, + Run: runCapeCommand, + PostRun: awsPostRun, + } + CloudformationCommand = &cobra.Command{ Use: "cloudformation", Aliases: []string{"cf", "cfstacks", "stacks"}, @@ -443,7 +463,6 @@ var ( Run: runTagsCommand, PostRun: awsPostRun, } - PmapperCommand = &cobra.Command{ Use: "pmapper", @@ -456,6 +475,16 @@ var ( PostRun: awsPostRun, } + GraphCommand = &cobra.Command{ + Use: "graph", + Short: "INACTIVE (Use cape command instead) Graph the relationships between resources and insert into local Neo4j db", + Long: "\nUse case examples:\n" + + os.Args[0] + " aws graph -l /path/to/profiles", + PreRun: awsPreRun, + Run: runGraphCommand, + PostRun: awsPostRun, + } + WorkloadsCommand = &cobra.Command{ Use: "workloads", Short: "Finds workloads with admin permissions or a path to admin permissions", @@ -552,7 +581,7 @@ func awsPreRun(cmd *cobra.Command, args []string) { continue } - orgModuleClient := aws.InitOrgClient(*caller, profile, cmd.Root().Version, Goroutines, AWSMFAToken) + orgModuleClient := aws.InitOrgsClient(*caller, profile, cmd.Root().Version, Goroutines, AWSMFAToken) isPartOfOrg := orgModuleClient.IsCallerAccountPartOfAnOrg() if isPartOfOrg { isMgmtAccount := orgModuleClient.IsManagementAccount(orgModuleClient.DescribeOrgOutput, ptr.ToString(caller.Account)) @@ -612,7 +641,7 @@ func FindOrgMgmtAccountAndReorderAccounts(AWSProfiles []string, version string) fmt.Printf("[%s][%s] Loaded cached AWS data for to %s\n", cyan(emoji.Sprintf(":fox:cloudfox v%s :fox:", version)), cyan(profile), ptr.ToString(caller.Account)) } } - orgModuleClient := aws.InitOrgClient(*caller, profile, version, Goroutines, AWSMFAToken) + orgModuleClient := aws.InitOrgsClient(*caller, profile, version, Goroutines, AWSMFAToken) orgModuleClient.DescribeOrgOutput, err = sdk.CachedOrganizationsDescribeOrganization(orgModuleClient.OrganizationsClient, ptr.ToString(caller.Account)) if err != nil { continue @@ -723,15 +752,16 @@ func runCodeBuildCommand(cmd *cobra.Command, args []string) { continue } m := aws.CodeBuildModule{ - CodeBuildClient: codebuild.NewFromConfig(AWSConfig), - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + CodeBuildClient: codebuild.NewFromConfig(AWSConfig), + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.PrintCodeBuildProjects(AWSOutputDirectory, Verbosity) } @@ -828,14 +858,15 @@ func runEKSCommand(cmd *cobra.Command, args []string) { IAMClient: iam.NewFromConfig(AWSConfig), EKSClient: eks.NewFromConfig(AWSConfig), - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.EKS(AWSOutputDirectory, Verbosity) } @@ -927,6 +958,378 @@ func runFilesystemsCommand(cmd *cobra.Command, args []string) { } } +func runGraphCommand(cmd *cobra.Command, args []string) { + + for _, profile := range AWSProfiles { + //var AWSConfig = internal.AWSConfigFileLoader(profile, cmd.Root().Version) + caller, err := internal.AWSWhoami(profile, cmd.Root().Version, AWSMFAToken) + if err != nil { + continue + } + + //instantiate a permissions client and populate the permissions data + fmt.Printf("[%s][%s] Getting account authorization details (GAAD) for account: %s\n", cyan("cape"), cyan(profile), ptr.ToString(caller.Account)) + + PermissionsCommandClient := aws.InitPermissionsClient(*caller, profile, cmd.Root().Version, Goroutines, AWSMFAToken) + PermissionsCommandClient.GetGAAD() + PermissionsCommandClient.ParsePermissions("") + common.PermissionRowsFromAllProfiles = append(common.PermissionRowsFromAllProfiles, PermissionsCommandClient.Rows...) + } + + for _, profile := range AWSProfiles { + var AWSConfig = internal.AWSConfigFileLoader(profile, cmd.Root().Version, AWSMFAToken) + caller, err := internal.AWSWhoami(profile, cmd.Root().Version, AWSMFAToken) + if err != nil { + continue + } + + graphCommandClient := aws.GraphCommand{ + Caller: *caller, + AWSProfile: profile, + Goroutines: Goroutines, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + AWSOutputDirectory: AWSOutputDirectory, + Verbosity: Verbosity, + AWSConfig: AWSConfig, + Version: cmd.Root().Version, + SkipAdminCheck: AWSSkipAdminCheck, + PmapperDataBasePath: PmapperDataBasePath, + } + graphCommandClient.RunGraphCommand() + } +} + +func runCapeCommand(cmd *cobra.Command, args []string) { + // map of all unique accountIDs and if they are included in the analysis or not + //analyzedAccounts := make(map[string]bool) + analyzedAccounts := make(map[string]aws.CapeJobInfo) + + GlobalGraph := graph.New(graph.StringHash, graph.Directed()) + //var PermissionRowsFromAllProfiles []common.PermissionsRow + var GlobalPmapperData aws.PmapperModule + var GlobalNodes []aws.Node + + vendors := knownawsaccountslookup.NewVendorMap() + vendors.PopulateKnownAWSAccounts() + + for _, profile := range AWSProfiles { + caller, err := internal.AWSWhoami(profile, cmd.Root().Version, AWSMFAToken) + if err != nil { + continue + } + _, err = internal.InitializeCloudFoxRunData(profile, cmd.Root().Version, AWSMFAToken, AWSOutputDirectory) + if err != nil { + continue + } + + // add account number to analyzedAccounts map and set the value to true + //analyzedAccounts[ptr.ToString(caller.Account)] = true + analyzedAccounts[ptr.ToString(caller.Account)] = aws.CapeJobInfo{AccountID: ptr.ToString(caller.Account), Profile: profile, AnalyzedSuccessfully: true, AdminOnlyAnalysis: CapeAdminOnly, Source: "user"} + + } + + pmapperData := make(map[string]aws.PmapperModule) + + for _, profile := range AWSProfiles { + caller, err := internal.AWSWhoami(profile, cmd.Root().Version, AWSMFAToken) + if err != nil { + continue + } + fmt.Printf("[%s][%s] Importing Pmapper data for: %s\n", cyan("cape"), cyan(profile), ptr.ToString(caller.Account)) + pmapperMod, pmapperError := aws.InitPmapperGraph(*caller, AWSProfile, Goroutines, PmapperDataBasePath) + if pmapperError != nil { + fmt.Println("Error importing pmapper data " + pmapperError.Error()) + analyzedAccounts[ptr.ToString(caller.Account)] = aws.CapeJobInfo{AnalyzedSuccessfully: false} + // give the user the option to continue or not + // if they choose to continue, we will skip the pmapper data and continue with the rest of the analysis + // if they choose to not continue, we will exit the program + fmt.Printf("Would you like to continue with the analysis without the pmapper data for profile %s? (y/n)", profile) + var continueAnalysis string + fmt.Scanln(&continueAnalysis) + if continueAnalysis == "y" { + continue + } else { + os.Exit(1) + } + } + + pmapperData[profile] = pmapperMod + } + for _, profile := range AWSProfiles { + var AWSConfig = internal.AWSConfigFileLoader(profile, cmd.Root().Version, AWSMFAToken) + caller, err := internal.AWSWhoami(profile, cmd.Root().Version, AWSMFAToken) + if err != nil { + continue + } + + //Gather all Permissions data + fmt.Printf("[%s][%s] Getting account authorization details (GAAD) for account: %s\n", cyan("cape"), cyan(profile), ptr.ToString(caller.Account)) + PermissionsCommandClient := aws.InitPermissionsClient(*caller, profile, cmd.Root().Version, Goroutines, AWSMFAToken) + PermissionsCommandClient.GetGAAD() + PermissionsCommandClient.ParsePermissions("") + if PermissionsCommandClient.Rows != nil { + common.PermissionRowsFromAllProfiles = append(common.PermissionRowsFromAllProfiles, PermissionsCommandClient.Rows...) + } else { + fmt.Println("Error gathering permissions for " + profile) + //analyzedAccounts[ptr.ToString(caller.Account)] = false + analyzedAccounts[ptr.ToString(caller.Account)] = aws.CapeJobInfo{AnalyzedSuccessfully: false} + } + + // Gather all Pmapper data. + //fmt.Printf("[%s][%s] Importing Pmapper for: %s\n", cyan("cape"), cyan(profile), ptr.ToString(caller.Account)) + + // pmapperMod, pmapperError := aws.InitPmapperGraph(*caller, AWSProfile, Goroutines) + // if pmapperError != nil { + // fmt.Println("Error importing pmapper data " + pmapperError.Error()) + // //analyzedAccounts[ptr.ToString(caller.Account)] = false + // analyzedAccounts[ptr.ToString(caller.Account)] = aws.CapeJobInfo{AnalyzedSuccessfully: false} + // } + + pmapperMod := pmapperData[profile] + + // add pmapper nodes to GlobalNodes (which will also soon include iam roles and users) + + for _, node := range pmapperMod.Nodes { + // add node to GlobalPmapperData + //GlobalPmapperData.Nodes = append(GlobalPmapperData.Nodes, node) + GlobalNodes = append(GlobalNodes, node) + } + fmt.Printf("[%s][%s] Added %d vertices from pmapper for %s\n", cyan("cape"), cyan(profile), len(pmapperMod.Nodes), ptr.ToString(caller.Account)) + + // same for adding pmapper edges to GlobalPmapperData + for _, edge := range pmapperMod.Edges { + // add edge to GlobalPmapperData + GlobalPmapperData.Edges = append(GlobalPmapperData.Edges, edge) + } + fmt.Printf("[%s][%s] Added %d edges from pmapper for %s\n", cyan("cape"), cyan(profile), len(pmapperMod.Edges), ptr.ToString(caller.Account)) + + //Gather all role data so we can later process all of the role trusts and add external nodes not looked at by pmapper + fmt.Printf("[%s][%s] Getting IAM roles for %s\n", cyan("cape"), cyan(profile), ptr.ToString(caller.Account)) + + IAMCommandClient := aws.InitIAMClient(AWSConfig) + ListRolesOutput, err := sdk.CachedIamListRoles(IAMCommandClient, ptr.ToString(caller.Account)) + if err != nil { + internal.TxtLog.Error(err) + } + for _, role := range ListRolesOutput { + //node := aws.ConvertIAMRoleToNode(role, vendors) + node := aws.ConvertIAMRoleToNode(role, vendors, analyzedAccounts) + + // First insert the role itself into the Nodes slice + GlobalNodes = append(GlobalNodes, node) + // Then insert all of the vertices that are in the role's trust policy + GlobalNodes = append(GlobalNodes, aws.FindVerticesInRoleTrust(node, vendors)...) + + } + + //Gather all user data + // Currently, there is no need to parse groups and build group-user relationships because + // the permissions command (and common.PermissionRowsFromAllProfiles above already has mapped/assigned group permissions to users within the group + fmt.Printf("[%s][%s] Getting IAM users for %s\n", cyan("cape"), cyan(profile), ptr.ToString(caller.Account)) + + ListUsersOutput, err := sdk.CachedIamListUsers(IAMCommandClient, ptr.ToString(caller.Account)) + if err != nil { + internal.TxtLog.Error(err) + } + for _, user := range ListUsersOutput { + GlobalNodes = append(GlobalNodes, aws.ConvertIAMUserToNode(user)) + + } + + } + + //GlobalGraph := models.MakeAllVertices(GlobalRoles, GlobalPmapperData) + + // make vertices + // you can't update vertices - so we need to make all of the vertices that are roles in the in-scope accounts + // all at once to make sure they have the most information possible + fmt.Printf("[%s] Making vertices for all profiles\n", cyan("cape")) + + // for _, role := range GlobalRoles { + // role.MakeVertices(GlobalGraph) + // } + mergedNodes := aws.MergeNodes(GlobalNodes) + for _, node := range mergedNodes { + GlobalGraph.AddVertex( + node.Arn, + graph.VertexAttribute("Type", node.Type), + graph.VertexAttribute("Name", node.Name), + graph.VertexAttribute("VendorName", node.VendorName), + graph.VertexAttribute("IsAdminString", node.IsAdminString), + graph.VertexAttribute("CanPrivEscToAdminString", node.CanPrivEscToAdminString), + graph.VertexAttribute("AccountID", node.AccountID), + ) + // for every node, check to see if the accountId exists in the analyzedAccounts map. If it does not, add it to the map and set the value to false only if the node.VendorName is empty + if _, ok := analyzedAccounts[node.AccountID]; !ok { + if node.VendorName == "" { + if node.AccountID != "" { + analyzedAccounts[node.AccountID] = aws.CapeJobInfo{AccountID: node.AccountID, Profile: "", AnalyzedSuccessfully: false, AdminOnlyAnalysis: CapeAdminOnly, Source: "cloudfox"} + } + } + } + } + + // make pmapper edges + //you can update edges, so we can just merge attributes as needed + // first we add the edges that already exist in pmapper, then later we will make more edges based on the cloudfox role trusts logic + for _, edge := range GlobalPmapperData.Edges { + err := GlobalGraph.AddEdge( + edge.Source, + edge.Destination, + graph.EdgeAttribute(edge.ShortReason, edge.Reason), + ) + if err != nil { + if err == graph.ErrEdgeAlreadyExists { + // update theedge by copying the existing graph.Edge with attributes and add the new attributes + //fmt.Println("Edge already exists") + + // get the existing edge + existingEdge, _ := GlobalGraph.Edge(edge.Source, edge.Destination) + // get the map of attributes + existingProperties := existingEdge.Properties + // add the new attributes to attributes map within the properties struct + // Check if the Attributes map is initialized, if not, initialize it + if existingProperties.Attributes == nil { + existingProperties.Attributes = make(map[string]string) + } + + // Add or update the attribute + existingProperties.Attributes[edge.ShortReason] = edge.Reason + GlobalGraph.UpdateEdge( + edge.Source, + edge.Destination, + graph.EdgeAttributes(existingProperties.Attributes), + ) + } + } + } + + //making edges + // these are the cloudfox created edges mainly based on role trusts + // at least for now, we don't need to make edges for users, groups, or anything else because pmapper already has all of the edges we need + fmt.Printf("[%s] Making edges for all profiles\n", cyan("cape")) + + //fmt.Printf("[%s] Total vertices from pmapper and cape: %d \n", cyan("cape"), len(mergedNodes)) + + for _, node := range mergedNodes { + if node.Type == "Role" { + node.MakeRoleEdges(GlobalGraph) + } + } + + // count the edges in the graph + edges, _ := GlobalGraph.Edges() + fmt.Printf("[%s] Total edges from pmapper and cape: %d \n", cyan("cape"), len(mergedNodes)) + fmt.Printf("[%s] Total edges from pmapper and cape: %d \n", cyan("cape"), len(edges)) + + for _, profile := range AWSProfiles { + var AWSConfig = internal.AWSConfigFileLoader(profile, cmd.Root().Version, AWSMFAToken) + caller, err := internal.AWSWhoami(profile, cmd.Root().Version, AWSMFAToken) + if err != nil { + continue + } + + capeCommandClient := aws.CapeCommand{ + + Caller: *caller, + AWSProfile: profile, + Goroutines: Goroutines, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + AWSOutputDirectory: AWSOutputDirectory, + Verbosity: Verbosity, + AWSConfig: AWSConfig, + Version: cmd.Root().Version, + SkipAdminCheck: AWSSkipAdminCheck, + GlobalGraph: GlobalGraph, + PmapperDataBasePath: PmapperDataBasePath, + AnalyzedAccounts: analyzedAccounts, + CapeAdminOnly: CapeAdminOnly, + } + + capeCommandClient.RunCapeCommand() + + // write a json file with job information to the output directory. Use the CapeJobName for the file name, and have the data include the list of AWSProfiles that were analyzed + // this will be used by a TUI to match a job name to the list of accounts that were analyzed + + // if CapeJobName == "" { + // // create random job name in the format of cape-timmefromepoch + // CapeJobName = fmt.Sprintf("cape-%s", time.Now().Format("2006-01-02-15-04-05")) + // } + // filename := fmt.Sprintf("%s.json", CapeJobName) + // filepath := filepath.Join(AWSOutputDirectory, "aws", "capeJobs") + // err = os.MkdirAll(filepath, 0755) + // if err != nil { + // fmt.Println("Error creating directory: " + err.Error()) + // } + // file, _ := os.Create(filepath + "/" + filename) + // defer file.Close() + // encoder := json.NewEncoder(file) + // encoder.SetIndent("", " ") + // err = encoder.Encode(analyzedAccounts) + // if err != nil { + // fmt.Println("Error writing job data to file: " + err.Error()) + // } else { + // fmt.Printf("[%s] Job output written to %s\n", cyan("cape"), file.Name()) + // fmt.Printf("[%s] %s\n\n", cyan("cape"), magenta("The results of the cape command are best viewed in the cape terminal user interface (TUI). Use the command below:")) + // fmt.Printf("[%s] \tcloudfox aws -l %s cape tui\n\n", cyan("cape"), AWSProfilesList) + // } + + // playing around with creating a graphviz file for image rendering. + // the goal here is to be able to export this graph data to a format that can be easily imported in neo4j. + // this is a work in progress and not yet complete + + // filename := fmt.Sprintf("./mygraph-%s-%s.gv", ptr.ToString(caller.Account), time.Now().Format("2006-01-02-15-04-05")) + // file, _ := os.Create(filename) + // _ = draw.DOT(GlobalGraph, file, draw.GraphAttribute( + // "ranksep", "3", + // )) + } + + fmt.Printf("[%s] %s\n\n", cyan("cape"), magenta("The results of the cape command are best viewed in the cape terminal user interface (TUI). Use the command below:")) + if CapeAdminOnly { + fmt.Printf("\t\tcloudfox aws -l %s cape tui --admin-only\n\n", AWSProfilesList) + } else { + fmt.Printf("\t\tcloudfox aws -l %s cape tui\n\n", AWSProfilesList) + } +} + +func runCapeTUICommand(cmd *cobra.Command, args []string) { + var capeOutputFileLocations []string + for i, profile := range AWSProfiles { + cloudfoxRunData, err := internal.InitializeCloudFoxRunData(profile, cmd.Root().Version, AWSMFAToken, AWSOutputDirectory) + //caller, err := internal.AWSWhoami(profile, cmd.Root().Version, AWSMFAToken) + if err != nil { + continue + } + var fileName string + if CapeAdminOnly { + fileName = "inbound-privesc-paths-admin-targets-only.json" + } else { + fileName = "inbound-privesc-paths-all-targets.json" + } + capeOutputFileLocations = append(capeOutputFileLocations, filepath.Join(cloudfoxRunData.OutputLocation, "json", fileName)) + //check to see if file exists, and if it doesn't, remove the profile from the list of profiles to analyze and print a message to the console + if _, err := os.Stat(filepath.Join(cloudfoxRunData.OutputLocation, "json", fileName)); os.IsNotExist(err) { + fmt.Printf("[%s] Could not retrieve CAPE data for profile %s.\n", cyan(emoji.Sprintf(":fox:cloudfox v%s :fox:", cmd.Root().Version)), profile) + //remove the profile from the list of profiles to analyze + AWSProfiles = append(AWSProfiles[:i], AWSProfiles[i+1:]...) + } + + } + if len(capeOutputFileLocations) == 0 { + fmt.Printf("[%s] Could not retrieve CAPE data.\n", cyan(emoji.Sprintf(":fox:cloudfox v%s :fox:", cmd.Root().Version))) + os.Exit(1) + } + aws.CapeTUI(capeOutputFileLocations) + +} + func runIamSimulatorCommand(cmd *cobra.Command, args []string) { for _, profile := range AWSProfiles { var AWSConfig = internal.AWSConfigFileLoader(profile, cmd.Root().Version, AWSMFAToken) @@ -966,6 +1369,7 @@ func runInstancesCommand(cmd *cobra.Command, args []string) { WrapTable: AWSWrapTable, AWSOutputType: AWSOutputType, AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.Instances(InstancesFilter, AWSOutputDirectory, Verbosity) } @@ -1039,16 +1443,17 @@ func runLambdasCommand(cmd *cobra.Command, args []string) { continue } m := aws.LambdasModule{ - LambdaClient: lambda.NewFromConfig(AWSConfig), - IAMClient: iam.NewFromConfig(AWSConfig), - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + LambdaClient: lambda.NewFromConfig(AWSConfig), + IAMClient: iam.NewFromConfig(AWSConfig), + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.PrintLambdas(AWSOutputDirectory, Verbosity) } @@ -1122,12 +1527,13 @@ func runPmapperCommand(cmd *cobra.Command, args []string) { continue } m := aws.PmapperModule{ - Caller: *caller, - AWSProfile: profile, - Goroutines: Goroutines, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + Caller: *caller, + AWSProfile: profile, + Goroutines: Goroutines, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.PrintPmapperData(AWSOutputDirectory, Verbosity) } @@ -1205,14 +1611,15 @@ func runRoleTrustCommand(cmd *cobra.Command, args []string) { continue } m := aws.RoleTrustsModule{ - IAMClient: iam.NewFromConfig(AWSConfig), - Caller: *caller, - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + IAMClient: iam.NewFromConfig(AWSConfig), + Caller: *caller, + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.PrintRoleTrusts(AWSOutputDirectory, Verbosity) } @@ -1293,19 +1700,20 @@ func runWorkloadsCommand(cmd *cobra.Command, args []string) { continue } m := aws.WorkloadsModule{ - ECSClient: ecs.NewFromConfig(AWSConfig), - EC2Client: ec2.NewFromConfig(AWSConfig), - LambdaClient: lambda.NewFromConfig(AWSConfig), - AppRunnerClient: apprunner.NewFromConfig(AWSConfig), - IAMClient: iam.NewFromConfig(AWSConfig), - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - SkipAdminCheck: AWSSkipAdminCheck, - AWSProfile: profile, - Goroutines: Goroutines, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + ECSClient: ecs.NewFromConfig(AWSConfig), + EC2Client: ec2.NewFromConfig(AWSConfig), + LambdaClient: lambda.NewFromConfig(AWSConfig), + AppRunnerClient: apprunner.NewFromConfig(AWSConfig), + IAMClient: iam.NewFromConfig(AWSConfig), + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + SkipAdminCheck: AWSSkipAdminCheck, + AWSProfile: profile, + Goroutines: Goroutines, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.PrintWorkloads(AWSOutputDirectory, Verbosity) } @@ -1319,14 +1727,14 @@ func runDirectoryServicesCommand(cmd *cobra.Command, args []string) { continue } m := aws.DirectoryModule{ - DSClient: directoryservice.NewFromConfig(AWSConfig), - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + DSClient: directoryservice.NewFromConfig(AWSConfig), + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, } m.PrintDirectories(AWSOutputDirectory, Verbosity) } @@ -1343,14 +1751,15 @@ func runECSTasksCommand(cmd *cobra.Command, args []string) { ECSClient: ecs.NewFromConfig(internal.AWSConfigFileLoader(profile, cmd.Root().Version, AWSMFAToken)), IAMClient: iam.NewFromConfig(internal.AWSConfigFileLoader(profile, cmd.Root().Version, AWSMFAToken)), - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } m.ECSTasks(AWSOutputDirectory, Verbosity) } @@ -1547,6 +1956,7 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) { WrapTable: AWSWrapTable, AWSOutputType: AWSOutputType, AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } instances.Instances(InstancesFilter, AWSOutputDirectory, Verbosity) route53 := aws.Route53Module{ @@ -1559,16 +1969,17 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) { } lambdasMod := aws.LambdasModule{ - LambdaClient: lambdaClient, - IAMClient: iamClient, - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + LambdaClient: lambdaClient, + IAMClient: iamClient, + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } lambdasMod.PrintLambdas(AWSOutputDirectory, Verbosity) @@ -1649,14 +2060,15 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) { ECSClient: ecsClient, IAMClient: iamClient, - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } ecstasks.ECSTasks(AWSOutputDirectory, Verbosity) @@ -1664,14 +2076,15 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) { EKSClient: eksClient, IAMClient: iamClient, - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - AWSProfile: profile, - Goroutines: Goroutines, - SkipAdminCheck: AWSSkipAdminCheck, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + AWSProfile: profile, + Goroutines: Goroutines, + SkipAdminCheck: AWSSkipAdminCheck, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } eksCommand.EKS(AWSOutputDirectory, Verbosity) @@ -1843,14 +2256,15 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) { resourceTrustsCommand.PrintResources(AWSOutputDirectory, Verbosity) codeBuildCommand := aws.CodeBuildModule{ - CodeBuildClient: codeBuildClient, - Caller: *caller, - AWSProfile: profile, - Goroutines: Goroutines, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + CodeBuildClient: codeBuildClient, + Caller: *caller, + AWSProfile: profile, + Goroutines: Goroutines, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } codeBuildCommand.PrintCodeBuildProjects(AWSOutputDirectory, Verbosity) @@ -1919,19 +2333,20 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) { iamSimulator.PrintIamSimulator(SimulatorPrincipal, SimulatorAction, SimulatorResource, AWSOutputDirectory, Verbosity) workloads := aws.WorkloadsModule{ - ECSClient: ecsClient, - EC2Client: ec2Client, - LambdaClient: lambdaClient, - AppRunnerClient: appRunnerClient, - IAMClient: iamClient, - Caller: *caller, - AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), - SkipAdminCheck: AWSSkipAdminCheck, - AWSProfile: profile, - Goroutines: Goroutines, - WrapTable: AWSWrapTable, - AWSOutputType: AWSOutputType, - AWSTableCols: AWSTableCols, + ECSClient: ecsClient, + EC2Client: ec2Client, + LambdaClient: lambdaClient, + AppRunnerClient: appRunnerClient, + IAMClient: iamClient, + Caller: *caller, + AWSRegions: internal.GetEnabledRegions(profile, cmd.Root().Version, AWSMFAToken), + SkipAdminCheck: AWSSkipAdminCheck, + AWSProfile: profile, + Goroutines: Goroutines, + WrapTable: AWSWrapTable, + AWSOutputType: AWSOutputType, + AWSTableCols: AWSTableCols, + PmapperDataBasePath: PmapperDataBasePath, } workloads.PrintWorkloads(AWSOutputDirectory, Verbosity) @@ -1940,6 +2355,17 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) { } } +var CapeTuiCmd = &cobra.Command{ + Use: "tui", + Aliases: []string{"TUI", "view", "report"}, + Short: "View Cape's output in a TUI", + Long: "\nUse case examples:\n" + + os.Args[0] + " aws cape tui -l /path/to/profiles-used-for-cape.txt", + //PreRun: awsPreRun, + Run: runCapeTUICommand, + //PostRun: awsPostRun, +} + func init() { cobra.OnInitialize(initAWSProfiles) @@ -1963,7 +2389,7 @@ func init() { SNSCommand.Flags().BoolVarP(&StoreSNSAccessPolicies, "policies", "", false, "Store all flagged access policies along with the output") // outbound-assumed-roles module flags - OutboundAssumedRolesCommand.Flags().IntVarP(&OutboundAssumedRolesDays, "days", "d", 7, "How many days of CloudTrail events should we go back and look at.") + OutboundAssumedRolesCommand.Flags().IntVarP(&OutboundAssumedRolesDays, "days", "d", -7, "How many days of CloudTrail events should we go back and look at.") // iam-simulator module flags IamSimulatorCommand.Flags().StringVar(&SimulatorPrincipal, "principal", "", "Principal Arn") @@ -1979,6 +2405,13 @@ func init() { // buckets command flags (for bucket policies) BucketsCommand.Flags().BoolVarP(&CheckBucketPolicies, "with-policies", "", false, "Analyze bucket policies (this is already done in the resource-trusts command)") + // cape command flags + CapeCommand.Flags().BoolVar(&CapeAdminOnly, "admin-only", false, "Only return paths that lead to an admin role - much faster") + //CapeCommand.Flags().StringVar(&CapeJobName, "job-name", "", "Name of the cape job") + + // cape tui command flags + CapeTuiCmd.Flags().BoolVar(&CapeAdminOnly, "admin-only", false, "Only return paths that lead to an admin role - much faster") + // Global flags for the AWS modules AWSCommands.PersistentFlags().StringVarP(&AWSProfile, "profile", "p", "", "AWS CLI Profile Name") AWSCommands.PersistentFlags().StringVarP(&AWSProfilesList, "profiles-list", "l", "", "File containing a AWS CLI profile names separated by newlines") @@ -1993,42 +2426,49 @@ func init() { AWSCommands.PersistentFlags().BoolVarP(&AWSUseCache, "cached", "c", false, "Load cached data from disk. Faster, but if changes have been recently made you'll miss them") AWSCommands.PersistentFlags().StringVarP(&AWSTableCols, "cols", "t", "", "Comma separated list of columns to display in table output") AWSCommands.PersistentFlags().StringVar(&AWSMFAToken, "mfa-token", "", "MFA Token") + AWSCommands.PersistentFlags().StringVar(&PmapperDataBasePath, "pmapper-data-basepath", "", "Supply the base path for the pmapper data files (useful if you have copied them from another machine)\nPoint to the parent directory that contains all of the pmapper data by account numbers. \n\tExample: /path/to/com.nccgroup.principalmapper/\n\tExample: ./pmapperdata/") AWSCommands.AddCommand( + AccessKeysCommand, AllChecksCommand, ApiGwCommand, - RoleTrustCommand, - AccessKeysCommand, - InstancesCommand, + BucketsCommand, + CapeCommand, + CloudformationCommand, + CodeBuildCommand, + DatabasesCommand, ECSTasksCommand, - ElasticNetworkInterfacesCommand, - InventoryCommand, - EndpointsCommand, - SecretsCommand, - Route53Command, ECRCommand, - SQSCommand, - SNSCommand, EKSCommand, - OutboundAssumedRolesCommand, + ElasticNetworkInterfacesCommand, + EndpointsCommand, EnvsCommand, - PrincipalsCommand, - IamSimulatorCommand, FilesystemsCommand, - BucketsCommand, - PermissionsCommand, - CloudformationCommand, - CodeBuildCommand, - RAMCommand, - TagsCommand, + //GraphCommand, + IamSimulatorCommand, + InstancesCommand, + InventoryCommand, LambdasCommand, NetworkPortsCommand, + OrgsCommand, + OutboundAssumedRolesCommand, + PermissionsCommand, + PrincipalsCommand, PmapperCommand, + RAMCommand, ResourceTrustsCommand, - OrgsCommand, - DatabasesCommand, + RoleTrustCommand, + Route53Command, + SQSCommand, + SNSCommand, + SecretsCommand, + TagsCommand, WorkloadsCommand, DirectoryServicesCommand, ) + CapeCommand.AddCommand( + CapeTuiCmd, + ) + } diff --git a/cli/gcp.go b/cli/gcp.go index e8b4ec4..e69efb6 100644 --- a/cli/gcp.go +++ b/cli/gcp.go @@ -89,7 +89,7 @@ func init() { // GCPCommands.PersistentFlags().BoolVarP(&GCPAllProjects, "all-projects", "a", false, "Use all project IDs available to activated gloud account or given gcloud account") // GCPCommands.PersistentFlags().BoolVarP(&GCPConfirm, "yes", "y", false, "Non-interactive mode (like apt/yum)") // GCPCommands.PersistentFlags().StringVarP(&GCPOutputFormat, "output", "", "brief", "[\"brief\" | \"wide\" ]") - GCPCommands.PersistentFlags().IntVarP(&Verbosity, "verbosity", "v", 1, "1 = Print control messages only\n2 = Print control messages, module output\n3 = Print control messages, module output, and loot file output\n") + GCPCommands.PersistentFlags().IntVarP(&Verbosity, "verbosity", "v", 2, "1 = Print control messages only\n2 = Print control messages, module output\n3 = Print control messages, module output, and loot file output\n") // defaultOutputDir is defined in cli.aws GCPCommands.PersistentFlags().StringVar(&GCPOutputDirectory, "outdir", defaultOutputDir, "Output Directory ") // GCPCommands.PersistentFlags().IntVarP(&Goroutines, "max-goroutines", "g", 30, "Maximum number of concurrent goroutines") diff --git a/gcp/commands/bigquery.go b/gcp/commands/bigquery.go index c324e8b..01b67fd 100644 --- a/gcp/commands/bigquery.go +++ b/gcp/commands/bigquery.go @@ -14,10 +14,10 @@ import ( var GCPBigQueryCommand = &cobra.Command{ Use: "bigquery", Aliases: []string{}, - Short: "Display Bigauery datasets and tables information", + Short: "Display Bigquery datasets and tables information", Args: cobra.MinimumNArgs(0), Long: ` -Display available Bigauery datasets and tables resource information: +Display available Bigquery datasets and tables resource information: cloudfox gcp bigquery`, Run: runGCPBigQueryCommand, } diff --git a/globals/utils.go b/globals/utils.go index ca07b6e..1fba2fe 100644 --- a/globals/utils.go +++ b/globals/utils.go @@ -4,4 +4,4 @@ const CLOUDFOX_USER_AGENT = "cloudfox" const CLOUDFOX_LOG_FILE_DIR_NAME = ".cloudfox" const CLOUDFOX_BASE_DIRECTORY = "cloudfox-output" const LOOT_DIRECTORY_NAME = "loot" -const CLOUDFOX_VERSION = "1.13.5" +const CLOUDFOX_VERSION = "1.14.0" diff --git a/go.mod b/go.mod index 7e4b27e..988b0b0 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/BishopFox/cloudfox go 1.21.2 +toolchain go1.21.6 + require ( cloud.google.com/go/artifactregistry v1.14.6 cloud.google.com/go/bigquery v1.57.1 @@ -73,6 +75,7 @@ require ( github.com/bishopfox/knownawsaccountslookup v0.0.0-20231228165844-c37ef8df33cb github.com/dominikbraun/graph v0.23.0 github.com/fatih/color v1.16.0 + github.com/goccy/go-json v0.10.2 github.com/googleapis/gax-go/v2 v2.12.0 github.com/jedib0t/go-pretty v4.3.0+incompatible github.com/kyokomi/emoji v2.2.4+incompatible @@ -81,6 +84,24 @@ require ( github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.0 golang.org/x/crypto v0.17.0 + golang.org/x/exp v0.0.0-20231226003508-02704c960a9b +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/golang-jwt/jwt/v5 v5.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + golang.org/x/sync v0.5.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) + +require ( golang.org/x/oauth2 v0.15.0 google.golang.org/api v0.152.0 google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 @@ -120,12 +141,13 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 github.com/dimchansky/utfbom v1.1.1 // indirect github.com/go-openapi/errors v0.21.0 // indirect github.com/go-openapi/strfmt v0.21.10 // indirect - github.com/goccy/go-json v0.9.11 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect - github.com/golang-jwt/jwt/v5 v5.2.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.4 // indirect @@ -146,26 +168,25 @@ require ( github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/neo4j/neo4j-go-driver/v5 v5.14.0 github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect - github.com/rivo/uniseg v0.4.4 // indirect + github.com/rivo/uniseg v0.4.6 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.mongodb.org/mongo-driver v1.13.1 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/mod v0.10.0 // indirect + golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect - golang.org/x/sync v0.5.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/term v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.9.1 // indirect + golang.org/x/tools v0.16.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/grpc v1.59.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index b4ae1aa..6ec34b9 100644 --- a/go.sum +++ b/go.sum @@ -213,13 +213,23 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.26.6 h1:HJeiuZ2fldpd0WqngyMR6KW7ofkX github.com/aws/aws-sdk-go-v2/service/sts v1.26.6/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bishopfox/awsservicemap v1.0.3 h1:0T+mJLwG+vQV9+o3dzwzxhWJWE40VpoCLWtaPBwixYc= github.com/bishopfox/awsservicemap v1.0.3/go.mod h1:oy9Fyqh6AozQjShSx+zRNouTlp7k3z3YEMoFkN8rquc= github.com/bishopfox/knownawsaccountslookup v0.0.0-20231228165844-c37ef8df33cb h1:ot96tC/kdm0GKV1kl+aXJorqJbyx92R9bjRQvbBmLKU= github.com/bishopfox/knownawsaccountslookup v0.0.0-20231228165844-c37ef8df33cb/go.mod h1:2OnSqu4B86+2xGSIE5D4z3Rze9yJ/LNNjNXHhwMR+vY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -240,8 +250,8 @@ github.com/go-openapi/errors v0.21.0 h1:FhChC/duCnfoLj1gZ0BgaBmzhJC2SL/sJr8a2vAo github.com/go-openapi/errors v0.21.0/go.mod h1:jxNTMUxRCKj65yb/okJGEtahVd7uvWnuWfj53bse4ho= github.com/go-openapi/strfmt v0.21.10 h1:JIsly3KXZB/Qf4UzvzJpg4OELH/0ASDQsyk//TTBDDk= github.com/go-openapi/strfmt v0.21.10/go.mod h1:vNDMwbilnl7xKiO/Ve/8H8Bb2JIInBnH+lqiw6QWgis= -github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= -github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= @@ -316,11 +326,16 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kyokomi/emoji v2.2.4+incompatible h1:np0woGKwx9LiHAQmwZx79Oc0rHpNw3o+3evou4BEPv4= github.com/kyokomi/emoji v2.2.4+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= @@ -332,6 +347,16 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/neo4j/neo4j-go-driver/v5 v5.14.0 h1:5x3vD4HkXQIktlG63jSG8v9iweGjmObIPU7Y9U0ThUI= +github.com/neo4j/neo4j-go-driver/v5 v5.14.0/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -343,9 +368,10 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= +github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -392,15 +418,15 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4= +golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= -golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -436,6 +462,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= @@ -463,8 +490,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= -golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/aws.go b/internal/aws.go index 77956b8..aae73aa 100644 --- a/internal/aws.go +++ b/internal/aws.go @@ -3,8 +3,11 @@ package internal import ( "bufio" "context" + "encoding/gob" + "encoding/json" "fmt" "os" + "path/filepath" "regexp" "strings" "time" @@ -18,6 +21,7 @@ import ( "github.com/aws/smithy-go/ptr" "github.com/bishopfox/awsservicemap" "github.com/kyokomi/emoji" + "github.com/patrickmn/go-cache" "github.com/sirupsen/logrus" "github.com/spf13/afero" ) @@ -30,11 +34,95 @@ var ( ConfigMap = map[string]aws.Config{} ) +type CloudFoxRunData struct { + Profile string + AccountID string + OutputLocation string +} + +func init() { + gob.Register(aws.Config{}) + gob.Register(sts.GetCallerIdentityOutput{}) + gob.Register(CloudFoxRunData{}) +} + +func InitializeCloudFoxRunData(AWSProfile string, version string, AwsMfaToken string, AWSOutputDirectory string) (CloudFoxRunData, error) { + var runData CloudFoxRunData + + cacheDirectory := filepath.Join(AWSOutputDirectory, "cached-data", "aws") + filename := filepath.Join(cacheDirectory, fmt.Sprintf("CloudFoxRunData-%s.json", AWSProfile)) + if _, err := os.Stat(filename); err == nil { + // unmarshall the data from the file into type CloudFoxRunData + + // Open the file (this is not actually needed if you use os.ReadFile, so you can skip this) + file, err := os.Open(filename) + if err != nil { + return CloudFoxRunData{}, err + } + defer file.Close() + + // Read the file content + jsonData, err := os.ReadFile(filename) + if err != nil { + return CloudFoxRunData{}, err + } + + // Unmarshal jsonData into runData (make sure to pass a pointer to runData) + err = json.Unmarshal(jsonData, &runData) + if err != nil { + return CloudFoxRunData{}, err + } + + return runData, nil + + } + + CallerIdentity, err := AWSWhoami(AWSProfile, version, AwsMfaToken) + if err != nil { + return CloudFoxRunData{}, err + } + outputLocation := filepath.Join(AWSOutputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", AWSProfile, ptr.ToString(CallerIdentity.Account))) + + runData = CloudFoxRunData{ + Profile: AWSProfile, + AccountID: aws.ToString(CallerIdentity.Account), + OutputLocation: outputLocation, + } + + // Marshall the data to a file + err = os.MkdirAll(cacheDirectory, 0755) + if err != nil { + return CloudFoxRunData{}, err + } + file, err := os.Create(filename) + if err != nil { + return CloudFoxRunData{}, err + } + defer file.Close() + jsonData, err := json.Marshal(runData) + if err != nil { + return CloudFoxRunData{}, err + } + _, err = file.Write(jsonData) + if err != nil { + return CloudFoxRunData{}, err + } + + return runData, nil +} + func AWSConfigFileLoader(AWSProfile string, version string, AwsMfaToken string) aws.Config { // Loads the AWS config file and returns a config object var cfg aws.Config var err error + // cacheKey := fmt.Sprintf("AWSConfigFileLoader-%s", AWSProfile) + // cached, found := Cache.Get(cacheKey) + // if found { + // cfg = cached.(aws.Config) + // return cfg + // } + // Check if the profile is already in the config map. If not, load it and retrieve the credentials. If it is, return the cached config object // The AssumeRoleOptions below are used to pass the MFA token to the AssumeRole call (when applicable) if _, ok := ConfigMap[AWSProfile]; !ok { @@ -81,6 +169,7 @@ func AWSConfigFileLoader(AWSProfile string, version string, AwsMfaToken string) // update the config map with the new config for future lookups ConfigMap[AWSProfile] = cfg //return the config object for this first iteration + //Cache.Set(cacheKey, cfg, cache.DefaultExpiration) return cfg } @@ -89,10 +178,21 @@ func AWSConfigFileLoader(AWSProfile string, version string, AwsMfaToken string) cfg = ConfigMap[AWSProfile] return cfg } + //Cache.Set(cacheKey, cfg, cache.DefaultExpiration) return cfg } func AWSWhoami(awsProfile string, version string, AwsMfaToken string) (*sts.GetCallerIdentityOutput, error) { + + cacheKey := fmt.Sprintf("sts-getCallerIdentity-%s", awsProfile) + if cached, found := Cache.Get(cacheKey); found { + // Correct type assertion: assert the type, not a variable. + if cachedValue, ok := cached.(*sts.GetCallerIdentityOutput); ok { + return cachedValue, nil + } + // Handle the case where type assertion fails, if necessary. + } + // Connects to STS and checks caller identity. Same as running "aws sts get-caller-identity" //fmt.Printf("[%s] Retrieving caller's identity\n", cyan(emoji.Sprintf(":fox:cloudfox v%s :fox:", version))) STSService := sts.NewFromConfig(AWSConfigFileLoader(awsProfile, version, AwsMfaToken)) @@ -103,10 +203,18 @@ func AWSWhoami(awsProfile string, version string, AwsMfaToken string) (*sts.GetC return CallerIdentity, err } + // Convert CallerIdentity to something i can store using the cache + Cache.Set(cacheKey, CallerIdentity, cache.DefaultExpiration) return CallerIdentity, err } func GetEnabledRegions(awsProfile string, version string, AwsMfaToken string) []string { + cacheKey := fmt.Sprintf("GetEnabledRegions-%s", awsProfile) + cached, found := Cache.Get(cacheKey) + if found { + return cached.([]string) + } + var enabledRegions []string ec2Client := ec2.NewFromConfig(ConfigMap[awsProfile]) regions, err := ec2Client.DescribeRegions( @@ -130,45 +238,11 @@ func GetEnabledRegions(awsProfile string, version string, AwsMfaToken string) [] for _, region := range regions.Regions { enabledRegions = append(enabledRegions, *region.RegionName) } - + Cache.Set(cacheKey, enabledRegions, cache.DefaultExpiration) return enabledRegions } -// func GetRegionsForService(awsProfile string, service string) []string { -// SSMClient := ssm.NewFromConfig(AWSConfigFileLoader(awsProfile)) -// var PaginationControl *string -// var supportedRegions []string -// path := fmt.Sprintf("/aws/service/global-infrastructure/services/%s/regions", service) - -// ServiceRegions, err := SSMClient.GetParametersByPath( -// context.TODO(), -// &(ssm.GetParametersByPathInput{ -// NextToken: PaginationControl, -// Path: &path, -// }), -// ) -// if err != nil { -// fmt.Println(err.Error()) - -// } - -// if ServiceRegions.Parameters != nil { -// for _, region := range ServiceRegions.Parameters { -// name := *region.Value -// supportedRegions = append(supportedRegions, name) -// } - -// // The "NextToken" value is nil when there's no more data to return. -// if ServiceRegions.NextToken != nil { -// PaginationControl = ServiceRegions.NextToken -// } else { -// PaginationControl = nil -// } -// } -// return supportedRegions -// } - // txtLogger - Returns the txt logger func TxtLogger() *logrus.Logger { var txtFile *os.File diff --git a/internal/aws/policy/policy.go b/internal/aws/policy/policy.go index 1c2caa7..9a629c1 100644 --- a/internal/aws/policy/policy.go +++ b/internal/aws/policy/policy.go @@ -3,6 +3,8 @@ package policy import ( "encoding/json" "fmt" + "regexp" + "strings" ) type Policy struct { @@ -79,3 +81,88 @@ func contains(list []string, elem string) bool { return false } + +// source: https://github.com/nccgroup/PMapper/blob/master/principalmapper/querying/local_policy_simulation.py +func composePattern(stringToTransform string) *regexp.Regexp { + // Escape special characters and replace wildcards + escaped := strings.ReplaceAll(stringToTransform, ".", "\\.") + escaped = strings.ReplaceAll(escaped, "*", ".*") + escaped = strings.ReplaceAll(escaped, "?", ".") + escaped = strings.ReplaceAll(escaped, "$", "\\$") + escaped = strings.ReplaceAll(escaped, "^", "\\^") + + // Compile the regular expression, ignoring case + pattern, err := regexp.Compile("(?i)^" + escaped + "$") + if err != nil { + panic("regexp compile error: " + err.Error()) + } + return pattern +} + +// source: https://github.com/nccgroup/PMapper/blob/master/principalmapper/querying/local_policy_simulation.py +// MatchesAfterExpansion checks the stringToCheck against stringToCheckAgainst. +func MatchesAfterExpansion(stringFromPolicyToCheck, stringToCheckAgainst string) bool { + // Transform the stringToCheckAgainst into a regex pattern + pattern := composePattern(stringToCheckAgainst) + + // Check if the pattern matches stringToCheck + return pattern.MatchString(stringFromPolicyToCheck) +} + +func (p *Policy) DoesPolicyHaveMatchingStatement(effect string, actionToCheck string, resourceToCheck string) bool { + + for _, statement := range p.Statement { + if statement.Effect == effect { + matchesAction, matchesResource := false, false + for _, action := range statement.Action { + if MatchesAfterExpansion(actionToCheck, action) { + matchesAction = true + if resourceToCheck != "" { + for _, resource := range statement.Resource { + if MatchesAfterExpansion(resourceToCheck, resource) { + matchesResource = true + } + } + for _, notResource := range statement.NotResource { + if MatchesAfterExpansion(resourceToCheck, notResource) { + matchesResource = false + } + } + } + } + } + for _, notAction := range statement.NotAction { + matchesAction = true + if notAction == "*" { + matchesAction = false + } + if notAction == actionToCheck { + matchesAction = false + } + + if MatchesAfterExpansion(actionToCheck, notAction) { + matchesAction = false + } + if resourceToCheck != "" { + for _, resource := range statement.Resource { + if MatchesAfterExpansion(resourceToCheck, resource) { + matchesResource = true + } + } + for _, notResource := range statement.NotResource { + if MatchesAfterExpansion(resourceToCheck, notResource) { + matchesResource = false + } + } + } + + } + if matchesAction && matchesResource { + return true + } + } + + } + + return false +} diff --git a/internal/aws/policy/policy_test.go b/internal/aws/policy/policy_test.go index 5746b89..3870a46 100644 --- a/internal/aws/policy/policy_test.go +++ b/internal/aws/policy/policy_test.go @@ -150,3 +150,75 @@ func getTestFixure(filename string) (*Policy, error) { return &policy, nil } +func TestDoesPolicyHaveMatchingStatement(t *testing.T) { + p := &Policy{ + Statement: []PolicyStatement{ + { + Effect: "Allow", + Action: []string{"ec2:*"}, + Resource: []string{"*"}, + }, + { + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"arn:aws:s3:::bucket/*"}, + }, + { + Effect: "Deny", + Action: []string{"s3:*"}, + Resource: []string{"arn:aws:s3:::bucket2/*"}, + }, + }, + } + + tests := []struct { + effect string + actionToCheck string + resourceToCheck string + want bool + }{ + { + effect: "Allow", + actionToCheck: "ec2:DescribeInstances", + resourceToCheck: "arn:aws:ec2:us-west-2:123456789012:instance/*", + want: true, + }, + { + effect: "Allow", + actionToCheck: "s3:GetObject", + resourceToCheck: "arn:aws:s3:::bucket/file.txt", + want: true, + }, + { + effect: "Allow", + actionToCheck: "s3:PutObject", + resourceToCheck: "arn:aws:s3:::bucket/file.txt", + want: false, + }, + { + effect: "Deny", + actionToCheck: "s3:GetObject", + resourceToCheck: "arn:aws:s3:::bucket2/file.txt", + want: true, + }, + { + effect: "Deny", + actionToCheck: "s3:PutObject", + resourceToCheck: "arn:aws:s3:::bucket2/file.txt", + want: true, + }, + { + effect: "Deny", + actionToCheck: "s3:GetObject", + resourceToCheck: "arn:aws:s3:::bucket/file.txt", + want: false, + }, + } + + for _, tt := range tests { + actual := p.DoesPolicyHaveMatchingStatement(tt.effect, tt.actionToCheck, tt.resourceToCheck) + if tt.want != actual { + t.Errorf("DoesPolicyHaveMatchingStatement(%s, %s, %s) is %v but should be %v", tt.effect, tt.actionToCheck, tt.resourceToCheck, actual, tt.want) + } + } +} diff --git a/internal/aws/policy/statement.go b/internal/aws/policy/statement.go index b68d97c..94b23d1 100644 --- a/internal/aws/policy/statement.go +++ b/internal/aws/policy/statement.go @@ -6,13 +6,14 @@ import ( ) type PolicyStatement struct { - Sid string `json:"Sid,omitempty"` - Effect string `json:"Effect"` - Principal PolicyStatementPrincipal `json:"Principal,omitempty"` - Action ListOrString `json:"Action"` - NotAction ListOrString `json:"NotAction,omitempty"` - Resource ListOrString `json:"Resource,omitempty"` - Condition PolicyStatementCondition `json:"Condition,omitempty"` + Sid string `json:"Sid,omitempty"` + Effect string `json:"Effect"` + Principal PolicyStatementPrincipal `json:"Principal,omitempty"` + Action ListOrString `json:"Action"` + NotAction ListOrString `json:"NotAction,omitempty"` + Resource ListOrString `json:"Resource,omitempty"` + NotResource ListOrString `json:"NotResource,omitempty"` + Condition PolicyStatementCondition `json:"Condition,omitempty"` } func (ps *PolicyStatement) IsEmpty() bool { diff --git a/internal/cache.go b/internal/cache.go index adb0dbc..bcc3e00 100644 --- a/internal/cache.go +++ b/internal/cache.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/dominikbraun/graph" "github.com/patrickmn/go-cache" ) @@ -23,7 +24,6 @@ func SaveCacheToFiles(directory string, accountID string) error { } for key, item := range Cache.Items() { - // only if the key contains the accountID if accountID != "" && strings.Contains(key, accountID) { entry := cacheEntry{ Value: item.Object, @@ -41,6 +41,7 @@ func SaveCacheToFiles(directory string, accountID string) error { return err } } + } return nil } @@ -89,6 +90,12 @@ type cacheEntry struct { Exp int64 } +type CacheableAWSConfig struct { + Region string + //Credentials aws.CredentialsProvider + //ConfigSources []interface{} +} + func SaveCacheToGobFiles(directory string, accountID string) error { err := os.MkdirAll(directory, 0755) if err != nil { @@ -96,6 +103,7 @@ func SaveCacheToGobFiles(directory string, accountID string) error { } for key, item := range Cache.Items() { + // only if the key contains the accountID if accountID != "" && strings.Contains(key, accountID) { entry := cacheEntry{ @@ -116,6 +124,7 @@ func SaveCacheToGobFiles(directory string, accountID string) error { sharedLogger.Errorf("Could not encode the following key: %s", key) return err } + // } } } return nil @@ -171,3 +180,26 @@ func LoadCacheFromGobFiles(directory string) error { //fmt.Println("Cache loaded from files.") return nil } + +func SaveGraphToGob[K comparable, T any](directory string, name string, g *graph.Graph[K, T]) error { + err := os.MkdirAll(directory, 0755) + if err != nil { + return err + } + + filename := filepath.Join(directory, name+".gob") + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + encoder := gob.NewEncoder(file) + err = encoder.Encode(g) + if err != nil { + sharedLogger.Errorf("Could not encode the following graph: %s", name) + return err + + } + return nil +} diff --git a/internal/common/common.go b/internal/common/common.go new file mode 100644 index 0000000..56c5530 --- /dev/null +++ b/internal/common/common.go @@ -0,0 +1,17 @@ +package common + +type PermissionsRow struct { + AWSService string + Type string + Name string + Arn string + PolicyType string + PolicyName string + PolicyArn string + Effect string + Action string + Resource string + Condition string +} + +var PermissionRowsFromAllProfiles []PermissionsRow diff --git a/internal/output2.go b/internal/output2.go index e1e1d19..355dfe5 100644 --- a/internal/output2.go +++ b/internal/output2.go @@ -218,8 +218,8 @@ func (l *LootClient) writeLootFiles() []string { for _, file := range l.LootFiles { contents := []byte(file.Contents) fullPath := path.Join(l.DirectoryName, "loot", file.Name) - - err := os.WriteFile(fullPath, contents, 0644) + err := afero.WriteFile(fileSystem, fullPath, contents, 0644) // Use Afero's WriteFile + //err := os.WriteFile(fullPath, contents, 0644) if err != nil { log.Fatalf("error writing loot file %s: %s", file.Name, err) } @@ -507,3 +507,15 @@ func adjustBodyForTable(tableHeaders []string, fullHeaders []string, fullBody [] return adjustedBody, selectedHeaders } + +func WriteJsonlFile(file *os.File, data interface{}) error { + bytes, err := json.Marshal(data) + if err != nil { + return err + } + + if _, err := file.Write(append(bytes, "\n"...)); err != nil { + return err + } + return nil +} diff --git a/main.go b/main.go index d8ba2df..4683f00 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,14 @@ import ( "os" "github.com/BishopFox/cloudfox/cli" + "github.com/BishopFox/cloudfox/globals" "github.com/spf13/cobra" ) var ( rootCmd = &cobra.Command{ Use: os.Args[0], - Version: "1.14.0-prerelease", + Version: globals.CLOUDFOX_VERSION, } )