Group
Extension

App-FargateStack/lib/App/FargateStack/Builder/WafV2.pm

package App::FargateStack::Builder::WafV2;

use strict;
use warnings;

use App::WafV2;
use App::FargateStack::Builder::Utils qw(dmp slurp_file log_die display_diffs);
use App::FargateStack::Constants;
use Carp;
use CLI::Simple::Constants qw(:booleans :chars);
use Data::Dumper;
use Digest::MD5 qw(md5_hex);
use English qw(no_match_vars);
use JSON;
use Storable qw(dclone);

use Role::Tiny;

########################################################################
sub managed_rules {
########################################################################
  my ( $self, $managed_rules ) = @_;

  my @remove_list;
  my @managed_rule_list;

  foreach my $rule_set ( @{$managed_rules} ) {
    if ( $rule_set =~ /^[\-](.*)$/xsmi ) {
      push @remove_list, $1;
      next;
    }

    if ( $WAF_MANAGED_RULE_BUNDLES{$rule_set} ) {
      foreach ( @{ $WAF_MANAGED_RULE_BUNDLES{$rule_set} } ) {
        push @managed_rule_list, @{ $WAF_MANAGED_RULES{$_} };
      }
    }
    elsif ( $WAF_MANAGED_RULES{$rule_set} ) {
      push @managed_rule_list, @{ $WAF_MANAGED_RULES{$rule_set} };
    }
    else {
      log_die( $self, 'ERROR: no such managed rule: %s', $rule_set );
    }
  }

  foreach my $rule (@remove_list) {
    @managed_rule_list = grep { $rule ne $_ } @managed_rule_list;
  }

  return \@managed_rule_list;
}

########################################################################
sub create_rule_list {
########################################################################
  my ( $self, $waf_config ) = @_;

  my @rule_list;

  my $priority = 1;

  my $rule_stub = decode_json($WAF_RULE_STUB);

  if ( !$waf_config->{managed_rules} ) {
    $waf_config->{managed_rules} = [qw(default)];
  }

  my $managed_rules = $self->managed_rules( $waf_config->{managed_rules} );

  foreach my $metric_name ( @{$managed_rules} ) {
    my $rule = dclone $rule_stub;

    $rule->{Name}                                           = $metric_name;
    $rule->{Statement}->{ManagedRuleGroupStatement}->{Name} = $metric_name;
    $rule->{VisibilityConfig}->{MetricName}                 = $metric_name;
    $rule->{Priority}                                       = $priority++;

    push @rule_list, $rule;
  }

  return \@rule_list;
}

########################################################################
sub build_waf {
########################################################################
  my ($self) = @_;

  ######################################################################
  ## init
  ######################################################################
  my $config = $self->get_config;

  my $dryrun = $self->get_dryrun;

  # if no alb or no waf section skip this build (avoid autovivification)
  return
    if !exists $config->{alb};

  my $alb = $config->{alb};

  return
    if !exists $alb->{waf};

  my $waf_config = $alb->{waf};

  return
    if !$waf_config->{enabled};

  log_die( $self, 'ERROR: no ALB ARN? you cannot have a WAF without an ALB' )
    if !$alb->{arn};

  $waf_config->{name} //= $self->create_default('web-acl-name');
  my $name = $waf_config->{name};

  my $waf = $self->fetch_wafv2;

  ######################################################################
  ## create or update web-acl
  ######################################################################
  my $web_acl = $waf->list_web_acls(
    scope => 'REGIONAL',
    query => sprintf 'WebACLs[?Name==`%s`]',
    $name
  );

  $waf->check_result( message => 'ERROR: could not list web acls' );

  if ( $web_acl && @{$web_acl} ) {
    my $web_acl_id = $web_acl->[0]->{Id};

    $web_acl = $waf->get_web_acl( name => $name, id => $web_acl_id );
    $waf->check_result( message => 'ERROR: could not get web-acl: [%s]', $name );

    my $web_acl_arn = $web_acl->{WebACL}->{ARN};

    # check to see if rules have been updated
    my $new_rules = $self->check_rules( waf => $waf, waf_config => $waf_config );

    # check to see if someone has mucked with web-acl.json
    my $needs_update = $self->check_web_acl_state(
      name       => $name,
      id         => $web_acl_id,
      waf_config => $waf_config,
      waf        => $waf,
      web_acl    => $web_acl,
      rules      => $new_rules,
    );

    # we would have died already if there was a conflict, so now we
    # have either FALSE = no update needed or TRUE = need to update web-acl
    if ($needs_update) {
      $self->log_warn( 'waf: web-acl: [%s] has changed...will be updated...%s', $name, $dryrun );

      if ( !$dryrun ) {
        $self->update_web_acl( $waf_config->{lock_token} );

        $self->save_web_acl(
          id         => $waf_config->{id},
          scope      => 'REGIONAL',
          name       => $name,
          waf_config => $waf_config,
          waf        => $waf,
        );
      }
    }
    else {
      $self->log_info( 'waf: web-acl: [%s] has not changed...skipping', $name );
      $self->inc_existing_resources( waf => $waf_config->{arn} );
    }

    my $arns = $waf->list_resources_for_web_acl( $web_acl_arn, 'ResourceArns' );
    $waf->check_result( message => 'ERROR: could not list resources for web acl: [%s]', $web_acl_arn );

    if ( !@{$arns} ) {
      $self->log_warn( 'waf: web-acl: [%s] not associated...will be associated with ALB: [%s]...%s',
        $name, $alb->{name}, $dryrun );

      if ( !$dryrun ) {
        $self->associate_web_acl(
          web_acl_arn  => $web_acl_arn,
          resource_arn => $alb->{arn},
          waf          => $waf,
          name         => $name
        );
      }
    }
  }
  else {
    $self->log_warn( 'waf: web-acl: [%s] does not exist...will be created...%s', $name, $dryrun );

    $self->inc_required_resources(
      waf => [
        sub {
          my ($dryrun) = @_;
          return $dryrun ? 'arn:???' : $waf_config->{arn};
        }
      ]
    );

    my $rules = $self->create_rule_list($waf_config);

    $self->log_warn( 'waf: rule list from: [%s]', join q{,}, @{ $waf_config->{managed_rules} } );
    $self->log_debug( "waf: managed rules: %s\n", join "\n", Dumper $rules );

    if ( !$dryrun ) {
      $self->create_web_acl(
        name       => $name,
        waf_config => $waf_config,
        waf        => $waf,
        rules      => $rules
      );
    }
  }

  return $SUCCESS;
}

