#!/usr/bin/env python3
"""Finds files in a folder where a password or an access key is written in plain text.

Run: python3 find_secrets.py <folder>
Prints the file path and the kind of what it found. It never prints the passwords themselves.
It changes nothing, it only reads. It also reads Word documents and Excel spreadsheets.
"""
import html
import os
import re
import sys
import zipfile

# How much to read from one file: only the beginning of a large file is read.
READ_MAX = 2_000_000
# Folders the check does not enter.
SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv'}
# The word "password" in English, Russian, Portuguese and Montenegrin.
LABELS = r'password|passwd|pwd|db_pass|\u043f\u0430\u0440\u043e\u043b|senha|lozink'

# Strong signs: only a real key has text like this.
STRONG = [
    ('private key', re.compile(r'-----BEGIN [A-Z ]*PRIVATE KEY-----')),
    ('JWT token', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.')),
    ('Google service account key', re.compile(r'"type"\s*:\s*"service_account"')),
    ('GitLab token', re.compile(r'glpat-[A-Za-z0-9_-]{15,}')),
    ('AWS key', re.compile(r'\bAKIA[0-9A-Z]{16}\b')),
    ('the word "password" in a line or cell of its own', re.compile(r'(?im)^[ \t"]*(?:' + LABELS + r')\w{0,30}(?:[ \t]+\w{1,30}){0,2}[ \t"\r]*$')),
]
# Soft signs: a word like "password", secret or api_key, and the value after it.
SOFT = [
    ('password', re.compile(r'(?i)\b(?:' + LABELS + r')[\w \t"\'-]{0,60}[=:]\s*(["\']?)(\S{6,})')),
    ('password', re.compile(r'(?i)\b(?:' + LABELS + r')\w{0,30}(?:[ \t]+\S{1,30}){0,6}?'
                          r'(?:[ \t]*[-\u2013\u2014:=][ \t]*|[ \t]+)(["\']?)((?=\S{0,200}?[\d!@#$%^&*?_+])\S{6,200})')),
    ('secret', re.compile(r'(?i)\b[A-Z0-9_]*secret[A-Z0-9_]*["\']?\s*[=:]\s*(["\']?)(\S{8,})')),
    ('access key', re.compile(r'(?i)\b[A-Z0-9_]*(?:api[_-]?key|apikey|access[_-]?key|auth[_-]?'
                                r'token|bot[_-]?token)[A-Z0-9_]*["\']?\s*[=:]\s*(["\']?)(\S{8,})')),
    ('password in a connection string', re.compile(r'(?i)(?:Password|Pwd)\s*=\s*([^;\s]{4,});')),
]
# The value is a reference to a variable or a setting, not the password itself.
REFERENCE = re.compile(r"""(?ix)^(
   os\.environ|os\.getenv|environ|getenv|process\.env|System\.|Environment\.|configuration|
   config\.|self\.|this\.|cfg\.|settings\.|require\(|import\b|input\(|prompt
)""")
BRACKET = re.compile(r'^(\$\{?[A-Za-z_]|%\(|\{\{|\{[A-Za-z_]|<[A-Za-z_]|@[A-Za-z_]|\[)')
# The value is a placeholder from an example, not a password. It is compared as a whole.
DUMMY = re.compile(r'(?ix)^(None|null|nil|true|false|undefined|x{3,}|y{3,}|\*+|\.{3,}|'
                   r'change_?me|your_?\w*|test|password|secret|dummy|example|sample|'
                   r'placeholder|\d{1,4}|["\'`]*)$')


def value(m):
    g = [x for x in m.groups() if x is not None]
    return (g[-1] if g else '').strip('"\'`,;)')


def is_secret(v):
    if not v or len(v) < 6:
        return False
    if REFERENCE.match(v) or BRACKET.match(v):
        return False
    if DUMMY.fullmatch(v):
        return False
    return True


def read_text(path):
    """Text of the file. For a Word document or an Excel spreadsheet, the text from their parts, one paragraph or cell per line."""
    low = path.lower()
    if low.endswith(('.docx', '.xlsx')):
        with zipfile.ZipFile(path) as z:
            parts = [z.open(n).read(READ_MAX) for n in z.namelist() if n.endswith('.xml')]
        text = b'\n'.join(parts).decode('utf-8', 'ignore')
        text = re.sub(r'</(?:w:p|w:tc|si|c|row)>', '\n', text)
        return html.unescape(re.sub(r'<[^>]+>', '', text))
    with open(path, 'rb') as fh:
        text = fh.read(READ_MAX).decode('utf-8', 'ignore')
    # In a CSV table, each field goes on its own line.
    return re.sub(r'[,;\t]', '\n', text) if low.endswith(('.csv', '.tsv')) else text


def signs(path):
    """Kinds of what was found in the file. The values themselves are never returned."""
    try:
        data = read_text(path)
    except Exception:
        return []
    found = [name for name, rx in STRONG if rx.search(data)]
    for name, rx in SOFT:
        for m in rx.finditer(data):
            if is_secret(value(m)):
                found.append(name)
                break
    return sorted(set(found))


def main():
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    root = sys.argv[1] if len(sys.argv) > 1 else '.'
    if not os.path.isdir(root):
        sys.exit(f'No such folder: {root}')
    total = 0
    for folder, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in files:
            path = os.path.join(folder, f)
            if os.path.islink(path) or not os.path.isfile(path):
                continue
            found = signs(path)
            if found:
                total += 1
                print(f'{path}: {", ".join(found)}')
    print(f'Files with an open password or key: {total}')


if __name__ == '__main__':
    main()
