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

3A python module to interface with the Veracode Static Analysis APIs 

4""" 

5 

6# __future__ built-ins 

7# See PEP 563 @ https://www.python.org/dev/peps/pep-0563/ 

8from __future__ import annotations 

9 

10# built-ins 

11from functools import wraps 

12import types 

13import os 

14from typing import cast, Any, Callable, Dict, Union, TYPE_CHECKING 

15from pathlib import Path 

16import logging 

17import re 

18import inspect 

19 

20from xml.etree import ( # nosec (This is only used for static typing) # nosem: python.lang.security.use-defused-xml.use-defused-xml 

21 ElementTree as InsecureElementTree, 

22) 

23from urllib.parse import urlparse 

24 

25# third party 

26import requests 

27from requests.exceptions import HTTPError, Timeout, RequestException, TooManyRedirects 

28 

29# The packages below are third party, but not PEP 561 compatible, therefore we 

30# must exclude the import statement from mypy analysis as described in 

31# https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports. For 

32# more detail, see 

33# https://mypy.readthedocs.io/en/latest/installed_packages.html 

34from veracode_api_signing.plugin_requests import RequestsAuthPluginVeracodeHMAC # type: ignore 

35from defusedxml import ElementTree # type: ignore 

36 

37# custom 

38from veracode import constants, __project_name__ 

39 

40if TYPE_CHECKING: 

41 from veracode.api import ResultsAPI, UploadAPI, SandboxAPI 

42 

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

44 

45 

46def validate(func: Callable) -> Callable: 

47 """ 

48 Decorator to validate config information 

49 """ 

50 # pylint: disable=undefined-variable 

51 @wraps(func) 

52 def wrapper(**kwargs: Any) -> Callable: 

53 for key, value in kwargs.items(): 

54 if type(value).__name__ in constants.SUPPORTED_API_CLASSES: 

55 validate_api(api=value) 

56 LOG.debug("%s is valid", key) 

57 

58 if is_valid_attribute(key=key, value=value): 

59 LOG.debug("%s is valid", key) 

60 else: 

61 LOG.error("%s is invalid", key) 

62 raise ValueError 

63 

64 return func(**kwargs) 

65 

66 return wrapper 

67 

68 

69# See https://github.com/tiran/defusedxml/issues/48 for why this doesn't have a 

70# return type 

71def parse_xml(*, content: bytes): 

72 """ 

73 Parse the XML returned by the Veracode XML APIs 

74 """ 

75 try: 

76 element = ElementTree.fromstring(content) 

77 LOG.debug("parse_xml successful") 

78 except InsecureElementTree.ParseError as parse_err: 

79 LOG.error("Failed to parse the XML response, untrustworthy endpoint") 

80 raise parse_err 

81 return element 

82 

83 

84def element_contains_error(*, parsed_xml: InsecureElementTree.Element) -> bool: 

85 """ 

86 Check for an error 

87 """ 

88 if parsed_xml.tag.casefold() == "error": 

89 return True 

90 

91 return False 

92 

93 

94@validate 

95def http_request( # pylint: disable=too-many-statements 

96 *, 

97 verb: str, 

98 url: str, 

99 data: str = None, 

100 params: Dict = None, 

101 headers: Dict = None, 

102) -> InsecureElementTree.Element: 

103 """ 

104 Make API requests 

