Files
spinifex/internal/awsmodel/request_hints.go
T

373 lines
13 KiB
Go

package awsmodel
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"regexp"
"slices"
"strings"
"sync"
"time"
)
// RequestOptions places generated ARNs in the account and region the
// requests are sent as. AccessKeyID is the key the requests are signed with.
// Fixtures maps a member to an existing resource it can name.
type RequestOptions struct {
AccountID string
Region string
AccessKeyID string
Fixtures map[string]string
}
// Models constrain most identifiers only by length, so "spxa" is a model-valid
// AMI ID that AWS rejects. Hints give resource-naming members a value of the
// right form, used only if the member's own constraints accept it.
// ec2IDPrefixes maps the resource an EC2 "<Resource>Id" member names to its
// ID prefix.
var ec2IDPrefixes = map[string]string{
"Allocation": "eipalloc",
"CapacityReservation": "cr",
"CarrierGateway": "cagw",
"DhcpOptions": "dopt",
"EgressOnlyInternetGateway": "eigw",
"Fleet": "fleet",
"Group": "sg",
"Host": "h",
"Image": "ami",
"Instance": "i",
"InternetGateway": "igw",
"KeyPair": "key",
"LaunchTemplate": "lt",
"NatGateway": "nat",
"NetworkAcl": "acl",
"NetworkInterface": "eni",
"PlacementGroup": "pg",
"PrefixList": "pl",
"RouteTable": "rtb",
"SecurityGroup": "sg",
"SecurityGroupRule": "sgr",
"Snapshot": "snap",
"SourceImage": "ami",
"SourceSnapshot": "snap",
"SpotInstanceRequest": "sir",
"Subnet": "subnet",
"TransitGateway": "tgw",
"Volume": "vol",
"Vpc": "vpc",
"VpcEndpoint": "vpce",
"VpcPeeringConnection": "pcx",
}
var ec2IDMember = regexp.MustCompile(`^(.*?)Ids?$`)
// conditionallyRequired names members the model marks optional that the
// operation still needs in its base request. The services refuse their absence
// with a code the judge cannot tell from a real finding.
var conditionallyRequired = map[Service]map[string][]string{
EC2: {"CreateCapacityReservation": {"AvailabilityZone"}},
ECS: {
"CreateCapacityProvider": {"autoScalingGroupProvider"},
"CreateService": {"taskDefinition"},
},
RDS: {
"CreateDBInstance": {"AllocatedStorage", "MasterUsername", "MasterUserPassword"},
"RestoreDBInstanceFromDBSnapshot": {"DBSnapshotIdentifier"},
},
}
const (
// imageManifest is well formed but names blobs no repository holds.
imageManifest = `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":2,"digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"layers":[]}`
lifecyclePolicy = `{"rules":[{"rulePriority":1,"description":"expire untagged","selection":{"tagStatus":"untagged","countType":"sinceImagePushed","countUnit":"days","countNumber":14},"action":{"type":"expire"}}]}`
repositoryPolicy = `{"Version":"2012-10-17","Statement":[{"Sid":"pull","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::%s:root"},"Action":"ecr:BatchGetImage"}]}`
policyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
trustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
)
// ec2TaggedTypes is the resource type each EC2 create tags through its
// TagSpecifications. AWS refuses any other type, so the enum's first value
// would make an otherwise valid request invalid.
var ec2TaggedTypes = map[string]string{
"AllocateAddress": "elastic-ip",
"AuthorizeSecurityGroupEgress": "security-group-rule",
"AuthorizeSecurityGroupIngress": "security-group-rule",
"CopyImage": "image",
"CopySnapshot": "snapshot",
"CreateCapacityReservation": "capacity-reservation",
"CreateEgressOnlyInternetGateway": "egress-only-internet-gateway",
"CreateImage": "image",
"CreateInternetGateway": "internet-gateway",
"CreateKeyPair": "key-pair",
"CreateLaunchTemplate": "launch-template",
"CreateNatGateway": "natgateway",
"CreateNetworkInterface": "network-interface",
"CreatePlacementGroup": "placement-group",
"CreateRouteTable": "route-table",
"CreateSecurityGroup": "security-group",
"CreateSnapshot": "snapshot",
"CreateSubnet": "subnet",
"CreateVolume": "volume",
"CreateVpc": "vpc",
"ImportKeyPair": "key-pair",
"RegisterImage": "image",
"RequestSpotInstances": "spot-instances-request",
"RunInstances": "instance",
}
// enumHint returns the enum value a valid request needs at path, where the
// first value would not do.
func (g *requestGenerator) enumHint(path []pathStep) (string, bool) {
if g.model.service != EC2 || len(path) == 0 || path[0] != "TagSpecifications" || memberName(path) != "ResourceType" {
return "", false
}
value, ok := ec2TaggedTypes[g.operation]
return value, ok
}
// hint returns a value of the right form for the member at path, if one is
// known. salt keeps names distinct between request cases.
func (g *requestGenerator) hint(path []pathStep) (string, bool) {
member := memberName(path)
if member == "" {
return "", false
}
options := g.options
if fixture, ok := options.Fixtures[member]; ok {
return fixture, true
}
suffix := fmt.Sprintf("%017x", g.saltNumber)
name := "spx" + g.salt
switch g.model.service {
case EC2:
switch member {
case "AvailabilityZone":
return options.Region + "a", true
case "SourceRegion":
return options.Region, true
}
if match := ec2IDMember.FindStringSubmatch(capitalize(member)); match != nil {
if prefix, ok := ec2IDPrefixes[match[1]]; ok {
return prefix + "-" + suffix, true
}
}
case ElasticLoadBalancingV2:
base := fmt.Sprintf("arn:aws:elasticloadbalancing:%s:%s:", options.Region, options.AccountID)
switch member {
case "LoadBalancerArn", "LoadBalancerArns", "ResourceArns":
return base + "loadbalancer/app/" + name + "/" + suffix[1:], true
case "TargetGroupArn", "TargetGroupArns":
return base + "targetgroup/" + name + "/" + suffix[1:], true
case "ListenerArn", "ListenerArns":
return base + "listener/app/" + name + "/" + suffix[1:] + "/" + suffix[1:], true
case "RuleArn", "RuleArns":
return base + "listener-rule/app/" + name + "/" + suffix[1:] + "/" + suffix[1:] + "/" + suffix[1:], true
case "ProtocolVersion":
return "HTTP1", true
case "HttpCode":
return "200", true
}
case RDS:
switch member {
case "ResourceName":
return fmt.Sprintf("arn:aws:rds:%s:%s:db:%s", options.Region, options.AccountID, name), true
case "Engine":
return "postgres", true
case "DBInstanceClass":
return "db.t3.medium", true
case "DBParameterGroupFamily":
return "postgres18", true
case "MasterUserPassword":
return "spx-conformance-" + g.salt, true
case "SnapshotType":
return "manual", true
case "Source":
return "user", true
}
case ECS:
switch member {
case "resourceArn":
return fmt.Sprintf("arn:aws:ecs:%s:%s:cluster/%s", options.Region, options.AccountID, name), true
case "taskDefinition":
return name + ":1", true
}
case ACM:
// The integration tenant CA permits example.com.
switch member {
case "DomainName", "SubjectAlternativeNames", "ValidationDomain":
return name + ".example.com", true
}
case ECR:
switch member {
case "resourceArn":
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", options.Region, options.AccountID, name), true
case "imageManifest":
return imageManifest, true
case "lifecyclePolicyText":
return lifecyclePolicy, true
case "policyText":
return fmt.Sprintf(repositoryPolicy, options.AccountID), true
}
case EKS:
switch member {
case "resourceArn":
return fmt.Sprintf("arn:aws:eks:%s:%s:cluster/%s", options.Region, options.AccountID, name), true
case "policyArn":
return "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", true
case "addonName":
return "aws-ebs-csi-driver", true
}
// An access entry's type; a nested type names an identity provider's.
if member == "type" && len(path) == 1 {
return "STANDARD", true
}
case STS:
// GetAccessKeyInfo answers only for a key that exists.
if member == "AccessKeyId" && options.AccessKeyID != "" {
return options.AccessKeyID, true
}
}
switch member {
case "PolicyArn", "PermissionsBoundary":
return fmt.Sprintf("arn:aws:iam::%s:policy/%s", options.AccountID, name), true
case "RoleArn":
return fmt.Sprintf("arn:aws:iam::%s:role/%s", options.AccountID, name), true
case "CertificateArn":
return fmt.Sprintf("arn:aws:acm:%s:%s:certificate/%s-0000-0000-0000-000000000000", options.Region, options.AccountID, suffix[9:]), true
case "PolicyDocument", "Policy":
return policyDocument, true
case "AssumeRolePolicyDocument":
return trustPolicyDocument, true
case "Url":
return "https://" + name + ".example.com", true
case "AccessKeyId":
return "AKIA" + strings.ToUpper(fmt.Sprintf("%016x", g.saltNumber)), true
}
return "", false
}
// integerHint returns a value AWS accepts for an integer member whose range
// the model leaves out, if one is known.
func (g *requestGenerator) integerHint(path []pathStep) (int64, bool) {
if g.model.service == RDS && memberName(path) == "AllocatedStorage" {
// The smallest gp2/gp3 allocation PostgreSQL, the Engine hint, accepts.
return 20, true
}
return 0, false
}
// blobHint returns a value AWS accepts for a blob member, if one is known.
func (g *requestGenerator) blobHint(path []pathStep) ([]byte, bool) {
if g.model.service != ACM {
return nil, false
}
// GenerateRequests has already surfaced a generation error.
material, err := importMaterial()
if err != nil {
return nil, false
}
switch memberName(path) {
case "Certificate":
return material.certificate, true
case "PrivateKey":
return material.privateKey, true
case "CertificateChain":
return material.chain, true
}
return nil, false
}
// certificateMaterial is a PEM leaf certificate, its key and the CA that
// signed it, as ImportCertificate takes them.
type certificateMaterial struct {
certificate, privateKey, chain []byte
}
// importMaterial is generated once per run; minting a key per request would
// dominate the ACM cases.
var importMaterial = sync.OnceValues(func() (certificateMaterial, error) {
now := time.Now().UTC()
caKey, caDER, err := newCertificate(&x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Spinifex Conformance CA"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}, nil, nil)
if err != nil {
return certificateMaterial{}, err
}
ca, err := x509.ParseCertificate(caDER)
if err != nil {
return certificateMaterial{}, fmt.Errorf("awsmodel: parse conformance CA: %w", err)
}
leafKey, leafDER, err := newCertificate(&x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "spx.example.com"},
DNSNames: []string{"spx.example.com"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}, ca, caKey)
if err != nil {
return certificateMaterial{}, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
if err != nil {
return certificateMaterial{}, fmt.Errorf("awsmodel: marshal conformance key: %w", err)
}
return certificateMaterial{
certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}),
privateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
chain: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}),
}, nil
})
// newCertificate creates a P-256 key and a certificate for it from template,
// signed by parent's key, or self-signed when parent is nil.
func newCertificate(template, parent *x509.Certificate, parentKey *ecdsa.PrivateKey) (*ecdsa.PrivateKey, []byte, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("awsmodel: generate conformance key: %w", err)
}
if parent == nil {
parent, parentKey = template, key
}
der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey)
if err != nil {
return nil, nil, fmt.Errorf("awsmodel: create conformance certificate: %w", err)
}
return key, der, nil
}
// memberName is the structure member a value at path belongs to, looking
// through list indices.
func memberName(path []pathStep) string {
for _, step := range slices.Backward(path) {
if name, ok := step.(string); ok {
return name
}
}
return ""
}
func capitalize(name string) string {
if name == "" {
return name
}
return strings.ToUpper(name[:1]) + name[1:]
}