Created
February 2, 2026 18:39
-
-
Save apeyroux/608b13894f3b462d985e294d232e0790 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Script de fusion des exports CSV de gestionnaires de mots de passe. | |
| Supporte : iCloud Keychain et Proton Pass | |
| Usage: | |
| python merge_passwords.py icloud.csv protonpass.csv -o merged.csv | |
| python merge_passwords.py icloud.csv protonpass.csv -o merged.csv --format proton | |
| """ | |
| import csv | |
| import argparse | |
| from pathlib import Path | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| import hashlib | |
| @dataclass | |
| class PasswordEntry: | |
| """Entrée de mot de passe normalisée.""" | |
| name: str | |
| url: str | |
| username: str | |
| password: str | |
| notes: str = "" | |
| totp: str = "" | |
| source: str = "" | |
| def unique_key(self) -> str: | |
| """Clé unique pour détecter les doublons (basée sur url + username).""" | |
| return hashlib.md5(f"{self.url.lower()}|{self.username.lower()}".encode()).hexdigest() | |
| def parse_icloud_csv(filepath: Path) -> list[PasswordEntry]: | |
| """ | |
| Parse un export CSV iCloud Keychain. | |
| Colonnes attendues : Title, URL, Username, Password, Notes, OTPAuth | |
| """ | |
| entries = [] | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| entry = PasswordEntry( | |
| name=row.get("Title", row.get("title", "")).strip(), | |
| url=row.get("URL", row.get("url", "")).strip(), | |
| username=row.get("Username", row.get("username", "")).strip(), | |
| password=row.get("Password", row.get("password", "")).strip(), | |
| notes=row.get("Notes", row.get("notes", "")).strip(), | |
| totp=row.get("OTPAuth", row.get("otpauth", "")).strip(), | |
| source="iCloud" | |
| ) | |
| if entry.url or entry.username: # Ignorer les entrées vides | |
| entries.append(entry) | |
| return entries | |
| def parse_protonpass_csv(filepath: Path) -> list[PasswordEntry]: | |
| """ | |
| Parse un export CSV Proton Pass. | |
| Colonnes attendues : name, url, username, password, note, totp, createTime, modifyTime, vault | |
| """ | |
| entries = [] | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| # Proton Pass peut avoir plusieurs URLs séparées par des virgules | |
| url = row.get("url", row.get("URL", "")).strip() | |
| entry = PasswordEntry( | |
| name=row.get("name", row.get("Name", "")).strip(), | |
| url=url, | |
| username=row.get("username", row.get("Username", "")).strip(), | |
| password=row.get("password", row.get("Password", "")).strip(), | |
| notes=row.get("note", row.get("notes", row.get("Notes", ""))).strip(), | |
| totp=row.get("totp", row.get("TOTP", "")).strip(), | |
| source="ProtonPass" | |
| ) | |
| if entry.url or entry.username: | |
| entries.append(entry) | |
| return entries | |
| @dataclass | |
| class Conflict: | |
| """Conflit entre deux entrées avec mots de passe différents.""" | |
| entry1: PasswordEntry | |
| entry2: PasswordEntry | |
| def display(self, index: int) -> str: | |
| return f""" | |
| ┌─ Conflit #{index} ───────────────────────────────── | |
| │ URL : {self.entry1.url} | |
| │ Username : {self.entry1.username} | |
| ├─ Option 1 ({self.entry1.source}): | |
| │ Nom : {self.entry1.name} | |
| │ MDP : {self.entry1.password[:3]}{'*' * (len(self.entry1.password) - 3)} ({len(self.entry1.password)} car.) | |
| ├─ Option 2 ({self.entry2.source}): | |
| │ Nom : {self.entry2.name} | |
| │ MDP : {self.entry2.password[:3]}{'*' * (len(self.entry2.password) - 3)} ({len(self.entry2.password)} car.) | |
| └────────────────────────────────────────────────""" | |
| def merge_entries( | |
| entries_list: list[list[PasswordEntry]], | |
| prefer_source: Optional[str] = None, | |
| conflict_mode: str = "keep_both" | |
| ) -> tuple[list[PasswordEntry], list[Conflict]]: | |
| """ | |
| Fusionne plusieurs listes d'entrées en éliminant les doublons. | |
| Args: | |
| entries_list: Liste de listes d'entrées à fusionner | |
| prefer_source: Source à privilégier en cas de doublon ("iCloud" ou "ProtonPass") | |
| conflict_mode: Comment gérer les conflits de mots de passe | |
| - "keep_both": Garde les deux entrées (suffixe le nom) | |
| - "prefer": Utilise prefer_source pour décider | |
| - "longest": Garde le mot de passe le plus long | |
| - "report": Garde le premier, liste les conflits | |
| Returns: | |
| Tuple (liste fusionnée, liste des conflits) | |
| """ | |
| seen: dict[str, PasswordEntry] = {} | |
| duplicates_same = 0 | |
| conflicts: list[Conflict] = [] | |
| for entries in entries_list: | |
| for entry in entries: | |
| key = entry.unique_key() | |
| if key in seen: | |
| existing = seen[key] | |
| # Même mot de passe = vrai doublon | |
| if existing.password == entry.password: | |
| duplicates_same += 1 | |
| # Garder l'entrée la plus complète | |
| if bool(entry.totp) and not bool(existing.totp): | |
| seen[key] = entry | |
| elif bool(entry.notes) and not bool(existing.notes): | |
| entry.totp = entry.totp or existing.totp | |
| seen[key] = entry | |
| else: | |
| # Conflit : mots de passe différents | |
| conflict = Conflict(existing, entry) | |
| conflicts.append(conflict) | |
| if conflict_mode == "keep_both": | |
| # Garder les deux avec noms différenciés | |
| existing.name = f"{existing.name} ({existing.source})" | |
| entry.name = f"{entry.name} ({entry.source})" | |
| new_key = f"{key}_conflict_{len(conflicts)}" | |
| seen[new_key] = entry | |
| elif conflict_mode == "prefer" and prefer_source: | |
| if entry.source == prefer_source: | |
| seen[key] = entry | |
| # Sinon on garde l'existant | |
| elif conflict_mode == "longest": | |
| if len(entry.password) > len(existing.password): | |
| seen[key] = entry | |
| # "report" : on garde l'existant, le conflit est déjà enregistré | |
| else: | |
| seen[key] = entry | |
| print(f" → {duplicates_same} doublon(s) identique(s) fusionné(s)") | |
| if conflicts: | |
| print(f" → ⚠️ {len(conflicts)} conflit(s) de mot de passe détecté(s)") | |
| return list(seen.values()), conflicts | |
| def export_conflicts_report(conflicts: list[Conflict], filepath: Path): | |
| """Exporte un rapport des conflits au format CSV.""" | |
| fieldnames = [ | |
| "url", "username", | |
| "source1", "name1", "password1", | |
| "source2", "name2", "password2" | |
| ] | |
| with open(filepath, "w", encoding="utf-8", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=fieldnames) | |
| writer.writeheader() | |
| for c in conflicts: | |
| writer.writerow({ | |
| "url": c.entry1.url, | |
| "username": c.entry1.username, | |
| "source1": c.entry1.source, | |
| "name1": c.entry1.name, | |
| "password1": c.entry1.password, | |
| "source2": c.entry2.source, | |
| "name2": c.entry2.name, | |
| "password2": c.entry2.password, | |
| }) | |
| def export_csv(entries: list[PasswordEntry], filepath: Path, format_type: str = "generic"): | |
| """ | |
| Exporte les entrées vers un fichier CSV. | |
| Args: | |
| entries: Liste des entrées à exporter | |
| filepath: Chemin du fichier de sortie | |
| format_type: "generic", "proton", "bitwarden", "1password" | |
| """ | |
| if format_type == "proton": | |
| # Format Proton Pass | |
| fieldnames = ["name", "url", "username", "password", "note", "totp"] | |
| row_mapper = lambda e: { | |
| "name": e.name, | |
| "url": e.url, | |
| "username": e.username, | |
| "password": e.password, | |
| "note": e.notes, | |
| "totp": e.totp | |
| } | |
| elif format_type == "bitwarden": | |
| # Format Bitwarden | |
| fieldnames = ["name", "login_uri", "login_username", "login_password", "notes", "login_totp"] | |
| row_mapper = lambda e: { | |
| "name": e.name, | |
| "login_uri": e.url, | |
| "login_username": e.username, | |
| "login_password": e.password, | |
| "notes": e.notes, | |
| "login_totp": e.totp | |
| } | |
| elif format_type == "1password": | |
| # Format 1Password | |
| fieldnames = ["Title", "Website", "Username", "Password", "Notes", "OTP"] | |
| row_mapper = lambda e: { | |
| "Title": e.name, | |
| "Website": e.url, | |
| "Username": e.username, | |
| "Password": e.password, | |
| "Notes": e.notes, | |
| "OTP": e.totp | |
| } | |
| else: | |
| # Format générique | |
| fieldnames = ["name", "url", "username", "password", "notes", "totp", "source"] | |
| row_mapper = lambda e: { | |
| "name": e.name, | |
| "url": e.url, | |
| "username": e.username, | |
| "password": e.password, | |
| "notes": e.notes, | |
| "totp": e.totp, | |
| "source": e.source | |
| } | |
| with open(filepath, "w", encoding="utf-8", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=fieldnames) | |
| writer.writeheader() | |
| for entry in entries: | |
| writer.writerow(row_mapper(entry)) | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Fusionne les exports CSV de gestionnaires de mots de passe (iCloud + Proton Pass)", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Exemples: | |
| %(prog)s icloud.csv protonpass.csv -o merged.csv | |
| %(prog)s icloud.csv protonpass.csv -o merged.csv --format proton | |
| %(prog)s icloud.csv protonpass.csv -o merged.csv --prefer proton | |
| """ | |
| ) | |
| parser.add_argument( | |
| "icloud_csv", | |
| type=Path, | |
| help="Fichier CSV exporté depuis iCloud Keychain" | |
| ) | |
| parser.add_argument( | |
| "proton_csv", | |
| type=Path, | |
| help="Fichier CSV exporté depuis Proton Pass" | |
| ) | |
| parser.add_argument( | |
| "-o", "--output", | |
| type=Path, | |
| default=Path("merged_passwords.csv"), | |
| help="Fichier CSV de sortie (défaut: merged_passwords.csv)" | |
| ) | |
| parser.add_argument( | |
| "--format", | |
| choices=["generic", "proton", "bitwarden", "1password"], | |
| default="generic", | |
| help="Format de sortie (défaut: generic)" | |
| ) | |
| parser.add_argument( | |
| "--prefer", | |
| choices=["icloud", "proton"], | |
| default=None, | |
| help="Source à privilégier en cas de doublon" | |
| ) | |
| parser.add_argument( | |
| "--conflict", | |
| choices=["keep_both", "prefer", "longest", "report"], | |
| default="keep_both", | |
| help="Gestion des conflits de mot de passe (défaut: keep_both)" | |
| ) | |
| parser.add_argument( | |
| "--conflict-report", | |
| type=Path, | |
| default=None, | |
| help="Fichier CSV pour le rapport des conflits" | |
| ) | |
| args = parser.parse_args() | |
| # Vérification des fichiers | |
| if not args.icloud_csv.exists(): | |
| print(f"Erreur: Le fichier '{args.icloud_csv}' n'existe pas.") | |
| return 1 | |
| if not args.proton_csv.exists(): | |
| print(f"Erreur: Le fichier '{args.proton_csv}' n'existe pas.") | |
| return 1 | |
| print("═" * 50) | |
| print(" Fusion des exports de mots de passe") | |
| print("═" * 50) | |
| # Parsing des fichiers | |
| print(f"\n📂 Lecture de '{args.icloud_csv}'...") | |
| icloud_entries = parse_icloud_csv(args.icloud_csv) | |
| print(f" → {len(icloud_entries)} entrée(s) trouvée(s)") | |
| print(f"\n📂 Lecture de '{args.proton_csv}'...") | |
| proton_entries = parse_protonpass_csv(args.proton_csv) | |
| print(f" → {len(proton_entries)} entrée(s) trouvée(s)") | |
| # Fusion | |
| print(f"\n🔀 Fusion en cours (mode conflit: {args.conflict})...") | |
| prefer_source = None | |
| if args.prefer == "icloud": | |
| prefer_source = "iCloud" | |
| elif args.prefer == "proton": | |
| prefer_source = "ProtonPass" | |
| merged, conflicts = merge_entries( | |
| [icloud_entries, proton_entries], | |
| prefer_source, | |
| args.conflict | |
| ) | |
| # Afficher les conflits | |
| if conflicts: | |
| print("\n⚠️ CONFLITS DÉTECTÉS (mots de passe différents) :") | |
| for i, c in enumerate(conflicts, 1): | |
| print(c.display(i)) | |
| # Export du rapport de conflits | |
| report_path = args.conflict_report or args.output.with_suffix(".conflicts.csv") | |
| print(f"\n📋 Rapport des conflits : '{report_path}'") | |
| export_conflicts_report(conflicts, report_path) | |
| # Export | |
| print(f"\n💾 Export vers '{args.output}' (format: {args.format})...") | |
| export_csv(merged, args.output, args.format) | |
| print("\n" + "═" * 50) | |
| print(f" ✅ Fusion terminée : {len(merged)} entrée(s)") | |
| if conflicts: | |
| print(f" ⚠️ {len(conflicts)} conflit(s) à vérifier manuellement") | |
| print("═" * 50) | |
| return 0 | |
| if __name__ == "__main__": | |
| exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment