Created
March 6, 2024 13:19
-
-
Save Arno0x/0c8ed8b4a46fe52d8ba2879d4e4f2769 to your computer and use it in GitHub Desktop.
Password conformity checker
This file contains 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
import re, json | |
from math import log2 | |
import getpass | |
import requests, zipfile, io | |
import os, datetime | |
import hashlib | |
from collections import Counter | |
from functools import reduce | |
#================================================================= | |
# CONSTANTS | |
#================================================================= | |
configPath = "pwTesterConfig.json" | |
banner = r""" | |
____ ____ ____ ____ _ ____ ____ ____ | |
/ __\/ _ \/ ___\/ ___\/ \ /|/ _ \/ __\/ _ \ | |
| \/|| / \|| \| \| | ||| / \|| \/|| | \| | |
| __/| |-||\___ |\___ || |/\||| \_/|| /| |_/| | |
\_/ \_/ \|\____/\____/\_/ \|\____/\_/\_\\____/ | |
_____ _____ ____ _____ _____ ____ | |
/__ __\/ __// ___\/__ __\/ __// __\ | |
/ \ | \ | \ / \ | \ | \/| | |
| | | /_ \___ | | | | /_ | / | |
\_/ \____\\____/ \_/ \____\\_/\_\ | |
""" | |
#================================================================= | |
# FUNCTIONS | |
#================================================================= | |
def printBanner(): | |
os.system('clear') | |
print("\33[1;34m="*50) | |
print(banner) | |
print("="*50, "\33[0m") | |
#----------------------------------------------------------------- | |
def printError(errorMessage, quit: bool): | |
print("\33[1;31m[ERREUR]\33[0m",errorMessage) | |
if quit: exit() | |
#----------------------------------------------------------------- | |
def printLabel(label): | |
widthRequired = 90 | |
l = len(label)+2 # Adding to characters to length to account for 2 spaces | |
left = right = int((widthRequired-l)/2) | |
if left+l+right < widthRequired: right+=1 | |
print("\33[1;35m","-"*left,label,"-"*right,"\33[0m") | |
#----------------------------------------------------------------- | |
def printFeedback(level, message): | |
if level == "CRITICAL": | |
print("\33[1;31m[PROBLÈME CRITIQUE]\33[0m ", end='') | |
elif level == "HIGH": | |
print("\33[5;31m[PROBLÈME HAUT]\33[0m ", end='') | |
elif level == "WARN": | |
print("\33[1;33m[PROBLÈME INTERMEDIAIRE]\33[0m ", end='') | |
elif level == "LOW": | |
print("\33[1;35m[PROBLÈME BAS]\33[0m ", end='') | |
elif level == "OK": | |
print("\33[1;32m[OK]\33[0m ", end='') | |
elif level == "INFO": | |
print("\33[1;34m[INFO]\33[0m ", end='') | |
print(message) | |
#----------------------------------------------------------------- | |
# This entropy calculator takes into account the Swiss french keyboard | |
# 37 possible special chars (including space): §°+¦"@%*#&¬/|(¢)=?'´<>\;,.:-_[]{}-£!€ | |
# 26 possible lowercase and uppercase chars | |
# 10 possible digits | |
def entropy(pw): | |
charsetSize = 0 | |
charsetSize += 26 if re.search("[a-z]", pw) else 0 | |
charsetSize += 26 if re.search("[A-Z]", pw) else 0 | |
charsetSize += 10 if re.search("[0-9]", pw) else 0 | |
charsetSize += 37 if re.search("[^a-zà-üA-ZÀ-Ü0-9]", pw) else 0 | |
passwordSize = len(pw) | |
return log2(charsetSize ** passwordSize) | |
#----------------------------------------------------------------- | |
def unique(list1): | |
return reduce(lambda re, x: re+[x] if x not in re else re, list1, []) | |
#----------------------------------------------------------------- | |
def loadConfig(configPath): | |
# Config path is a URL | |
if configPath.startswith("http"): | |
try: | |
x = requests.get(configPath, verify=False) | |
if x.status_code == 200: | |
jsonConfig = x.text | |
except: | |
printError("Le fichier de configuration [{}] n'a pas pu être téléchargé.".format(configPath), True) | |
# Config path is a local file | |
else: | |
try: | |
f = open(configPath, "r") | |
jsonConfig = f.read() | |
f.close() | |
except FileNotFoundError: | |
printError("Le fichier de configuration [{}] n'a pas pu être chargé.".format(configPath), True) | |
return jsonConfig | |
#----------------------------------------------------------------- | |
def loadDictionnaryArchive(dictPath): | |
dictArchive = None | |
# Dictionnary path is a URL | |
if dictPath.startswith("http"): | |
try: | |
x = requests.get(dictPath, verify=False) | |
if x.status_code == 200: | |
dictArchive = zipfile.ZipFile(io.BytesIO(x.content)) | |
else: printError("Le fichier de dictionnaire [{}] n'a pas pu être téléchargé.".format(dictPath), True) | |
except zipfile.BadZipFile: | |
printError("Le fichier de dictionnaire [{}] est corrompu.".format(dictPath), True) | |
except: | |
printError("Le fichier de dictionnaire [{}] n'a pas pu être téléchargé.".format(dictPath), True) | |
# Config file is a local file | |
else: | |
try: | |
dictArchive = zipfile.ZipFile(dictPath, 'r') | |
except FileNotFoundError: | |
printError("Le fichier de dictionnaire [{}] est introuvable.".format(dictPath), True) | |
except zipfile.BadZipFile: | |
printError("Le fichier de dictionnaire [{}] est corrompu.".format(dictPath), True) | |
return dictArchive | |
#----------------------------------------------------------------- | |
def loadDictionnary(dictArchive, dictFile): | |
dictContent = None | |
try: | |
dictContent = dictArchive.read(dictFile).decode("utf-8") | |
except: | |
printError("Le fichier de dictionnaire [{}] n'a pas pu être lu.".format(dictFile), False) | |
return dictContent | |
#----------------------------------------------------------------- | |
def searchDict(dictArchive, dictFile, pw): | |
wordsFound=[] | |
dictContent = loadDictionnary(dictArchive, dictFile) | |
if dictContent != None: | |
for line in dictContent.splitlines(): | |
if pw.find(line) != -1: | |
wordsFound.append(line) | |
# Return the list of words found | |
return wordsFound if len(wordsFound) > 0 else None | |
#================================================================= | |
# MAIN | |
#================================================================= | |
printBanner() | |
#----------------------------------------------------------------- | |
# Loading config file | |
try: | |
config = json.loads(loadConfig(configPath)) | |
except json.decoder.JSONDecodeError: | |
printError("Le format JSON du fichier de configuration n'est pas conforme.", True) | |
try: | |
while True: | |
#----------------------------------------------------------------- | |
# Get password from user | |
# while True: | |
# PASSWORD = getpass.getpass("Veuillez entrer le mot de passe à tester: ") | |
# validation = getpass.getpass("Veuillez entrer le mot de passe à nouveau (validation): ") | |
# if PASSWORD == validation: break | |
# else: printError("La validation ne correspond pas.", False) | |
PASSWORD = input("Veuillez entrer le mot de passe à tester (Ctrl+C pour quitter): ") | |
password = PASSWORD.lower() # Get a lowercase version of passwords for some checks | |
#----------------------------------------------------------------- | |
# CHECK 1: Check password Length | |
rule = config['checks']['length'] | |
printLabel(rule['label']) | |
pwLength, pwLengthRule = len(PASSWORD), rule['minLength'] | |
if pwLength < pwLengthRule: | |
printFeedback(rule['level'],"La longueur du mot de passe ({} caractères) est inférieure à la longeur recommandée ({} caractères).".format(pwLength,pwLengthRule)) | |
else: | |
printFeedback("OK", "La longueur du mot de passe ({} caractères) est conforme à la longeur recommandée.".format(pwLength)) | |
#----------------------------------------------------------------- | |
# CHECK 2: Forbidden characters | |
rule = config['checks']['forbiddenChars'] | |
printLabel(rule['label']) | |
forbiddenChars = re.findall(rule['forbiddenCharsRE'], PASSWORD) | |
if len(forbiddenChars) > 0: | |
printFeedback(rule['level'], "Les caractères diacritiques ou l'espace sont interdits: [{}]".format("|".join(forbiddenChars))) | |
printFeedback("INFO", "Le testeur de mot de passe ne peut aller plus loin tant que des caractères interdits sont utilisés.") | |
exit() | |
else: | |
printFeedback("OK", "Aucun caractère diacritique ou espace utilisé.".format(pwLength)) | |
#----------------------------------------------------------------- | |
# CHECK 3: Check number of distincts charsets | |
rule = config['checks']['charsets'] | |
printLabel(rule['label']) | |
nbDigits = len(re.findall(rule['digitsRE'], PASSWORD)) | |
nbLowerCase = len(re.findall(rule['lowerCaseRE'], PASSWORD)) | |
nbUpperCase = len(re.findall(rule['upperCaseRE'], PASSWORD)) | |
nbSpecChar = len(re.findall(rule['specCharRE'], PASSWORD)) | |
nbOtherCharset = pwLength - (nbDigits + nbLowerCase + nbUpperCase + nbSpecChar) | |
nbCharset = (1 if nbDigits > 0 else 0) + (1 if nbLowerCase > 0 else 0) + (1 if nbUpperCase > 0 else 0) + (1 if nbSpecChar > 0 else 0) + (1 if nbOtherCharset > 0 else 0) | |
minNbCharset = rule['minNb'] | |
if nbCharset < minNbCharset: | |
printFeedback(rule['level'], "Le nombre de jeux de caratères ({}) est inférieur au nombre recommandé ({}).".format(nbCharset,minNbCharset)) | |
else: | |
printFeedback("OK", "Le nombre de jeux de caratères ({}) est conforme au nombre recommandé ({}).".format(nbCharset,minNbCharset)) | |
print("\tNombre de chiffres: {}\n\tNombre de minuscules: {}\n\tNombre de majuscules: {}\n\tNombre de caratères spéciaux: {}\n\tNombre d'autres caractères non reconnus: {}" \ | |
.format(nbDigits,nbLowerCase,nbUpperCase,nbSpecChar,nbOtherCharset)) | |
#----------------------------------------------------------------- | |
# CHECK 4: Check user account is not included in the password | |
rule = config['checks']['accountInPW'] | |
printLabel(rule['label']) | |
username = getpass.getuser().lower() | |
username = "|".join([username, username[:-1], username[:-2]]) | |
if re.search(username,password): | |
printFeedback(rule['level'], "Utilisation du compte utilisateur dans le mot de passe.") | |
else: | |
printFeedback("OK","Le nom du compte utilisateur n'est pas présent dans le mot de passe.") | |
#----------------------------------------------------------------- | |
# CHECK 5: Check for repeating patterns | |
rule = config['checks']['repeatingPattern'] | |
printLabel(rule['label']) | |
patterns = rule['patternsRE'] | |
patternFound = False | |
for pattern in patterns.keys(): | |
repeatingPattern = re.findall(patterns[pattern], PASSWORD) | |
if len(repeatingPattern) > 0: | |
patternFound = True | |
printFeedback(rule['level'], "Répétition trop importante des schémas suivants: [{}]".format("|".join(repeatingPattern))) | |
if not patternFound: | |
printFeedback("OK","Aucune répétition de schéma trouvée.") | |
#----------------------------------------------------------------- | |
# CHECK 6: Check for sequential patterns | |
rule = config['checks']['sequentialPattern'] | |
printLabel(rule['label']) | |
pattern = rule['seqPatternRE'] | |
sequentialPattern = re.findall(pattern, password) | |
if len(sequentialPattern) > 0: | |
printFeedback(rule['level'], "Utilisation des schémas séquentiels suivants: [{}]".format("|".join(sequentialPattern))) | |
else: | |
printFeedback("OK","Pas d'utilisation de schémas séquentiels.") | |
#----------------------------------------------------------------- | |
# CHECK 7: Check for date pattern | |
rule = config['checks']['datePattern'] | |
printLabel(rule['label']) | |
pattern = rule['datePatternRE'] | |
datePattern = re.findall(pattern, PASSWORD) | |
if len(datePattern) > 0: | |
printFeedback(rule['level'], "Utilisation de date: [{}]".format("|".join(datePattern))) | |
else: | |
printFeedback("OK", "Pas d'utilisation de date.") | |
#----------------------------------------------------------------- | |
# CHECK 8: Check for current year | |
rule = config['checks']['currentYear'] | |
printLabel(rule['label']) | |
currentYear = str(datetime.date.today().year) | |
if re.search(currentYear,PASSWORD): | |
printFeedback(rule['level'], "Utilisation de l'année courante dans le mot de passe: [{}]".format(currentYear)) | |
else: | |
printFeedback("OK","L'année courante n'est pas présente dans le mot de passe.") | |
#----------------------------------------------------------------- | |
# CHECK 9: Check for password versionning | |
rule = config['checks']['pwVersion'] | |
printLabel(rule['label']) | |
pattern = rule['pwVersionRE'] | |
if re.search(pattern,PASSWORD): | |
printFeedback(rule['level'], "Possible utilisation d'un versionning en fin de mot de passe.") | |
else: | |
printFeedback("OK","Pas d'utilisation de versionning dans le mot de passe.") | |
#----------------------------------------------------------------- | |
# CHECK 10: Check for password entropy | |
rule = config['checks']['entropy'] | |
printLabel(rule['label']) | |
pwEntropy = int(entropy(PASSWORD)) | |
if pwEntropy < int(rule['minEntropy']): | |
printFeedback(rule['level'], "Entropie du mot de passe faible: {} bits".format(pwEntropy)) | |
else: | |
printFeedback("OK","Entropie du mot de passe bonne: {} bits".format(pwEntropy)) | |
#----------------------------------------------------------------- | |
# CHECK 11: Check hash of the file not in HIBP Database | |
rule = config['checks']['hibpAPI'] | |
printLabel(rule['label']) | |
if rule['hashFunction'] == "sha1": | |
pwHash = hashlib.sha1(bytes(PASSWORD, encoding='utf-8')).hexdigest() | |
elif rule['hashFunction'] == "sha256": | |
pwHash = hashlib.sha256(bytes(PASSWORD, encoding='utf-8')).hexdigest() | |
pwHash = pwHash.upper() | |
hashRangeLeft = pwHash[0:rule['hashRange']] | |
hashRangeRight = pwHash[rule['hashRange']:] | |
try: | |
x = requests.get(rule['apiURL']+hashRangeLeft, proxies=rule['proxies']) | |
if x.status_code == 200: | |
pwLeaked = False | |
for line in x.text.splitlines(): | |
if line.find(hashRangeRight) != -1: | |
pwLeaked = True | |
hashCount = int(line.split(':')[1]) | |
if hashCount == 1: | |
level = "WARN" | |
elif hashCount < 10: | |
level = "HIGH" | |
elif hashCount > 10: | |
level = "CRITICAL" | |
if pwLeaked: | |
printFeedback(level, "Le mot de passe a été trouvé {} fois dans les fuites de données connues des hackers".format(hashCount)) | |
else: | |
printFeedback("OK", "Le mot de passe n'a pas été trouvé dans les fuites de données connues des hackers") | |
except: | |
printError("Le service HIBP n'est pas joignable ou n'a pas répondu correctement ({}).".format(rule['apiURL']), False) | |
#----------------------------------------------------------------- | |
# CHECK 12: Check words are not part of any dictionnary | |
# Also check classic digits <-> char permutations | |
rule = config['checks']['dictRules'] | |
printLabel(rule['label']) | |
pw1 = password | |
# Perform classic permutations | |
permutations = rule['permutations'] | |
for i in range(len(permutations[0])): | |
pw1 = pw1.replace(permutations[0][i], permutations[1][i]) | |
pw1 = pw1.lower() | |
#--------------------------------- | |
# Load dictionnaries archive in memory | |
dictArchive = loadDictionnaryArchive(config['dict']['dictPath']) | |
# Search through all dictionnaries | |
for dictionnary in config['dict']['dictFiles']: | |
if dictionnary['check'] == "True": | |
printFeedback("INFO", "Recherche de mots dans le dictionnaire: [{}]".format(dictionnary['name'])) | |
if dictionnary['accented'] == "True": | |
wordsFound = searchDict(dictArchive, dictionnary['file'], pw1) | |
else: | |
# Replace all accented characters | |
pw2 = pw1 | |
normalization = rule['normalize'] | |
for i in range(len(normalization[0])): | |
pw2 = re.sub(normalization[0][i],normalization[1][i],pw2) | |
wordsFound = searchDict(dictArchive, dictionnary['file'], pw2) | |
# Check and print results | |
if wordsFound: | |
longestWord = reduce(lambda x, y: x if len(x) > len(y) else y, wordsFound) | |
#wordList = "|".join(wordsFound) | |
printFeedback(rule['level'], "{} mot(s) trouvé(s) dans le dictionnaire. Mot le plus long: [{}]\n".format(len(wordsFound),longestWord)) | |
else: | |
printFeedback("OK", "Aucune correspondance trouvée dans ce dictionnaire.".format(dictionnary['name'])) | |
print() | |
except KeyboardInterrupt: | |
exit() |
This file contains 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
{ | |
"dict": { | |
"dictPath" : "/mnt/c/Temp/SecurityResearch/pwTester/dictionnaries.zip", | |
"dictFiles": [ | |
{ | |
"name": "Dictionnaire des mots français SANS ACCENTS", | |
"file": "ods6_normalized.txt", | |
"accented": "False", | |
"check": "True" | |
}, | |
{ | |
"name": "Dictionnaire des mots français", | |
"file": "gutenberg_normalized.txt", | |
"accented": "True", | |
"check": "False" | |
}, | |
{ | |
"name": "Dictionnaire des mots anglais", | |
"file": "sowpods_normalized.txt", | |
"accented": "False", | |
"check": "True" | |
}, | |
{ | |
"name": "Liste de prénoms", | |
"file": "prenoms_normalized.txt", | |
"accented": "False", | |
"check": "True" | |
}, | |
{ | |
"name": "TOP 20'000 mots de passe français", | |
"file": "pwList_top20000_fr_normalized.txt", | |
"accented": "False", | |
"check": "True" | |
} | |
]}, | |
"checks":{ | |
"length" : { | |
"level": "CRITICAL", | |
"label": "Nombre de caractères", | |
"minLength": 9 | |
}, | |
"forbiddenChars" : { | |
"level": "CRITICAL", | |
"label": "Caractères interdits", | |
"forbiddenCharsRE" : "[à-üÀ-Ü\\ ]" | |
}, | |
"charsets" : { | |
"level": "CRITICAL", | |
"label": "Nombre de jeux de caractères", | |
"digitsRE": "\\d", | |
"lowerCaseRE": "[a-z]", | |
"upperCaseRE": "[A-Z]", | |
"specCharRE" : "[^a-zà-üA-ZÀ-Ü0-9]", | |
"minNb" : 3 | |
}, | |
"accountInPW" : { | |
"level": "CRITICAL", | |
"label": "Présence du compte" | |
}, | |
"repeatingPattern": { | |
"level": "HIGH", | |
"label": "Répétion de schéma", | |
"patternsRE": { | |
"singleCharRE": "(.)\\1{2,}", | |
"twoCharRE": "(.{2})\\1{2,}", | |
"three+CharRE": "(.{3,})\\1{1,}" | |
} | |
}, | |
"sequentialPattern": { | |
"level": "WARN", | |
"label": "Séquence de caractères", | |
"seqPatternRE" : "(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|012|123|234|345|456|567|678|789)+" | |
}, | |
"datePattern" : { | |
"level": "WARN", | |
"label": "Présence de dates", | |
"datePatternRE" : "\\d{2}[\\.\\-\\/]\\d{2}[\\.\\-\\/]\\d{2,4}" | |
}, | |
"currentYear": { | |
"level": "WARN", | |
"label": "Présence de l'année courante" | |
}, | |
"pwVersion": { | |
"level": "HIGH", | |
"label": "Version de mot de passe", | |
"pwVersionRE": "[0-9]{2}$" | |
}, | |
"entropy": { | |
"level": "LOW", | |
"label": "Entropie du mot de passe", | |
"minEntropy" : 60 | |
}, | |
"hibpAPI": { | |
"label": "Présence dans des fuites de données antérieures (données HIBP)", | |
"proxies": { "http": "monproxy:3128", "https": "monproxy:3128"}, | |
"apiURL": "https://api.pwnedpasswords.com/range/", | |
"hashFunction": "sha1", | |
"hashRange": 5 | |
}, | |
"dictRules": { | |
"level": "WARN", | |
"label": "Mots du dictionnaire", | |
"normalize": [["[àäâ]","[éèëê]","[îïì]","[ôòö]","[ûùü]","[ỳŷÿ]","ç"], | |
["a","e","i","o","u","y","c"]], | |
"permutations": [["0","@","3","1","5", "7"], | |
["o","a","e","l","s", "t"]] | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment