//go:build mage_cloudactions package main import ( "bufio" "context" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/antchfx/htmlquery" "github.com/aquasecurity/trivy/pkg/log" "golang.org/x/net/html" "golang.org/x/sync/errgroup" ) const ( serviceAuthURL = "https://docs.aws.amazon.com/service-authorization/latest/reference/" serviceActionReferencesURL = "https://docs.aws.amazon.com/service-authorization/latest/reference/reference_policies_actions-resources-contextkeys.html" targetFile = "pkg/iac/providers/aws/iam/actions.go" defaultParallel = 10 ) func parseServiceURLs(doc *html.Node) ([]string, error) { nodes, err := htmlquery.QueryAll(doc, `//div[@class="highlights"]/ul/li/a/@href`) if err != nil { return nil, fmt.Errorf("failed to search nodes: %w\n", err) } res := make([]string, 0, len(nodes)) for _, node := range nodes { // AWS Account Management if node.FirstChild != nil { res = append(res, serviceAuthURL+node.FirstChild.Data[2:]) } } return res, nil } func parseActions(url string) ([]string, error) { doc, err := htmlquery.LoadURL(url) if err != nil { return nil, err } servicePrefix, err := parseServicePrefix(doc) if err != nil { return nil, err } actions, err := parseServiceActions(doc) if err != nil { return nil, err } res := make([]string, 0, len(actions)) for _, act := range actions { res = append(res, servicePrefix+":"+act) } fmt.Printf("Parsing of %q actions is completed\n", servicePrefix) return res, nil } func parseServiceActions(doc *html.Node) ([]string, error) { table, err := htmlquery.Query(doc, `//div[@class="table-container"]/div/table/tbody`) if table == nil { return nil, errors.New("actions table not found") } if err != nil { return nil, fmt.Errorf("failed to query tables: %w\n", err) } var actions []string var f func(*html.Node) f = func(n *html.Node) { for _, tr := range findSubtags(n, "tr") { var action string for k, td := range findSubtags(tr, "td") { // first column - action if k == 0 { if a := findSubtag(td, "a"); a != nil && a.FirstChild != nil { action = a.FirstChild.Data } // fourth column - resource type // If the column is empty, then the action does not support resource-level permissions // and you must specify all resources ("*") in your policy } else if action != "" && k == 3 && td.FirstChild == nil { actions = append(actions, action) } } } for c := n.FirstChild; c != nil; c = c.NextSibling { f(c) } } f(table) return actions, err } func findSubtag(n *html.Node, tagName string) *html.Node { for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.ElementNode && c.Data == tagName { return c } } return nil } func findSubtags(n *html.Node, tagName string) []*html.Node { var result []*html.Node for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.ElementNode && c.Data == tagName { result = append(result, c) } } return result } func parseServicePrefix(doc *html.Node) (string, error) { nodes, err := htmlquery.QueryAll(doc, `//div[@id="main-col-body"]/p/descendant-or-self::*/text()`) if err != nil { return "", fmt.Errorf("failed to query paragraph: %w\n", err) } var sb strings.Builder for _, node := range nodes { sb.WriteString(node.Data) } p := sb.String() sb.Reset() idx := strings.Index(p, "service prefix: ") if idx == -1 { return "", fmt.Errorf("failed extract service prefix from text: %s\n", p) } idx += len("service prefix: ") if len(p)-1 <= idx { return "", fmt.Errorf("failed to parse service prefix from text: %s\n", p) } var parsed bool for _, r := range p[idx:] { if r == ')' { parsed = true break } sb.WriteRune(r) } if !parsed { return "", fmt.Errorf("failed to parse service prefix from text: %s\n", p) } return sb.String(), nil } func generateFile(path string, actions []string) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("failed to create file: %w\n", err) } defer f.Close() w := bufio.NewWriter(f) _, _ = w.WriteString( `// Code generated by mage genallowedactions DO NOT EDIT. package iam var allowedActionsForResourceWildcardsMap = map[string]struct{}{ `, ) for _, action := range actions { _, _ = w.WriteString("\t\"" + action + "\": {},\n") } _, _ = w.WriteString("}") return w.Flush() } func main() { if err := GenAllowedActions(); err != nil { log.Fatal(err) } } // GenAllowedActions generates the list of valid actions for wildcard support func GenAllowedActions() error { log.Info("Start parsing actions") startTime := time.Now() defer func() { log.Info("Parsing is completed", log.Duration(time.Since(startTime).Seconds())) }() doc, err := htmlquery.LoadURL(serviceActionReferencesURL) if err != nil { return fmt.Errorf("failed to retrieve action references: %w\n", err) } urls, err := parseServiceURLs(doc) if err != nil { return err } g, ctx := errgroup.WithContext(context.TODO()) g.SetLimit(defaultParallel) // actions may be the same for services of different versions, // e.g. Elastic Load Balancing and Elastic Load Balancing V2 actionsSet := make(map[string]struct{}) var mu sync.Mutex for _, url := range urls { url := url if ctx.Err() != nil { break } g.Go(func() error { serviceActions, err := parseActions(url) if err != nil { return fmt.Errorf("failed to parse actions from %q: %w\n", url, err) } mu.Lock() for _, act := range serviceActions { actionsSet[act] = struct{}{} } mu.Unlock() return nil }) } if err := g.Wait(); err != nil { return err } actions := make([]string, 0, len(actionsSet)) for act := range actionsSet { actions = append(actions, act) } sort.Strings(actions) path := filepath.FromSlash(targetFile) if err := generateFile(path, actions); err != nil { return fmt.Errorf("failed to generate file: %w\n", err) } return nil }