Skip to content

Instantly share code, notes, and snippets.

@Arno0x
Created March 6, 2024 13:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Arno0x/0c8ed8b4a46fe52d8ba2879d4e4f2769 to your computer and use it in GitHub Desktop.
Save Arno0x/0c8ed8b4a46fe52d8ba2879d4e4f2769 to your computer and use it in GitHub Desktop.
Password conformity checker
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()
{
"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