Hide keyboard shortcuts

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""" 

5 

6# built-ins 

7import copy 

8from argparse import ArgumentParser 

9import logging 

10from pathlib import Path 

11from typing import Any, Dict, List, Union 

12import os 

13 

14# third party 

15import yaml 

16 

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__ 

22 

23LOG = logging.getLogger(__project_name__ + "." + __name__) 

24 

25 

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 

39 

40 

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 

54 

55 

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) 

63 

64 # If filtering did not result in any changes, return the config 

65 if config == filtered_config: 

66 return config 

67 

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 

77 

78 

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] = {} 

92 

93 return default_config 

94 

95 

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 

108 

109 return normalized_file_config 

110 

111 

112def parse_file_config(*, config_file: Path) -> Dict: 

113 """ 

114 Parse the sast-veracode config file 

115 """ 

116 # Filter 

117 suffix_whitelist = {".yml", ".yaml"} 

118 

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 {} 

122 

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 

142 

143 return config 

144 

145 

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 

154 

155 

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"] = {} 

163 

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] = {} 

168 

169 return config 

170 

171 

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) 

180 

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 

185 

186 # Distribute the keys into the appropriate slots 

187 for api in constants.SUPPORTED_APIS: 

188 config["apis"][api][common_attribute] = config[common_attribute] 

189 

190 # Clean up 

191 del config[common_attribute] 

192 

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 

202 

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() 

211 

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) 

216 

217 if "apis" not in config.keys(): 

218 return config 

219 

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 

226 

227 return config 

228 

229 

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()) 

236 

237 ## Load parsed arguments into args_config 

238 args_config = add_apis_to_config(config={}) 

239 

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] 

252 

253 normalized_args_config = normalize_config(config=args_config) 

254 

255 return normalized_args_config 

256 

257 

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 ) 

267 

268 parser.add_argument("--version", action="version", version=__version__) 

269 

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 ) 

285 

286 return parser 

287 

288 

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 

296 

297 if not is_valid_attribute(key=key, value=value): 

298 LOG.error("Unable to validate the non-api configs") 

299 return False 

300 

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 

307 

308 return True 

309 

310 

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 

328 

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 

333 

334 return True 

335 

336 

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() 

345 

346 ## Create the final config 

347 # Start with the minimal default 

348 config = copy.deepcopy(default_config) 

349 

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]) 

354 

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] 

360 

361 # Apply the config from environment variables 

362 config.update(env_config) 

363 

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]) 

368 

369 for option in constants.ALL_OPTIONS_SET: 

370 if option in args_config.keys(): 

371 config[option] = args_config[option] 

372 

373 # Perform validation of the non-api configs 

374 if not is_valid_non_api_config(config=config): 

375 raise ValueError 

376 

377 # Perform validation of the api configs 

378 if not is_valid_api_config(config=config): 

379 raise ValueError 

380 

381 return config 

382 

383 

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) 

391 

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 

404 

405 return api