From 119dfdda97e517cd1776e99efd0f137178e884cb Mon Sep 17 00:00:00 2001 From: Alexandre Pujol Date: Sun, 30 Jan 2022 14:14:11 +0000 Subject: [PATCH] Check for duplicate / re-used passwords. Fix #22. --- pass_audit/__main__.py | 18 ++++++++++++------ pass_audit/audit.py | 18 ++++++++++++++++++ tests/test_audit.py | 17 +++++++++++++++++ tests/test_main.py | 10 ++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/pass_audit/__main__.py b/pass_audit/__main__.py index c1d411d..ca71084 100644 --- a/pass_audit/__main__.py +++ b/pass_audit/__main__.py @@ -33,8 +33,8 @@ class ArgParser(ArgumentParser): def __init__(self): description = """ A pass extension for auditing your password repository. It supports safe - breached password detection from haveibeenpwned.com using K-anonymity method - and password strength estimaton using zxcvbn.""" + breached password detection from haveibeenpwned.com using K-anonymity method, + duplicated passwords, and password strength estimaton using zxcvbn.""" epilog = "More information may be found in the pass-audit(1) man page." super().__init__(prog='pass audit', @@ -135,12 +135,18 @@ def main(): msg.warning(f"Weak password detected: {payload} from {path}" f" might be weak. {zxcvbn_parse(details)}") - if not breached and not weak: - msg.success( - f"None of the {len(data)} passwords tested are breached or weak.") + msg.verbose("Checking for duplicated passwords") + duplicated = audit.duplicates() + for paths in duplicated: + msg.warning(f"Duplicated passwords detected in {', '.join(paths)}") + + if not breached and not weak and not duplicated: + msg.success(f"None of the {len(data)} passwords tested are " + "breached, duplicated or weak.") else: msg.error(f"{len(data)} passwords tested and {len(breached)} breached," - f" {len(weak)} weak passwords found.") + f" {len(weak)} weak passwords found," + f" {len(duplicated)} duplicated passwords found.") msg.message("You should update them with 'pass update'.") diff --git a/pass_audit/audit.py b/pass_audit/audit.py index 2cac46c..25235ed 100644 --- a/pass_audit/audit.py +++ b/pass_audit/audit.py @@ -90,3 +90,21 @@ def zxcvbn(self): if results['score'] <= 2: weak.append((path, password, results)) return weak + + def duplicates(self): + """Check for duplicated passwords.""" + seen = {} + for path, entry in self.data.items(): + if entry.get('password', '') == '': + continue + password = entry['password'] + if password in seen: + seen[password].append(path) + else: + seen[password] = [path] + + duplicated = [] + for paths in seen.values(): + if len(paths) > 1: + duplicated.append(paths) + return duplicated diff --git a/tests/test_audit.py b/tests/test_audit.py index 0ab6e30..6d493b5 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -55,11 +55,28 @@ def test_zxcvbn_strong(self): weak = audit.zxcvbn() self.assertTrue(len(weak) == 0) + def test_duplicates_yes(self): + """Testing: pass audit for duplicates password.""" + data = tests.getdata('Password/notpwned/1') + data['Password/notpwned/copy'] = data['Password/notpwned/1'] + audit = pass_audit.audit.PassAudit(data, True) + duplicated = audit.duplicates() + self.assertTrue(len(duplicated) == 1) + + def test_duplicates_no(self): + """Testing: pass audit for not duplicated password.""" + data = tests.getdata('Password/notpwned/') + audit = pass_audit.audit.PassAudit(data, True) + duplicated = audit.duplicates() + self.assertTrue(len(duplicated) == 0) + def test_empty(self): """Testing: pass audit for empty password.""" data = {'empty': {'password': ''}} audit = pass_audit.audit.PassAudit(data, self.msg) weak = audit.zxcvbn() breached = audit.password() + duplicated = audit.duplicates() self.assertTrue(len(weak) == 0) self.assertTrue(len(breached) == 0) + self.assertTrue(len(duplicated) == 0) diff --git a/tests/test_main.py b/tests/test_main.py index f6982f8..54712f8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -4,6 +4,7 @@ # import os +import shutil from unittest import mock from pass_audit.passwordstore import PasswordStore @@ -81,6 +82,15 @@ def test_main_passwords_pwned(self): cmd = ['Password/pwned'] self.main(cmd) + @mock.patch('requests.get', tests.mock_request) + def test_main_passwords_duplicate(self): + """Testing: pass audit for duplicates.""" + shutil.copy(os.path.join(self.store.prefix, 'Password/good/1.gpg'), + os.path.join(self.store.prefix, 'Password/good/10.gpg')) + cmd = ['Password/good'] + self.main(cmd) + os.remove(os.path.join(self.store.prefix, 'Password/good/10.gpg')) + @mock.patch('requests.get', tests.mock_request) def test_main_passwords_good(self): """Testing: pass audit Password/good."""