Skip to content

Commit

Permalink
Split preimports.pswd.getcompliantpass to helpers.password
Browse files Browse the repository at this point in the history
  • Loading branch information
dhondta committed Nov 8, 2020
1 parent 26e1815 commit 5ea8498
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 165 deletions.
17 changes: 13 additions & 4 deletions docs/helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ According to the [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) ph
--- | ---
`ts.clear` | multi-platform clear screen function
`ts.confirm` | Python2/3-compatible Yes/No input function (supporting style and palette, relying on [`colorful`](https://github.com/timofurrer/colorful)
`ts.getpass` | `getpass.getpass`-based function that allows to enter a policy for making compliant passwords (see [`getpass` enhancement](enhancements.html#getpass) for more details about how a policy can be described)
`ts.notify` | shortcut to the `notification.notify` function of [`plyer`](https://github.com/kivy/plyer)
`ts.pause` | Python2/3-compatible dummy input function, waiting for a key to be pressed (supporting style and palette, relying on [`colorful`](https://github.com/timofurrer/colorful)
`ts.std_input` | Python2/3-compatible input function (supporting style and palette, relying on [`colorful`](https://github.com/timofurrer/colorful))
Expand Down Expand Up @@ -231,17 +232,25 @@ Tinyscript also provides 2 `pathlib`-related functions:

Basically, a path can be mirrored this way: `MirrorPath(destination, source)`. However, it can also be defined as `p = MirrorPath(destination)` and the `p.mirror(source)` method can then be used.

- `ts.PyFolderPath`: new class for loading all Python modules within a given folder
- `ts.ProjectPath`: new class for managing a project, given a structure

This allows to dynamically load Python modules at runtime given a folder containing the target modules. The `modules` attribute holds the list of all loaded modules.
This class allows to manage a project folder in a handy manner using the following methods:

- `archive`: this method allows to archive the project folder to a ZIP file in a given destination path, optionally encrypted using a given password (`password` argument) or by asking the user to enter one (with `ask=True`) ; by default, the project folder is removed after compression (this behavior can be disabled by using `remove=False`) and this method returns a new `ProjectPath` with the new path (to the ZIP file)
- `create`: this creates the project structure given a dictionary describing it ; each key is a folder (with its content described with a subdictionary) or a file (if the value is `None`, meaning that an empty file is to be created, or the content of it)
- `load` (the complementary method of `archive`): this allows to unzip an archive to a given destination given a password or by asking it ; by default, the ZIP archive is removed after decompression (this behavior can be disabled by using `remove=False`)

The `todo` attribute allows to get a dictionary of all the "`#TODO: `" markers contained in the project.

- `ts.PyModulePath`: new class for dynamically loading a Python module
- `ts.PythonPath`: new class for dynamically loading Python modules, either directly from a file or from a folder

This dynamically loads a Python file. It has the following useful methods:
This dynamically loads Python files in the given path. It has the following useful methods:

- `get_classes(base_cls)`: for getting the list of all classes from the given Python module
- `has_baseclass(base_cls)`: for checking whether the given Python module has a class inheriting the given base class
- `has_class(cls)`: for checking whether the given Python module has the given class

When a file is given as argument, the `module` attribute holds the related Python module (if the given file is indeed a Python source file). When a folder is given, the `modules` attribute holds a list of all the loaded modules within that path.

- `ts.TempPath`: additional class for handling temporary folder

Expand Down
40 changes: 40 additions & 0 deletions tests/test_helpers_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""Password input function tests.
"""
from tinyscript.helpers.password import getpass

from utils import *


class TestHelpersPassword(TestCase):
def test_getpass(self):
# test the policy
self.assertRaises(ValueError, getpass, policy="BAD")
self.assertRaises(ValueError, getpass, policy={'allowed': "BAD"})
self.assertRaises(ValueError, getpass, policy={'allowed': "?l", 'required': "?L"})
self.assertRaises(ValueError, getpass, policy={'wordlists': "BAD"})
for l in [(-1, 10), (10, -1), (10, 1)]:
self.assertRaises(ValueError, getpass, policy={'length': l})
# test a few bad passwords
WORDLIST = "./.wordlist"
with open(WORDLIST, 'wt') as f:
f.write("Test4321!")
kwargs = {'policy': {'wordlists': ["wl_does_not_exist"]}}
for i, p in enumerate(["test", "Test1", "Test1!", "Testtest", "testtesttest", "\x01\x02\x03", "Test4321!",
"Th1s 1s 4 l0ng, v3ry l0ng, t00 l0ng c0mpl3x s3nt3nc3!"]):
if i > 2:
kwargs['policy'] = {'wordlists': [WORDLIST]}
with mock_patch("getpass.getpass", return_value=p):
self.assertRaises(ValueError, getpass, **kwargs)
remove(WORDLIST)
# test a few good passwords
kwargs = {}
for i, p in enumerate(["Test1234!", "Th1s 1s 4 l0ng s3nt3nc3!"]):
if i > 1:
kwargs['policy'] = {'wordlists': None}
with mock_patch("getpass.getpass", return_value=p):
pswd = getpass(**kwargs)
self.assertEqual(pswd, p)

2 changes: 0 additions & 2 deletions tests/test_preimports_getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
"""Preimports getpass assets' tests.
"""
from tinyscript.helpers.inputs import capture, Capture
from tinyscript.loglib import logger as ts_logger
from tinyscript.preimports import getpass

from utils import *
Expand Down
2 changes: 1 addition & 1 deletion tinyscript/VERSION.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.22.11
1.23.1
4 changes: 3 additions & 1 deletion tinyscript/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .layout import *
from .licenses import *
from .notify import *
from .password import *
from .path import *
from .termsize import *
from .text import *
Expand All @@ -36,13 +37,14 @@
from .layout import __features__ as _layout
from .licenses import __features__ as _lic
from .notify import __features__ as _notify
from .password import __features__ as _pswd
from .path import __features__ as _path
from .termsize import __features__ as _tsize
from .text import __features__ as _text
from .timeout import __features__ as _to


__helpers__ = _attack + _common + _data + _dec + _dict + _fexec + _inputs + _layout + _lic + _notify + _path + \
__helpers__ = _attack + _common + _data + _dec + _dict + _fexec + _inputs + _layout + _lic + _notify + _path + _pswd + \
_tsize + _text + _to

ts = ModuleType("ts", """
Expand Down
165 changes: 165 additions & 0 deletions tinyscript/helpers/password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# -*- coding: utf8 -*-
""" Compliant password prompt function relying on a simple password policy.
Policy:
- Prevents from using a few conjunction characters (i.e. whitespace, tabulation,
newline)
- Use passwords of 8 to 40 characters (lengths by default)
- Use at least one lowercase character
- Use at least one uppercase character
- Use at least one digit
- Use at least one special character
- Do not use a password known in a dictionary (e.g. this of John the Ripper)
"""
import getpass as _getpass
import os
from platform import system

from .attack import expand_mask, parse_rule, MASKS
from .constants import *
from .data.utils import entropy_bits


__all__ = __features__ = ["getpass"]


BAD_PASSWORDS_LISTS = {
'default': {
'password.lst': ["./", "~/"],
'rockyou.txt': ["./", "~/"],
},
'Linux': {
'password.lst': ["./", "~/", "/opt/john/run", "/usr/local/share/john", "/usr/share/john", "/var/lib/john"],
'rockyou.txt': ["./", "~/"],
},
}
DEFAULT_POLICY = {
'allowed': "?l?L?d?s",
'length': (8, 40),
'rules': "lut", # .lower(), .upper(), .title()
'entropy': 32,
'wordlists': BAD_PASSWORDS_LISTS.get(system(), "default"),
}
MASK_DESCRIPTIONS = {
'd': "digits",
'h': "lowercase hexadecimal",
'H': "uppercase hexadecimal",
'l': "lowercase letters",
'L': "uppercase letters",
'p': "printable characters",
's': "special characters",
}
MASK_MODIFIERS = {v: k for k, v in MASKS.items()}

__policies_registry = {}


def __validate(policy):
policy = policy or {}
if not isinstance(policy, dict):
raise ValueError("Bad policy format ; should be a dictionary")
if hash(str(policy)) in __policies_registry:
return policy
for k in DEFAULT_POLICY.keys():
if k not in policy.keys():
policy[k] = DEFAULT_POLICY[k]
policy['allowed_expanded'] = expand_mask(policy['allowed'])
policy['allowed'] = policy['allowed'].replace("?", "")
policy['required'] = policy.get('required', policy['allowed']).replace("?", "")
for k in ["allowed", "required"]:
if any(c not in "dhHlLps" for c in policy[k]):
raise ValueError("Bad %s character set mask ; should be only amongst 'dhHlLps'" % k)
for m in policy['required']:
if m not in policy['allowed']:
raise ValueError("Bad allowed/required set mask ; '?%s' is not in the allowed set" % m)
# compose charset string
s = ""
for m in policy['allowed']:
s += MASK_DESCRIPTIONS[m] + ", "
s = s.rstrip(", ")
s = s.split(", ")
s = ", ".join(s[:-1]) + " and " + s[-1]
policy['charset_string'] = s
minl, maxl = policy['length']
if minl < 1:
raise ValueError("Bad minimum length ; should be greater than 0")
if maxl < 1:
raise ValueError("Bad maximum length ; should be greater than 0")
if maxl < minl:
raise ValueError("Bad maximum length ; should be greater than the minimum length")
if isinstance(policy['wordlists'], list):
# filter out non-existing wordlists
for p in policy['wordlists']:
if not os.path.isfile(os.path.expanduser(p)):
policy['wordlists'].remove(p)
elif isinstance(policy['wordlists'], dict):
# transform the dictionary of potential wordlist paths to a list of existing wordlists
e = []
for filename, paths in policy.pop('wordlists').items():
for path in paths:
p = os.path.join(os.path.expanduser(path), filename)
if os.path.isfile(p):
e.append(p)
break
policy['wordlists'] = tuple(e)
elif policy.get('wordlists') is not None:
raise ValueError("Bad policy passwords files exclusion list ; should be a list or a dictionary")
__policies_registry[hash(str(policy))] = policy
return policy


def getpass(prompt="Password: ", stream=None, policy=None):
""" This function allows to enter a password enforced through a password policy able to check for the password
length, characters set and presence in the given wordlists.
:param prompt: prompt text
:param stream: a writable file object to display the prompt (defaults to the tty or to sys.stderr if not available)
:param policy: password policy to be considered
:return: policy-compliant password
"""
policy = __validate(policy)
pwd, error = None, False
# get values from policy
minl, maxl = policy['length']
pwd = _getpass.getpass(prompt, stream).strip()
# first, check the length
errors = []
if len(pwd) < minl:
errors.append("Please enter a password of at least {} characters".format(minl))
if len(pwd) > maxl:
errors.append("Please enter a password of at most {} characters".format(maxl))
# second, check the characters
if any(c not in "".join(policy['allowed_expanded']) for c in pwd):
errors.append("Please enter a password with only " + policy['charset_string'])
# third, check the entropy
e = entropy_bits(pwd)
if e < policy['entropy']:
errors.append("Too weak password ; should have %d bits of entropy (currently %d)" % (policy['entropy'], e))
# now, check for minimal character requirements
for m, group in zip(policy['allowed'], policy['allowed_expanded']):
if not any(c in group for c in pwd) and m in policy['required']:
errors.append("Please enter a password that contains at least one " + MASK_DESCRIPTIONS[m].rstrip("s"))
# then, check for bad passwords
found = False
for fp in (policy.get('wordlists') or []):
with open(fp) as f:
for l in f:
passwords = [l.strip()]
for r in parse_rule(policy.get('rules', "")):
passwords.append(r(passwords[0]))
for p in set(passwords):
if pwd == p:
found = True
break
if found:
break
if found:
errors.append("Please enter a more complex password (found in %s)" % fp)
break
if len(errors) > 0:
g = {'__name__': "__main__"}
exec("class NonCompliantPasswordError(ValueError):\n def __init__(self, msg, errors, **kwargs):\n " \
"super(NonCompliantPasswordError, self).__init__(msg, **kwargs)\n self.errors = errors", g)
raise g['NonCompliantPasswordError'](pwd, errors)
return pwd

0 comments on commit 5ea8498

Please sign in to comment.