105 """ 

106 try: 

107 LOG.debug("Querying the %s endpoint with a %s", url, verb) 

108 if verb == "get": 

109 response = requests.get( 

110 url, 

111 params=params, 

112 headers=headers, 

113 auth=RequestsAuthPluginVeracodeHMAC(), 

114 ) 

115 if verb == "post": 

116 response = requests.post( 

117 url, 

118 data=data, 

119 params=params, 

120 headers=headers, 

121 auth=RequestsAuthPluginVeracodeHMAC(), 

122 ) 

123 

124 LOG.debug("Received a status code of %s", response.status_code) 

125 LOG.debug("Received content of %s", response.content) 

126 if response.status_code != 200: 

127 LOG.error("Encountered an issue with the response status code") 

128 response.raise_for_status() 

129 except HTTPError as http_err: 

130 function_name = cast(types.FrameType, inspect.currentframe()).f_code.co_name 

131 LOG.error("%s encountered a HTTP error: %s", function_name, http_err) 

132 raise http_err 

133 except ConnectionError as conn_err: 

134 function_name = cast(types.FrameType, inspect.currentframe()).f_code.co_name 

135 LOG.error("%s encountered a connection error: %s", function_name, conn_err) 

136 raise conn_err 

137 except Timeout as time_err: 

138 function_name = cast(types.FrameType, inspect.currentframe()).f_code.co_name 

139 LOG.error("%s encountered a timeout error: %s", function_name, time_err) 

140 raise time_err 

141 except TooManyRedirects as redir_err: 

142 function_name = cast(types.FrameType, inspect.currentframe()).f_code.co_name 

143 LOG.error("%s encountered too many redirects: %s", function_name, redir_err) 

144 raise redir_err 

145 except RequestException as req_err: 

146 function_name = cast(types.FrameType, inspect.currentframe()).f_code.co_name 

147 LOG.error( 

148 "%s encountered a request exception error: %s", function_name, req_err 

149 ) 

150 raise req_err 

151 

152 # Parse the XML response 

153 parsed_xml = parse_xml(content=response.content) 

154 

155 return parsed_xml 

156 

157 

158def is_valid_attribute( # pylint: disable=too-many-branches, too-many-statements 

159 *, key: str, value: Any 

160) -> bool: 

161 """ 

162 Validate the provided attribute 