########################################################################
sub check_rules {
########################################################################
  my ( $self, %args ) = @_;

  my ( $waf, $waf_config ) = @args{qw(waf waf_config web_acl)};

  my $web_acl = $self->fetch_web_acl;

  my $current_rules = $web_acl->{WebACL}->{Rules};

  my $rules = $self->create_rule_list($waf_config);

  return $rules
    if JSON->new->pretty->canonical->encode($rules) ne JSON->new->pretty->canonical->encode($current_rules);

  return;
}

########################################################################
sub check_web_acl_state {
########################################################################
  my ( $self, %args ) = @_;

  my ( $name, $id, $waf_config, $waf, $web_acl, $rules ) = @args{qw(name id waf_config waf web_acl rules)};

  my ( $aws_id, $aws_arn ) = @{ $web_acl->{WebACL} }{qw(Id ARN)};
  my $aws_lock_token = $web_acl->{LockToken};

  my $local_web_acl = $self->fetch_web_acl;

  if ($rules) {
    $self->log_warn('waf: rules have changed, updating web-acl.json');
    $local_web_acl->{WebACL}->{Rules} = $rules;
    $self->save_web_acl( name => $name, web_acl => $local_web_acl, waf => $waf, waf_config => $waf_config );
  }

  # determine if the web-acl has been modified "out-of-band"
  # and/or our local web-acl.json file has been modified

  my $md5 = $self->calculate_md5($web_acl);

  my $md5_match = $md5 eq $waf_config->{md5};

  if ( $md5 ne $waf_config->{md5} ) {
    $self->log_warn( 'waf: MD5 hashes do not match: [%s] != [%s]', $waf_config->{md5}, $md5 );
  }

  my $lock_token_match = $aws_lock_token eq $waf_config->{lock_token};

  return $FALSE  # no need to update, everything OK
    if $md5_match && $lock_token_match;

  return $TRUE   # update OK
    if !$md5_match && $lock_token_match;

  if ( $md5_match && !$lock_token_match ) {
    my $err_msg = <<'END_OF_ERR_MESSAGE';
WAF configuration appears to be in sync, but the remote resource has
abeen modified out-of-band. The local configuration file is being updated with
the latest LockToken. Please run 'plan' again to confirm.
END_OF_ERR_MESSAGE
    $waf_config->{id}         = $aws_id;
    $waf_config->{arn}        = $aws_arn;
    $waf_config->{lock_token} = $aws_lock_token;

    return $FALSE;  # no need to update, just update local state
  }

  # if we are here, then conflict !$md5_match && !$lock_token_match

  # rut-roh...conflict
  my $diff = $self->web_acl_diffs($web_acl);

  my $err_msg = <<'END_OF_ERR_MESSAGE';
ERROR: State conflict detected for Web ACL %s 

A change has been made to the local 'web-acl.json' file, but the
Web ACL has also been modified in AWS since the last run.

Applying local changes now would overwrite the remote modifications.

%s

To resolve this conflict:
1. Review the diff above to understand the remote changes.
2. Manually merge the desired remote changes into your local 'web-acl.json' file.
3. Once the local file represents the true desired state, run 'app-FargateStack plan' again.
END_OF_ERR_MESSAGE

  log_die( $self, $err_msg, $name, $diff, $name )
    if !$self->get_force;

  $waf_config->{lock_token} = $aws_lock_token;

  return $TRUE;
}

