diff --git a/.gitignore b/.gitignore index 3970208c6..dd68378fd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ cloudformation-guard.tar.gz *# .idea/ *~ -cmake-build-debug \ No newline at end of file +cmake-build-debug +*gcno +*gcda diff --git a/Examples/conditional-ddb-template.ruleset b/Examples/conditional-ddb-template.ruleset new file mode 100644 index 000000000..977519e8f --- /dev/null +++ b/Examples/conditional-ddb-template.ruleset @@ -0,0 +1,3 @@ +# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-product-attribute-reference.html +AWS::DynamoDB::Table if Tags == /.*PROD.*/ then .DeletionPolicy == Retain +AWS::DynamoDB::Table if Tags == /.*PROD.*/ then .DeletionPolicy != Retain diff --git a/Examples/conditional-ddb-template.yaml b/Examples/conditional-ddb-template.yaml new file mode 100644 index 000000000..402497092 --- /dev/null +++ b/Examples/conditional-ddb-template.yaml @@ -0,0 +1,64 @@ +{ + "Resources": { + "DDBTable": { + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Abort", + "DeletionPolicy": "Retain", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "ArtistId", + "AttributeType": "S" + }, + { + "AttributeName": "Concert", + "AttributeType": "S" + }, + { + "AttributeName": "TicketSales", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "ArtistId", + + "KeyType": "HASH" + }, + { + "AttributeName": "Concert", + "KeyType": "RANGE" + } + ], + "GlobalSecondaryIndexes": [ + { + + "IndexName": "GSI", + "KeySchema": [ + + { + + "AttributeName": "TicketSales", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "KEYS_ONLY" + }, + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "Tags": [ + {"Key": "ENV", "Value": "PROD"} + ] + } + } + } +} diff --git a/Examples/ddb-template.yaml b/Examples/ddb-template.yaml new file mode 100644 index 000000000..402497092 --- /dev/null +++ b/Examples/ddb-template.yaml @@ -0,0 +1,64 @@ +{ + "Resources": { + "DDBTable": { + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Abort", + "DeletionPolicy": "Retain", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "ArtistId", + "AttributeType": "S" + }, + { + "AttributeName": "Concert", + "AttributeType": "S" + }, + { + "AttributeName": "TicketSales", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "ArtistId", + + "KeyType": "HASH" + }, + { + "AttributeName": "Concert", + "KeyType": "RANGE" + } + ], + "GlobalSecondaryIndexes": [ + { + + "IndexName": "GSI", + "KeySchema": [ + + { + + "AttributeName": "TicketSales", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "KEYS_ONLY" + }, + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "Tags": [ + {"Key": "ENV", "Value": "PROD"} + ] + } + } + } +} diff --git a/Examples/ddb.ruleset b/Examples/ddb.ruleset new file mode 100644 index 000000000..d096ec6e8 --- /dev/null +++ b/Examples/ddb.ruleset @@ -0,0 +1 @@ +AWS::DynamoDB::Table if Tags == /.*PROD.*/ then .DeletionPolicy == Retain diff --git a/Examples/ebs-volume-template.json b/Examples/ebs-volume-template.json index 36fe0b755..0d9d21b55 100644 --- a/Examples/ebs-volume-template.json +++ b/Examples/ebs-volume-template.json @@ -3,7 +3,7 @@ "NewVolume" : { "Type" : "AWS::EC2::Volume", "Properties" : { - "Size" : 101, + "Size" : 500, "Encrypted": false, "AvailabilityZone" : "us-west-2b" } @@ -11,7 +11,7 @@ "NewVolume2" : { "Type" : "AWS::EC2::Volume", "Properties" : { - "Size" : 99, + "Size" : 50, "Encrypted": false, "AvailabilityZone" : "us-west-2c" } diff --git a/Examples/ebs-volume-template.ruleset b/Examples/ebs-volume-template.ruleset index 2965d82d8..489afed7c 100644 --- a/Examples/ebs-volume-template.ruleset +++ b/Examples/ebs-volume-template.ruleset @@ -1,6 +1,4 @@ let encryption_flag = true -let allowed_azs = [us-east-1a,us-east-1b,us-east-1c] -AWS::EC2::Volume AvailabilityZone IN %allowed_azs AWS::EC2::Volume Encrypted == %encryption_flag -AWS::EC2::Volume Size == 100 +AWS::EC2::Volume Size <= 100 diff --git a/README.md b/README.md index ba50d7763..71e51ac59 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ For example, given a CloudFormation template: "NewVolume" : { "Type" : "AWS::EC2::Volume", "Properties" : { - "Size" : 101, + "Size" : 500, "Encrypted": false, "AvailabilityZone" : "us-west-2b" } @@ -27,7 +27,7 @@ For example, given a CloudFormation template: "NewVolume2" : { "Type" : "AWS::EC2::Volume", "Properties" : { - "Size" : 99, + "Size" : 50, "Encrypted": false, "AvailabilityZone" : "us-west-2c" } @@ -40,24 +40,20 @@ And a set of rules: ``` let encryption_flag = true -let allowed_azs = [us-east-1a,us-east-1b,us-east-1c] -AWS::EC2::Volume AvailabilityZone IN %allowed_azs AWS::EC2::Volume Encrypted == %encryption_flag -AWS::EC2::Volume Size == 100 +AWS::EC2::Volume Size <= 100 ``` You can check the template to ensure that it adheres to the rules. ``` $> cfn-guard -t Examples/ebs_volume_template.json -r Examples/ebs_volume_template.ruleset - "[NewVolume2] failed because [Encrypted] is [false] and the permitted value is [true]" - "[NewVolume2] failed because [Size] is [99] and the permitted value is [100]" - "[NewVolume2] failed because [us-west-2c] is not in [us-east-1a,us-east-1b,us-east-1c] for [AvailabilityZone]" - "[NewVolume] failed because [Encrypted] is [false] and the permitted value is [true]" - "[NewVolume] failed because [Size] is [101] and the permitted value is [100]" - "[NewVolume] failed because [us-west-2b] is not in [us-east-1a,us-east-1b,us-east-1c] for [AvailabilityZone]" - Number of failures: 6 + +[NewVolume2] failed because [Encrypted] is [false] and the permitted value is [true] +[NewVolume] failed because [Encrypted] is [false] and the permitted value is [true] +[NewVolume] failed because [Size] is [500] and the permitted value is [<= 100] +Number of failures: 3 ``` ### Evaluating Security Policies diff --git a/cfn-guard-lambda/Cargo.lock b/cfn-guard-lambda/Cargo.lock index d7cf68f09..8f9091765 100644 --- a/cfn-guard-lambda/Cargo.lock +++ b/cfn-guard-lambda/Cargo.lock @@ -88,7 +88,7 @@ checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" [[package]] name = "cfn-guard" -version = "0.5.2" +version = "0.6.0" dependencies = [ "clap", "lazy_static", @@ -102,7 +102,7 @@ dependencies = [ [[package]] name = "cfn-guard-lambda" -version = "0.5.2" +version = "0.6.0" dependencies = [ "cfn-guard", "lambda_runtime", diff --git a/cfn-guard-lambda/Cargo.toml b/cfn-guard-lambda/Cargo.toml index 1912eb919..a9130376f 100644 --- a/cfn-guard-lambda/Cargo.toml +++ b/cfn-guard-lambda/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cfn-guard-lambda" -version = "0.5.2" +version = "0.6.0" edition = "2018" [dependencies] diff --git a/cfn-guard-lambda/src/main.rs b/cfn-guard-lambda/src/main.rs index e59626ef7..442e0ecfa 100644 --- a/cfn-guard-lambda/src/main.rs +++ b/cfn-guard-lambda/src/main.rs @@ -34,7 +34,11 @@ fn my_handler(e: CustomEvent, _c: Context) -> Result //dbg!(&e); info!("Template is [{}]", &e.template); info!("Rule Set is [{}]", &e.rule_set); - let (result, exit_code) = cfn_guard::run_check(&e.template, &e.rule_set, e.strict_checks); + let (result, exit_code) = match cfn_guard::run_check(&e.template, &e.rule_set, e.strict_checks) + { + Ok(t) => t, + Err(e) => (vec![e], 1), + }; let exit_status = match exit_code { 0 => "PASS", diff --git a/cfn-guard-rulegen-lambda/Cargo.lock b/cfn-guard-rulegen-lambda/Cargo.lock index b2cf8bd82..6cb6134a2 100644 --- a/cfn-guard-rulegen-lambda/Cargo.lock +++ b/cfn-guard-rulegen-lambda/Cargo.lock @@ -89,7 +89,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "cfn-guard-rulegen" -version = "0.5.2" +version = "0.6.0" dependencies = [ "clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)", @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "cfn-guard-rulegen-lambda" -version = "0.5.2" +version = "0.6.0" dependencies = [ - "cfn-guard-rulegen 0.5.2", + "cfn-guard-rulegen 0.6.0", "lambda_runtime 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.95 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/cfn-guard-rulegen-lambda/Cargo.toml b/cfn-guard-rulegen-lambda/Cargo.toml index 80dda92f4..d995eb978 100644 --- a/cfn-guard-rulegen-lambda/Cargo.toml +++ b/cfn-guard-rulegen-lambda/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cfn-guard-rulegen-lambda" -version = "0.5.2" +version = "0.6.0" edition = "2018" [dependencies] diff --git a/cfn-guard-rulegen/Cargo.lock b/cfn-guard-rulegen/Cargo.lock index 4148815f7..18a368308 100644 --- a/cfn-guard-rulegen/Cargo.lock +++ b/cfn-guard-rulegen/Cargo.lock @@ -40,7 +40,7 @@ checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" [[package]] name = "cfn-guard-rulegen" -version = "0.5.2" +version = "0.6.0" dependencies = [ "clap", "log", diff --git a/cfn-guard-rulegen/Cargo.toml b/cfn-guard-rulegen/Cargo.toml index 5b987f90b..fbc22b4be 100644 --- a/cfn-guard-rulegen/Cargo.toml +++ b/cfn-guard-rulegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cfn-guard-rulegen" -version = "0.5.2" +version = "0.6.0" edition = "2018" [dependencies] diff --git a/cfn-guard-rulegen/src/lib.rs b/cfn-guard-rulegen/src/lib.rs index 44e0c82ac..4670649f9 100644 --- a/cfn-guard-rulegen/src/lib.rs +++ b/cfn-guard-rulegen/src/lib.rs @@ -29,10 +29,11 @@ pub fn run_gen(template_file_contents: &str) -> Vec { Err(_) => match serde_yaml::from_str(template_file_contents) { Ok(y) => y, Err(e) => { - let msg_string = format!("Template file format was unreadable as json or yaml: {}", e); + let msg_string = + format!("Template file format was unreadable as json or yaml: {}", e); error!("{}", &msg_string); - return vec![msg_string] - }, + return vec![msg_string]; + } }, }; trace!("CFN Template is {:#?}", &cfn_template); @@ -41,18 +42,17 @@ pub fn run_gen(template_file_contents: &str) -> Vec { None => { let msg_string = format!("Template lacks a Resources section"); error!("{}", &msg_string); - return vec![msg_string] - }, + return vec![msg_string]; + } + }; + let cfn_resources: HashMap = match serde_json::from_value(cfn_resources_clone) { + Ok(y) => y, + Err(e) => { + let msg_string = format!("Template Resources section has an invalid structure: {}", e); + error!("{}", &msg_string); + return vec![msg_string]; + } }; - let cfn_resources: HashMap = - match serde_json::from_value(cfn_resources_clone) { - Ok(y) => y, - Err(e) => { - let msg_string = format!("Template Resources section has an invalid structure: {}", e); - error!("{}", &msg_string); - return vec![msg_string] - }, - }; trace!("CFN resources are: {:?}", cfn_resources); gen_rules(cfn_resources) } @@ -65,7 +65,7 @@ fn gen_rules(cfn_resources: HashMap) -> Vec { let props: HashMap = match serde_json::from_value(cfn_resource["Properties"].clone()) { Ok(s) => s, - Err(_) => continue + Err(_) => continue, }; for (prop_name, prop_val) in props { let stripped_val = match prop_val.as_str() { @@ -76,7 +76,8 @@ fn gen_rules(cfn_resources: HashMap) -> Vec { let key_name = format!("{} {}", &cfn_resource["Type"].as_str().unwrap(), prop_name); // If the key doesn't exist, create it and set its value to a new HashSet with the rule value in it if !rule_map.contains_key(&key_name) { - let value_set: HashSet = vec![no_newline_stripped_val].into_iter().collect(); + let value_set: HashSet = + vec![no_newline_stripped_val].into_iter().collect(); rule_map.insert(key_name, value_set); } else { // If the key does exist, add the item to the HashSet @@ -88,7 +89,9 @@ fn gen_rules(cfn_resources: HashMap) -> Vec { for (key, val_set) in rule_map { let mut rule_string: String = String::from(""); let mut count = 0; - for r in val_set { + let mut sorted_val_set: Vec = val_set.into_iter().collect::>(); + sorted_val_set.sort(); + for r in sorted_val_set { let temp_rule_string = format!("{} == {}", key, r); if count > 0 { rule_string = format!("{} |OR| {}", rule_string, temp_rule_string); @@ -101,4 +104,3 @@ fn gen_rules(cfn_resources: HashMap) -> Vec { } rule_set.into_iter().collect() } - diff --git a/cfn-guard-rulegen/src/main.rs b/cfn-guard-rulegen/src/main.rs index 41a97ea99..b13499621 100644 --- a/cfn-guard-rulegen/src/main.rs +++ b/cfn-guard-rulegen/src/main.rs @@ -35,12 +35,13 @@ fn main() { info!("Generating rules from {}", &template_file); - let result = cfn_guard_rulegen::run(template_file).unwrap_or_else(|err| { + let mut result = cfn_guard_rulegen::run(template_file).unwrap_or_else(|err| { println!("Problem generating rules: {}", err); process::exit(1); }); if !result.is_empty() { + result.sort(); for res in result.iter() { println!("{}", res); } diff --git a/cfn-guard/Cargo.lock b/cfn-guard/Cargo.lock index 8bad63b83..a4e46b5e9 100644 --- a/cfn-guard/Cargo.lock +++ b/cfn-guard/Cargo.lock @@ -49,7 +49,7 @@ checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" [[package]] name = "cfn-guard" -version = "0.5.2" +version = "0.6.0" dependencies = [ "clap", "lazy_static", diff --git a/cfn-guard/Cargo.toml b/cfn-guard/Cargo.toml index 2302ad141..86aade01f 100644 --- a/cfn-guard/Cargo.toml +++ b/cfn-guard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cfn-guard" -version = "0.5.2" +version = "0.6.0" edition = "2018" [dependencies] diff --git a/cfn-guard/README.md b/cfn-guard/README.md index b8d50df55..72970cec8 100644 --- a/cfn-guard/README.md +++ b/cfn-guard/README.md @@ -101,10 +101,10 @@ We modeled `cfn-guard` rules on firewall rules. They're easy to write and have The most basic CloudFormation Guard rule has the form: ``` - == + ``` -The available operations are: +The available operators are: * `==` - Equal * `!=` - Not Equal @@ -115,6 +115,45 @@ The available operations are: * `IN` - In a list of form `[x, y, z]` * `NOT_IN` - Not in a list of form `[x, y, z]` +## Checking Resource Properties and Attributes + +Properties in a rule can take two forms. The basic form exists to make writing simple rules very straightforward: + +``` +AWS::EC2::Volume Encryption == true +``` + +This simple form makes the assumption that the property you're checking is in the resource's `Properties` section: + +``` + "NewVolume" : { + "Type" : "AWS::EC2::Volume", + "Properties" : { + "Size" : 101, + "Encrypted": true, + "AvailabilityZone" : "us-west-2b" + } + } +``` + +However, you may also want to write a rule that checks the resource's [Attributes](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-product-attribute-reference.html): + +``` + "NewVolume" : { + "Type" : "AWS::EC2::Volume", + "Properties" : { + "Size" : "100", + "Encrypted" : "true", + }, + "DeletionPolicy" : "Snapshot" + } +``` +In this case, let's say we want to check the `DeletionPolicy` for deployment safety reasons. We could write a rule that checks attributes at the level above `Properties` by preceding the symbol in the property position with a `.` to indicate that you want to examine a value at the root of the resource: + +``` +AWS::EC2::Volume .DeletionPolicy == Snapshot +``` + ## Comments Comments can be added to a rule set via the `#` operator: @@ -125,15 +164,41 @@ Comments can be added to a rule set via the `#` operator: ## Rule Logic +### ANDs and ORs Each rule in a given rule set is implicitly `AND`'d to every other rule. -You can `OR` rules to provide alternate acceptable values of arbitrary types using `|OR|`: +You can `OR` rules on a single line to provide alternate acceptable values of arbitrary types using `|OR|`: ``` AWS::EBS::Volume Size == 500 |OR| AWS::EBS::Volume AvailabiltyZone == us-east-1b ``` +### WHEN checks +At times, you may not want to check every resource of a particular type in a template for the same values. You can write conditional checks using the `WHEN-CHECK` syntax: + +``` + WHEN CHECK ``` +``` +As an example: +``` +AWS::DynamoDB::Table WHEN Tags.* == /.*PROD.*/ CHECK .DeletionPolicy == Retain +``` +The first section (`WHEN Tags.* == /.*PROD.*/`) is the `condition` you want to filter on. It uses the same property and value syntax and semantics as a basic rule. + +The second section (`CHECK .DeletionPolicy == Retain`) is the `consequent` that the resource must pass for the rule to pass. + +If the `condition` matches, the `consequent` is evaluated and the result of that evaluation is added to the overall ruleset results. + +Note that `WHEN` checks **can only operate on a single resource type at a time**. They can also be aggregated using `OR`'s like a regular rule: + +``` +AWS::DynamoDB::Table when Tags == /.*PROD.*/ check .DeletionPolicy == Retain |OR| AWS::DynamoDB::Table WHEN Tags.* == /.*DEV.*/ CHECK .DeletionPolicy == Delete +``` + +To see a practical example of a conditional rule, look at the `conditional-ddb-template` files in the [Examples](../Examples) directory. + ## Checking nested fields +### Using explicit paths Fields that are nested inside CloudFormation [resource properties](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html) can be addressed using a dotted notation: ``` @@ -154,9 +219,7 @@ Resources: Service: - lambda.amazonaws.com ``` -## Wildcards -### Syntax - +### Using Wildcards You can also refer to template items, lists and maps as wildcards (`*`). Wildcards are a preprocessor macro that examines both the rules file and the template to expand the wildcards into lists of rules of the same length as those contained in the template that's being checked. In other words, given a template of the form: @@ -185,7 +248,7 @@ CloudFormation Guard will walk the template and internally convert the wildcard ``` AWS::IAM::Role AssumeRolePolicyDocument.Statement.0.Principal.Service.0 == lambda.amazonaws.com |OR| AWS::IAM::Role AssumeRolePolicyDocument.Statement.1.Principal.Service.0 == ec2.amazonaws.com ``` -### Semantics +#### Wildcard Semantics Note carefully the different semantic meanings between the equality (`==`) or in-a-list (`IN`) operators and the inequality (`!=`) or not-in-a-list (`NOT_IN`) ones with wildcards: ``` @@ -288,7 +351,7 @@ That will give you more information on how `cfn-guard` is processing it. (See [Troubleshooting](#troubleshooting) for more details on using the different logging levels to see how your template and rule set are being processed.) -### Environment Varibles +### Environment Variables You can even reference **environment variables** using the Makefile-style notation: `%{Name}` So you could rewrite the IAM Role rule above as: @@ -351,6 +414,20 @@ The result looks like an erroneous repeat: "[NewVolume2] failed because [AvailabilityZone] is [us-west-2c] and lorem ipsum" "[NewVolume2] failed because [AvailabilityZone] is [us-west-2c] and lorem ipsum" ``` + +Custom messages are syntactically valid on both sides of a [WHEN check](README.md#when-checks): + +``` +AWS::DynamoDB::Table WHEN Tags == /.*PROD.*/ << custom conditional message CHECK .DeletionPolicy != Retain << custom consequent message +``` + +But the `condition`'s custom message is only exposed inline as part of the raw rule included in the error message: + +``` +[DDBTable] failed because [.DeletionPolicy] is [Retain] and custom consequent message when AWS::DynamoDB::Table Tags == /.*PROD.*/ << custom conditional message +``` + + ## Working with CloudFormation Intrinsic Functions Because of the way YAML is parsed by serde_yaml, functions like `!GetAtt` are treated as comments and ignored. For example: ``` @@ -425,6 +502,164 @@ AWS::EC2::Volume Size == 100 |OR| AWS::EC2::Volume Size == 99 AWS::EC2::Volume Encrypted == true |OR| AWS::EC2::Volume Encrypted == false AWS::EC2::Volume AvailabilityZone == {"Fn::GetAtt":["EC2Instance","AvailabilityZone"]} ``` +# Strict Checks +The `--strict-check` flag will cause a resource to fail a check if it does not contain the property the rule is checking. This is useful to enforce the presence of optional properties like `Encryption == true`. + +Strict checks and wildcards need to be carefully thought out before being used together, however. Wildcards create rules at runtime that map to all of the values that *each* resource of that type has at the position of the wildcard. That means means that overly broad wildcards will give overly broad failures. + +As an example, let's look at the following wildcard scenario: + +Here's a template snippet: +``` +{ + "Resources": { + "NewVolume" : { + "Type" : "AWS::EC2::Volume", + "Properties" : { + "AutoEnableIO": true, + "Size" : 101, + "Encrypted": true, + "AvailabilityZone" : "us-west-2b" + } + }, + "NewVolume2" : { + "Type" : "AWS::EC2::Volume", + "Properties" : { + "Size" : 99, + "Encrypted": true, + "AvailabilityZone" : "us-west-2c" + } + } + } +} +``` +It's perfectly valid semantically (although of dubious practical value) to use a wildcard to ensure that at least one property has a value equal to true: +``` +AWS::EC2::Volume * == true +``` +As discussed above in the section about wildcards, this translates at runtime to a rule for each property being created and joined by an `|OR|`: +``` +> cfn-guard -t ~/scratch-template.yaml -r ~/scratch.ruleset -vvv +... +2020-08-07 17:25:59,000 INFO [cfn_guard] Applying rule 'CompoundRule( + CompoundRule { + compound_type: OR, + raw_rule: "AWS::EC2::Volume * == true", + rule_list: [ + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "AvailabilityZone", + operation: Require, + value: "true", + rule_vtype: Value, + custom_msg: None, + }, + ), + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "Size", + operation: Require, + value: "true", + rule_vtype: Value, + custom_msg: None, + }, + ), + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "Encrypted", + operation: Require, + value: "true", + rule_vtype: Value, + custom_msg: None, + }, + ), + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "AutoEnableIO", + operation: Require, + value: "true", + rule_vtype: Value, + custom_msg: None, + }, + ), + ], + }, +)' + +``` +And the check will pass. + +However, if you change your wildcard rule to be a `!=`: +``` +AWS::EC2::Volume * != false +``` + +The `OR` rule becomes an `AND` rule: +``` +2020-08-07 17:33:20,637 INFO [cfn_guard] Applying rule 'CompoundRule( + CompoundRule { + compound_type: AND, + raw_rule: "AWS::EC2::Volume * != false", + rule_list: [ + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "AvailabilityZone", + operation: RequireNot, + value: "false", + rule_vtype: Value, + custom_msg: None, + }, + ), + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "AutoEnableIO", + operation: RequireNot, + value: "false", + rule_vtype: Value, + custom_msg: None, + }, + ), + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "Size", + operation: RequireNot, + value: "false", + rule_vtype: Value, + custom_msg: None, + }, + ), + SimpleRule( + Rule { + resource_type: "AWS::EC2::Volume", + field: "Encrypted", + operation: RequireNot, + value: "false", + rule_vtype: Value, + custom_msg: None, + }, + ), + ], + }, +)' +``` + +And if you run it with `--strict-checks` it'll fail because `NewVolume2` does not contain the `AutoEnableIO` property: + +``` +> cfn-guard -t ~/scratch-template.yaml -r ~/scratch.ruleset --strict-checks +[NewVolume2] failed because it does not contain the required property of [AutoEnableIO] +Number of failures: 1 +``` +Admittedly, this is a very contrived example, but it's an important to behavior understand. + + # Troubleshooting `cfn-guard` is meant to be used as part of a tool chain. It does not, for instance, check to see if the CloudFormation template presented to it is valid CloudFormation. The [cfn-lint](https://github.com/aws-cloudformation/cfn-python-lint) tool already does a deep and thorough inspection of template structure and provides copious feedback to help users write high-quality templates. diff --git a/cfn-guard/src/guard_types.rs b/cfn-guard/src/guard_types.rs index 9cb0932f5..4017b92e5 100644 --- a/cfn-guard/src/guard_types.rs +++ b/cfn-guard/src/guard_types.rs @@ -6,6 +6,7 @@ pub mod enums { pub enum LineType { Assignment, Comment, + Conditional, Rule, WhiteSpace, } @@ -29,11 +30,19 @@ pub mod enums { Regex, Variable, } - #[derive(Debug, Clone, Eq, PartialEq)] + #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum CompoundType { OR, AND, } + + #[derive(Debug, Clone, Eq, PartialEq, Hash)] + pub enum RuleType { + CompoundRule(super::structs::CompoundRule), + ConditionalRule(super::structs::ConditionalRule), + SimpleRule(super::structs::Rule), // SimpleRule is a rule that cannot be reduced/transformed any further + // It's the base case for recursing into rule processing + } } pub mod structs { @@ -49,15 +58,22 @@ pub mod structs { pub(crate) custom_msg: Option, } - #[derive(Debug, Eq, PartialEq)] + #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct CompoundRule { pub(crate) compound_type: super::enums::CompoundType, - pub(crate) rule_list: Vec, + pub(crate) raw_rule: String, + pub(crate) rule_list: Vec, + } + + #[derive(Debug, Clone, Eq, PartialEq, Hash)] + pub struct ConditionalRule { + pub(crate) condition: CompoundRule, + pub(crate) consequent: CompoundRule, } - #[derive(Debug, Eq, PartialEq)] + #[derive(Debug)] pub struct ParsedRuleSet { pub(crate) variables: HashMap, - pub(crate) rule_set: Vec, + pub(crate) rule_set: Vec, } } diff --git a/cfn-guard/src/lib.rs b/cfn-guard/src/lib.rs index 3418addf2..3e06a911f 100644 --- a/cfn-guard/src/lib.rs +++ b/cfn-guard/src/lib.rs @@ -36,16 +36,20 @@ pub fn run( rules_file_contents.to_string() ); - let (outcome, exit_code) = run_check(&template_contents, &rules_file_contents, strict_checks); - debug!("Outcome was: '{:#?}'", &outcome); - Ok((outcome, exit_code)) + match run_check(&template_contents, &rules_file_contents, strict_checks) { + Ok(res) => { + debug!("Outcome was: '{:#?}'", &res.0); + Ok(res) + } + Err(e) => Err(e.into()), + } } pub fn run_check( template_file_contents: &str, rules_file_contents: &str, strict_checks: bool, -) -> (Vec, usize) { +) -> Result<(Vec, usize), String> { info!("Loading CloudFormation Template and Rule Set"); debug!("Entered run_check"); @@ -69,13 +73,10 @@ pub fn run_check( Err(_) => match serde_yaml::from_str(&cleaned_template_file_contents) { Ok(y) => y, Err(e) => { - return ( - vec![format!( - "ERROR: Template file format was unreadable as json or yaml: {}", - e - )], - 1, - ); + return Err(format!( + "Template file format was unreadable as json or yaml: {}", + e + )); } }, }; @@ -84,12 +85,8 @@ pub fn run_check( let cfn_resources: HashMap = match cfn_template.get("Resources") { Some(r) => serde_json::from_value(r.clone()).unwrap(), None => { - return ( - vec![ - "ERROR: Template file does not contain a [Resources] section to check" - .to_string(), - ], - 1, + return Err( + "Template file does not contain a [Resources] section to check".to_string(), ); } }; @@ -97,76 +94,242 @@ pub fn run_check( trace!("CFN resources are: {:?}", cfn_resources); info!("Parsing rule set"); - let parsed_rule_set = parser::parse_rules(&cleaned_rules_file_contents, &cfn_resources); - - let mut outcome = check_resources(&cfn_resources, &parsed_rule_set, strict_checks); - outcome.sort(); + match parser::parse_rules(&cleaned_rules_file_contents, &cfn_resources) { + Ok(pr) => { + let mut outcome: Vec = vec![]; + match check_resources(&cfn_resources, &pr, strict_checks) { + Some(x) => { + outcome.extend(x); + } + None => (), + } + outcome.sort(); - let exit_code = match outcome.len() { - 0 => 0, - _ => 2, - }; - (outcome, exit_code) + let exit_code = match outcome.len() { + 0 => 0, + _ => 2, + }; + return Ok((outcome, exit_code)); + } + Err(e) => Err(e), + } } fn check_resources( cfn_resources: &HashMap, parsed_rule_set: &structs::ParsedRuleSet, strict_checks: bool, -) -> Vec { +) -> Option> { info!("Checking resources"); let mut result: Vec = vec![]; for c_rule in parsed_rule_set.rule_set.iter() { info!("Applying rule '{:#?}'", &c_rule); - match c_rule.compound_type { - enums::CompoundType::OR => { + match c_rule { + enums::RuleType::SimpleRule(r) => { + trace!("Simple rule is {:#?}", r); + if let Some(rule_result) = + apply_rule(&cfn_resources, r, &parsed_rule_set.variables, strict_checks) + { + result.extend(rule_result); + } + } + enums::RuleType::ConditionalRule(r) => { + trace!("Conditional rule is {:#?}", r); for (name, cfn_resource) in cfn_resources { - trace!("OR'ing [{}] against {:?}", name, c_rule); - let mut pass_fail = HashSet::new(); - let mut temp_results: Vec = vec![]; + trace!("Checking condition: {:?}", r.condition); + let mut cfn_resource_map: HashMap = HashMap::new(); cfn_resource_map.insert(name.clone(), cfn_resource.clone()); - for rule in &c_rule.rule_list { - match apply_rule( - &cfn_resource_map, - &rule, - &parsed_rule_set.variables, - strict_checks, - ) { - Some(rule_result) => { - pass_fail.insert("fail"); - temp_results.extend(rule_result); - } - None => { - pass_fail.insert("pass"); + trace!("Temporary resource map is {:#?}", cfn_resource_map); + + let condition_rule_set = structs::ParsedRuleSet { + variables: parsed_rule_set.variables.clone(), + rule_set: vec![enums::RuleType::CompoundRule(r.clone().condition)], + }; + trace!( + "condition_rule_set is {{variables: {:#?}, rule_set: {:#?}}}", + util::filter_for_env_vars(&condition_rule_set.variables), + condition_rule_set.rule_set + ); + + // Use the existing rules logic to see if there's a hit on the Condition clause + match check_resources(&cfn_resource_map, &condition_rule_set, true) { + Some(_) => (), // A result from a condition check means that it *wasn't* met (by def) + None => { + trace!("Condition met for {}", r.condition.raw_rule); + let consequent_rule_set = structs::ParsedRuleSet { + variables: parsed_rule_set.variables.clone(), + rule_set: vec![enums::RuleType::CompoundRule(r.clone().consequent)], + }; + let postscript = format!("when {}", r.condition.raw_rule); + match check_resources( + &cfn_resource_map, + &consequent_rule_set, + strict_checks, + ) { + Some(x) => { + let temp_result = x.into_iter().map(|x| { + if !x.contains("when") { + format!("{} {}", x, postscript) + } else { + x + } + }); + result.extend(temp_result); + } + None => (), + }; + } + }; + } + } + enums::RuleType::CompoundRule(r) => match r.compound_type { + enums::CompoundType::OR => { + for (name, cfn_resource) in cfn_resources { + trace!("OR'ing [{}] against {:?}", name, r); + let mut pass_fail = HashSet::new(); + let mut temp_results: Vec = vec![]; + let mut cfn_resource_map: HashMap = HashMap::new(); + cfn_resource_map.insert(name.clone(), cfn_resource.clone()); + for typed_rule in &r.rule_list { + match typed_rule { + enums::RuleType::SimpleRule(r) => { + match apply_rule( + &cfn_resource_map, + r, + &parsed_rule_set.variables, + strict_checks, + ) { + Some(rule_result) => { + pass_fail.insert("fail"); + temp_results.extend(rule_result); + } + None => { + pass_fail.insert("pass"); + } + } + } + enums::RuleType::CompoundRule(r) => { + let rule_set = structs::ParsedRuleSet { + variables: parsed_rule_set.variables.clone(), + rule_set: vec![enums::RuleType::CompoundRule(r.clone())], + }; + let postscript = format!("when {}", r.raw_rule); + match check_resources( + &cfn_resource_map, + &rule_set, + strict_checks, + ) { + Some(x) => { + let temp_result = x.into_iter().map(|x| { + if !x.contains("when") { + format!("{} {}", x, postscript) + } else { + x + } + }); + result.extend(temp_result); + } + None => (), + }; + } + enums::RuleType::ConditionalRule(r) => { + let rule_set = structs::ParsedRuleSet { + variables: parsed_rule_set.variables.clone(), + rule_set: vec![enums::RuleType::ConditionalRule(r.clone())], + }; + let postscript = format!("when {}", r.condition.raw_rule); + match check_resources( + &cfn_resource_map, + &rule_set, + strict_checks, + ) { + Some(x) => { + let temp_result = x.into_iter().map(|x| { + if !x.contains("when") { + format!("{} {}", x, postscript) + } else { + x + } + }); + result.extend(temp_result); + } + None => (), + }; + } } } - } - trace! {"pass_fail set is {:?}", &pass_fail}; - trace! {"temp_results are {:?}", &temp_results}; - if !pass_fail.contains("pass") { - result.extend(temp_results); + trace! {"temp_results are {:?}", &temp_results}; + trace! {"pass_fail set is {:?}", &pass_fail}; + if !pass_fail.contains("pass") { + result.extend(temp_results); + } } } - } - enums::CompoundType::AND => { - for rule in &c_rule.rule_list { - if let Some(rule_result) = apply_rule( - &cfn_resources, - &rule, - &parsed_rule_set.variables, - strict_checks, - ) { - result.extend(rule_result); + enums::CompoundType::AND => { + for typed_rule in &r.rule_list { + match typed_rule { + enums::RuleType::SimpleRule(r) => { + if let Some(rule_result) = apply_rule( + &cfn_resources, + r, + &parsed_rule_set.variables, + strict_checks, + ) { + result.extend(rule_result); + } + } + enums::RuleType::CompoundRule(r) => { + let rule_set = structs::ParsedRuleSet { + variables: parsed_rule_set.variables.clone(), + rule_set: vec![enums::RuleType::CompoundRule(r.clone())], + }; + let postscript = format!("when {}", r.raw_rule); + match check_resources(cfn_resources, &rule_set, strict_checks) { + Some(x) => { + let temp_result = x.into_iter().map(|x| { + if !x.contains("when") { + format!("{} {}", x, postscript) + } else { + x + } + }); + result.extend(temp_result); + } + None => (), + }; + } + enums::RuleType::ConditionalRule(r) => { + let rule_set = structs::ParsedRuleSet { + variables: parsed_rule_set.variables.clone(), + rule_set: vec![enums::RuleType::ConditionalRule(r.clone())], + }; + let postscript = format!("when {}", r.condition.raw_rule); + match check_resources(cfn_resources, &rule_set, strict_checks) { + Some(x) => { + let temp_result = x.into_iter().map(|x| { + if !x.contains("when") { + format!("{} {}", x, postscript) + } else { + x + } + }); + result.extend(temp_result); + } + None => (), + }; + } + } } } - } + }, } } - if result.is_empty() { - info!("All CloudFormation resources passed"); + if !result.is_empty() { + Some(result) + } else { + None } - result } fn apply_rule( @@ -183,8 +346,24 @@ fn apply_rule( "Checking [{}] which is of type {}", &name, &cfn_resource["Type"] ); - let target_field: Vec<&str> = rule.field.split('.').collect(); - match util::get_resource_prop_value(&cfn_resource["Properties"], &target_field) { + let mut target_field: Vec<&str> = rule.field.split('.').collect(); + let (property_root, address) = match target_field.first() { + Some(x) => { + if *x == "" { + // If the first address segment is a '.' + target_field.remove(0); + target_field.insert(0, "."); // Replace the empty first element with a "." + (cfn_resource, target_field) // Return the root of the Value for lookup + } else { + (&cfn_resource["Properties"], target_field) // Otherwise, treat it as a normal property lookup + } + } + None => { + error!("Invalid property address: {}", rule.field); + return None; + } + }; + match util::get_resource_prop_value(property_root, &address) { Err(e) => { if strict_checks { rule_result.push(match &rule.custom_msg { diff --git a/cfn-guard/src/main.rs b/cfn-guard/src/main.rs index 1a19391b7..772b2f440 100644 --- a/cfn-guard/src/main.rs +++ b/cfn-guard/src/main.rs @@ -87,5 +87,7 @@ fn main() { } else { process::exit(exit_code as i32); } + } else { + info!("All CloudFormation resources passed"); } } diff --git a/cfn-guard/src/parser.rs b/cfn-guard/src/parser.rs index a332dc7fa..312750b47 100644 --- a/cfn-guard/src/parser.rs +++ b/cfn-guard/src/parser.rs @@ -2,33 +2,33 @@ use std::collections::{HashMap, HashSet}; use std::env; -use std::process; use log::{self, debug, error, trace}; use regex::{Captures, Regex}; use serde_json::Value; -use crate::guard_types::enums::{CompoundType, LineType, OpCode, RValueType}; -use crate::guard_types::structs::{CompoundRule, ParsedRuleSet, Rule}; +use crate::guard_types::enums::{CompoundType, LineType, OpCode, RValueType, RuleType}; +use crate::guard_types::structs::{CompoundRule, ConditionalRule, ParsedRuleSet, Rule}; use crate::util; use lazy_static::lazy_static; // This sets it up so the regexen only get compiled once // See: https://docs.rs/regex/1.3.9/regex/#example-avoid-compiling-the-same-regex-in-a-loop lazy_static! { - static ref ASSIGN_REG: Regex = Regex::new(r"let (?P\w+) +(?P\S+) +(?P.*)").unwrap(); - static ref RULE_REG: Regex = Regex::new(r"(?P\S+) +(?P[\w\.\*]+) +(?P\S+) +(?P[^\n\r]+)").unwrap(); + static ref ASSIGN_REG: Regex = Regex::new(r"let (?P\w+) +(?P\S+) +(?P.+)").unwrap(); + static ref RULE_REG: Regex = Regex::new(r"^(?P\S+) +(?P[\w\.\*]+) +(?P==|!=|<|>|<=|>=|IN|NOT_IN) +(?P[^\n\r]+)").unwrap(); static ref COMMENT_REG: Regex = Regex::new(r#"#(?P.*)"#).unwrap(); - static ref WILDCARD_OR_RULE_REG: Regex = Regex::new(r"(\S+) (\S+\*\S*) (==|IN) (.+)").unwrap(); + static ref WILDCARD_OR_RULE_REG: Regex = Regex::new(r"(\S+) (\S*\*\S*) (==|IN) (.+)").unwrap(); static ref RULE_WITH_OPTIONAL_MESSAGE_REG: Regex = Regex::new( - r"(?P\S+) +(?P[\w\.\*]+) +(?P\S+) +(?P[^\n\r]+) +<{2} *(?P.*)").unwrap(); - static ref WHITE_SPACE_REG: Regex = Regex::new(r"\s+").unwrap(); + r"^(?P\S+) +(?P[\w\.\*]+) +(?P==|!=|<|>|<=|>=|IN|NOT_IN) +(?P[^\n\r]+) +<{2} *(?P.*)").unwrap(); + static ref WHITE_SPACE_REG: Regex = Regex::new(r"^\s+$").unwrap(); + static ref CONDITIONAL_RULE_REG: Regex = Regex::new(r"(?P\S+) +(when|WHEN) +(?P.+) +(check|CHECK) +(?P.*)").unwrap(); } pub(crate) fn parse_rules( rules_file_contents: &str, cfn_resources: &HashMap, -) -> ParsedRuleSet { +) -> Result { debug!("Entered parse_rules"); trace!( "Parse rules entered with rules_file_contents: {:#?}", @@ -39,33 +39,40 @@ pub(crate) fn parse_rules( &cfn_resources ); - let mut rule_set: Vec = vec![]; + let mut rule_set: Vec = vec![]; let mut variables = HashMap::new(); let lines = rules_file_contents.lines(); - trace!("Rules file lines: {:#?}", &lines); + trace!( + "Rules file lines: {:#?}", + lines.clone().into_iter().collect::>() + ); for l in lines { debug!("Parsing '{}'", &l); - if l.is_empty() { + let trimmed_line = l.trim(); + if trimmed_line.is_empty() { continue; }; - let line_type = find_line_type(l); + let line_type = match find_line_type(trimmed_line) { + Ok(lt) => lt, + Err(e) => return Err(e), + }; debug!("line_type is {:#?}", line_type); match line_type { LineType::Assignment => { - let caps = match process_assignment(l) { - Some(a) => a, - None => continue, + let caps = match process_assignment(trimmed_line) { + Ok(a) => a, + Err(e) => return Err(e), }; trace!("Parsed assignment's captures are: {:#?}", &caps); if caps["operator"] != *"=" { let msg_string = format!( "Bad Assignment Operator: [{}] in '{}'", - &caps["operator"], l + &caps["operator"], trimmed_line ); error!("{}", &msg_string); - process::exit(1) + return Err(msg_string); } let var_name = caps["var_name"].to_string(); let var_value = caps["var_value"].to_string(); @@ -77,17 +84,20 @@ pub(crate) fn parse_rules( variables.insert(var_name, var_value); } LineType::Comment => (), - LineType::Rule => { - let compound_rule: CompoundRule = if is_or_rule(l) { - debug!("Line is an |OR| rule"); - process_or_rule(l, &cfn_resources) - } else { - debug!("Line is an 'AND' rule"); - process_and_rule(l, &cfn_resources) - }; - debug!("Parsed rule is: {:#?}", &compound_rule); - rule_set.push(compound_rule); - } + LineType::Rule => match parse_rule_line(trimmed_line, &cfn_resources) { + Ok(prl) => { + debug!("Parsed rule is: {:#?}", &prl); + rule_set.push(prl) + } + Err(e) => return Err(e), + }, + LineType::Conditional => match parse_rule_line(trimmed_line, &cfn_resources) { + Ok(c) => { + debug!("Parsed conditional is {:#?}", &c); + rule_set.push(c); + } + Err(e) => return Err(e), + }, LineType::WhiteSpace => { debug!("Line is white space"); continue; @@ -101,34 +111,108 @@ pub(crate) fn parse_rules( let filtered_env_vars = util::filter_for_env_vars(&variables); debug!("Variables dictionary is {:?}", &filtered_env_vars); debug!("Rule Set is {:#?}", &rule_set); - ParsedRuleSet { + Ok(ParsedRuleSet { variables, rule_set, + }) +} + +fn parse_rule_line(l: &str, cfn_resources: &HashMap) -> Result { + match is_or_rule(l) { + true => { + debug!("Line is an |OR| rule"); + match process_or_rule(l, &cfn_resources) { + Ok(r) => Ok(r), + Err(e) => Err(e), + } + } + false => { + debug!("Line is an 'AND' rule"); + match process_and_rule(l, &cfn_resources) { + Ok(r) => Ok(r), + Err(e) => return Err(e), + } + } } } -fn find_line_type(line: &str) -> LineType { +fn process_conditional( + line: &str, + cfn_resources: &HashMap, +) -> Result { + let caps = CONDITIONAL_RULE_REG.captures(line).unwrap(); + trace!("ConditionalRule regex captures are {:#?}", &caps); + + if RULE_REG.is_match(&caps["condition"]) + || RULE_WITH_OPTIONAL_MESSAGE_REG.is_match(&caps["condition"]) + { + return Err(format!( + "Invalid condition: '{}' in '{}'", + &caps["condition"], line + )); + } + let conjd_caps_conditional = format!("{} {}", &caps["resource_type"], &caps["condition"]); + trace!("conjd_caps_conditional is {:#?}", conjd_caps_conditional); + match parse_rule_line(&conjd_caps_conditional, cfn_resources) { + Ok(cond) => { + let condition = match cond { + RuleType::CompoundRule(s) => s, + _ => return Err(format!("Bad destructure of conditional rule: {}", line)), + }; + if RULE_REG.is_match(&caps["consequent"]) + || RULE_WITH_OPTIONAL_MESSAGE_REG.is_match(&caps["consequent"]) + { + return Err(format!( + "Invalid consequent: '{}' in '{}'. Consequents cannot contain resource types.", + &caps["consequent"], line + )); + } + let conjd_caps_consequent = + format!("{} {}", &caps["resource_type"], &caps["consequent"]); + trace!("conjd_caps_consequent is {:#?}", conjd_caps_consequent); + match parse_rule_line(&conjd_caps_consequent, cfn_resources) { + Ok(cons) => { + let consequent = match cons { + RuleType::CompoundRule(s) => s, + _ => return Err(format!("Bad destructure of conditional rule: {}", line)), + }; + Ok(ConditionalRule { + condition, + consequent, + }) + } + Err(e) => Err(e), + } + } + Err(e) => Err(e), + } +} + +fn find_line_type(line: &str) -> Result { if COMMENT_REG.is_match(line) { - return LineType::Comment; + return Ok(LineType::Comment); }; if ASSIGN_REG.is_match(line) { - return LineType::Assignment; + return Ok(LineType::Assignment); + }; + if CONDITIONAL_RULE_REG.is_match(line) { + return Ok(LineType::Conditional); }; if RULE_REG.is_match(line) { - return LineType::Rule; + return Ok(LineType::Rule); }; if WHITE_SPACE_REG.is_match(line) { - return LineType::WhiteSpace; + return Ok(LineType::WhiteSpace); } let msg_string = format!("BAD RULE: {:?}", line); error!("{}", &msg_string); - process::exit(1) + Err(msg_string) } -fn process_assignment(line: &str) -> Option { +fn process_assignment(line: &str) -> Result { match ASSIGN_REG.captures(line) { - Some(c) => Some(c), - None => None, + Some(c) => Ok(c), + None => Err(format!("Invalid assignment statement: '{}", line)), } } @@ -136,121 +220,170 @@ fn is_or_rule(line: &str) -> bool { line.contains("|OR|") || WILDCARD_OR_RULE_REG.is_match(line) } -fn process_or_rule(line: &str, cfn_resources: &HashMap) -> CompoundRule { +fn process_or_rule(line: &str, cfn_resources: &HashMap) -> Result { trace!("Entered process_or_rule"); let branches = line.split("|OR|"); - debug!("Rule branches are: {:#?}", &branches); - let mut rules: Vec = vec![]; + // debug!("Rule branches are: {:#?}", &branches); + let mut rules: Vec = vec![]; for b in branches { - rules.append(destructure_rule(b.trim(), cfn_resources).as_mut()); + debug!("Rule |OR| branch is '{}'", b); + match destructure_rule(b.trim(), cfn_resources) { + Ok(r) => rules.append(&mut r.clone()), + Err(e) => return Err(e), + } } - CompoundRule { + Ok(RuleType::CompoundRule(CompoundRule { compound_type: CompoundType::OR, + raw_rule: line.to_string(), rule_list: rules, - } + })) } -fn process_and_rule(line: &str, cfn_resources: &HashMap) -> CompoundRule { - CompoundRule { - compound_type: CompoundType::AND, - rule_list: destructure_rule(line, cfn_resources), +fn process_and_rule( + line: &str, + cfn_resources: &HashMap, +) -> Result { + trace!("Entered process_and_rule"); + let branches = line.split("|AND|"); + let mut rules: Vec = vec![]; + for b in branches { + debug!("AND rule branch is: {:#?}", &b); + match destructure_rule(b.trim(), cfn_resources) { + Ok(r) => rules.append(&mut r.clone()), + Err(e) => return Err(e), + } } + Ok(RuleType::CompoundRule(CompoundRule { + compound_type: CompoundType::AND, + raw_rule: line.to_string(), + rule_list: rules, + })) } -fn destructure_rule(rule_text: &str, cfn_resources: &HashMap) -> Vec { +fn destructure_rule( + rule_text: &str, + cfn_resources: &HashMap, +) -> Result, String> { trace!("Entered destructure_rule"); - let mut rules_hash: HashSet = HashSet::new(); - let caps = match RULE_WITH_OPTIONAL_MESSAGE_REG.captures(rule_text) { - Some(c) => c, - None => match RULE_REG.captures(rule_text) { - Some(c) => c, - None => { - trace!("No captures from rule regex"); - return vec![]; - } - }, - }; - - trace!("Parsed rule's captures are: {:#?}", &caps); - let mut props: Vec = vec![]; - if caps["resource_property"].contains('*') { - for (_name, value) in cfn_resources { - if caps["resource_type"] == value["Type"] { - if let Some(p) = util::expand_wildcard_props( - &value["Properties"], - caps["resource_property"].to_string(), - String::from(""), - ) { - props.append(&mut p.clone()); - trace!("Expanded props are {:#?}", &props); - } + let mut rules_hash: HashSet = HashSet::new(); + if CONDITIONAL_RULE_REG.is_match(rule_text) { + match process_conditional(rule_text, cfn_resources) { + Ok(r) => { + rules_hash.insert(RuleType::ConditionalRule(r)); } + Err(e) => return Err(e), } } else { - props.push(caps["resource_property"].to_string()); - }; + let caps = match RULE_WITH_OPTIONAL_MESSAGE_REG.captures(rule_text) { + Some(c) => c, + None => match RULE_REG.captures(rule_text) { + Some(c) => c, + None => { + return Err(format!("Invalid rule: {}", rule_text)); + } + }, + }; - for p in props { - rules_hash.insert(Rule { - resource_type: caps["resource_type"].to_string(), - field: p.to_string(), - operation: { - match &caps["operator"] { - "==" => OpCode::Require, - "!=" => OpCode::RequireNot, - "<" => OpCode::LessThan, - ">" => OpCode::GreaterThan, - "<=" => OpCode::LessThanOrEqualTo, - ">=" => OpCode::GreaterThanOrEqualTo, - "IN" => OpCode::In, - "NOT_IN" => OpCode::NotIn, - _ => { - let msg_string = format!( - "Bad Rule Operator: [{}] in '{}'", - &caps["operator"], rule_text - ); - error!("{}", &msg_string); - process::exit(1) + trace!("Parsed rule's captures are: {:#?}", &caps); + let mut props: Vec = vec![]; + if caps["resource_property"].contains('*') { + for (_name, value) in cfn_resources { + if caps["resource_type"] == value["Type"] { + let target_field: Vec<&str> = caps["resource_property"].split('.').collect(); + let (property_root, address) = match target_field.first() { + Some(x) => { + if *x == "" { + // If the first address segment is a '.' + (value, target_field) // Return the root of the Value for lookup + } else { + // Otherwise, treat it as a normal property lookup + (&value["Properties"], target_field) + } + } + None => { + let msg_string = + format!("Invalid property address: {:#?}", target_field); + error!("{}", msg_string); + return Err(msg_string); + } + }; + if let Some(p) = util::expand_wildcard_props( + property_root, + address.join("."), + String::from(""), + ) { + props.append(&mut p.clone()); + trace!("Expanded props are {:#?}", &props); } } - }, - rule_vtype: { - let rv = caps["rule_value"].chars().next().unwrap(); - match rv { - '[' => match &caps["operator"] { - "==" | "!=" | "<=" | ">=" | "<" | ">" => RValueType::Value, - "IN" | "NOT_IN" => RValueType::List, + } + } else { + props.push(caps["resource_property"].to_string()); + }; + + for p in props { + let rule = Rule { + resource_type: caps["resource_type"].to_string(), + field: p.to_string(), + operation: { + match &caps["operator"] { + "==" => OpCode::Require, + "!=" => OpCode::RequireNot, + "<" => OpCode::LessThan, + ">" => OpCode::GreaterThan, + "<=" => OpCode::LessThanOrEqualTo, + ">=" => OpCode::GreaterThanOrEqualTo, + "IN" => OpCode::In, + "NOT_IN" => OpCode::NotIn, _ => { let msg_string = format!( "Bad Rule Operator: [{}] in '{}'", &caps["operator"], rule_text ); error!("{}", &msg_string); - process::exit(1) + return Err(msg_string); } - }, - '/' => RValueType::Regex, - '%' => RValueType::Variable, - _ => RValueType::Value, - } - }, - value: { - let rv = caps["rule_value"].chars().next().unwrap(); - match rv { - '/' => caps["rule_value"].trim_matches('/').to_string(), - _ => caps["rule_value"].to_string().trim().to_string(), - } - }, - custom_msg: match caps.name("custom_msg") { - Some(s) => Some(s.as_str().to_string()), - None => None, - }, - }); + } + }, + rule_vtype: { + let rv = caps["rule_value"].chars().next().unwrap(); + match rv { + '[' => match &caps["operator"] { + "==" | "!=" | "<=" | ">=" | "<" | ">" => RValueType::Value, + "IN" | "NOT_IN" => RValueType::List, + _ => { + let msg_string = format!( + "Bad Rule Operator: [{}] in '{}'", + &caps["operator"], rule_text + ); + error!("{}", &msg_string); + return Err(msg_string); + } + }, + '/' => RValueType::Regex, + '%' => RValueType::Variable, + _ => RValueType::Value, + } + }, + value: { + let rv = caps["rule_value"].chars().next().unwrap(); + match rv { + '/' => caps["rule_value"].trim_matches('/').to_string(), + _ => caps["rule_value"].to_string().trim().to_string(), + } + }, + custom_msg: match caps.name("custom_msg") { + Some(s) => Some(s.as_str().to_string()), + None => None, + }, + }; + rules_hash.insert(RuleType::SimpleRule(rule)); + } } - let rules = rules_hash.into_iter().collect::>(); + let rules = rules_hash.into_iter().collect::>(); trace!("Destructured rules are: {:#?}", &rules); - rules + Ok(rules) } mod tests { @@ -263,10 +396,10 @@ mod tests { let assignment = find_line_type("let x = assignment"); let rule = find_line_type("AWS::EC2::Volume Encryption == true"); let white_space = find_line_type(" "); - assert_eq!(comment, crate::enums::LineType::Comment); - assert_eq!(assignment, crate::enums::LineType::Assignment); - assert_eq!(rule, crate::enums::LineType::Rule); - assert_eq!(white_space, crate::enums::LineType::WhiteSpace) + assert_eq!(comment, Ok(crate::enums::LineType::Comment)); + assert_eq!(assignment, Ok(crate::enums::LineType::Assignment)); + assert_eq!(rule, Ok(crate::enums::LineType::Rule)); + assert_eq!(white_space, Ok(crate::enums::LineType::WhiteSpace)) } #[test] @@ -276,7 +409,7 @@ mod tests { let mut var_map: HashMap = HashMap::new(); var_map.insert("var".to_string(), "[128]".to_string()); - let parsed_rules = parse_rules(assignment, &cfn_resources); + let parsed_rules = parse_rules(assignment, &cfn_resources).unwrap(); assert!(parsed_rules.variables["var"] == "[128]"); } } diff --git a/cfn-guard/src/util.rs b/cfn-guard/src/util.rs index e72cb80bc..925d736e6 100644 --- a/cfn-guard/src/util.rs +++ b/cfn-guard/src/util.rs @@ -70,6 +70,7 @@ pub fn convert_list_var_to_vec(rule_val: &str) -> Vec { fn match_props<'a>(props: &'a Value, n: &'a dyn serde_json::value::Index) -> Result<&'a Value, ()> { trace!("props are {:#?}", props); + match props.get(n) { Some(v) => Ok(v), None => Err(()), @@ -84,40 +85,44 @@ pub fn get_resource_prop_value(props: &Value, field: &[&str]) -> Result() { - Ok(n) => { - trace!( - "next_field is {:?} and field_list is now {:?}", - &n, - &field_list - ); + if next_field == "." { + get_resource_prop_value(&props, &field_list) + } else { + match next_field.parse::() { + Ok(n) => { + trace!( + "next_field is {:?} and field_list is now {:?}", + &n, + &field_list + ); - match match_props(props, &n) { - Ok(v) => { - if !field_list.is_empty() { - get_resource_prop_value(&v, &field_list) - } else { - Ok(v.clone()) + match match_props(props, &n) { + Ok(v) => { + if !field_list.is_empty() { + get_resource_prop_value(&v, &field_list) + } else { + Ok(v.clone()) + } } + Err(_) => Err(n.to_string()), } - Err(_) => Err(n.to_string()), } - } - Err(_) => { - trace!( - "next_field is {:?} and field_list is now {:?}", - &next_field, - &field_list - ); - match match_props(props, &next_field) { - Ok(v) => { - if !field_list.is_empty() { - get_resource_prop_value(&v, &field_list) - } else { - Ok(v.clone()) + Err(_) => { + trace!( + "next_field is {:?} and field_list is now {:?}", + &next_field, + &field_list + ); + match match_props(props, &next_field) { + Ok(v) => { + if !field_list.is_empty() { + get_resource_prop_value(&v, &field_list) + } else { + Ok(v.clone()) + } } + Err(_) => Err(next_field.to_string()), } - Err(_) => Err(next_field.to_string()), } } } diff --git a/cfn-guard/tests/conditional-ddb-template.ruleset b/cfn-guard/tests/conditional-ddb-template.ruleset new file mode 100644 index 000000000..977519e8f --- /dev/null +++ b/cfn-guard/tests/conditional-ddb-template.ruleset @@ -0,0 +1,3 @@ +# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-product-attribute-reference.html +AWS::DynamoDB::Table if Tags == /.*PROD.*/ then .DeletionPolicy == Retain +AWS::DynamoDB::Table if Tags == /.*PROD.*/ then .DeletionPolicy != Retain diff --git a/cfn-guard/tests/conditional-ddb-template.yaml b/cfn-guard/tests/conditional-ddb-template.yaml new file mode 100644 index 000000000..402497092 --- /dev/null +++ b/cfn-guard/tests/conditional-ddb-template.yaml @@ -0,0 +1,64 @@ +{ + "Resources": { + "DDBTable": { + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Abort", + "DeletionPolicy": "Retain", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "ArtistId", + "AttributeType": "S" + }, + { + "AttributeName": "Concert", + "AttributeType": "S" + }, + { + "AttributeName": "TicketSales", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "ArtistId", + + "KeyType": "HASH" + }, + { + "AttributeName": "Concert", + "KeyType": "RANGE" + } + ], + "GlobalSecondaryIndexes": [ + { + + "IndexName": "GSI", + "KeySchema": [ + + { + + "AttributeName": "TicketSales", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "KEYS_ONLY" + }, + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "Tags": [ + {"Key": "ENV", "Value": "PROD"} + ] + } + } + } +} diff --git a/cfn-guard/tests/functional.rs b/cfn-guard/tests/functional.rs index c24b6f977..88929d7ff 100644 --- a/cfn-guard/tests/functional.rs +++ b/cfn-guard/tests/functional.rs @@ -55,12 +55,12 @@ mod tests { ); let mut rules_file_contents = String::from("AWS::EC2::Volume Encrypted == true"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); rules_file_contents = String::from("AWS::EC2::Volume Encrypted == True"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); @@ -89,13 +89,13 @@ mod tests { ); rules_file_contents = String::from("AWS::EC2::Volume Encrypted == false"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); rules_file_contents = String::from("AWS::EC2::Volume Encrypted == False"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); @@ -124,13 +124,13 @@ mod tests { ); rules_file_contents = String::from("AWS::EC2::Volume Encrypted == false"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); rules_file_contents = String::from("AWS::EC2::Volume Encrypted == False"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); @@ -157,13 +157,13 @@ mod tests { ); rules_file_contents = String::from("AWS::EC2::Volume Encrypted == false"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![], 0) ); rules_file_contents = String::from("AWS::EC2::Volume Encrypted == False"); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![], 0) ); } @@ -174,7 +174,7 @@ mod tests { .unwrap_or_else(|err| format!("{}", err)); let rules_file_content = String::from(r#"AWS::EC2::Volume Encrypted != /true/"#); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_content, true), + cfn_guard::run_check(&template_contents, &rules_file_content, true).unwrap(), ( vec![ String::from( @@ -196,7 +196,7 @@ mod tests { let rules_file_content = String::from(r#"AWS::EC2::Volume Encrypted != /true/ << lorem ipsum"#); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_content, true), + cfn_guard::run_check(&template_contents, &rules_file_content, true).unwrap(), ( vec![ String::from( @@ -218,7 +218,7 @@ mod tests { let rules_file_content = String::from(r#"AWS::EC2::Volume Encrypted != true << lorem ipsum"#); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_content, true), + cfn_guard::run_check(&template_contents, &rules_file_content, true).unwrap(), ( vec![ String::from( @@ -240,9 +240,13 @@ mod tests { let rules_file_contents = fs::read_to_string("tests/ebs_volume_rule_set_custom_msg.passing") .unwrap_or_else(|err| format!("{}", err)); + let error = match cfn_guard::run_check(&template_contents, &rules_file_contents, true) { + Ok(_) => "".to_string(), + Err(e) => e, + }; assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), - (vec![String::from("ERROR: Template file format was unreadable as json or yaml: invalid type: string \"THIS IS MEANT TO BE INVALID\", expected a map at line 1 column 1")], 1) + error, + String::from("Template file format was unreadable as json or yaml: invalid type: string \"THIS IS MEANT TO BE INVALID\", expected a map at line 1 column 1") ); } @@ -254,7 +258,7 @@ mod tests { fs::read_to_string("tests/ebs_volume_rule_set_custom_msg.passing") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); } @@ -287,7 +291,7 @@ mod tests { ]; outcome.sort(); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (outcome, 2) ); } @@ -321,7 +325,7 @@ mod tests { let rules_file_contents = fs::read_to_string("tests/test_not_in_list_fail.ruleset") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![ String::from("[NewVolume2] failed because [us-west-2c] is not in [us-east-1a,us-east-1b,us-east-1c] for [AvailabilityZone]"), String::from("[NewVolume] failed because [us-west-2b] is not in [us-east-1a,us-east-1b,us-east-1c] for [AvailabilityZone]"), @@ -359,7 +363,7 @@ mod tests { fs::read_to_string("tests/test_in_list_fail_custom_message.ruleset") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![ String::from("[NewVolume2] failed because [AvailabilityZone] is [us-west-2c] and lorem ipsum"), String::from("[NewVolume] failed because [AvailabilityZone] is [us-west-2b] and lorem ipsum"), @@ -421,7 +425,7 @@ mod tests { env::set_var("MOTP", "ec2.amazonaws.com"); // Env vars need to be unique to each test because they're global when `cargo test` runs let empty_vec: Vec = Vec::new(); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (empty_vec, 0) ); } @@ -463,7 +467,7 @@ mod tests { ); env::set_var("MOTF", "motf"); // Env vars need to be unique to each test because they're global when `cargo test` runs assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Statement.0.Principal.Service.0] is [ec2.amazonaws.com] and the permitted value is [motf]"), String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Version] is [2012-10-17] and the permitted pattern is [(\\d{5})-(\\d{2})-(\\d{2})]"), ], 2) @@ -507,7 +511,7 @@ mod tests { AWS::IAM::Role AssumeRolePolicyDocument.Statement.*.Principal.Service.* != ec2.amazonaws.com"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![ String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Statement.0.Principal.Service.0] is [ec2.amazonaws.com] and that value is not permitted"), String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Statement.0.Principal.Service.1] is [lambda.amazonaws.com] and that value is not permitted"), @@ -556,7 +560,7 @@ mod tests { ); let empty_vec: Vec = Vec::new(); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (empty_vec, 0) ); } @@ -598,7 +602,7 @@ mod tests { ); let empty_vec: Vec = Vec::new(); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (empty_vec, 0) ); } @@ -639,7 +643,7 @@ mod tests { r#"AWS::IAM::Role AssumeRolePolicyDocument.Statement.*.Principal.Service.* == wcf"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Statement.0.Principal.Service.0] is [ec2.amazonaws.com] and the permitted value is [wcf]"), String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Statement.0.Principal.Service.1] is [lambda.amazonaws.com] and the permitted value is [wcf]"), String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Statement.1.Principal.Service.0] is [lambda.amazonaws.com] and the permitted value is [wcf]"), @@ -674,7 +678,7 @@ mod tests { env::set_var("SERV_PRIN_EVP", "lambda.amazonaws.com"); // Env vars need to be unique to each test because they're global when `cargo test` runs let empty_vec: Vec = Vec::new(); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (empty_vec, 0) ); } @@ -701,7 +705,7 @@ mod tests { ); env::set_var("SERV_PRIN_EVF", "evf.amazonaws.com"); // Env vars need to be unique to each test because they're global when `cargo test` runs assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Statement.0.Principal.Service.0] is [lambda.amazonaws.com] and the permitted value is [evf.amazonaws.com]")], 2) ); } @@ -728,7 +732,7 @@ mod tests { ); let empty_vec: Vec = Vec::new(); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (empty_vec, 0) ); } @@ -754,7 +758,7 @@ mod tests { r#"AWS::IAM::Role AssumeRolePolicyDocument.Version == /(\d{5})-(\d{2})-(\d{2})/"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[LambdaRoleHelper] failed because [AssumeRolePolicyDocument.Version] is [2012-10-17] and the permitted pattern is [(\\d{5})-(\\d{2})-(\\d{2})]")], 2) ); } @@ -780,7 +784,7 @@ mod tests { AWS::EC2::Volume Encrypted != %require_encryption"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume] failed because it does not contain the required property of [Encrypted]")], 2) ); } @@ -807,7 +811,7 @@ AWS::EC2::Volume Encrypted != %require_encryption"#, AWS::EC2::Volume Encrypted != %require_encryption"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume] failed because there is no value defined for [%require_encryption] to check [Encrypted] against")], 2) ); } @@ -843,7 +847,7 @@ AWS::EC2::Volume Encrypted != %require_encryption"#, AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume] failed because [Size] is [100] and the permitted value is [101]"), String::from("[NewVolume] failed because [Size] is [100] and the permitted value is [99]"),], 2) @@ -881,7 +885,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, AWS::EC2::Volume Size < 101"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume2] failed because [Size] is [101] and the permitted value is [< 101]"), ], 2) ); @@ -918,7 +922,7 @@ AWS::EC2::Volume Size < 101"#, AWS::EC2::Volume Size > 100"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), ( vec![String::from( "[NewVolume] failed because [Size] is [100] and the permitted value is [> 100]" @@ -959,7 +963,7 @@ AWS::EC2::Volume Size > 100"#, AWS::EC2::Volume Size <= 100"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume2] failed because [Size] is [101] and the permitted value is [<= 100]"), ], 2) ); @@ -996,38 +1000,12 @@ AWS::EC2::Volume Size <= 100"#, AWS::EC2::Volume Size >= 101"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume] failed because [Size] is [100] and the permitted value is [>= 101]"), ], 2) ); } - // TODO: Create test for clean_exit() scenarios - // #[test] - // #[should_panic] - // fn test_non_numeric_value_comparison_fail() { - // let template_file_contents = String::from( - // r#"{ - // "Resources": { - // "NewVolume" : { - // "Type" : "AWS::EC2::Volume", - // "Properties" : { - // "Size" : 100, - // "Encrypted" : true, - // "AvailabilityZone" : "us-east-1b", - // "DeletionPolicy" : "Snapshot" - // } - // } - // } - // }"#, - // ); - // let rules_file_contents = String::from( - // r#" - // AWS::EC2::Volume Size < a"#, - // ); - // cfn_guard::run_check(&template_file_contents, &rules_file_contents, true); - // } - #[test] fn test_json_results() { let template_file_contents = String::from( @@ -1058,7 +1036,7 @@ AWS::EC2::Volume Encrypted != %require_encryption AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume] failed because [Encrypted] is [true] and that value is not permitted"), String::from("[NewVolume] failed because [Size] is [100] and the permitted value is [101]"), String::from("[NewVolume] failed because [Size] is [100] and the permitted value is [99]"), @@ -1092,7 +1070,7 @@ AWS::EC2::Volume Encrypted != %require_encryption AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, ); assert_eq!( - cfn_guard::run_check(&template_file_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_file_contents, &rules_file_contents, true).unwrap(), (vec![ String::from("[NewVolume] failed because [Encrypted] is [true] and that value is not permitted"), String::from("[NewVolume] failed because [Size] is [100] and the permitted value is [101]"), @@ -1111,7 +1089,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, AWS::EC2::SecurityGroup Tags.* == {"Key":"OwnerContact","Value":"OwnerContact"}"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); } @@ -1125,7 +1103,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, AWS::EC2::SecurityGroup Tags.* == {"Key":"OwnerContact","Value":"OwnerContact"}"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), ( vec![ String::from( @@ -1159,7 +1137,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, let rules_file_contents = fs::read_to_string("tests/wildcard_iam_rule_set.passing") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); } @@ -1171,7 +1149,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, let rules_file_contents = fs::read_to_string("tests/wildcard_not_in_iam_rule_set.failing") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[LambdaRoleHelper] failed because [lambda.amazonaws.com] is in [lambda.amazonaws.com, ec2.amazonaws.com] which is not permitted for [AssumeRolePolicyDocument.Statement.0.Principal.Service.0]"), ], 2) ); @@ -1184,7 +1162,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, let rules_file_contents = fs::read_to_string("tests/wildcard_action.pass") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); } @@ -1196,7 +1174,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, let rules_file_contents = fs::read_to_string("tests/wildcard_action.fail") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[EndpointCloudWatchRoleC3C64E0F] failed because [AssumeRolePolicyDocument.Statement.0.Action] is [sts:AssumeRole] and that value is not permitted"), String::from("[HelloHandlerServiceRole11EF7C63] failed because [AssumeRolePolicyDocument.Statement.0.Action] is [sts:AssumeRole] and that value is not permitted")], 2) @@ -1218,7 +1196,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, ) .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![String::from("[NewVolume2] failed because it does not contain the required property of [Tags]"), String::from("[NewVolume2] failed because it does not contain the required property of [Tags]"), String::from("[NewVolume] failed because [Tags.0.Key] is [uaid] and the permitted value is [uai]"), @@ -1242,7 +1220,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, ) .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![String::from("[NewVolume] failed because [Tags.0.Key] is [uaid] and the permitted value is [uai]"), String::from("[NewVolume] failed because [Tags.1.Key] is [tag2] and the permitted value is [uai]")], 2) @@ -1256,7 +1234,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, let rules_file_contents = fs::read_to_string("tests/getatt_template.ruleset") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![String::from("[EC2Instance] failed because [t3.medium] is not in [t2.nano,t2.micro,t2.small,t3.nano,t3.micro,t3.small] for [InstanceType]"), String::from("[InstanceSecurityGroup] failed because [SecurityGroupIngress] is [[{\"CidrIp\":\"SSHLocation\",\"FromPort\":22,\"IpProtocol\":\"tcp\",\"ToPort\":22}]] and the permitted value is [[{\"CidrIp\":\"SSHLocation\",\"FromPort\":33322,\"IpProtocol\":\"tcp\",\"ToPort\":33322}]]"), String::from("[NewVolume] failed because [Size] is [512] and the permitted value is [128]"), @@ -1273,7 +1251,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, let rules_file_contents = fs::read_to_string("tests/getatt_template.ruleset") .unwrap_or_else(|err| format!("{}", err)); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![String::from("[NewVolume2] failed because [Encrypted] is [true] and that value is not permitted"), String::from("[NewVolume2] failed because [Size] is [99] and the permitted value is [128]"), String::from("[NewVolume2] failed because [Size] is [99] and the permitted value is [256]"), @@ -1291,14 +1269,13 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, .unwrap_or_else(|err| format!("{}", err)); let rules_file_contents = fs::read_to_string("tests/no_resources_template.ruleset") .unwrap_or_else(|err| format!("{}", err)); + let error = match cfn_guard::run_check(&template_contents, &rules_file_contents, true) { + Ok(_) => "".to_string(), + Err(e) => e, + }; assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), - ( - vec![String::from( - "ERROR: Template file does not contain a [Resources] section to check" - )], - 1 - ) + error, + String::from("Template file does not contain a [Resources] section to check") ) } @@ -1314,7 +1291,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, "#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), ( vec![String::from( r#"[ClusterSg] failed because [{"Key":"OwnerContact","Value":"OwnerContact"}] is in ["test", 1, ["a", "b"], {"Key":"OwnerContact","Value":"OwnerContact"},{"Key":"OwnerContact","Value":{"Ref":"OwnerContact"}}] which is not permitted for [Tags.1]"# @@ -1329,7 +1306,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, "#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![], 0) ); template_contents = fs::read_to_string("tests/parse_lists_with_json_test-template.json") @@ -1341,7 +1318,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, "#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), ( vec![String::from( r#"[ClusterSg] failed because [{"Key":"OwnerContact","Value":{"Ref":"OwnerContact"}}] is in ["test", 1, ["a", "b"], {"Key":"OwnerContact","Value":"OwnerContact"},{"Key":"OwnerContact","Value":{"Ref":"OwnerContact"}}] which is not permitted for [Tags.1]"# @@ -1356,7 +1333,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, "#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![], 0) ); } @@ -1372,7 +1349,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, AWS::Lambda::Function Environment.*.*.* IN %log_stuff"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); @@ -1383,7 +1360,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, AWS::Lambda::Function Environment.*.*.* NOT_IN %log_stuff"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), ( vec![ String::from( @@ -1403,7 +1380,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, // Pass over-wildcarding (ie, wildcards in structures that don't extend that deeply rules_file_contents = String::from(r#"AWS::Lambda::Function MemorySize.*.* IN [128]"#); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); @@ -1412,7 +1389,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, r#"AWS::Lambda::Function Environment.*.* IN [["Solution", "Data", "LogLevel"]]"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); @@ -1421,7 +1398,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, r#"AWS::Lambda::Function Environment.*.* NOT_IN [["Solution", "Data", "LogLevel"]]"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), ( vec![String::from( r#"[LambdaWAFHelperFunction] failed because [["Solution","Data","LogLevel"]] is in [["Solution", "Data", "LogLevel"]] which is not permitted for [Environment.Variables.LOG_LEVEL]"# @@ -1435,7 +1412,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, r#"AWS::Lambda::Function Environment.*.LOG_LEVEL IN [["Solution", "Data", "LogLevel"]]"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), (vec![], 0) ); @@ -1444,7 +1421,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, r#"AWS::Lambda::Function Environment.*.LOG_LEVEL NOT_IN [["Solution", "Data", "LogLevel"]]"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, true), + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), ( vec![String::from( r#"[LambdaWAFHelperFunction] failed because [["Solution","Data","LogLevel"]] is in [["Solution", "Data", "LogLevel"]] which is not permitted for [Environment.Variables.LOG_LEVEL]"# @@ -1458,7 +1435,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, r#"AWS::Lambda::Function *.*.LOG_LEVEL IN [["Solution", "Data", "LogLevel"]]"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), (vec![], 0) ); @@ -1467,7 +1444,7 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, r#"AWS::Lambda::Function *.*.LOG_LEVEL NOT_IN [["Solution", "Data", "LogLevel"]]"#, ); assert_eq!( - cfn_guard::run_check(&template_contents, &rules_file_contents, false), + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), ( vec![String::from( r#"[LambdaWAFHelperFunction] failed because [["Solution","Data","LogLevel"]] is in [["Solution", "Data", "LogLevel"]] which is not permitted for [Environment.Variables.LOG_LEVEL]"# @@ -1476,4 +1453,199 @@ AWS::EC2::Volume Size == 101 |OR| AWS::EC2::Volume Size == 99"#, ) ); } + + #[test] + fn test_bad_rule() { + let template_contents = fs::read_to_string("tests/conditional-ddb-template.yaml") + .unwrap_or_else(|err| format!("{}", err)); + + let rules_file_contents = String::from( + "AWS::DynamoDB::Table if Tags == /.*PROD.*/ then .DeletionPolicy == Retain", + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false), + Err(String::from( + r#"BAD RULE: "AWS::DynamoDB::Table if Tags == /.*PROD.*/ then .DeletionPolicy == Retain""# + )) + ); + } + + #[test] + fn test_conditional_check() { + let template_contents = fs::read_to_string("tests/conditional-ddb-template.yaml") + .unwrap_or_else(|err| format!("{}", err)); + + let mut rules_file_contents = String::from( + "AWS::DynamoDB::Table when Tags == /.*PROD.*/ check .DeletionPolicy == Retain", + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + (vec![], 0) + ); + + rules_file_contents = String::from( + "AWS::DynamoDB::Table when Tags != /.*DEV.*/ check .DeletionPolicy == Retain", + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + (vec![], 0) + ); + + rules_file_contents = String::from( + "AWS::DynamoDB::Table WHEN Tags == /.*PROD.*/ check .DeletionPolicy != Retain", + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + (vec![String::from("[DDBTable] failed because [.DeletionPolicy] is [Retain] and that value is not permitted when AWS::DynamoDB::Table Tags == /.*PROD.*/")], + 2) + ); + rules_file_contents = String::from( + "AWS::DynamoDB::Table when Tags != /.*DEV.*/ CHECK .DeletionPolicy != Retain", + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + (vec![String::from("[DDBTable] failed because [.DeletionPolicy] is [Retain] and that value is not permitted when AWS::DynamoDB::Table Tags != /.*DEV.*/")], 2) + ) + } + + #[test] + fn test_compound_conditional_check() { + let template_contents = fs::read_to_string("tests/conditional-ddb-template.yaml") + .unwrap_or_else(|err| format!("{}", err)); + + let mut rules_file_contents = String::from( + "AWS::DynamoDB::Table when Tags == /.*PROD.*/ check .DeletionPolicy == Retain |OR|AWS::DynamoDB::Table WHEN Tags.* == /.*DEV.*/ CHECK .UpdateReplacePolicy == Retain", + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + (vec![], 0) + ); + + rules_file_contents = String::from( + "AWS::DynamoDB::Table when Tags != /.*DEV.*/ CHECK .DeletionPolicy == Retain |OR| AWS::DynamoDB::Table Tags.* != PROD", + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + (vec![], 0) + ); + + rules_file_contents = String::from( + r#"AWS::DynamoDB::Table WHEN Tags == /.*PROD.*/ check .DeletionPolicy != Retain"#, + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + ( + vec![String::from( + r#"[DDBTable] failed because [.DeletionPolicy] is [Retain] and that value is not permitted when AWS::DynamoDB::Table Tags == /.*PROD.*/"# + )], + 2 + ) + ); + } + + #[test] + fn test_conditional_checks_with_custom_messages() { + let template_contents = fs::read_to_string("tests/conditional-ddb-template.yaml") + .unwrap_or_else(|err| format!("{}", err)); + + let rules_file_contents = String::from( + r#"AWS::DynamoDB::Table WHEN Tags == /.*PROD.*/ << custom conditional message check .DeletionPolicy != Retain << custom consequent message"#, + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + ( + vec![String::from( + r#"[DDBTable] failed because [.DeletionPolicy] is [Retain] and custom consequent message when AWS::DynamoDB::Table Tags == /.*PROD.*/ << custom conditional message"# + )], + 2 + ) + ); + } + + #[test] + fn test_conditional_corner_cases() { + // This test ensures that missing properties _aren't_ checked and an alternative approach to expressing conditional forms across different types + let template_contents = + fs::read_to_string("tests/test-multiple-resources-conditional-template.yaml") + .unwrap_or_else(|err| format!("{}", err)); + + let rules_file_contents = String::from( + r#"AWS::Lambda::Function WHEN madeupproperty == somevalue << test of cond message CHECK ProvisioningArtifactName == apig_2.0 << test of cons message + AWS::Lambda::Function Runtime != /.*/ |OR| AWS::ServiceCatalog::CloudFormationProvisionedProduct ProvisioningParameters.*.Value == lambdaFunction.Arn + AWS::Lambda::Function Runtime != Java |OR| AWS::ServiceCatalog::CloudFormationProvisionedProduct ProvisioningParameters.*.Value == lambdaFunction.Arn + AWS::ServiceCatalog::CloudFormationProvisionedProduct ProvisioningParameters.*.Value == lambdaFunction.Arn"#, + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false).unwrap(), + (vec![], 0) + ) + } + + #[test] + fn test_conditional_bad_consequent() { + // This test ensures that missing properties _aren't_ checked and an alternative approach to expressing conditional forms across different types + let template_contents = + fs::read_to_string("tests/test-multiple-resources-conditional-template.yaml") + .unwrap_or_else(|err| format!("{}", err)); + + let rules_file_contents = String::from( + r#"AWS::Lambda::Function WHEN madeupproperty == somevalue << test of cond message CHECK AWS::EC2::Volume ProvisioningArtifactName == apig_2.0 << test of cons message"#, + ); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false), + Err(String::from( + r#"Invalid consequent: 'AWS::EC2::Volume ProvisioningArtifactName == apig_2.0 << test of cons message' in 'AWS::Lambda::Function WHEN madeupproperty == somevalue << test of cond message CHECK AWS::EC2::Volume ProvisioningArtifactName == apig_2.0 << test of cons message'. Consequents cannot contain resource types."# + )) + ) + } + + #[test] + fn test_bad_assignment() { + let template_contents = + fs::read_to_string("tests/test-multiple-resources-conditional-template.yaml") + .unwrap_or_else(|err| format!("{}", err)); + + let rules_file_contents = String::from(r#"let x == y"#); + + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, false), + Err(String::from( + r#"Bad Assignment Operator: [==] in 'let x == y'"# + )) + ) + } + #[test] + fn test_root_wildcard() { + let template_contents = fs::read_to_string("tests/root-wildcard-template.json") + .unwrap_or_else(|err| format!("{}", err)); + + let mut rules_file_contents = String::from(r#"AWS::EC2::Volume * != true"#); + + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), + ( + vec![ + String::from( + r#"[NewVolume2] failed because [Encrypted] is [true] and that value is not permitted"# + ), + String::from( + r#"[NewVolume2] failed because it does not contain the required property of [AutoEnableIO]"# + ), + String::from( + r#"[NewVolume] failed because [AutoEnableIO] is [true] and that value is not permitted"# + ), + String::from( + r#"[NewVolume] failed because [Encrypted] is [true] and that value is not permitted"# + ) + ], + 2 + ) + ); + + rules_file_contents = String::from(r#"AWS::EC2::Volume * == true"#); + assert_eq!( + cfn_guard::run_check(&template_contents, &rules_file_contents, true).unwrap(), + (vec![], 0) + ) + } } diff --git a/cfn-guard/tests/root-wildcard-template.json b/cfn-guard/tests/root-wildcard-template.json new file mode 100644 index 000000000..ac9fa7376 --- /dev/null +++ b/cfn-guard/tests/root-wildcard-template.json @@ -0,0 +1,21 @@ +{ + "Resources": { + "NewVolume" : { + "Type" : "AWS::EC2::Volume", + "Properties" : { + "AutoEnableIO": true, + "Size" : 101, + "Encrypted": true, + "AvailabilityZone" : "us-west-2b" + } + }, + "NewVolume2" : { + "Type" : "AWS::EC2::Volume", + "Properties" : { + "Size" : 99, + "Encrypted": true, + "AvailabilityZone" : "us-west-2c" + } + } + } +} diff --git a/cfn-guard/tests/test-multiple-resources-conditional-template.yaml b/cfn-guard/tests/test-multiple-resources-conditional-template.yaml new file mode 100644 index 000000000..5b6ce8ea3 --- /dev/null +++ b/cfn-guard/tests/test-multiple-resources-conditional-template.yaml @@ -0,0 +1,32 @@ +Resources: + lambdaFunction: + Type: "AWS::Lambda::Function" + Properties: + Code: + ZipFile: | + def handler(event,context): + return { + 'body': 'Hello there {0}'.format(event['requestContext']['identity']['sourceIp']), + 'headers': { + 'Content-Type': 'text/plain' + }, + 'statusCode': 200 + } + Description: "Governance Lambda Demo" + FunctionName: !Ref "lambdaFunctionName" + Handler: "index.handler" + MemorySize: 128 + Role: !GetAtt "lambdaIAMRole.Arn" + Runtime: "python2.7" + Timeout: 10 + + APIGatewayPP: + Type: "AWS::ServiceCatalog::CloudFormationProvisionedProduct" + Properties: + AcceptLanguage: en + ProvisionedProductName: "PP" + ProductName: "PG" + ProvisioningArtifactName: "apig_1.0" + ProvisioningParameters: + - Key: "lambdaFunctionArn" + Value: !GetAtt "lambdaFunction.Arn" \ No newline at end of file