|
18 | 18 | import calendar
|
19 | 19 | import datetime
|
20 | 20 | from email.message import Message
|
| 21 | +import hashlib |
| 22 | +import json |
| 23 | +import logging |
21 | 24 | import sys
|
| 25 | +from typing import Any, Dict, Mapping, Optional, Union |
22 | 26 | import urllib
|
23 | 27 |
|
24 | 28 | from google.auth import exceptions
|
25 | 29 |
|
| 30 | + |
| 31 | +# _BASE_LOGGER_NAME is the base logger for all google-based loggers. |
| 32 | +_BASE_LOGGER_NAME = "google" |
| 33 | + |
| 34 | +# _LOGGING_INITIALIZED ensures that base logger is only configured once |
| 35 | +# (unless already configured by the end-user). |
| 36 | +_LOGGING_INITIALIZED = False |
| 37 | + |
| 38 | + |
26 | 39 | # The smallest MDS cache used by this library stores tokens until 4 minutes from
|
27 | 40 | # expiry.
|
28 | 41 | REFRESH_THRESHOLD = datetime.timedelta(minutes=3, seconds=45)
|
29 | 42 |
|
| 43 | +# TODO(https://github.com/googleapis/google-auth-library-python/issues/1684): Audit and update the list below. |
| 44 | +_SENSITIVE_FIELDS = { |
| 45 | + "accessToken", |
| 46 | + "access_token", |
| 47 | + "id_token", |
| 48 | + "client_id", |
| 49 | + "refresh_token", |
| 50 | + "client_secret", |
| 51 | +} |
| 52 | + |
30 | 53 |
|
31 | 54 | def copy_docstring(source_class):
|
32 | 55 | """Decorator that copies a method's docstring from another class.
|
@@ -271,3 +294,220 @@ def is_python_3():
|
271 | 294 | bool: True if the Python interpreter is Python 3 and False otherwise.
|
272 | 295 | """
|
273 | 296 | return sys.version_info > (3, 0)
|
| 297 | + |
| 298 | + |
| 299 | +def _hash_sensitive_info(data: Union[dict, list]) -> Union[dict, list, str]: |
| 300 | + """ |
| 301 | + Hashes sensitive information within a dictionary. |
| 302 | +
|
| 303 | + Args: |
| 304 | + data: The dictionary containing data to be processed. |
| 305 | +
|
| 306 | + Returns: |
| 307 | + A new dictionary with sensitive values replaced by their SHA512 hashes. |
| 308 | + If the input is a list, returns a list with each element recursively processed. |
| 309 | + If the input is neither a dict nor a list, returns the type of the input as a string. |
| 310 | +
|
| 311 | + """ |
| 312 | + if isinstance(data, dict): |
| 313 | + hashed_data: Dict[Any, Union[Optional[str], dict, list]] = {} |
| 314 | + for key, value in data.items(): |
| 315 | + if key in _SENSITIVE_FIELDS and not isinstance(value, (dict, list)): |
| 316 | + hashed_data[key] = _hash_value(value, key) |
| 317 | + elif isinstance(value, (dict, list)): |
| 318 | + hashed_data[key] = _hash_sensitive_info(value) |
| 319 | + else: |
| 320 | + hashed_data[key] = value |
| 321 | + return hashed_data |
| 322 | + elif isinstance(data, list): |
| 323 | + hashed_list = [] |
| 324 | + for val in data: |
| 325 | + hashed_list.append(_hash_sensitive_info(val)) |
| 326 | + return hashed_list |
| 327 | + else: |
| 328 | + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1701): |
| 329 | + # Investigate and hash sensitive info before logging when the data type is |
| 330 | + # not a dict or a list. |
| 331 | + return str(type(data)) |
| 332 | + |
| 333 | + |
| 334 | +def _hash_value(value, field_name: str) -> Optional[str]: |
| 335 | + """Hashes a value and returns a formatted hash string.""" |
| 336 | + if value is None: |
| 337 | + return None |
| 338 | + encoded_value = str(value).encode("utf-8") |
| 339 | + hash_object = hashlib.sha512() |
| 340 | + hash_object.update(encoded_value) |
| 341 | + hex_digest = hash_object.hexdigest() |
| 342 | + return f"hashed_{field_name}-{hex_digest}" |
| 343 | + |
| 344 | + |
| 345 | +def _logger_configured(logger: logging.Logger) -> bool: |
| 346 | + """Determines whether `logger` has non-default configuration |
| 347 | +
|
| 348 | + Args: |
| 349 | + logger: The logger to check. |
| 350 | +
|
| 351 | + Returns: |
| 352 | + bool: Whether the logger has any non-default configuration. |
| 353 | + """ |
| 354 | + return ( |
| 355 | + logger.handlers != [] or logger.level != logging.NOTSET or not logger.propagate |
| 356 | + ) |
| 357 | + |
| 358 | + |
| 359 | +def is_logging_enabled(logger: logging.Logger) -> bool: |
| 360 | + """ |
| 361 | + Checks if debug logging is enabled for the given logger. |
| 362 | +
|
| 363 | + Args: |
| 364 | + logger: The logging.Logger instance to check. |
| 365 | +
|
| 366 | + Returns: |
| 367 | + True if debug logging is enabled, False otherwise. |
| 368 | + """ |
| 369 | + # NOTE: Log propagation to the root logger is disabled unless |
| 370 | + # the base logger i.e. logging.getLogger("google") is |
| 371 | + # explicitly configured by the end user. Ideally this |
| 372 | + # needs to happen in the client layer (already does for GAPICs). |
| 373 | + # However, this is implemented here to avoid logging |
| 374 | + # (if a root logger is configured) when a version of google-auth |
| 375 | + # which supports logging is used with: |
| 376 | + # - an older version of a GAPIC which does not support logging. |
| 377 | + # - Apiary client which does not support logging. |
| 378 | + global _LOGGING_INITIALIZED |
| 379 | + if not _LOGGING_INITIALIZED: |
| 380 | + base_logger = logging.getLogger(_BASE_LOGGER_NAME) |
| 381 | + if not _logger_configured(base_logger): |
| 382 | + base_logger.propagate = False |
| 383 | + _LOGGING_INITIALIZED = True |
| 384 | + |
| 385 | + return logger.isEnabledFor(logging.DEBUG) |
| 386 | + |
| 387 | + |
| 388 | +def request_log( |
| 389 | + logger: logging.Logger, |
| 390 | + method: str, |
| 391 | + url: str, |
| 392 | + body: Optional[bytes], |
| 393 | + headers: Optional[Mapping[str, str]], |
| 394 | +) -> None: |
| 395 | + """ |
| 396 | + Logs an HTTP request at the DEBUG level if logging is enabled. |
| 397 | +
|
| 398 | + Args: |
| 399 | + logger: The logging.Logger instance to use. |
| 400 | + method: The HTTP method (e.g., "GET", "POST"). |
| 401 | + url: The URL of the request. |
| 402 | + body: The request body (can be None). |
| 403 | + headers: The request headers (can be None). |
| 404 | + """ |
| 405 | + if is_logging_enabled(logger): |
| 406 | + content_type = ( |
| 407 | + headers["Content-Type"] if headers and "Content-Type" in headers else "" |
| 408 | + ) |
| 409 | + json_body = _parse_request_body(body, content_type=content_type) |
| 410 | + logged_body = _hash_sensitive_info(json_body) |
| 411 | + logger.debug( |
| 412 | + "Making request...", |
| 413 | + extra={ |
| 414 | + "httpRequest": { |
| 415 | + "method": method, |
| 416 | + "url": url, |
| 417 | + "body": logged_body, |
| 418 | + "headers": headers, |
| 419 | + } |
| 420 | + }, |
| 421 | + ) |
| 422 | + |
| 423 | + |
| 424 | +def _parse_request_body(body: Optional[bytes], content_type: str = "") -> Any: |
| 425 | + """ |
| 426 | + Parses a request body, handling bytes and string types, and different content types. |
| 427 | +
|
| 428 | + Args: |
| 429 | + body (Optional[bytes]): The request body. |
| 430 | + content_type (str): The content type of the request body, e.g., "application/json", |
| 431 | + "application/x-www-form-urlencoded", or "text/plain". If empty, attempts |
| 432 | + to parse as JSON. |
| 433 | +
|
| 434 | + Returns: |
| 435 | + Parsed body (dict, str, or None). |
| 436 | + - JSON: Decodes if content_type is "application/json" or None (fallback). |
| 437 | + - URL-encoded: Parses if content_type is "application/x-www-form-urlencoded". |
| 438 | + - Plain text: Returns string if content_type is "text/plain". |
| 439 | + - None: Returns if body is None, UTF-8 decode fails, or content_type is unknown. |
| 440 | + """ |
| 441 | + if body is None: |
| 442 | + return None |
| 443 | + try: |
| 444 | + body_str = body.decode("utf-8") |
| 445 | + except (UnicodeDecodeError, AttributeError): |
| 446 | + return None |
| 447 | + content_type = content_type.lower() |
| 448 | + if not content_type or "application/json" in content_type: |
| 449 | + try: |
| 450 | + return json.loads(body_str) |
| 451 | + except (json.JSONDecodeError, TypeError): |
| 452 | + return body_str |
| 453 | + if "application/x-www-form-urlencoded" in content_type: |
| 454 | + parsed_query = urllib.parse.parse_qs(body_str) |
| 455 | + result = {k: v[0] for k, v in parsed_query.items()} |
| 456 | + return result |
| 457 | + if "text/plain" in content_type: |
| 458 | + return body_str |
| 459 | + return None |
| 460 | + |
| 461 | + |
| 462 | +def _parse_response(response: Any) -> Any: |
| 463 | + """ |
| 464 | + Parses a response, attempting to decode JSON. |
| 465 | +
|
| 466 | + Args: |
| 467 | + response: The response object to parse. This can be any type, but |
| 468 | + it is expected to have a `json()` method if it contains JSON. |
| 469 | +
|
| 470 | + Returns: |
| 471 | + The parsed response. If the response contains valid JSON, the |
| 472 | + decoded JSON object (e.g., a dictionary or list) is returned. |
| 473 | + If the response does not have a `json()` method or if the JSON |
| 474 | + decoding fails, None is returned. |
| 475 | + """ |
| 476 | + try: |
| 477 | + json_response = response.json() |
| 478 | + return json_response |
| 479 | + except Exception: |
| 480 | + # TODO(https://github.com/googleapis/google-auth-library-python/issues/1744): |
| 481 | + # Parse and return response payload as json based on different content types. |
| 482 | + return None |
| 483 | + |
| 484 | + |
| 485 | +def _response_log_base(logger: logging.Logger, parsed_response: Any) -> None: |
| 486 | + """ |
| 487 | + Logs a parsed HTTP response at the DEBUG level. |
| 488 | +
|
| 489 | + This internal helper function takes a parsed response and logs it |
| 490 | + using the provided logger. It also applies a hashing function to |
| 491 | + potentially sensitive information before logging. |
| 492 | +
|
| 493 | + Args: |
| 494 | + logger: The logging.Logger instance to use for logging. |
| 495 | + parsed_response: The parsed HTTP response object (e.g., a dictionary, |
| 496 | + list, or the original response if parsing failed). |
| 497 | + """ |
| 498 | + |
| 499 | + logged_response = _hash_sensitive_info(parsed_response) |
| 500 | + logger.debug("Response received...", extra={"httpResponse": logged_response}) |
| 501 | + |
| 502 | + |
| 503 | +def response_log(logger: logging.Logger, response: Any) -> None: |
| 504 | + """ |
| 505 | + Logs an HTTP response at the DEBUG level if logging is enabled. |
| 506 | +
|
| 507 | + Args: |
| 508 | + logger: The logging.Logger instance to use. |
| 509 | + response: The HTTP response object to log. |
| 510 | + """ |
| 511 | + if is_logging_enabled(logger): |
| 512 | + json_response = _parse_response(response) |
| 513 | + _response_log_base(logger, json_response) |
0 commit comments