Skip to content

Instantly share code, notes, and snippets.

@apeyroux
Created February 2, 2026 18:39
Show Gist options
  • Select an option

  • Save apeyroux/608b13894f3b462d985e294d232e0790 to your computer and use it in GitHub Desktop.

Select an option

Save apeyroux/608b13894f3b462d985e294d232e0790 to your computer and use it in GitHub Desktop.
#!/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