package awsinv import ( "context" "fmt" "strconv" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/acm" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" "github.com/aws/aws-sdk-go-v2/service/route53" r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/carlosframework/platform/internal/infraview" ) // joinedRecordTypes are the record types that can participate in the // DNS -> IP -> machine join, and so the only ones materialised as their own // Resource. Everything else is counted into the zone's record_count: a zone // can hold thousands of records and this object is read on every page load. var joinedRecordTypes = map[string]bool{"A": true, "AAAA": true} func s3Resources(ctx context.Context, api S3API) (out []infraview.Resource, partial []string) { o, err := api.ListBuckets(ctx, &s3.ListBucketsInput{}) if err != nil { return nil, []string{fmt.Sprintf("buckets: %v", err)} } if o == nil { return nil, partial } for _, b := range o.Buckets { name := aws.ToString(b.Name) r := res(infraview.KindBucket, "s3:bucket", name, name, "") // Each of the four posture reads below is best-effort per bucket: a // bucket whose versioning cannot be read still appears, with the // attribute absent rather than a false "off". "off" is a claim. if loc, err := api.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: b.Name}); err == nil && loc != nil { region := string(loc.LocationConstraint) if region == "" { region = "us-east-1" // the API's documented empty-means-us-east-1 } r.Region = region } if v, err := api.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: b.Name}); err == nil && v != nil { set(&r, "versioning", strings.ToLower(string(v.Status))) } if _, err := api.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: b.Name}); err == nil { set(&r, "encryption", "on") } if p, err := api.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: b.Name}); err == nil && p != nil && p.PublicAccessBlockConfiguration != nil { c := p.PublicAccessBlockConfiguration if aws.ToBool(c.BlockPublicAcls) && aws.ToBool(c.BlockPublicPolicy) { set(&r, "public_access_block", "on") } else { set(&r, "public_access_block", "partial") } } out = append(out, r) } return out, partial } // unescapeDNSName turns Route53's wire form of a record name into the one // an operator recognises. The API returns non-ASCII and special characters // octal-escaped, so the wildcard every CARLOS deployment leans on comes back // as `\052.woodstar.app` โ€” which is what the architecture page displayed // until someone looked at it. Only the printable-ASCII escapes are decoded; // anything else is left exactly as the API sent it, since a half-decoded // name would be worse than an escaped one. func unescapeDNSName(s string) string { if !strings.Contains(s, `\`) { return s } var b strings.Builder for i := 0; i < len(s); i++ { if s[i] == '\\' && i+3 < len(s) { if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil && n >= 0x20 && n < 0x7f { b.WriteByte(byte(n)) i += 3 continue } } b.WriteByte(s[i]) } return b.String() } func route53Resources(ctx context.Context, api Route53API) (out []infraview.Resource, partial []string) { zones, err := api.ListHostedZones(ctx, &route53.ListHostedZonesInput{}) if err != nil { return nil, []string{fmt.Sprintf("hosted zones: %v", err)} } if zones == nil { return nil, partial } for _, z := range zones.HostedZones { // AWS's own ID field carries an "/hostedzone/" prefix // (aws.ToString(z.Id) == "/hostedzone/Z1"); the bare "Z1" is the // canonical hosted-zone id used everywhere else (the console, the // CLI, parseARN's tail), and nothing downstream depends on the // prefixed form โ€” the console joins records to their zone by NAME, // not by id. Using the bare id here is what lets the catch-all's // dedup, keyed on the literal Resource.ID, find this zone without // a second, service-specific seeding rule. id := strings.TrimPrefix(aws.ToString(z.Id), "/hostedzone/") name := unescapeDNSName(strings.TrimSuffix(aws.ToString(z.Name), ".")) zr := res(infraview.KindDNSZone, "route53:hosted-zone", id, name, "") if z.Config != nil && z.Config.PrivateZone { set(&zr, "private", "yes") } count := 0 // Route53 needs the full StartRecordName+StartRecordType+ // StartRecordIdentifier triple to resume at the exact record; a // name-only cursor restarts each page at the FIRST record carrying // that name, which both double-counts records at the page boundary // and, when a single name owns a whole page, never advances at all. var nextName, nextIdentifier *string var nextType r53types.RRType for { recs, err := api.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{ HostedZoneId: z.Id, StartRecordName: nextName, StartRecordType: nextType, StartRecordIdentifier: nextIdentifier, }) if err != nil { partial = append(partial, fmt.Sprintf("records in %s: %v", name, err)) break } if recs == nil { break } for _, rec := range recs.ResourceRecordSets { count++ rtype := string(rec.Type) if !joinedRecordTypes[rtype] { continue } rname := unescapeDNSName(strings.TrimSuffix(aws.ToString(rec.Name), ".")) for _, v := range rec.ResourceRecords { rr := res(infraview.KindDNSRecord, "route53:record", rname+"/"+rtype+"/"+aws.ToString(v.Value), rname, "") set(&rr, "zone", name) set(&rr, "type", rtype) set(&rr, "value", aws.ToString(v.Value)) out = append(out, rr) } if rec.AliasTarget != nil { rr := res(infraview.KindDNSRecord, "route53:alias", rname+"/ALIAS", rname, "") set(&rr, "zone", name) set(&rr, "type", "ALIAS") set(&rr, "value", strings.TrimSuffix(aws.ToString(rec.AliasTarget.DNSName), ".")) out = append(out, rr) } } if !recs.IsTruncated { break } // A nil next-name, or a next name+type+identifier triple // identical to what we just asked for, means the cursor did not // advance. All three must match to call it a stall: weighted, // latency, geolocation and failover record sets legitimately // repeat the same name and type across a page boundary while // only the identifier moves, and treating that as a stall would // truncate the record set. Trusting IsTruncated forever on a // genuine non-advance would spin an unbounded loop against a // live API on an hourly pass, so stop and name the shortfall // rather than loop or restart the zone. if recs.NextRecordName == nil || (aws.ToString(recs.NextRecordName) == aws.ToString(nextName) && recs.NextRecordType == nextType && aws.ToString(recs.NextRecordIdentifier) == aws.ToString(nextIdentifier)) { partial = append(partial, fmt.Sprintf("records in %s: pagination cursor did not advance, stopped short", name)) break } nextName = recs.NextRecordName nextType = recs.NextRecordType nextIdentifier = recs.NextRecordIdentifier } set(&zr, "record_count", strconv.Itoa(count)) out = append(out, zr) } return out, partial } // iamResources reports identities as NAMES and a count of attached // policies. Policy documents are deliberately never read: this page is a // map, not a credential viewer (spec ยง4). func iamResources(ctx context.Context, api IAMAPI) (out []infraview.Resource, partial []string) { if o, err := api.ListRoles(ctx, &iam.ListRolesInput{}); err != nil { partial = append(partial, fmt.Sprintf("IAM roles: %v", err)) } else if o != nil { for _, role := range o.Roles { name := aws.ToString(role.RoleName) r := res(infraview.KindIdentity, "iam:role", aws.ToString(role.Arn), name, "") set(&r, "kind", "role") if pol, err := api.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{RoleName: role.RoleName}); err == nil && pol != nil { set(&r, "attached_policies", strconv.Itoa(len(pol.AttachedPolicies))) } out = append(out, r) } } if o, err := api.ListUsers(ctx, &iam.ListUsersInput{}); err != nil { partial = append(partial, fmt.Sprintf("IAM users: %v", err)) } else if o != nil { for _, u := range o.Users { r := res(infraview.KindIdentity, "iam:user", aws.ToString(u.Arn), aws.ToString(u.UserName), "") set(&r, "kind", "user") out = append(out, r) } } return out, partial } func alarmResources(ctx context.Context, api CloudWatchAPI, region string) (out []infraview.Resource, partial []string) { o, err := api.DescribeAlarms(ctx, &cloudwatch.DescribeAlarmsInput{}) if err != nil { return nil, []string{fmt.Sprintf("alarms in %s: %v", region, err)} } if o == nil { return nil, partial } for _, a := range o.MetricAlarms { r := res(infraview.KindAlarm, "cloudwatch:alarm", aws.ToString(a.AlarmArn), aws.ToString(a.AlarmName), region) set(&r, "state", string(a.StateValue)) set(&r, "metric", aws.ToString(a.MetricName)) for _, d := range a.Dimensions { if aws.ToString(d.Name) == "InstanceId" { set(&r, "target", aws.ToString(d.Value)) } } out = append(out, r) } return out, partial } func certResources(ctx context.Context, api ACMAPI, region string) (out []infraview.Resource, partial []string) { o, err := api.ListCertificates(ctx, &acm.ListCertificatesInput{}) if err != nil { return nil, []string{fmt.Sprintf("certificates in %s: %v", region, err)} } if o == nil { return nil, partial } for _, c := range o.CertificateSummaryList { r := res(infraview.KindCertificate, "acm:certificate", aws.ToString(c.CertificateArn), aws.ToString(c.DomainName), region) set(&r, "domain", aws.ToString(c.DomainName)) set(&r, "status", string(c.Status)) if c.NotAfter != nil { set(&r, "expires", c.NotAfter.UTC().Format("2006-01-02")) } out = append(out, r) } return out, partial } // catchAll is the one genuinely AWS-shaped trick in this package: one call // that returns every TAGGABLE resource in the region, so a resource type // nobody wrote a mapping for is still visible. seen holds the ids the typed // mappings already produced โ€” an ARN whose id is in it is skipped, so the // catch-all only ever ADDS. func catchAll(ctx context.Context, api TaggingAPI, region string, seen map[string]bool) (out []infraview.Resource, partial []string) { var token *string for { o, err := api.GetResources(ctx, &resourcegroupstaggingapi.GetResourcesInput{PaginationToken: token}) if err != nil { return out, append(partial, fmt.Sprintf("resource inventory in %s: %v", region, err)) } if o == nil { return out, partial } for _, m := range o.ResourceTagMappingList { arn := aws.ToString(m.ResourceARN) service, id := parseARN(arn) if id == "" || seen[id] || seen[arn] { continue } r := res(infraview.KindOther, service, arn, id, region) for _, t := range m.Tags { if r.Tags == nil { r.Tags = map[string]string{} } r.Tags[aws.ToString(t.Key)] = aws.ToString(t.Value) } out = append(out, r) } if o.PaginationToken == nil || *o.PaginationToken == "" { return out, partial } token = o.PaginationToken } } // parseARN pulls the service and the trailing resource id out of an ARN: // arn:aws:ec2:eu-west-1:1234:instance/i-123 -> ("ec2", "i-123"). func parseARN(arn string) (service, id string) { parts := strings.SplitN(arn, ":", 6) if len(parts) < 6 { return "", "" } service = parts[2] tail := parts[5] if i := strings.LastIndexAny(tail, "/:"); i >= 0 { tail = tail[i+1:] } return service, tail }