#!/usr/bin/env python3
"""
qilin_config_scan.py : detecteur de configuration embarquee et de fuite de cle
privee RSA dans les echantillons Agenda / Qilin (ransomware Rust).

La famille Agenda/Qilin embarque sa configuration en JSON clair dans le binaire.
Le champ `private_rsa_pem` doit normalement etre vide (la cle privee reste chez
l'operateur). Certains builds l'embarquent en clair : dans ce cas les fichiers
chiffres par ce build sont dechiffrables sans payer. Ce script localise la
config, verifie l'etat de `private_rsa_pem`, et rend un verdict de recuperabilite.

Analyse statique uniquement : lecture d'octets, aucune execution.

Usage :
    qilin_config_scan.py FICHIER [FICHIER ...]
    qilin_config_scan.py --json FICHIER
"""
import sys
import re
import json
import argparse
from json import JSONDecoder

_DEC = JSONDecoder()
_ANCHORS = (b'"company_id"', b'company_id', b'private_rsa_pem', b'directory_black_list')


def find_config(data: bytes):
    """Localise l'objet JSON de config embarque. Retourne (dict, offset) ou (None, None)."""
    for anchor in _ANCHORS:
        idx = 0
        while True:
            i = data.find(anchor, idx)
            if i < 0:
                break
            idx = i + 1
            start = data.rfind(b'{', 0, i)
            if start < 0:
                continue
            for size in (20000, 80000, 300000):
                chunk = data[start:start + size].decode('utf-8', 'replace')
                try:
                    cfg, _ = _DEC.raw_decode(chunk)
                except ValueError:
                    continue
                if isinstance(cfg, dict) and 'company_id' in cfg:
                    return cfg, start
    return None, None


def check_private_key(pem: str):
    """Etat du champ private_rsa_pem : (statut, taille_bits).

    Gere le PEM avec vrais newlines (issu d'un json.loads) ET avec newlines
    echappes (\\n litteral, tel que stocke dans le binaire pour le fallback brut)."""
    if not pem:
        return 'vide', None
    if 'ENCRYPTED' in pem or 'DEK-Info' in pem:
        return 'chiffre_par_mot_de_passe', None
    # candidats : version brute puis version des-echappee des newlines
    candidates = [pem, pem.replace('\\r\\n', '\n').replace('\\n', '\n').replace('\\r', '')]
    try:
        from cryptography.hazmat.primitives.serialization import load_pem_private_key
    except ImportError:
        if any('BEGIN RSA PRIVATE KEY' in c or 'BEGIN PRIVATE KEY' in c for c in candidates):
            return 'CLAIR_PROBABLE', None
        return 'present_non_parsable', None
    for cand in candidates:
        try:
            key = load_pem_private_key(cand.encode(), password=None)
            return 'CLAIR_VALIDE', getattr(key, 'key_size', None)
        except Exception:
            continue
    return 'present_non_parsable', None


_PEM_RE = re.compile(
    rb'-----BEGIN (?:RSA )?PRIVATE KEY-----.*?-----END (?:RSA )?PRIVATE KEY-----',
    re.DOTALL)
_LEAK = ('CLAIR_VALIDE', 'CLAIR_PROBABLE')


def find_pem_private_keys(data: bytes):
    """Recherche directe de blocs PEM de cle privee dans les octets bruts.

    Fallback qui attrape une cle fuitee meme quand la config JSON n'est pas
    extractible (variante ou le JSON n'est pas en clair, format different, etc.).
    Retourne le meilleur statut trouve : (statut, bits)."""
    best = ('aucune', None)
    for m in _PEM_RE.finditer(data):
        pem = m.group(0).decode('latin-1', 'ignore')
        status, bits = check_private_key(pem)
        if status in _LEAK:
            return status, bits
        best = (status, bits)
    return best


def scan(path: str) -> dict:
    with open(path, 'rb') as fh:
        data = fh.read()
    cfg, off = find_config(data)
    raw_status, raw_bits = find_pem_private_keys(data)   # fallback hors JSON

    if not cfg:
        # pas de config JSON extractible, mais une cle PEM peut trainer en clair
        recoverable = raw_status in _LEAK
        return {
            'file': path,
            'config_found': False,
            'company_id': None,
            'public_rsa_len': None,
            'private_key_status': raw_status if raw_status != 'aucune' else None,
            'private_key_bits': raw_bits,
            'accounts': None,
            'password_hash': None,
            'onions': [],
            'key_source': 'raw_pem' if recoverable else None,
            'recoverable': recoverable,
        }

    status, bits = check_private_key(cfg.get('private_rsa_pem', ''))
    key_source = 'config' if status in _LEAK else None
    # la config ne montre pas de cle, mais une cle PEM valide traine dans les octets
    if status not in _LEAK and raw_status in _LEAK:
        status, bits, key_source = raw_status, raw_bits, 'raw_pem'

    onions = sorted(set(re.findall(r'[a-z2-7]{56}\.onion', cfg.get('note', ''))))
    return {
        'file': path,
        'config_found': True,
        'config_offset': hex(off),
        'company_id': cfg.get('company_id'),
        'public_rsa_len': len(cfg.get('public_rsa_pem', '') or ''),
        'private_key_status': status,
        'private_key_bits': bits,
        'accounts': len(cfg.get('accounts', []) or []),
        'password_hash': bool(cfg.get('password_hash')),
        'onions': onions,
        'key_source': key_source,
        'recoverable': status in _LEAK,
    }


def main() -> int:
    ap = argparse.ArgumentParser(description='Scan Agenda/Qilin : config + fuite de cle privee RSA')
    ap.add_argument('files', nargs='+')
    ap.add_argument('--json', action='store_true', help='sortie JSON brute')
    args = ap.parse_args()

    results = [scan(f) for f in args.files]
    if args.json:
        print(json.dumps(results, indent=2, ensure_ascii=False))
        return 0

    leaked = 0
    for r in results:
        name = r['file'].rsplit('/', 1)[-1]
        if r['recoverable']:
            leaked += 1
            src = ' (cle PEM brute, hors config JSON)' if r.get('key_source') == 'raw_pem' else ''
            verdict = f"[LEAK ] cle privee {r['private_key_status']}"
            if r['private_key_bits']:
                verdict += f" RSA-{r['private_key_bits']}"
            verdict += f" -> FICHIERS RECUPERABLES{src}"
            print(verdict)
            print(f"        {name}  company_id={r.get('company_id')}  accounts={r.get('accounts')}  "
                  f"pw_hash={r.get('password_hash')}  onions={len(r.get('onions') or [])}")
        elif not r['config_found']:
            print(f"[  ?  ] {name}  : pas de config ni cle PEM en clair")
        else:
            print(f"[ ok  ] private_rsa_pem={r['private_key_status']} (conception saine)")
            print(f"        {name}  company_id={r.get('company_id')}  accounts={r.get('accounts')}  "
                  f"pw_hash={r.get('password_hash')}  onions={len(r.get('onions') or [])}")
    print(f"\nResume : {leaked} build(s) avec cle privee en clair sur {len(results)} fichiers analyses.")
    return 0


if __name__ == '__main__':
    sys.exit(main())