163 """ 

164 # Do not log the values to avoid sensitive information disclosure 

165 LOG.debug("Provided key to is_valid_attribute: %s", key) 

166 

167 is_valid = True 

168 

169 # Key-specific validation 

170 if key == "base_url": 

171 try: 

172 parsed_url = urlparse(value) 

173 if protocol_is_insecure(protocol=parsed_url.scheme): 

174 is_valid = False 

175 LOG.error("An insecure protocol was provided in the base_url") 

176 if parsed_url.netloc == "": 

177 is_valid = False 

178 LOG.error("An empty network location was provided in the base_url") 

179 if not is_valid_netloc(netloc=parsed_url.netloc): 

180 is_valid = False 

181 LOG.error("An invalid network location was provided in the base_url") 

182 # A useful side effect of the below check is that it will cause a 

183 # ValueError if the port is specified but invalid 

184 if parsed_url.port is None: 

185 LOG.debug("A port was not specified in the base_url") 

186 if parsed_url.path == "/" or parsed_url.path == "": 

187 is_valid = False 

188 LOG.error("An empty path was provided in the base_url") 

189 if not parsed_url.path.endswith("/"): 

190 is_valid = False 

191 LOG.error("An invalid basepath was provided. Must end with /") 

192 except ValueError as val_err: 

193 is_valid = False 

194 LOG.error("An invalid base_url was provided") 

195 raise val_err 

196 elif key == "version": 

197 if not isinstance(value, dict): 

198 is_valid = False 

199 LOG.error("The version must be a dict") 

200 else: 

201 for inner_key, inner_value in value.items(): 

202 if not isinstance(inner_key, str): 

203 is_valid = False 

204 LOG.error("The keys in the version dict must be strings") 

205 if not isinstance(inner_value, str): 

206 is_valid = False 

207 LOG.error("The values in the version dict must be strings") 

208 elif key == "endpoint": 

209 if not isinstance(value, str): 

210 is_valid = False 

211 LOG.error("endpoint must be a string") 

212 # Limiting to unreserved characters as defined in 

213 # https://tools.ietf.org/html/rfc3986#section-2.3 

214 elif not re.fullmatch("[a-zA-Z0-9-._~]+", value): 

215 is_valid = False 

216 LOG.error("An invalid endpoint was provided") 

217 elif key == "app_id": 

218 if not isinstance(value, str): 

219 is_valid = False 

220 LOG.error("app_id must be a string") 

221 

222 try: 

223 int(value) 

224 except (ValueError, TypeError): 

225 is_valid = False 

226 LOG.error("app_id must be a string containing a whole number") 

227 elif key == "app_name": 

228 if not isinstance(value, str): 

229 is_valid = False 

230 LOG.error("app_name must be a string") 

231 # Roughly the Veracode app name allowed characters, excluding \ 

232 elif not re.fullmatch(r"[a-zA-Z0-9`~!@#$%^&*()_+=\-\[\]|}{;:,./? ]+", value): 

233 is_valid = False 

234 LOG.error("An invalid app_name was provided") 

235 elif key == "build_dir": 

236 if not isinstance(value, Path): 

237 is_valid = False 

238 LOG.error("An invalid build_dir was provided") 

239 elif key == "build_id": 

240 if not isinstance(value, str): 

241 is_valid = False 

242 LOG.error("build_id must be a string") 

243 # Limiting to unreserved characters as defined in 

244 # https://tools.ietf.org/html/rfc3986#section-2.3 

245 elif not re.fullmatch("[a-zA-Z0-9-._~]+", value): 

246 is_valid = False 

247 LOG.error("An invalid build_id was provided") 

248 elif key == "sandbox_id": 

249 if not isinstance(value, str) and value is not None: 

250 is_valid = False 

251 LOG.error("sandbox_id must be a string or None") 

252 

253 if isinstance(value, str): 

254 try: 

255 int(value) 

256 except (ValueError, TypeError): 

257 is_valid = False 

258 LOG.error( 

259 "sandbox_id must be None or a string containing a whole number" 

260 ) 

261 elif key == "scan_all_nonfatal_top_level_modules": 

262 if not isinstance(value, bool): 

263 is_valid = False 

264 LOG.error("scan_all_nonfatal_top_level_modules must be a boolean") 

265 elif key == "auto_scan": 

266 if not isinstance(value, bool): 

267 is_valid = False 

268 LOG.error("auto_scan must be a boolean") 

269 elif key == "sandbox_name": 

270 if not isinstance(value, str): 

271 is_valid = False 

272 LOG.error("sandbox_name must be a string") 

273 # Roughly the Veracode Sandbox allowed characters, excluding \ 

274 elif not re.fullmatch(r"[a-zA-Z0-9`~!@#$%^&*()_+=\-\[\]|}{;:,./? ]+", value): 

275 is_valid = False 

276 LOG.error("An invalid sandbox_name was provided") 

277 elif key == "api_key_id": 

278 if not isinstance(value, str) or len(str(value)) != 32: 

279 is_valid = False 

280 LOG.error("api_key_id must be a 32 character string") 

281 

282 try: 

283 int(value, 16) 

284 except (ValueError, TypeError): 

285 is_valid = False 

286 LOG.error("api_key_id must be hex") 

287 elif key == "api_key_secret": 

288 if len(str(value)) != 128: 

289 is_valid = False 

290 LOG.error("api_key_secret must be 128 characters") 

291 

292 try: 

293 int(value, 16) 

294 except (ValueError, TypeError): 

295 is_valid = False 

296 LOG.error("api_key_secret must be hex") 

297 elif key == "ignore_compliance_status": 

298 if not isinstance(value, bool): 

299 is_valid = False 

300 LOG.error("ignore_compliance_status must be a boolean") 

301 elif key == "loglevel": 

302 if value not in constants.ALLOWED_LOG_LEVELS: 

303 is_valid = False 

304 LOG.error("Invalid log level: %s", value) 

305 elif key == "workflow": 

306 if not isinstance(value, list): 

307 is_valid = False 

308 LOG.error("workflow must be a list") 

309 if not constants.SUPPORTED_WORKFLOWS.issuperset(set(value)): 

310 is_valid = False 

311 LOG.error("Invalid workflow: %s", value) 

312 elif key == "verb": 

313 if value not in constants.SUPPORTED_VERBS: 

314 is_valid = False 

315 LOG.error("Invalid or unsupported verb provided") 

316 else: 

317 # Do not log the values to avoid sensitive information disclosure 

318 LOG.debug("Unknown argument provided with key: %s", key) 

319 

320 return is_valid 

321 

322 

323@validate 

324def configure_environment(*, api_key_id: str, api_key_secret: str) -> None: 

325 """ 

326 Configure the environment variables 

