rules:
  - id: auth.py.flow.secret-in-response
    languages:
      - python
    severity: ERROR
    message: |
      A server-side secret read from the environment flows into an HTTP
      response sent back to the client, leaking it (CWE-200). Anything you
      return from a Flask view (via `jsonify(...)`, `make_response(...)`,
      `Response(...)`) is visible to every caller, so an env value whose name
      looks like a credential (`API_KEY`, `CLIENT_SECRET`, `*_TOKEN`,
      `*_PASSWORD`, a private key, an access key, ...) must never reach it.

      Never send a server secret to the client. Return only the data the
      caller needs; keep credentials server-side. If a value genuinely must be
      surfaced, redact or mask it first (`redact(...)` / `mask_secret(...)`),
      and prefer exposing only public configuration (names prefixed
      `PUBLIC_` / `NEXT_PUBLIC_` / `VITE_`) to clients.
    # REVERSE-direction taint: the SOURCE is the secret (an os.environ / getenv
    # value whose name looks like a credential), the SINK is the HTTP response.
    # Taint mode so indirection (secret = os.getenv('CLIENT_SECRET');
    # return jsonify(secret=secret)) is caught, not just the inline form.
    # `metavariable-regex` constrains the env var NAME to credential-looking
    # words while excluding public-by-convention prefixes, and passing the value
    # through a redaction/mask helper clears the taint. The sink focuses the
    # response value argument. `by-side-effect` is unsupported on semgrep 1.157,
    # so the source is constrained inline rather than via a propagator.
    mode: taint
    pattern-sources:
      - patterns:
          - pattern-either:
              - pattern: os.environ[$KEY]
              - pattern: os.environ.get($KEY)
              - pattern: 'os.environ.get($KEY, ...)'
              - pattern: os.getenv($KEY)
              - pattern: 'os.getenv($KEY, ...)'
              # Qualified `from os import environ, getenv` forms.
              - pattern: environ[$KEY]
              - pattern: environ.get($KEY)
              - pattern: 'environ.get($KEY, ...)'
              - pattern: getenv($KEY)
              - pattern: 'getenv($KEY, ...)'
          # $KEY must look like a credential AND must not be a public-by-
          # convention name. A single regex: a negative lookahead drops the
          # PUBLIC_/NEXT_PUBLIC_/VITE_ prefixes (so NEXT_PUBLIC_API_KEY is NOT a
          # source), then a credential keyword must appear. The optional leading
          # quote tolerates semgrep binding the string literal with or without
          # its quotes; `(?i)` makes the whole match case-insensitive.
          - metavariable-regex:
              metavariable: $KEY
              regex: '(?i)^[''"]?(?!(?:NEXT_PUBLIC_|VITE_|PUBLIC_))\w*(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|client[_-]?secret|credential|access[_-]?key)'
    pattern-sanitizers:
      # Redaction / masking helpers: a secret passed through one of these is no
      # longer sensitive, so returning the result is safe.
      - pattern: redact(...)
      - pattern: mask(...)
      - pattern: mask_secret(...)
    pattern-sinks:
      - patterns:
          - pattern-either:
              # Flask response constructors, the reliable sinks. Each covers a
              # positional value and a keyword value (jsonify(secret=...)), and
              # taint reaching a value nested inside (e.g. a dict) still fires.
              - pattern: 'jsonify(..., $X, ...)'
              - pattern: 'jsonify(..., $K=$X, ...)'
              - pattern: 'flask.jsonify(..., $X, ...)'
              - pattern: 'flask.jsonify(..., $K=$X, ...)'
              - pattern: 'make_response(..., $X, ...)'
              - pattern: 'make_response(..., $K=$X, ...)'
              - pattern: 'flask.make_response(..., $X, ...)'
              - pattern: 'flask.make_response(..., $K=$X, ...)'
              - pattern: 'Response(..., $X, ...)'
              - pattern: 'Response(..., $K=$X, ...)'
              - pattern: 'flask.Response(..., $X, ...)'
              - pattern: 'flask.Response(..., $K=$X, ...)'
          - focus-metavariable: $X
    metadata:
      oauthlint-rule-id: AUTH-PY-FLOW-008
      oauthlint-doc-url: https://oauthlint.dev/rules/py-flow-secret-in-response
      category: security
      cwe: CWE-200
      owasp: API3:2023
      llm-prevalence: HIGH
      technology:
        - flask
      references:
        - https://cwe.mitre.org/data/definitions/200.html
        - https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/
