test_no_fail_open_in_auth.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. """GHSA-6mf4-q26m-47pv backstop: no fail-open ``except Exception`` in auth code.
  2. The advisory's root cause was a single ``except Exception:`` block in the
  3. auth probe path that returned ``False`` (treated as "auth disabled, allow
  4. everything") when the DB raised an error during the check. CVSS 9.8.
  5. Other Python ecosystems would call this CWE-636 ("Not Failing
  6. Securely").
  7. The fundamental problem is that ``except Exception:`` is too broad to
  8. audit at review time — the reviewer cannot tell, just from the except
  9. clause, whether the handler re-raises, denies, or silently returns a
  10. permissive value. So every such block in auth-sensitive code must be
  11. explicitly tagged with the reviewer's audit conclusion using the
  12. ``# SEC-AUTH-EXC: <reason>`` marker. Untagged blocks fail this
  13. test.
  14. The tag forces three things every time an ``except Exception:`` lands
  15. in scope:
  16. 1. A reviewer has read the handler body and confirmed fail-closed semantics.
  17. 2. The reasoning is captured at the exact line so future readers can verify.
  18. 3. ``grep SEC-AUTH-EXC`` enumerates every audited exception path for spot-checks.
  19. Scope (where this rule applies, mirrors SECURITY.md rule 2):
  20. - backend/app/core/auth.py
  21. - backend/app/core/permissions.py
  22. - backend/app/api/routes/auth.py
  23. To add a new ``except Exception:`` block in scope, append a comment
  24. ``# SEC-AUTH-EXC: <short reason>`` on the same line as the
  25. ``except`` keyword. The reason should describe what makes the handler
  26. safe (e.g. "rollback + raise 500", "returns None which caller treats
  27. as invalid → 401", "logged only, no access decision made here").
  28. """
  29. from __future__ import annotations
  30. import ast
  31. from pathlib import Path
  32. import pytest
  33. REPO_ROOT = Path(__file__).resolve().parents[3]
  34. # Files in scope for the lint. Adding a file here widens the safety net;
  35. # removing one weakens it. Either decision belongs in a PR description.
  36. IN_SCOPE: tuple[Path, ...] = (
  37. REPO_ROOT / "backend" / "app" / "core" / "auth.py",
  38. REPO_ROOT / "backend" / "app" / "core" / "permissions.py",
  39. REPO_ROOT / "backend" / "app" / "api" / "routes" / "auth.py",
  40. )
  41. TAG_MARKER = "# SEC-AUTH-EXC:"
  42. def _is_broad_except(handler: ast.ExceptHandler) -> bool:
  43. """Return True if ``handler`` catches Exception or bare ``except:``.
  44. Excludes narrower catches like ``except (OperationalError, ProgrammingError):``
  45. or ``except JWTError:`` which are explicit about what they handle and
  46. not the GHSA-6mf4 shape.
  47. """
  48. if handler.type is None:
  49. return True # bare `except:`
  50. # Single Name catching Exception
  51. if isinstance(handler.type, ast.Name) and handler.type.id == "Exception":
  52. return True
  53. # Tuple catching Exception alongside other types — e.g. `except (Exception, OSError):`
  54. if isinstance(handler.type, ast.Tuple):
  55. return any(isinstance(elt, ast.Name) and elt.id == "Exception" for elt in handler.type.elts)
  56. return False
  57. @pytest.mark.unit
  58. def test_no_fail_open_in_auth_modules() -> None:
  59. """SEC-AUTH-2 (SECURITY.md): every broad except in auth modules must carry SEC-AUTH-EXC tag.
  60. Walks the AST of each in-scope module, finds every ``except Exception:``
  61. (or bare ``except:``) block, and asserts the source line containing
  62. the ``except`` keyword has a ``# SEC-AUTH-EXC: <reason>`` tag.
  63. The tag is the reviewer's signed-off audit conclusion. Without it,
  64. the broad except is indistinguishable from the GHSA-6mf4 shape.
  65. """
  66. findings: list[str] = []
  67. for source_path in IN_SCOPE:
  68. assert source_path.is_file(), f"Expected in-scope file at {source_path}"
  69. source = source_path.read_text(encoding="utf-8")
  70. source_lines = source.splitlines()
  71. tree = ast.parse(source)
  72. relative = source_path.relative_to(REPO_ROOT)
  73. for node in ast.walk(tree):
  74. if not isinstance(node, ast.ExceptHandler):
  75. continue
  76. if not _is_broad_except(node):
  77. continue
  78. # The ``except`` keyword is on node.lineno. Comment must appear
  79. # on that line (1-indexed in ast, 0-indexed in our list).
  80. line_text = source_lines[node.lineno - 1]
  81. if TAG_MARKER not in line_text:
  82. # Show enough context for the operator to find the block.
  83. handler_preview = line_text.strip()
  84. findings.append(
  85. f" {relative}:{node.lineno} {handler_preview}\n"
  86. f" → add `{TAG_MARKER} <reason>` describing why this is fail-closed"
  87. )
  88. assert not findings, (
  89. "Untagged ``except Exception:`` (or bare ``except:``) blocks found in auth modules. "
  90. "Each one is indistinguishable at review time from the GHSA-6mf4-q26m-47pv shape (CVSS 9.8). "
  91. "Either narrow the catch to the specific exception type you handle, or tag the line with "
  92. "`# SEC-AUTH-EXC: <reason>` documenting what makes the handler fail-closed. "
  93. "See SECURITY.md rule 2 'Fail-closed in auth code'.\n\n" + "\n".join(findings)
  94. )