rules:
  - id: auth.flow.password-plaintext
    languages:
      - javascript
      - typescript
    severity: ERROR
    # Non-production code (example apps, demos, sample projects, benchmarks,
    # integration harnesses, docs, vendored copies, tests) is not the library
    # surface users ship, so findings there are noise for a low-FP linter.
    paths:
      exclude:
        - "**/test/**"
        - "**/__tests__/**"
        - "**/*.test.*"
        - "**/*.spec.*"
        - "**/example/**"
        - "**/examples/**"
        - "**/demo/**"
        - "**/sample/**"
        - "**/samples/**"
        - "**/benchmark/**"
        - "**/benchmarks/**"
        - "**/bench/**"
        - "**/integration/**"
        - "**/docs/**"
        - "**/__mocks__/**"
        - "**/mocks/**"
        - "**/vendored/**"
        - "**/node_modules/**"
        - "**/*.stories.*"
    message: |
      A user-supplied password is being persisted WITHOUT being hashed first.
      Storing plaintext passwords means any database read (backup, SQL injection,
      misconfigured backup, contractor with read-only access) leaks every
      credential in one shot.

      Hash with argon2id (recommended), bcrypt, or scrypt before persisting.
      Never use plain SHA-256, MD5, or any unsalted hash for passwords.

      OWASP ASVS V2.4 mandates an adaptive, salted hash. Every modern stack
      ships one. There is no reason to roll your own.
    # We fire only when the value persisted into the `password` column is a RAW
    # password reference: a bare `password` identifier or a member chain ending in
    # `.password` (`req.body.password`, `dto.password`, `credentials.password`).
    # This is the precise "persisting the plaintext straight from the request"
    # shape. It deliberately does NOT fire when the value is any other expression,
    # which removes a whole class of false positives: hashing into an intermediate
    # variable and then persisting it (`const digest = await bcrypt.hash(pw);
    # User.create({ password: digest })`), where the engine cannot see that
    # `digest` already holds a hash. An inline hash call
    # (`password: await argon2.hash(req.body.password)`) is likewise not a raw
    # reference (its text is a call, not a `.password` chain), so it is left alone.
    # The trade-off is intentional for a low-FP linter: we accept missing a raw
    # password laundered through an oddly-named variable rather than firing on
    # correctly-hashed code.
    pattern-either:
      # Flat persistence object on a user-ish model/repo:
      # db.users.create({password}), User.create({password}), userRepo.save({password}),
      # new User({password}), prisma.user.insertOne({password}), …
      - patterns:
          - pattern-either:
              - pattern: '$DB.$MODEL.create({..., password: $PWD, ...})'
              - pattern: '$DB.$MODEL.insert({..., password: $PWD, ...})'
              - pattern: '$DB.$MODEL.insertOne({..., password: $PWD, ...})'
              - pattern: '$DB.$MODEL.save({..., password: $PWD, ...})'
              - pattern: '$MODEL.create({..., password: $PWD, ...})'
              - pattern: '$MODEL.insert({..., password: $PWD, ...})'
              - pattern: '$MODEL.save({..., password: $PWD, ...})'
              - pattern: 'new $MODEL({..., password: $PWD, ...})'
          - metavariable-regex:
              metavariable: $MODEL
              regex: '(?i)user'
          - metavariable-regex:
              metavariable: $PWD
              regex: '^(?:[A-Za-z_$][\w$]*\.)*[Pp]assword$'
      # Prisma-style nested data wrapper: prisma.user.create({ data: { password } }).
      - patterns:
          - pattern-either:
              - pattern: '$DB.$MODEL.create({..., data: {..., password: $PWD, ...}, ...})'
              - pattern: '$DB.$MODEL.update({..., data: {..., password: $PWD, ...}, ...})'
          - metavariable-regex:
              metavariable: $MODEL
              regex: '(?i)user'
          - metavariable-regex:
              metavariable: $PWD
              regex: '^(?:[A-Za-z_$][\w$]*\.)*[Pp]assword$'
    metadata:
      oauthlint-rule-id: AUTH-FLOW-001
      oauthlint-doc-url: https://oauthlint.dev/rules/password-plaintext
      category: security
      cwe: CWE-256
      owasp: API2:2023
      llm-prevalence: MEDIUM
      references:
        - https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
        - https://cwe.mitre.org/data/definitions/256.html
