Skip to main content

cfn_guard/commands/
rulegen.rs

1use std::fs;
2use std::process;
3
4use crate::commands::Executable;
5use crate::commands::SUCCESS_STATUS_CODE;
6use crate::rules::Result;
7use crate::utils::reader::Reader;
8use crate::utils::writer::Writer;
9use clap::Args;
10use itertools::Itertools;
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13use std::io::Write;
14use string_builder::Builder;
15
16const ABOUT: &str = "Autogenerate rules from an existing JSON- or YAML- formatted data. (Currently works with only CloudFormation templates)";
17const TEMPLATE_HELP: &str = "Provide path to a CloudFormation template file in JSON or YAML";
18const OUTPUT_HELP: &str = "Write to output file";
19
20#[derive(Debug, Clone, Eq, PartialEq, Args)]
21#[clap(arg_required_else_help = true)]
22#[clap(about=ABOUT)]
23/// .
24/// The Rulegen command auto generates rules from an existing CloudFormation template
25/// Please note this currently only works on CloudFormation templates
26pub struct Rulegen {
27    /// the path to the file which the generated rules will be outputted to
28    /// default None
29    /// if set to None rules will be outputted to the stdout
30    #[arg(short, long, help=OUTPUT_HELP)]
31    pub(crate) output: Option<String>,
32    /// the path to the CloudFormation template
33    #[arg(short, long, help=TEMPLATE_HELP)]
34    pub(crate) template: String,
35}
36
37impl Executable for Rulegen {
38    /// .
39    /// autogenerate rules from an existing CloudFormation template
40    ///
41    /// This function will return an error if
42    /// - any of the specified paths do not exist
43    /// - illegal json or yaml syntax present in any of the data/input parameter files
44    fn execute(&self, writer: &mut Writer, _: &mut Reader) -> Result<i32> {
45        let template_contents = fs::read_to_string(&self.template)?;
46
47        let result = parse_template_and_call_gen(&template_contents, writer);
48        print_rules(result, writer)?;
49
50        Ok(SUCCESS_STATUS_CODE)
51    }
52}
53
54pub fn parse_template_and_call_gen(
55    template_contents: &str,
56    writer: &mut Writer,
57) -> HashMap<String, HashMap<String, HashSet<String>>> {
58    let cfn_template: HashMap<String, Value> = match serde_yaml::from_str(template_contents) {
59        Ok(s) => s,
60        Err(e) => {
61            writer
62                .write_err(format!("Parsing error handling template file, Error = {e}"))
63                .expect("failed to write to stderr");
64            process::exit(1);
65        }
66    };
67
68    let cfn_resources_clone = match cfn_template.get("Resources") {
69        Some(y) => y.clone(),
70        None => {
71            writer
72                .write_err(String::from("Template lacks a Resources section"))
73                .expect("failed to write to stderr");
74            process::exit(1);
75        }
76    };
77
78    let cfn_resources: HashMap<String, Value> = match serde_json::from_value(cfn_resources_clone) {
79        Ok(y) => y,
80        Err(e) => {
81            writer
82                .write_err(format!(
83                    "Template Resources section has an invalid structure: {e}"
84                ))
85                .expect("failed to write to stderr");
86            process::exit(1);
87        }
88    };
89
90    gen_rules(cfn_resources)
91}
92
93#[allow(clippy::map_entry)]
94fn gen_rules(
95    cfn_resources: HashMap<String, Value>,
96) -> HashMap<String, HashMap<String, HashSet<String>>> {
97    // Create hashmap of resource name, property name and property values
98    // For example, the following template:
99    //
100    //        {
101    //            "Resources": {
102    //                "NewVolume" : {
103    //                    "Type" : "AWS::EC2::Volume",
104    //                    "Properties" : {
105    //                        "Size" : 500,
106    //                        "Encrypted": false,
107    //                        "AvailabilityZone" : "us-west-2b"
108    //                    }
109    //                },
110    //                "NewVolume2" : {
111    //                    "Type" : "AWS::EC2::Volume",
112    //                    "Properties" : {
113    //                        "Size" : 50,
114    //                        "Encrypted": false,
115    //                        "AvailabilityZone" : "us-west-2c"
116    //                    }
117    //                }
118    //            }
119    //        }
120    //
121    //
122    // The data structure would contain:
123    // <AWS::EC2::Volume> <Encrypted> <false>
124    //                    <Size> <500, 50>
125    //                    <AvailabilityZone> <us-west-2c, us-west-2b>
126    //
127    //
128    //
129    let mut rule_map: HashMap<String, HashMap<String, HashSet<String>>> = HashMap::new();
130    for (_name, cfn_resource) in cfn_resources {
131        let props: HashMap<String, Value> =
132            match serde_json::from_value(cfn_resource["Properties"].clone()) {
133                Ok(s) => s,
134                Err(_) => continue,
135            };
136
137        for (prop_name, prop_val) in props {
138            let stripped_val = match prop_val.as_str() {
139                Some(v) => String::from(v),
140                None => prop_val.to_string(),
141            };
142
143            let mut no_newline_stripped_val = stripped_val.trim().replace('\n', "");
144
145            // Preserve double quotes for strings.
146            if prop_val.is_string() {
147                let test_str = format!("{}{}{}", "\"", no_newline_stripped_val, "\"");
148                no_newline_stripped_val = test_str;
149            }
150            let resource_name = (&cfn_resource["Type"].as_str().unwrap()).to_string();
151
152            if !rule_map.contains_key(&resource_name) {
153                let value_set: HashSet<String> =
154                    vec![no_newline_stripped_val].into_iter().collect();
155
156                let mut property_map = HashMap::new();
157                property_map.insert(prop_name, value_set);
158                rule_map.insert(resource_name, property_map);
159            } else {
160                let property_map = rule_map.get_mut(&resource_name).unwrap();
161
162                if !property_map.contains_key(&prop_name) {
163                    let value_set: HashSet<String> =
164                        vec![no_newline_stripped_val].into_iter().collect();
165                    property_map.insert(prop_name, value_set);
166                } else {
167                    let value_set = property_map.get_mut(&prop_name).unwrap();
168                    value_set.insert(no_newline_stripped_val);
169                }
170            };
171        }
172    }
173
174    rule_map
175}
176
177// Prints the generated rules data structure to stdout. If there are properties mapping to
178// multiple values in the template, the rules are put in one statement using the IN keyword so that
179// the generated rules are interpreted as ALL by default.
180// Using the same example in the comment above, the rules printed for the template will be:
181//     let aws_ec2_volume_resources = Resources.*[ Type == 'AWS::EC2::Volume' ]
182//     rule aws_ec2_volume when %aws_ec2_volume_resources !empty {
183//          %aws_ec2_volume_resources.Properties.Size IN [500, 50]
184//          %aws_ec2_volume_resources.Properties.AvailabilityZone IN ["us-west-2b", "us-west-2c"]
185//          %aws_ec2_volume_resources.Properties.Encrypted == false
186//     }
187fn print_rules(
188    rule_map: HashMap<String, HashMap<String, HashSet<String>>>,
189    writer: &mut Writer,
190) -> Result<()> {
191    let mut str = Builder::default();
192
193    for (resource, properties) in &rule_map {
194        let resource_name_underscore = resource.replace("::", "_").to_lowercase();
195        let variable_name = format!("{}_resources", resource_name_underscore);
196
197        str.append(format!(
198            "let {} = Resources.*[ Type == '{}' ]\n",
199            variable_name, resource
200        ));
201        str.append(format!(
202            "rule {} when %{} !empty {{\n",
203            resource_name_underscore, variable_name
204        ));
205
206        for (property, values) in properties {
207            if values.len() > 1 {
208                str.append(format!(
209                    "  %{}.Properties.{} IN [{}]\n",
210                    variable_name,
211                    property,
212                    values.iter().join(", ")
213                ));
214            } else {
215                str.append(format!(
216                    "  %{}.Properties.{} == {}\n",
217                    variable_name,
218                    property,
219                    values.iter().next().unwrap()
220                ));
221            }
222        }
223
224        str.append("}\n");
225    }
226
227    // validate rules generated
228    let generated_rules = str.string().unwrap();
229
230    let span = crate::rules::parser::Span::new_extra(&generated_rules, "");
231    match crate::rules::parser::rules_file(span) {
232        Ok(_rules) => {
233            //
234            // TODO fix with Error return
235            //
236            write!(writer, "{}", generated_rules)?;
237        }
238        Err(e) => {
239            writer.write_err(format!(
240                "Parsing error with generated rules file, Error = {e}"
241            ))?;
242        }
243    }
244    Ok(())
245}
246
247#[cfg(test)]
248#[path = "rulegen_tests.rs"]
249mod rulegen_tests;