327 """ 

328 ## Set environment variables for veracode-api-signing 

329 if ( 

330 "VERACODE_API_KEY_ID" in os.environ 

331 and os.environ.get("VERACODE_API_KEY_ID") != api_key_id 

332 ): 

333 LOG.warning( 

334 "VERACODE_API_KEY_ID environment variable is being overwritten based on the effective configuration" 

335 ) 

336 elif "VERACODE_API_KEY_ID" not in os.environ: 

337 LOG.debug("VERACODE_API_KEY_ID environment variable not detected") 

338 

339 os.environ["VERACODE_API_KEY_ID"] = api_key_id 

340 

341 if ( 

342 "VERACODE_API_KEY_SECRET" in os.environ 

343 and os.environ.get("VERACODE_API_KEY_SECRET") != api_key_secret 

344 ): 

345 LOG.warning( 

346 "VERACODE_API_KEY_SECRET environment variable is being overwritten based on the effective configuration" 

347 ) 

348 elif "VERACODE_API_KEY_SECRET" not in os.environ: 

349 LOG.debug("VERACODE_API_KEY_SECRET environment variable not detected") 

350 

351 os.environ["VERACODE_API_KEY_SECRET"] = api_key_secret 

352 

353 

354def validate_api(*, api: Union[ResultsAPI, UploadAPI, SandboxAPI]) -> None: 

355 """ 

356 Validate that an api object contains the required information 

357 """ 

358 api_type = type(api) 

359 

360 property_set = set( 

361 p for p in dir(api_type) if isinstance(getattr(api_type, p), property) 

362 ) 

363 

364 # Validate that the properties pass sanitization 

365 for prop in property_set: 

366 # Use the getters to ensure that all of the properties of the instance 

367 # are valid 

368 getattr(api, prop) 

369 

370 LOG.debug("The provided %s passed validation", api_type) 

371 

372 

373def protocol_is_insecure(*, protocol: str) -> bool: 

374 """ 

375 Identify the use of insecure protocols 

376 """ 

377 return bool(protocol.casefold() != "https") 

378 

379 

380def is_null(*, value: str) -> bool: 

381 """ 

382 Identify if the passed value is null 

383 """ 

384 if value is None: 

385 return True 

386 return False 

387 

388 

389def is_valid_netloc(*, netloc: str) -> bool: 

390 """ 

391 Identify if a given netloc is valid 

392 """ 

393 if not isinstance(netloc, str): 

394 return False 

395 

396 pattern = re.compile( 

397 r"(?:[a-z0-9](?:[a-z0-9-_]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]((:[0-9]{1,4}|:[1-5][0-9]{4}|:6[0-4][0-9]{3}|:65[0-4][0-9]{2}|:655[0-2][0-9]|:6553[0-5])?)" 

398 ) 

399 

400 if pattern.fullmatch(netloc): 

401 return True 

402 

403 return False 

404 

405 

406@validate 

407def get_app_id(*, app_name: str) -> Union[str, None]: 

408 """ 

409 Query for and return the app_id associated with the app_name 

410 

411 https://help.veracode.com/reader/orRWez4I0tnZNaA_i0zn9g/Z4Ecf1fw7868vYPVgkglww 

412 """ 

413 try: 

414 endpoint = "getapplist.do" 

415 version = constants.UPLOAD_API_VERSIONS[endpoint] 

416 base_url = constants.API_BASE_URL 

417 params = {"include_user_info": False} 

418 

419 applications = http_request( 

420 verb="get", url=base_url + version + "/" + endpoint, params=params 

421 ) 

422 if element_contains_error(parsed_xml=applications): 

423 LOG.error("Veracode returned an error when attempting to call %s", endpoint) 

424 raise RuntimeError 

425 

426 for application in applications: 

427 if application.get("app_name") == app_name: 

428 return application.attrib["app_id"] 

429 

430 # No app_id exists with the provided app_name 

431 LOG.info("An application named %s does not exist", app_name) 

432 return None 

433 except ( 

434 HTTPError, 

435 ConnectionError, 

436 Timeout, 

437 TooManyRedirects, 

438 RequestException, 

439 RuntimeError, 

440 ) as e: 

441 raise RuntimeError from e