From 21c1623326f62f5e7af735523ad6a659126ac2d9 Mon Sep 17 00:00:00 2001 From: Austin Byers Date: Fri, 8 May 2020 10:53:34 -0700 Subject: [PATCH] Add custom certificate CFN resource (#857) --- deployments/bootstrap.yml | 38 +-- deployments/bootstrap_gateway.yml | 83 +++++- deployments/web_server.yml | 48 +++- docs/gitbook/operations/runbooks.md | 6 + go.mod | 2 +- go.sum | 4 +- .../pollers/aws/cloudformation_stack.go | 2 +- .../snapshot_poller/pollers/aws/iam_user.go | 2 +- internal/core/custom_resources/main/lambda.go | 52 ++++ .../custom_resources/resources/certificate.go | 250 ++++++++++++++++++ .../custom_resources/resources/clients.go | 56 ++++ .../core/custom_resources/resources/map.go | 33 +++ .../dynamodbbatch/batch_write_item.go | 2 +- pkg/awsbatch/kinesisbatch/put_records.go | 2 +- pkg/awsbatch/s3batch/delete_objects.go | 2 +- pkg/awsbatch/sqsbatch/send_message_batch.go | 2 +- tools/mage/deploy.go | 56 ++-- tools/mage/deploy_certificate.go | 185 ------------- tools/mage/deploy_frontend.go | 2 + tools/mage/deploy_template.go | 13 +- tools/mage/teardown.go | 110 ++------ tools/mage/util.go | 7 +- 22 files changed, 573 insertions(+), 384 deletions(-) create mode 100644 internal/core/custom_resources/main/lambda.go create mode 100644 internal/core/custom_resources/resources/certificate.go create mode 100644 internal/core/custom_resources/resources/clients.go create mode 100644 internal/core/custom_resources/resources/map.go delete mode 100644 tools/mage/deploy_certificate.go diff --git a/deployments/bootstrap.yml b/deployments/bootstrap.yml index 0435f4ae29..61ae71e200 100644 --- a/deployments/bootstrap.yml +++ b/deployments/bootstrap.yml @@ -30,11 +30,6 @@ Description: The very first Panther stack - static resources which don't require # - The cognito user pool and appsync API Parameters: - # Required - CertificateArn: - Type: String - Description: The ARN of the TLS certificate that is going to be used by the ALB listener - # Optional LogSubscriptionPrincipals: Type: CommaDelimitedList @@ -637,33 +632,6 @@ Resources: IpProtocol: '-1' SourceSecurityGroupId: !Ref PublicLoadBalancerSecurityGroup - PublicLoadBalancerListener: - Type: AWS::ElasticLoadBalancingV2::Listener - Properties: - Certificates: - - CertificateArn: !Ref CertificateArn - DefaultActions: - - TargetGroupArn: !Ref TargetGroup - Type: forward - LoadBalancerArn: !Ref PublicLoadBalancer - Port: 443 - Protocol: HTTPS - SslPolicy: ELBSecurityPolicy-TLS-1-2-Ext-2018-06 - - # Create a rule on the load balancer for routing traffic to the target group - LoadBalancerRule: - Type: AWS::ElasticLoadBalancingV2::ListenerRule - Properties: - Actions: - - Type: forward - TargetGroupArn: !Ref TargetGroup - Conditions: - - Field: path-pattern - Values: - - '*' - ListenerArn: !Ref PublicLoadBalancerListener - Priority: 1 - ########## Appsync ########## AppsyncServiceRole: Type: AWS::IAM::Role @@ -816,15 +784,15 @@ Outputs: Value: !Ref Source # Networking + elb - CertificateArn: - Description: The ARN of the TLS certificate used by the ALB listener - Value: !Ref CertificateArn SubnetOneId: Description: Public subnet one Value: !Ref PublicSubnetOne SubnetTwoId: Description: Public subnet two Value: !Ref PublicSubnetTwo + LoadBalancerArn: + Description: Web load balancer arn + Value: !Ref PublicLoadBalancer LoadBalancerFullName: Description: Web load balancer full resource name Value: !GetAtt PublicLoadBalancer.LoadBalancerFullName diff --git a/deployments/bootstrap_gateway.yml b/deployments/bootstrap_gateway.yml index 0b74664bdc..c0e445fedc 100644 --- a/deployments/bootstrap_gateway.yml +++ b/deployments/bootstrap_gateway.yml @@ -24,18 +24,27 @@ Description: API gateway and Python layer # - API gateways: 'mage deploy' embeds swagger definitions directly into this template, # making the template too large to deploy without an S3 bucket # - Python layer: the layer source has to be uploaded to S3 before the cfn resource can be created +# - Custom resource: the Lambda function which handles custom CFN resources Parameters: # From the config file + CloudWatchLogRetentionDays: + Type: Number + Description: CloudWatch log retention period + Default: 365 + LayerVersionArns: + Type: CommaDelimitedList + Description: List of LayerVersion ARNs to attach to each function + Default: '' PythonLayerVersionArn: Type: String Description: Custom Python layer for analysis and remediation Default: '' - TracingEnabled: + TracingMode: Type: String - Description: Enable XRay tracing on API Gateway - AllowedValues: [true, false] - Default: false + Description: Enable XRay tracing on Lambda and API Gateway + AllowedValues: ['', Active, PassThrough] + Default: '' # If no custom layer is provided, 'mage deploy' will have uploaded a layer here: SourceBucket: @@ -52,7 +61,9 @@ Parameters: Default: '' Conditions: + AttachLayers: !Not [!Equals [!Join ['', !Ref LayerVersionArns], '']] CreatePythonLayer: !Equals [!Ref PythonLayerVersionArn, ''] + TracingEnabled: !Not [!Equals ['', !Ref TracingMode]] Resources: PythonLayer: @@ -69,6 +80,62 @@ Resources: Description: Pip libraries available to the Python analysis/remediation functions LayerName: panther-analysis + # When deploying from source, the S3 "source" bucket must exist before this function can be packaged. + # That is why this resource is here instead of in the very first "bootstrap" stack. + CustomResourceFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: ../bin/internal/core/custom_resources/main + Description: Custom CloudFormation resources when deploying Panther + Environment: + Variables: + DEBUG: true + FunctionName: panther-cfn-custom-resources + # + # Used by CloudFormation when deploying or updating Panther. + # + # Failure Impact + # * Panther itself will not be affected, but deployments may be failing + # + Handler: main + Layers: !If [AttachLayers, !Ref LayerVersionArns, !Ref 'AWS::NoValue'] + MemorySize: 256 + Runtime: go1.x + Timeout: 900 # Maximum allowed: 15 minutes + Tracing: !If [TracingEnabled, !Ref TracingMode, !Ref 'AWS::NoValue'] + + # This function has more permissions than usual because it creates and destroys infrastructure. + # It is used only by CloudFormation in the deploy process and not by the Panther application itself. + Policies: + - Id: CertificateManagement + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - acm:AddTagsToCertificate + - acm:DeleteCertificate + - acm:ImportCertificate + - acm:RemoveTagsFromCertificate + # ACM certificate IDs are random and at the time of writing, DeleteCertificate does + # not support using resource tags as a condition. + # So this is as narrow as this resource can get: + Resource: !Sub arn:${AWS::Partition}:acm:${AWS::Region}:${AWS::AccountId}:certificate/* + - Effect: Allow + Action: + - iam:DeleteServerCertificate + - iam:UploadServerCertificate + Resource: + - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:server-certificate/panther/* + # IAM sometimes requires permissions to match the certificate name, not the full path + # This seems like a bug in IAM, but in any case a fresh deploy/teardown will not work without this: + - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:server-certificate/Panther* + + CustomResourceLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: /aws/lambda/panther-cfn-custom-resources + RetentionInDays: !Ref CloudWatchLogRetentionDays + AnalysisApi: Type: AWS::Serverless::Api Properties: @@ -79,7 +146,7 @@ Resources: # The `panther-analysis-api` API Gateway calls the `panther-analysis-api` lambda. # StageName: v1 - TracingEnabled: !Ref TracingEnabled + TracingEnabled: !If [TracingEnabled, true, false] ComplianceApi: Type: AWS::Serverless::Api @@ -91,7 +158,7 @@ Resources: # The `panther-compliance-api` API Gateway calls the `panther-compliance-api` lambda. # StageName: v1 - TracingEnabled: !Ref TracingEnabled + TracingEnabled: !If [TracingEnabled, true, false] RemediationApi: Type: AWS::Serverless::Api @@ -103,7 +170,7 @@ Resources: # The `panther-remediation-api` API Gateway calls the `panther-remediation-api` lambda. # StageName: v1 - TracingEnabled: !Ref TracingEnabled + TracingEnabled: !If [TracingEnabled, true, false] ResourcesApi: Type: AWS::Serverless::Api @@ -115,7 +182,7 @@ Resources: # The `panther-resources-api` API Gateway calls the `panther-resources-api` lambda. # StageName: v1 - TracingEnabled: !Ref TracingEnabled + TracingEnabled: !If [TracingEnabled, true, false] Outputs: PythonLayerVersionArn: diff --git a/deployments/web_server.yml b/deployments/web_server.yml index d46e4319a6..edbb703ff2 100644 --- a/deployments/web_server.yml +++ b/deployments/web_server.yml @@ -18,13 +18,16 @@ AWSTemplateFormatVersion: 2010-09-09 Description: The service that defines the front-end NodeJS server that serves the Panther web application statics Parameters: - # Passed in from bootstrap.yml stack + # Passed in from bootstrap stacks SubnetOneId: Type: String Description: The ID of a subnet in the VPC above SubnetTwoId: Type: String Description: The ID of another subnet in the VPC above + ElbArn: + Type: String + Description: The ARN of the load balancer ElbTargetGroup: Type: String Description: The ARN of the load balancer target group @@ -37,6 +40,10 @@ Parameters: Type: Number Description: CloudWatch log retention period Default: 365 + CertificateArn: + Type: String + Description: TLS certificate used by the web app. If not specfied, a self-signed cert is created for you. + Default: '' # Generated in deploy process Image: @@ -56,10 +63,49 @@ Parameters: Default: 80 Description: The exposed port of the application, that will be used by the VPC & Container +Conditions: + CreateCertificate: !Equals [!Ref CertificateArn, ''] + Resources: + SelfSignedCertificate: + Condition: CreateCertificate + Type: Custom::Certificate + Properties: + ServiceToken: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:panther-cfn-custom-resources + + PublicLoadBalancerListener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + Certificates: + - CertificateArn: + !If [CreateCertificate, !GetAtt SelfSignedCertificate.Arn, !Ref CertificateArn] + DefaultActions: + - TargetGroupArn: !Ref ElbTargetGroup + Type: forward + LoadBalancerArn: !Ref ElbArn + Port: 443 + Protocol: HTTPS + SslPolicy: ELBSecurityPolicy-TLS-1-2-Ext-2018-06 + + # Create a rule on the load balancer for routing traffic to the target group + LoadBalancerRule: + Type: AWS::ElasticLoadBalancingV2::ListenerRule + Properties: + Actions: + - Type: forward + TargetGroupArn: !Ref ElbTargetGroup + Conditions: + - Field: path-pattern + Values: + - '*' + ListenerArn: !Ref PublicLoadBalancerListener + Priority: 1 + # The service that will instantiate a server task and restrict access through our ALB WebApplicationServer: Type: AWS::ECS::Service + # The cert in the listener can't be deleted until the service has stopped. + DependsOn: PublicLoadBalancerListener Properties: Cluster: panther-web-cluster DeploymentConfiguration: diff --git a/docs/gitbook/operations/runbooks.md b/docs/gitbook/operations/runbooks.md index cc4c5e7451..77be9baeed 100644 --- a/docs/gitbook/operations/runbooks.md +++ b/docs/gitbook/operations/runbooks.md @@ -136,6 +136,12 @@ The `panther-aws-remediation` lambda executes automated infrastructure remediati Failure Impact * Failure of this lambda will mean specific remediations are failing and infrastructure will remain in violation of policy. +## panther-cfn-custom-resources +Used by CloudFormation when deploying or updating Panther. + + Failure Impact + * Panther itself will not be affected, but deployments may be failing + ## panther-compliance This ddb table holds policy violation events for associated resources in the `panther-resources` ddb table. diff --git a/go.mod b/go.mod index 31c69b7081..2359e10207 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/alecthomas/jsonschema v0.0.0-20200217214135-7152f22193c9 github.com/aws/aws-lambda-go v1.16.0 github.com/aws/aws-sdk-go v1.30.7 - github.com/cenkalti/backoff v2.2.1+incompatible + github.com/cenkalti/backoff/v4 v4.0.2 github.com/go-openapi/errors v0.19.4 github.com/go-openapi/runtime v0.19.15 github.com/go-openapi/strfmt v0.19.5 diff --git a/go.sum b/go.sum index 6893372a9d..91f0a00f03 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/aws/aws-lambda-go v1.16.0 h1:9+Pp1/6cjEXYhwadp8faFXKSOWt7/tHRCnQxQmKv github.com/aws/aws-lambda-go v1.16.0/go.mod h1:FEwgPLE6+8wcGBTe5cJN3JWurd1Ztm9zN4jsXsjzKKw= github.com/aws/aws-sdk-go v1.30.7 h1:IaXfqtioP6p9SFAnNfsqdNczbR5UNbYqvcZUSsCAdTY= github.com/aws/aws-sdk-go v1.30.7/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.0.2 h1:JIufpQLbh4DkbQoii76ItQIUFzevQSqOLZca4eamEDs= +github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 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= diff --git a/internal/compliance/snapshot_poller/pollers/aws/cloudformation_stack.go b/internal/compliance/snapshot_poller/pollers/aws/cloudformation_stack.go index 38215108ec..c859f1372f 100644 --- a/internal/compliance/snapshot_poller/pollers/aws/cloudformation_stack.go +++ b/internal/compliance/snapshot_poller/pollers/aws/cloudformation_stack.go @@ -28,7 +28,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v4" "github.com/pkg/errors" "go.uber.org/zap" diff --git a/internal/compliance/snapshot_poller/pollers/aws/iam_user.go b/internal/compliance/snapshot_poller/pollers/aws/iam_user.go index 1d7bb1739f..f5954f634c 100644 --- a/internal/compliance/snapshot_poller/pollers/aws/iam_user.go +++ b/internal/compliance/snapshot_poller/pollers/aws/iam_user.go @@ -32,7 +32,7 @@ import ( "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/iam/iamiface" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v4" "go.uber.org/zap" apimodels "github.com/panther-labs/panther/api/gateway/resources/models" diff --git a/internal/core/custom_resources/main/lambda.go b/internal/core/custom_resources/main/lambda.go new file mode 100644 index 0000000000..8417c98c15 --- /dev/null +++ b/internal/core/custom_resources/main/lambda.go @@ -0,0 +1,52 @@ +package main + +/** + * Panther is a Cloud-Native SIEM for the Modern Security Team. + * Copyright (C) 2020 Panther Labs Inc + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import ( + "context" + "fmt" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/aws/aws-lambda-go/lambda" + "go.uber.org/zap" + + "github.com/panther-labs/panther/internal/core/custom_resources/resources" + "github.com/panther-labs/panther/pkg/lambdalogger" +) + +// Returns physicalResourceID and outputs +func customResourceHandler(ctx context.Context, event cfn.Event) (string, map[string]interface{}, error) { + _, logger := lambdalogger.ConfigureGlobal(ctx, map[string]interface{}{ + "requestType": event.RequestType, + "resourceType": event.ResourceType, + "physicalResourceId": event.PhysicalResourceID, + }) + logger.Info("received custom resource request", zap.Any("event", event)) + + handler, ok := resources.CustomResources[event.ResourceType] + if !ok { + return "", nil, fmt.Errorf("unsupported resource type: %s", event.ResourceType) + } + + return handler(ctx, event) +} + +func main() { + lambda.Start(cfn.LambdaWrap(customResourceHandler)) +} diff --git a/internal/core/custom_resources/resources/certificate.go b/internal/core/custom_resources/resources/certificate.go new file mode 100644 index 0000000000..aac45755de --- /dev/null +++ b/internal/core/custom_resources/resources/certificate.go @@ -0,0 +1,250 @@ +package resources + +/** + * Panther is a Cloud-Native SIEM for the Modern Security Team. + * Copyright (C) 2020 Panther Labs Inc + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "strings" + "time" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/acm" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/cenkalti/backoff/v4" + "go.uber.org/zap" +) + +const keyLength = 2048 + +// Try to upload a self-signed ACM certificate, falling back to an IAM server certificate if necessary. +func customCertificate(_ context.Context, event cfn.Event) (string, map[string]interface{}, error) { + switch event.RequestType { + case cfn.RequestCreate: + cert, privateKey, err := generateKeys() + if err != nil { + return "", nil, err + } + + certArn, err := importCert(cert, privateKey) + if err != nil { + return "", nil, err + } + + return certArn, map[string]interface{}{"Arn": certArn}, nil + + case cfn.RequestDelete: + return event.PhysicalResourceID, nil, deleteCert(event.PhysicalResourceID) + + default: + // There is nothing to update on an existing certificate. + certArn := event.PhysicalResourceID + return certArn, map[string]interface{}{"Arn": certArn}, nil + } +} + +// Generate a self-signed certificate and private key. +func generateKeys() ([]byte, []byte, error) { + now := time.Now().UTC() + certificateTemplate := x509.Certificate{ + BasicConstraintsValid: true, + // AWS will not attach a certificate that does not have a domain specified + // example.com is reserved by IANA and is not available for registration so there is no risk + // of confusion about us trying to MITM someone (ref: https://www.iana.org/domains/reserved) + DNSNames: []string{"example.com"}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + NotAfter: now.Add(time.Hour * 24 * 365), + NotBefore: now, + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Panther User"}, + }, + } + + // Generate the key pair. + // NOTE: This key is never saved to disk + key, err := rsa.GenerateKey(rand.Reader, keyLength) + if err != nil { + return nil, nil, fmt.Errorf("rsa key generation failed: %v", err) + } + + // Create the certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &certificateTemplate, &certificateTemplate, &key.PublicKey, key) + if err != nil { + return nil, nil, fmt.Errorf("x509 cert creation failed: %v", err) + } + + // PEM encode the certificate + var certBuffer bytes.Buffer + if err = pem.Encode(&certBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil { + return nil, nil, fmt.Errorf("cert encoding failed: %v", err) + } + + // PEM encode the private key + var keyBuffer bytes.Buffer + err = pem.Encode(&keyBuffer, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + if err != nil { + return nil, nil, fmt.Errorf("key encoding failed: %v", err) + } + + return certBuffer.Bytes(), keyBuffer.Bytes(), nil +} + +// Import a cert in ACM if possible, falling back to IAM if necessary. Returns the certificate arn. +func importCert(cert, privateKey []byte) (string, error) { + certArn, err := importAcmCert(cert, privateKey) + if err == nil { + return certArn, nil + } + + zap.L().Warn("ACM import failed, falling back to IAM", zap.Error(err)) + return importIamCert(cert, privateKey) +} + +func importAcmCert(cert, privateKey []byte) (string, error) { + output, err := getAcmClient().ImportCertificate(&acm.ImportCertificateInput{ + Certificate: cert, + PrivateKey: privateKey, + Tags: []*acm.Tag{ + { + Key: aws.String("Application"), + Value: aws.String("Panther"), + }, + }, + }) + + if err != nil { + return "", err + } + return *output.CertificateArn, nil +} + +func importIamCert(cert, privateKey []byte) (string, error) { + output, err := getIamClient().UploadServerCertificate(&iam.UploadServerCertificateInput{ + CertificateBody: aws.String(string(cert)), + Path: aws.String("/panther/" + *getSession().Config.Region + "/"), + PrivateKey: aws.String(string(privateKey)), + ServerCertificateName: aws.String( + "PantherCertificate-" + time.Now().Format("2006-01-02T15-04-05")), + }) + + if err != nil { + return "", err + } + + // It takes quite a few seconds for IAM server certificates to be visible to other services like ELB. + // Without this sleep, the ELB which depends on the custom cert fails to create with: + // Certificate 'arn:aws:iam::XXXX:server-certificate/panther/...' not found + // + // NOTE: we can make this a parameter in the future if we need to use this resource more than once. + // For example, + // Type: Custom::Certificate + // Properties: + // WaitAfterCreation: 10s + time.Sleep(10 * time.Second) + return *output.ServerCertificateMetadata.Arn, nil +} + +func deleteCert(certArn string) error { + parsedArn, err := arn.Parse(certArn) + if err != nil { + return fmt.Errorf("failed to parse %s as arn: %v", certArn, err) + } + + backoffConfig := backoff.NewExponentialBackOff() + backoffConfig.MaxInterval = 30 * time.Second + backoffConfig.MaxElapsedTime = 5 * time.Minute + + switch parsedArn.Service { + case "acm": + input := &acm.DeleteCertificateInput{CertificateArn: &certArn} + + deleteFunc := func() error { + _, err := getAcmClient().DeleteCertificate(input) + if err == nil { + return nil + } + + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == acm.ErrCodeResourceNotFoundException { + return nil // cert has already been deleted out of band + } + + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == acm.ErrCodeResourceInUseException { + // The certificate is still in use - log a warning and try again with backoff. + // When the cert is deleted in the same stack it is used, it can take awhile for ACM + // to realize it's safe to delete. + zap.L().Warn("acm certificate still in use", zap.Error(err)) + + return err + } + + // Some other error - don't retry + return backoff.Permanent(err) + } + + return backoff.Retry(deleteFunc, backoffConfig) + + case "iam": + // Pull the certificate name out of the arn. Example: + // arn:aws:iam::XXXX:server-certificate/panther/us-east-1/PantherCertificate-2020-04-27T17-23-11 + // IAM cert names cannot contain "/", so we know everything after the last / is the name + split := strings.Split(parsedArn.Resource, "/") + name := split[len(split)-1] + input := &iam.DeleteServerCertificateInput{ServerCertificateName: &name} + + deleteFunc := func() error { + _, err := getIamClient().DeleteServerCertificate(input) + if err == nil { + return nil + } + + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == iam.ErrCodeNoSuchEntityException { + return nil // cert has already been deleted out of band + } + + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == iam.ErrCodeDeleteConflictException { + // The certificate is still in use - log a warning and try again with backoff. + // When the cert is deleted in the same stack it is used, it can take awhile for IAM + // to realize it's safe to delete. + zap.L().Warn("iam server certificate still in use", zap.Error(err)) + + return err + } + + // Some other error - don't retry + return backoff.Permanent(err) + } + + return backoff.Retry(deleteFunc, backoffConfig) + + default: + return fmt.Errorf("%s is not an ACM/IAM cert", certArn) + } +} diff --git a/internal/core/custom_resources/resources/clients.go b/internal/core/custom_resources/resources/clients.go new file mode 100644 index 0000000000..58d260015a --- /dev/null +++ b/internal/core/custom_resources/resources/clients.go @@ -0,0 +1,56 @@ +package resources + +/** + * Panther is a Cloud-Native SIEM for the Modern Security Team. + * Copyright (C) 2020 Panther Labs Inc + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import ( + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/acm" + "github.com/aws/aws-sdk-go/service/acm/acmiface" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/iam/iamiface" +) + +// Lazily build all AWS clients - each Lambda invocation usually needs at most 1 of these +var ( + awsSession *session.Session + + acmClient acmiface.ACMAPI + iamClient iamiface.IAMAPI +) + +func getSession() *session.Session { + if awsSession == nil { + awsSession = session.Must(session.NewSession()) + } + return awsSession +} + +func getAcmClient() acmiface.ACMAPI { + if acmClient == nil { + acmClient = acm.New(getSession()) + } + return acmClient +} + +func getIamClient() iamiface.IAMAPI { + if iamClient == nil { + iamClient = iam.New(getSession()) + } + return iamClient +} diff --git a/internal/core/custom_resources/resources/map.go b/internal/core/custom_resources/resources/map.go new file mode 100644 index 0000000000..1ca88f6a63 --- /dev/null +++ b/internal/core/custom_resources/resources/map.go @@ -0,0 +1,33 @@ +package resources + +/** + * Panther is a Cloud-Native SIEM for the Modern Security Team. + * Copyright (C) 2020 Panther Labs Inc + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import ( + "github.com/aws/aws-lambda-go/cfn" +) + +// CustomResources map type names to their respective handler functions. +var CustomResources = map[string]cfn.CustomResourceFunction{ + // Creates a self-signed ACM or IAM server certificate. + // + // Parameters: None + // Outputs: + // CertificateArn: ACM or IAM certificate arn + "Custom::Certificate": customCertificate, +} diff --git a/pkg/awsbatch/dynamodbbatch/batch_write_item.go b/pkg/awsbatch/dynamodbbatch/batch_write_item.go index dc9208e499..828fe96148 100644 --- a/pkg/awsbatch/dynamodbbatch/batch_write_item.go +++ b/pkg/awsbatch/dynamodbbatch/batch_write_item.go @@ -25,7 +25,7 @@ import ( "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/dynamodb" "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v4" "go.uber.org/zap" ) diff --git a/pkg/awsbatch/kinesisbatch/put_records.go b/pkg/awsbatch/kinesisbatch/put_records.go index 8502ff81c0..0df964c0c7 100644 --- a/pkg/awsbatch/kinesisbatch/put_records.go +++ b/pkg/awsbatch/kinesisbatch/put_records.go @@ -25,7 +25,7 @@ import ( "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/kinesis" "github.com/aws/aws-sdk-go/service/kinesis/kinesisiface" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v4" "go.uber.org/zap" ) diff --git a/pkg/awsbatch/s3batch/delete_objects.go b/pkg/awsbatch/s3batch/delete_objects.go index 0c22e76c8c..765ae7347e 100644 --- a/pkg/awsbatch/s3batch/delete_objects.go +++ b/pkg/awsbatch/s3batch/delete_objects.go @@ -24,7 +24,7 @@ import ( "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3/s3iface" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v4" "go.uber.org/zap" ) diff --git a/pkg/awsbatch/sqsbatch/send_message_batch.go b/pkg/awsbatch/sqsbatch/send_message_batch.go index 011e45ee7c..5aed1ddb23 100644 --- a/pkg/awsbatch/sqsbatch/send_message_batch.go +++ b/pkg/awsbatch/sqsbatch/send_message_batch.go @@ -25,7 +25,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/sqs" "github.com/aws/aws-sdk-go/service/sqs/sqsiface" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v4" "github.com/pkg/errors" "go.uber.org/zap" ) diff --git a/tools/mage/deploy.go b/tools/mage/deploy.go index a6fd3de3c4..b43588062e 100644 --- a/tools/mage/deploy.go +++ b/tools/mage/deploy.go @@ -192,39 +192,16 @@ func deployPrecheck(awsRegion string) { // // Returns combined outputs from bootstrap stacks. func bootstrap(awsSession *session.Session, settings *config.PantherConfig) map[string]string { - var outputs map[string]string + build.API() + build.Cfn() + build.Lambda() // Lambda compilation required for most stacks, including bootstrap-gateway - results := make(chan goroutineResult) - count := 0 - - // If the bootstrap stack is ROLLBACK_COMPLETE or similar, we need to do a full teardown. - // Check for that now, instead of waiting until the actual deployTemplate() call: - // - teardown can get user confirmation without other log messages running in parallel - // - bootstrap stack needs to be stable before we read its outputs to find the certificate arn - oldBootstrapOutputs, err := prepareStack(awsSession, bootstrapStack) - if err != nil && !errStackDoesNotExist(err) { + // Deploy bootstrap stacks + outputs, err := deployBoostrapStacks(awsSession, settings) + if err != nil { logger.Fatal(err) } - // Deploy bootstrap stacks - count++ - go func(c chan goroutineResult) { - var err error - outputs, err = deployBoostrapStacks(awsSession, settings, oldBootstrapOutputs["CertificateArn"]) - c <- goroutineResult{summary: "bootstrap: stacks", err: err} - }(results) - - // Compile Lambda functions - count++ - go func(c chan goroutineResult) { - var err error - if err = build.api(); err == nil { - err = build.lambda() - } - c <- goroutineResult{summary: "bootstrap: compile source", err: err} - }(results) - - logResults(results, "deploy: bootstrap", 1, count, count) return outputs } @@ -232,14 +209,12 @@ func bootstrap(awsSession *session.Session, settings *config.PantherConfig) map[ func deployBoostrapStacks( awsSession *session.Session, settings *config.PantherConfig, - existingCertArn string, ) (map[string]string, error) { params := map[string]string{ "LogSubscriptionPrincipals": strings.Join(settings.Setup.LogSubscriptions.PrincipalARNs, ","), "EnableS3AccessLogs": strconv.FormatBool(settings.Setup.EnableS3AccessLogs), "AccessLogsBucket": settings.Setup.S3AccessLogsBucket, - "CertificateArn": certificateArn(awsSession, settings, existingCertArn), "CloudWatchLogRetentionDays": strconv.Itoa(settings.Monitoring.CloudWatchLogRetentionDays), "CustomDomain": settings.Web.CustomDomain, "Debug": strconv.FormatBool(settings.Monitoring.Debug), @@ -265,16 +240,14 @@ func deployBoostrapStacks( if err != nil { return nil, fmt.Errorf("failed to enable TOTP for user pool %s: %v", userPoolID, err) } + logger.Infof(" √ %s finished (1/%d)", bootstrapStack, len(allStacks)) - if err := build.cfn(); err != nil { - return nil, err - } - - // Now that the S3 buckets are in place and swagger specs are embedded, we can deploy the second - // bootstrap stack (API gateways and the Python layer). + // Now that the S3 buckets are in place, we can deploy the second bootstrap stack. sourceBucket := outputs["SourceBucket"] params = map[string]string{ - "TracingEnabled": strconv.FormatBool(settings.Monitoring.TracingMode != ""), + "CloudWatchLogRetentionDays": strconv.Itoa(settings.Monitoring.CloudWatchLogRetentionDays), + "LayerVersionArns": settings.Infra.BaseLayerVersionArns, + "TracingMode": settings.Monitoring.TracingMode, } if settings.Infra.PythonLayerVersionArn == "" { @@ -300,6 +273,7 @@ func deployBoostrapStacks( outputs[k] = v } + logger.Infof(" √ %s finished (2/%d)", gatewayStack, len(allStacks)) return outputs, nil } @@ -464,8 +438,8 @@ func deployMainStacks(awsSession *session.Session, settings *config.PantherConfi }(results) // Wait for stacks to finish. - // There will be two stacks after this one (metric filters + onboarding) - logResults(results, "deploy", 1, count, count+2) + // There are two stacks before and after this one + logResults(results, "deploy", 3, count+2, len(allStacks)) // Metric filters have to be deployed after all log groups have been created go func(c chan goroutineResult) { @@ -484,7 +458,7 @@ func deployMainStacks(awsSession *session.Session, settings *config.PantherConfi // Log stack results, counting where the last parallel group left off to give the illusion of // one continuous deploy progress tracker. - logResults(results, "deploy", count+1, count+2, count+2) + logResults(results, "deploy", count+3, len(allStacks), len(allStacks)) } func deployGlue(awsSession *session.Session, outputs map[string]string) error { diff --git a/tools/mage/deploy_certificate.go b/tools/mage/deploy_certificate.go deleted file mode 100644 index b543b399b7..0000000000 --- a/tools/mage/deploy_certificate.go +++ /dev/null @@ -1,185 +0,0 @@ -package mage - -/** - * Panther is a Cloud-Native SIEM for the Modern Security Team. - * Copyright (C) 2020 Panther Labs Inc - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import ( - "bytes" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "math/big" - "os" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/acm" - "github.com/aws/aws-sdk-go/service/iam" - - "github.com/panther-labs/panther/tools/config" -) - -const ( - keysDirectory = "keys" - certificateFile = keysDirectory + "/panther-tls-public.crt" - privateKeyFile = keysDirectory + "/panther-tls-private.key" - keyLength = 2048 - certFilePermissions = 0700 -) - -// Returns the certificate arn for the bootstrap stack. One of: -// -// 1) The settings file, if it's specified -// 2) The bootstrap stack output (existingCertArn) -// 3) Uploading an ACM or IAM cert -func certificateArn(awsSession *session.Session, settings *config.PantherConfig, existingCertArn string) string { - if settings.Web.CertificateArn != "" { - // Always use the value in the settings file first, if it exists - return settings.Web.CertificateArn - } - - // If the bootstrap stack already exists and has a certificate arn, use that - if existingCertArn != "" { - return existingCertArn - } - - // If the stack outputs are blank, it never deployed successfully - upload a new cert - return uploadLocalCertificate(awsSession) -} - -// Upload a local self-signed TLS certificate to ACM. Only needs to happen once per installation -// -// In regions/partitions where ACM is not supported, we fall back to IAM certificate management. -func uploadLocalCertificate(awsSession *session.Session) string { - // Ensure the certificate and key file exist. If not, create them. - _, certErr := os.Stat(certificateFile) - _, keyErr := os.Stat(privateKeyFile) - if os.IsNotExist(certErr) || os.IsNotExist(keyErr) { - if err := generateKeys(); err != nil { - logger.Fatal(err) - } - } - - logger.Infof("deploy: uploading load balancer certificate %s with key %s", certificateFile, privateKeyFile) - - // Check if the ACM service is supported before tossing the private key out into the ether - acmClient := acm.New(awsSession) - if _, err := acmClient.ListCertificates(&acm.ListCertificatesInput{MaxItems: aws.Int64(1)}); err != nil { - if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "SubscriptionRequiredException" { - // ACM is not supported in this region or for this user, fall back to IAM - logger.Warn("deploy: ACM not supported, falling back to IAM for certificate management") - return uploadIAMCertificate(awsSession) - } - logger.Fatalf("failed to list certificates: %v", err) - } - - output, err := acmClient.ImportCertificate(&acm.ImportCertificateInput{ - Certificate: readFile(certificateFile), - PrivateKey: readFile(privateKeyFile), - Tags: []*acm.Tag{ - { - Key: aws.String("Application"), - Value: aws.String("Panther"), - }, - }, - }) - - if err != nil { - if awsErr, ok := err.(awserr.Error); ok { - if awsErr.Code() == "LimitExceededException" { - logger.Warn("deploy: ACM certificate import limit reached, falling back to IAM for certificate management") - return uploadIAMCertificate(awsSession) - } - } - logger.Fatalf("ACM certificate import failed: %v", err) - } - - return *output.CertificateArn -} - -// generateKeys generates the self signed private key and certificate for HTTPS access to the web application -func generateKeys() error { - logger.Warn("deploy: no certificate provided in config nor in keys/, generating a self-signed certificate") - // Create the private key - key, err := rsa.GenerateKey(rand.Reader, keyLength) - if err != nil { - return fmt.Errorf("rsa key generation failed: %v", err) - } - - // Setup the certificate template - certificateTemplate := x509.Certificate{ - BasicConstraintsValid: true, - // AWS will not attach a certificate that does not have a domain specified - // example.com is reserved by IANA and is not available for registration so there is no risk - // of confusion about us trying to MITM someone (ref: https://www.iana.org/domains/reserved) - DNSNames: []string{"example.com"}, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - NotAfter: time.Now().Add(time.Hour * 24 * 365), - NotBefore: time.Now(), - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"Panther User"}, - }, - } - - // Create the certificate - certBytes, err := x509.CreateCertificate(rand.Reader, &certificateTemplate, &certificateTemplate, &key.PublicKey, key) - if err != nil { - return fmt.Errorf("x509 cert creation failed: %v", err) - } - - // PEM encode the certificate and write it to disk - var certBuffer bytes.Buffer - if err = pem.Encode(&certBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil { - return fmt.Errorf("cert encoding failed: %v", err) - } - if err = writeFile(certificateFile, certBuffer.Bytes()); err != nil { - return err - } - - // PEM Encode the private key and write it to disk - var keyBuffer bytes.Buffer - err = pem.Encode(&keyBuffer, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) - if err != nil { - return fmt.Errorf("key encoding failed: %v", err) - } - return writeFile(privateKeyFile, keyBuffer.Bytes()) -} - -// uploadIAMCertificate creates an IAM certificate resource and returns its ARN -func uploadIAMCertificate(awsSession *session.Session) string { - certName := "PantherCertificate-" + time.Now().Format("2006-01-02T15-04-05") - input := &iam.UploadServerCertificateInput{ - CertificateBody: aws.String(string(readFile(certificateFile))), - Path: aws.String("/panther/" + *awsSession.Config.Region + "/"), - PrivateKey: aws.String(string(readFile(privateKeyFile))), - ServerCertificateName: aws.String(certName), - } - output, err := iam.New(awsSession).UploadServerCertificate(input) - if err != nil { - logger.Fatalf("failed to upload cert to IAM: %v", err) - } - - return *output.ServerCertificateMetadata.Arn -} diff --git a/tools/mage/deploy_frontend.go b/tools/mage/deploy_frontend.go index 3cae0d7cc6..9fc03877d8 100644 --- a/tools/mage/deploy_frontend.go +++ b/tools/mage/deploy_frontend.go @@ -67,10 +67,12 @@ func deployFrontend( params := map[string]string{ "SubnetOneId": bootstrapOutputs["SubnetOneId"], "SubnetTwoId": bootstrapOutputs["SubnetTwoId"], + "ElbArn": bootstrapOutputs["LoadBalancerArn"], "ElbTargetGroup": bootstrapOutputs["LoadBalancerTargetGroup"], "SecurityGroup": bootstrapOutputs["WebSecurityGroup"], "Image": dockerImage, "CloudWatchLogRetentionDays": strconv.Itoa(settings.Monitoring.CloudWatchLogRetentionDays), + "CertificateArn": settings.Web.CertificateArn, } return deployTemplate(awsSession, frontendTemplate, bucket, frontendStack, params) } diff --git a/tools/mage/deploy_template.go b/tools/mage/deploy_template.go index bfe5a99bd9..cd739a3169 100644 --- a/tools/mage/deploy_template.go +++ b/tools/mage/deploy_template.go @@ -221,6 +221,7 @@ func uploadAsset(awsSession *session.Session, assetPath, bucket, stack string) ( // If the stack is ROLLBACK_COMPLETE or otherwise failed to create, it will be deleted automatically. // If the stack is still in progress, this will wait until it finishes. // If the stack exists, its outputs are returned to the caller (once complete). +// A return of (nil, nil) means the stack does not exist. func prepareStack(awsSession *session.Session, stackName string) (map[string]string, error) { client := cfn.New(awsSession) @@ -248,7 +249,7 @@ func prepareStack(awsSession *session.Session, stackName string) (map[string]str if stackName == bootstrapStack { // If the very first stack failed to create, we need to do a full teardown before trying again. - // Otherwise, there may be orphaned S3 buckets and an ACM cert that will never be used. + // Otherwise, there may be orphaned S3 buckets that will never be used. logger.Warnf("The very first %s stack never created successfully (%s)", bootstrapStack, status) logger.Warnf("Running 'mage teardown' to fully remove orphaned resources before trying again") Teardown() @@ -259,12 +260,12 @@ func prepareStack(awsSession *session.Session, stackName string) (map[string]str if _, err := client.DeleteStack(&cfn.DeleteStackInput{StackName: &stackName}); err != nil { return nil, fmt.Errorf("failed to start stack %s deletion: %v", stackName, err) } - if _, err := waitForStackDelete(client, stackName); err != nil { - return nil, err - } - } + _, err = waitForStackDelete(client, stackName) + return nil, err // stack deleted - there are no outputs - return flattenStackOutputs(stack), nil + default: + return flattenStackOutputs(stack), nil + } } // Create a CloudFormation change set, returning its id. diff --git a/tools/mage/teardown.go b/tools/mage/teardown.go index e82d84c494..d824ee4498 100644 --- a/tools/mage/teardown.go +++ b/tools/mage/teardown.go @@ -26,11 +26,9 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/acm" "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/aws-sdk-go/service/cloudwatchlogs" "github.com/aws/aws-sdk-go/service/ecr" - "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/lambda" "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/sts" @@ -105,11 +103,6 @@ func Teardown() { logger.Fatal(cfnErr) } - // Remove self-signed certs that may have been uploaded. - // - // Certs can only be deleted if they aren't in use, so don't try unless the stacks deleted successfully. - // Certificates are not managed with CloudFormation, we have to list them explicitly. - destroyCerts(awsSession) logger.Info("successfully removed Panther infrastructure") } @@ -201,18 +194,20 @@ func destroyCfnStacks(awsSession *session.Session, identity *sts.GetCallerIdenti client := cloudformation.New(awsSession) // Define a common routine for processing stack delete results - var errCount int + var errCount, finishCount int handleResult := func(result deleteStackResult) { + finishCount++ if result.err != nil { - logger.Errorf(" - %s failed to delete: %v", result.stackName, result.err) + logger.Errorf(" - %s failed to delete (%d/%d): %v", + result.stackName, finishCount, len(allStacks), result.err) errCount++ return } if strings.Contains(result.stackName, "skipped") { - logger.Infof(" √ %s", result.stackName) + logger.Infof(" √ %s (%d/%d)", result.stackName, finishCount, len(allStacks)) } else { - logger.Infof(" √ %s successfully deleted", result.stackName) + logger.Infof(" √ %s successfully deleted (%d/%d)", result.stackName, finishCount, len(allStacks)) } } @@ -223,10 +218,8 @@ func destroyCfnStacks(awsSession *session.Session, identity *sts.GetCallerIdenti // Trigger the deletion of the main stacks in parallel // - // The ECS cluster in the bootstrap stack has to wait until the ECS service in the frontend stack is - // completely stopped. So we don't include the bootstrap stack in the initial parallel set + // The bootstrap stacks have to be last because of the ECS cluster and custom resource Lambda. parallelStacks := []string{ - gatewayStack, alarmsStack, appsyncStack, cloudsecStack, @@ -238,21 +231,21 @@ func destroyCfnStacks(awsSession *session.Session, identity *sts.GetCallerIdenti metricFilterStack, onboardStack, } - logger.Infof("deleting CloudFormation stacks: %s", - strings.Join(append(parallelStacks, bootstrapStack), ", ")) + logger.Infof("deleting %d CloudFormation stacks", len(allStacks)) for _, stack := range parallelStacks { go deleteStack(client, aws.String(stack), results) } - // Wait for all of the stacks (incl. bootstrap) to finish deleting - for i := 0; i < len(parallelStacks)+1; i++ { - r := <-results - handleResult(r) + // Wait for all of the main stacks to finish deleting + for i := 0; i < len(parallelStacks); i++ { + handleResult(<-results) + } - if r.stackName == frontendStack { - // now we can delete the bootstrap stack - go deleteStack(client, aws.String(bootstrapStack), results) - } + // Now finish with the bootstrap stacks + go deleteStack(client, aws.String(bootstrapStack), results) + go deleteStack(client, aws.String(gatewayStack), results) + for i := 0; i < 2; i++ { + handleResult(<-results) } if errCount > 0 { @@ -419,75 +412,6 @@ func removeBucket(client *s3.S3, bucketName *string) { } } -// Destroy Panther ACM or IAM certificates. -// -// In ACM, delete certs for "example.com" tagged with "Application:Panther" -// In IAM, delete certs in "/panther/(region)/" path whose names start with "PantherCertificate-" -func destroyCerts(awsSession *session.Session) { - logger.Debug("checking for ACM certificates") - acmClient := acm.New(awsSession) - err := acmClient.ListCertificatesPages( - &acm.ListCertificatesInput{}, - func(page *acm.ListCertificatesOutput, isLast bool) bool { - for _, summary := range page.CertificateSummaryList { - if canRemoveAcmCert(acmClient, summary) { - logger.Infof("deleting ACM cert %s", *summary.CertificateArn) - input := &acm.DeleteCertificateInput{CertificateArn: summary.CertificateArn} - if _, err := acmClient.DeleteCertificate(input); err != nil { - logger.Fatalf("failed to delete cert %s: %v", *summary.CertificateArn, err) - } - } - } - return true // keep paging - }, - ) - if err != nil { - logger.Fatalf("failed to list ACM certificates: %v", err) - } - - logger.Debug("checking for IAM server certificates") - iamClient := iam.New(awsSession) - path := "/panther/" + *awsSession.Config.Region + "/" - input := &iam.ListServerCertificatesInput{PathPrefix: &path} - err = iamClient.ListServerCertificatesPages(input, func(page *iam.ListServerCertificatesOutput, isLast bool) bool { - for _, cert := range page.ServerCertificateMetadataList { - name := cert.ServerCertificateName - if strings.HasPrefix(*name, "PantherCertificate-") { - logger.Infof("deleting IAM cert %s", *name) - if _, err := iamClient.DeleteServerCertificate(&iam.DeleteServerCertificateInput{ - ServerCertificateName: name, - }); err != nil { - logger.Fatalf("failed to delete IAM cert %s: %v", *name, err) - } - } - } - return true // keep paging - }) - if err != nil { - logger.Fatalf("failed to list IAM server certificates: %v", err) - } -} - -// Returns true if the ACM cert is for example.com and tagged with Application:Panther -func canRemoveAcmCert(client *acm.ACM, summary *acm.CertificateSummary) bool { - if aws.StringValue(summary.DomainName) != "example.com" { - return false - } - - certArn := summary.CertificateArn - tags, err := client.ListTagsForCertificate(&acm.ListTagsForCertificateInput{CertificateArn: certArn}) - if err != nil { - logger.Fatalf("failed to list tags for ACM cert %s: %v", *certArn, err) - } - - for _, tag := range tags.Tags { - if aws.StringValue(tag.Key) == "Application" && aws.StringValue(tag.Value) == "Panther" { - return true - } - } - return false -} - // Destroy any leftover CloudWatch log groups func destroyLogGroups(awsSession *session.Session, groupNames []*string) { logger.Debug("checking for leftover Panther log groups") diff --git a/tools/mage/util.go b/tools/mage/util.go index c326a82ec6..535e9382a7 100644 --- a/tools/mage/util.go +++ b/tools/mage/util.go @@ -136,12 +136,7 @@ func writeFile(path string, data []byte) error { return fmt.Errorf("failed to create directory %s: %v", filepath.Dir(path), err) } - var permissions os.FileMode = 0644 - if strings.HasSuffix(path, ".key") || strings.HasSuffix(path, ".crt") { - permissions = certFilePermissions - } - - if err := ioutil.WriteFile(path, data, permissions); err != nil { + if err := ioutil.WriteFile(path, data, 0644); err != nil { return fmt.Errorf("failed to write file %s: %v", path, err) } return nil