########################################################################
sub create_web_acl {
########################################################################
  my ( $self, %args ) = @_;

  my ( $name, $waf_config, $waf, $rule_list ) = @args{qw(name waf_config waf rules)};

  my $web_acl_summary = $waf->create_web_acl(
    name        => $name,
    rules       => $rule_list,
    query       => 'Summary',
    scope       => 'REGIONAL',
    metric_name => $self->get_config->{app}->{name},
  );

  $waf->check_result( message => 'ERROR: unable to create web-acl: [%s]', $name );

  @{$waf_config}{qw(id lock_token arn)} = @{$web_acl_summary}{qw(Id LockToken ARN)};

  my $web_acl = $self->save_web_acl(
    id         => $waf_config->{id},
    scope      => 'REGIONAL',
    name       => $name,
    waf_config => $waf_config,
    waf        => $waf,
  );

  $self->associate_web_acl(
    web_acl_arn  => $waf_config->{arn},
    resource_arn => $self->get_config->{alb}->{arn},
    name         => $name,
    waf          => $waf
  );

  return;
}

########################################################################
sub associate_web_acl {
########################################################################
  my ( $self, %args ) = @_;

  my ( $web_acl_arn, $resource_arn, $waf, $name ) = @args{qw(web_acl_arn resource_arn waf name)};

  my $timeout = $WAF_AVAILABILITY_TIMEOUT;

  # may need to wait for WAF to become available before associating...
  $self->log_warn('waf: associating WAF with ALB...');
  while ($TRUE) {
    $waf->associate_web_acl( $web_acl_arn, $resource_arn );

    $waf->check_result(
      message => 'ERROR: could not associate web-acl: [%s] with ALB',
      params  => [$name],
      regexp  => qr/WAFUnavailableEntityException/xmsi
    );

    $self->log_warn( 'waf: waiting for resource to become available...sleeping for %s seconds - %s until timeout',
      $WAF_AVAILABILITY_SLEEP_TIME, $timeout );

    sleep $WAF_AVAILABILITY_SLEEP_TIME;

    $timeout -= $WAF_AVAILABILITY_SLEEP_TIME;
    last if $timeout <= 0 || !$waf->get_error;
  }

  log_die( $self, 'ERROR: unable to associate web-acl to ALB: %s', $waf->get_error )
    if $waf->get_error;

  return;
}

########################################################################
sub calculate_md5 {
########################################################################
  my ( $self, $web_acl ) = @_;

  my $json = JSON->new->pretty->canonical->encode($web_acl);

  return md5_hex($json);
}

########################################################################
sub fetch_web_acl {
########################################################################
  my ($self) = @_;

  return slurp_file( 'web-acl.json', $TRUE );
}

########################################################################
sub save_web_acl {
########################################################################
  my ( $self, %args ) = @_;

  my ( $id, $name, $scope, $waf_config, $waf, $web_acl ) = @args{qw(id name scope waf_config waf web_acl)};

  $web_acl //= $waf->get_web_acl(
    scope => $scope,
    id    => $id,
    name  => $name,
  );

  $waf->check_result( message => 'ERROR: could not get web-acl: [%s]', $name );

  $waf_config->{md5} = $self->calculate_md5($web_acl);

  my $json = JSON->new->pretty->canonical->encode($web_acl);

  open my $fh, '>', 'web-acl.json'
    or croak "ERROR: could not open web-acl.json for writing\n";

  print {$fh} $json;

  close $fh;

  return;
}

# produce a diff between the stored web-acl and the current AWS web-acl
########################################################################
sub web_acl_diffs {
########################################################################
  my ( $self, $web_acl ) = @_;

  my $current_web_acl = slurp_file( 'web-acl.json', $TRUE );

  return $self->display_diffs( $current_web_acl, $web_acl );
}

########################################################################
sub update_web_acl {
########################################################################
  my ( $self, $lock_token ) = @_;

  my $web_acl = $self->fetch_web_acl;

  my ( $id, $visibility_config, $rules, $description, $default_action )
    = @{ $web_acl->{WebACL} }{qw(Id VisibilityConfig Rules Description DefaultAction)};

  my $scope = 'REGIONAL';

  my $name = $self->get_config->{alb}->{waf}->{name};

  my $waf = $self->fetch_wafv2;

  my $result = $waf->update_web_acl(
    scope             => $scope,
    name              => $name,
    visibility_config => encode_json($visibility_config),
    rules             => $rules,
    lock_token        => $lock_token,
    default_action    => encode_json($default_action),
    description       => $description,
    metric_name       => $self->get_config->{app}->{name},
    id                => $id,
  );

  $waf->check_result( message => 'ERROR: could not update web-acl: [%s]', $name );

  return;
}

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.