Coverage for veracode/config.py : 100%
Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python3
2"""
3Config parser for easy_sast
4"""
6# built-ins
7import copy
8from argparse import ArgumentParser
9import logging
10from pathlib import Path
11from typing import Any, Dict, List, Union
12import os
14# third party
15import yaml
17# custom
18from veracode.api import ResultsAPI, UploadAPI, SandboxAPI
19from veracode.utils import is_valid_attribute
20from veracode import constants
21from veracode import __version__, __project_name__
23LOG = logging.getLogger(__project_name__ + "." + __name__)
26def remove_nones(*, obj: Any) -> Any:
27 """
28 Remove Nones from a provided object
29 """
30 if isinstance(obj, (list, tuple, set)):
31 return type(obj)(remove_nones(obj=item) for item in obj if item is not None)
32 if isinstance(obj, dict):
33 return type(obj)(
34 (remove_nones(obj=key), remove_nones(obj=value))
35 for key, value in obj.items()
36 if key is not None and value is not None
37 )
38 return obj
41def remove_empty_dicts(*, obj: Any) -> Any:
42 """
43 Remove empty dicts from a provided object
44 """
45 if isinstance(obj, (list, tuple, set)):
46 return type(obj)(remove_empty_dicts(obj=item) for item in obj if item != {})
47 if isinstance(obj, dict):
48 return type(obj)(
49 (remove_empty_dicts(obj=key), remove_empty_dicts(obj=value))
50 for key, value in obj.items()
51 if value != {}
52 )
53 return obj
56def filter_config(*, config: Dict) -> Dict:
57 """
58 Perform config filtering
59 """
60 # Perform initial filtering
61 filtered_config = remove_nones(obj=config)
62 filtered_config = remove_empty_dicts(obj=filtered_config)
64 # If filtering did not result in any changes, return the config
65 if config == filtered_config:
66 return config
68 # Perform recursive filtering
69 previous_iteration_filtered_config = filtered_config
70 while True:
71 if (
72 filtered_config := remove_empty_dicts(obj=remove_nones(obj=filtered_config))
73 ) != previous_iteration_filtered_config:
74 previous_iteration_filtered_config = filtered_config
75 else:
76 return filtered_config
79def get_default_config() -> Dict:
80 """
81 Return a dict of the default values
82 """
83 default_config: Dict[str, Union[int, Dict[str, Dict], str, List[str]]] = {}
84 # Set the workflow default
85 default_config["workflow"] = constants.DEFAULT_WORKFLOW
86 # Set the loglevel default
87 default_config["loglevel"] = "WARNING"
88 # Set placeholders for the various APIs
89 default_config["apis"] = {}
90 for api in constants.SUPPORTED_APIS:
91 default_config["apis"][api] = {}
93 return default_config
96def get_file_config(*, config_file: Path) -> Dict:
97 """
98 Return a dict of the values provided in the config file
99 """
100 if isinstance(config_file, Path):
101 # Build the file_config
102 file_config = parse_file_config(config_file=config_file)
103 normalized_file_config = normalize_config(config=file_config)
104 else:
105 # Invalid argument
106 LOG.error("config_file must be a Path object")
107 raise ValueError
109 return normalized_file_config
112def parse_file_config(*, config_file: Path) -> Dict:
113 """
114 Parse the sast-veracode config file
115 """
116 # Filter
117 suffix_whitelist = {".yml", ".yaml"}
119 if config_file.suffix not in suffix_whitelist:
120 LOG.error("Suffix for the config file %s is not allowed", config_file)
121 return {}
123 try:
124 with open(config_file) as yaml_data:
125 config = yaml.safe_load(yaml_data)
126 except FileNotFoundError:
127 LOG.warning("The config file %s was not found", config_file)
128 config = {}
129 except PermissionError as pe_err:
130 LOG.error(
131 "Permission denied when attempting to read the config file %s", config_file
132 )
133 raise pe_err
134 except IsADirectoryError as isdir_err:
135 LOG.error("The specified config file is a directory: %s", config_file)
136 raise isdir_err
137 except OSError as os_err:
138 LOG.error(
139 "Unknown OS error when attempting to read the config file %s", config_file
140 )
141 raise os_err
143 return config
146def get_env_config() -> Dict:
147 """
148 Return a dict of the environment variables
149 """
150 env_var_config = {}
151 env_var_config["api_key_id"] = os.environ.get("VERACODE_API_KEY_ID", None)
152 env_var_config["api_key_secret"] = os.environ.get("VERACODE_API_KEY_SECRET", None)
153 return env_var_config
156def add_apis_to_config(*, config: Dict) -> Dict:
157 """
158 Add the supported apis to a config
159 """
160 # Add the top level "apis" key
161 if "apis" not in config.keys():
162 config["apis"] = {}
164 # Add a key for each of the supported APIs under the top level "apis" key
165 for api in constants.SUPPORTED_APIS:
166 if api not in config["apis"].keys():
167 config["apis"][api] = {}
169 return config
172# pylint: disable=too-many-branches
173def normalize_config(*, config: Dict) -> Dict:
174 """
175 Normalize a provided config dict into the preferred format for the
176 supported APIs
177 """
178 ## Establish normalized structure
179 config = add_apis_to_config(config=config)
181 ## Move configs into the normalized structure
182 for common_attribute in constants.COMMON_API_ATTRIBUTES:
183 if common_attribute not in config.keys():
184 continue
186 # Distribute the keys into the appropriate slots
187 for api in constants.SUPPORTED_APIS:
188 config["apis"][api][common_attribute] = config[common_attribute]
190 # Clean up
191 del config[common_attribute]
193 ## Normalize config value formats
194 # Search for a loglevel value provided as a string and modify it to be an
195 # accurate level
196 if "loglevel" in config.keys() and isinstance(config["loglevel"], str):
197 if hasattr(logging, config["loglevel"].upper()):
198 config["loglevel"] = config["loglevel"].upper()
199 else:
200 LOG.error("Unable to normalize the provided loglevel")
201 raise AttributeError
203 # Search for a build_dir value provided as a string in the upload API
204 # config and modify it to be a Path object
205 if "build_dir" in config["apis"]["upload"].keys() and isinstance(
206 config["apis"]["upload"]["build_dir"], str
207 ):
208 config["apis"]["upload"]["build_dir"] = Path(
209 config["apis"]["upload"]["build_dir"]
210 ).absolute()
212 ## Sanitize the config
213 # Perform a final filter of the config (may remove the top level "apis"
214 # key, among other keys, if they were never populated)
215 config = filter_config(config=config)
217 if "apis" not in config.keys():
218 return config
220 ## Validate the api config values
221 for api in set(config["apis"].keys()).intersection(constants.SUPPORTED_APIS):
222 for key, value in config["apis"][api].items():
223 if not is_valid_attribute(key=key, value=value):
224 LOG.error("Unable to validate the normalized config")
225 raise ValueError
227 return config
230def get_args_config() -> Dict:
231 """
232 Get the configs passed as arguments
233 """
234 parser = create_arg_parser()
235 parsed_args = vars(parser.parse_args())
237 ## Load parsed arguments into args_config
238 args_config = add_apis_to_config(config={})
240 # Apply the configs from parsed_args
241 for key in parsed_args.keys():
242 # Distribute the parsed_args configurations appropriately
243 if key in constants.ONLY_UPLOAD_ATTRIBUTES:
244 args_config["apis"]["upload"][key] = parsed_args[key]
245 elif key in constants.ONLY_RESULTS_ATTRIBUTES:
246 args_config["apis"]["results"][key] = parsed_args[key]
247 elif key in constants.ONLY_SANDBOX_ATTRIBUTES:
248 args_config["apis"]["sandbox"][key] = parsed_args[key]
249 else:
250 # Put in top level
251 args_config[key] = parsed_args[key]
253 normalized_args_config = normalize_config(config=args_config)
255 return normalized_args_config
258def create_arg_parser() -> ArgumentParser:
259 """Parse the arguments"""
260 parser = ArgumentParser()
261 parser.add_argument(
262 "--config-file",
263 type=lambda p: Path(p).absolute(),
264 default=Path("easy_sast.yml").absolute(),
265 help="specify a config file",
266 )
268 parser.add_argument("--version", action="version", version=__version__)
270 group = parser.add_mutually_exclusive_group()
271 group.add_argument(
272 "--debug",
273 action="store_const",
274 dest="loglevel",
275 const="DEBUG",
276 help="enable debug level logging",
277 )
278 group.add_argument(
279 "--verbose",
280 action="store_const",
281 dest="loglevel",
282 const="INFO",
283 help="enable info level logging",
284 )
286 return parser
289def is_valid_non_api_config(*, config: dict) -> bool:
290 """
291 Validate the non-api portions of a config
292 """
293 for key, value in config.items():
294 if key == "apis":
295 continue
297 if not is_valid_attribute(key=key, value=value):
298 LOG.error("Unable to validate the non-api configs")
299 return False
301 for attribute in constants.REQUIRED_CONFIG_ATTRIBUTES_TOP:
302 if attribute not in config:
303 LOG.error(
304 "The final config does not contain the minimum required information"
305 )
306 return False
308 return True
311def is_valid_api_config(*, config: dict) -> bool:
312 """
313 Validate the api portions of a config
314 """
315 for step in config["workflow"]:
316 # Don't validate configs for apis that won't be used
317 for api in constants.WORKFLOW_TO_API_MAP[step].intersection(
318 constants.SUPPORTED_APIS
319 ):
320 for attribute in constants.REQUIRED_CONFIG_ATTRIBUTES_API:
321 if attribute not in config["apis"][api]:
322 LOG.error(
323 "The %s API config is missing the required %s config",
324 api,
325 attribute,
326 )
327 return False
329 for key, value in config["apis"][api].items():
330 if not is_valid_attribute(key=key, value=value):
331 LOG.error("Unable to validate the %s api config")
332 return False
334 return True
337def get_config() -> Dict:
338 """
339 Get the config dict
340 """
341 default_config = get_default_config()
342 args_config = get_args_config()
343 file_config = get_file_config(config_file=args_config["config_file"])
344 env_config = get_env_config()
346 ## Create the final config
347 # Start with the minimal default
348 config = copy.deepcopy(default_config)
350 # Apply the config from the config file
351 for api in constants.SUPPORTED_APIS:
352 if "apis" in file_config and api in file_config["apis"].keys():
353 config["apis"][api].update(file_config["apis"][api])
355 # A subset of the options are used here to deter storing sensitive
356 # information in a config file
357 for option in constants.LIMITED_OPTIONS_SET:
358 if option in file_config.keys():
359 config[option] = file_config[option]
361 # Apply the config from environment variables
362 config.update(env_config)
364 # Apply the config from the arguments
365 for api in constants.SUPPORTED_APIS:
366 if "apis" in args_config and api in args_config["apis"].keys():
367 config["apis"][api].update(args_config["apis"][api])
369 for option in constants.ALL_OPTIONS_SET:
370 if option in args_config.keys():
371 config[option] = args_config[option]
373 # Perform validation of the non-api configs
374 if not is_valid_non_api_config(config=config):
375 raise ValueError
377 # Perform validation of the api configs
378 if not is_valid_api_config(config=config):
379 raise ValueError
381 return config
384def apply_config(
385 *, api: Union[ResultsAPI, UploadAPI, SandboxAPI], config: dict
386) -> Union[ResultsAPI, UploadAPI, SandboxAPI]:
387 """
388 Apply a provided config dict to a provided object
389 """
390 config = add_apis_to_config(config=config)
392 if isinstance(api, ResultsAPI):
393 for key, value in config["apis"]["results"].items():
394 setattr(api, key, value)
395 elif isinstance(api, UploadAPI):
396 for key, value in config["apis"]["upload"].items():
397 setattr(api, key, value)
398 elif isinstance(api, SandboxAPI):
399 for key, value in config["apis"]["sandbox"].items():
400 setattr(api, key, value)
401 else:
402 LOG.error("api argument is not a supported type (%s)", type(api))
403 raise TypeError
405 return api