Coverage for veracode/utils.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"""
3A python module to interface with the Veracode Static Analysis APIs
4"""
6# __future__ built-ins
7# See PEP 563 @ https://www.python.org/dev/peps/pep-0563/
8from __future__ import annotations
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
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
25# third party
26import requests
27from requests.exceptions import HTTPError, Timeout, RequestException, TooManyRedirects
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
37# custom
38from veracode import constants, __project_name__
40if TYPE_CHECKING:
41 from veracode.api import ResultsAPI, UploadAPI, SandboxAPI
43LOG = logging.getLogger(__project_name__ + "." + __name__)
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)
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
64 return func(**kwargs)
66 return wrapper
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
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
91 return False
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 )
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
152 # Parse the XML response
153 parsed_xml = parse_xml(content=response.content)
155 return parsed_xml
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)
167 is_valid = True
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")
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")
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")
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")
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)
320 return is_valid
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")
339 os.environ["VERACODE_API_KEY_ID"] = api_key_id
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")
351 os.environ["VERACODE_API_KEY_SECRET"] = api_key_secret
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)
360 property_set = set(
361 p for p in dir(api_type) if isinstance(getattr(api_type, p), property)
362 )
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)
370 LOG.debug("The provided %s passed validation", api_type)
373def protocol_is_insecure(*, protocol: str) -> bool:
374 """
375 Identify the use of insecure protocols
376 """
377 return bool(protocol.casefold() != "https")
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
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
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 )
400 if pattern.fullmatch(netloc):
401 return True
403 return False
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
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}
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
426 for application in applications:
427 if application.get("app_name") == app_name:
428 return application.attrib["app_id"]
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