diff --git a/.gitignore b/.gitignore index a7cb844..8d230ae 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ # Created by https://www.gitignore.io/api/code,python,windows # Edit at https://www.gitignore.io/?templates=code,python,windows +tox.ini +.gitignore + ### Code ### .vscode/* @@ -142,4 +145,4 @@ TransportSecurity wordlist.txt .venv -flake8.xml \ No newline at end of file +flake8.xml diff --git a/hstsparser.py b/hstsparser.py deleted file mode 100644 index 489f428..0000000 --- a/hstsparser.py +++ /dev/null @@ -1,194 +0,0 @@ -import csv -import datetime -import json -import os -import re -import typing -from argparse import ArgumentParser, Namespace -from base64 import b64encode -from hashlib import sha256 - -from prettytable import PrettyTable - - -def convert_domain(domain: str) -> str: - """Convert string to the Google Chrome domain format""" - output = [chr(0)] - idx = 0 - for char in reversed(domain): - if char == ".": - output.append(chr(idx)) - idx = 0 - else: - output.append(char) - idx += 1 - output.append(chr(idx)) - return b64encode(sha256("".join(reversed(output)).encode("utf-8")).digest()) - - -def print_db(database: list, field_names: list) -> None: - """Print the database in a formatted table""" - table = PrettyTable() - table.field_names = field_names - for i in database: - table.add_row(i) - print(table) - - -def serial_date_to_string(srl_no: str) -> str: - """Convert serial date object to printable string""" - new_date = datetime.datetime.utcfromtimestamp(0) + datetime.timedelta(int(srl_no)) - return new_date.strftime("%Y-%m-%d") - - -def is_valid_file(parser: ArgumentParser, arg: str) -> typing.TextIO: - """Check that file already exists""" - if not os.path.exists(arg): - parser.error(f"The file {arg} does not exist!") - else: - return open(arg, "r") - - -def file_already_exists(parser: ArgumentParser, arg: str) -> typing.TextIO: - """Check that the file does not already exist""" - if os.path.exists(arg): - parser.error(f"The file {arg} already exists!") - else: - return open(arg, "w", newline="") - - -def print_if_no_args(args: Namespace, database: list, field_names: list) -> None: - """Print database if no output file has been specified""" - if not args.csv_file: - print_db(database, field_names) - else: - file_write(args, database, field_names) - - -def file_write(args: Namespace, database: list, field_names: list) -> None: - """Write to a CSV file""" - if args.csv_file: - with args.csv_file as csvfile: - csvfile = csv.writer(csvfile) - csvfile.writerow(field_names) - for i in database: - csvfile.writerow(i) - - -def date_round(date: datetime.datetime) -> datetime.datetime: - """Round `datetime` object""" - return date - datetime.timedelta( - minutes=date.minute % 10, seconds=date.second, microseconds=date.microsecond - ) - - -parser = ArgumentParser(description="Process HSTS databases") -parser.add_argument( - dest="database_file", - help="The path to the database to be processed", - metavar="FILE", - type=lambda x: is_valid_file(parser, x), -) -parser.add_argument( - "-w", - dest="wordlist_file", - help="The path to the database to be processed", - metavar="WORDLIST", - type=lambda x: is_valid_file(parser, x), -) -parser.add_argument( - "--csv", - dest="csv_file", - help="Output to a CSV file", - metavar="CSV", - type=lambda x: file_already_exists(parser, x), -) -group = parser.add_mutually_exclusive_group(required=True) -group.add_argument("--firefox", action="store_true", help="Process a Firefox database") -group.add_argument("--chrome", action="store_true", help="Process a Chrome database") - - -def main() -> None: - """Entry point for command line alias.""" - args = parser.parse_args() - - dirtydb = args.database_file.read() - database = [] - - if args.firefox: - dirtydb = dirtydb.split("\n") - for i in dirtydb: - if i != "": - record = re.split(r"\t+", i) - record.append(record[0][-4:]) - record[0] = re.search(r"^([^:\^]+)", record[0]).group(0) - record[2] = serial_date_to_string(record[2]) - cleaned = record[3].split(",") - try: - record[3] = datetime.datetime.fromtimestamp(int(cleaned[0]) / 1000) - except (OSError, ValueError): - record[3] = datetime.datetime.fromtimestamp(32503680000) # 3000-01-01 00:00:00 - if int(cleaned[2]): - record.append("Yes") - else: - record.append("No") - if args.csv_file: - record[3] = date_round(record[3]) - database.append(record) - print_if_no_args( - args, - database, - ["URL", "Visits", "Last Accessed", "Expiry", "Type", "Include Subdomains"], - ) - - if args.chrome: - dirtydb = json.loads(dirtydb) - for i in dirtydb: - current = dirtydb[i] - if "expect_ct" not in current: - if bool(current["sts_include_subdomains"]): - subdomains = "Yes" - else: - subdomains = "No" - record = [ - i, - datetime.datetime.fromtimestamp(current["expiry"]), - subdomains, - datetime.datetime.fromtimestamp(current["sts_observed"]), - ] - if args.csv_file: - record[1] = date_round(record[1]) - record[3] = date_round(record[3]) - database.append(record) - if args.wordlist_file: - wordlist = args.wordlist_file.read().splitlines() - rainbow = [] - for i in wordlist: - rainbow.append(convert_domain(i)) - for i in database: - for j in range(0, len(rainbow)): - if i[0] == rainbow[j].decode("utf-8"): - i.append(wordlist[j]) - if len(i) == 4: - i.append("") - print_if_no_args( - args, - database, - [ - "Base64 URL Hash", - "Expiry", - "Include Subdomains", - "Last Observed", - "Cracked Hash", - ], - ) - else: - print_if_no_args( - args, - database, - ["Base64 URL Hash", "Expiry", "Include Subdomains", "Last Observed"], - ) - - -if __name__ == '__main__': - main() diff --git a/hstsparser/__init__.py b/hstsparser/__init__.py new file mode 100644 index 0000000..782dd02 --- /dev/null +++ b/hstsparser/__init__.py @@ -0,0 +1,42 @@ +""" +HSTSParser: Parse browser HSTS databases into forensic artifacts. + +Usage: + hstsparser table [--firefox|--chrome] [--sort=] [] + hstsparser -h | --help + hstsparser --version + +Options: + -h --help Show this screen. + --version Show version. + path Folder to search, or path to HSTS file. + --sort= Sort a specified column [default: Last Accessed]. +""" + +__version__ = '1.2.0' + +import importlib +import platform + +from docopt import docopt + +from hstsparser.utils import HSTSReader + + +system = platform.system() + +if system == 'Windows': + win32api = importlib.import_module('win32api') + *drives, _ = win32api.GetLogicalDriveStrings().split('\000') + print(drives) + + +def main() -> None: + """Entry point for the command-line alias.""" + arguments = docopt(__doc__, version=f'hstsparser {__version__}') + browser = 'firefox' + if browser == 'chrome': + pass + else: + pass + print(arguments.get('--sort')) diff --git a/hstsparser/__main__.py b/hstsparser/__main__.py new file mode 100644 index 0000000..e1a6f0b --- /dev/null +++ b/hstsparser/__main__.py @@ -0,0 +1,5 @@ +"""Executed on 'python -m hstsparser'.""" + +from hstsparser import main + +main() diff --git a/hstsparser/constants.py b/hstsparser/constants.py new file mode 100644 index 0000000..2424698 --- /dev/null +++ b/hstsparser/constants.py @@ -0,0 +1,17 @@ +"""Constants and Enums for use in HSTSReaders.""" + + +class Paths: + """Location of HSTS databases.""" + + class Windows: + """Location of HSTS Files on Windows.""" + + CHROME = r'Users\*\AppData\Roaming\Mozilla\Firefox\Profiles\*\SiteSecurityServiceState.txt' + FIREFOX = r'Users\*\AppData\Local\Google\Chrome\User Data\TransportSecurity' + + class Linux: + """Location of HSTS Files on Linux.""" + + CHROME = 'home/*/.config/google-chrome*/Default/TransportSecurity' + FIREFOX = 'home/*/.mozilla/firefox/*/SiteSecurityServiceState.txt' diff --git a/hstsparser/utils.py b/hstsparser/utils.py new file mode 100644 index 0000000..7398694 --- /dev/null +++ b/hstsparser/utils.py @@ -0,0 +1,49 @@ +"""Utilities for reading and parsing HSTS databases.""" + +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import Iterator, TextIO, Union + + +class HSTSReader: + """Implements a HSTS database reader.""" + + def __init__(self, file: Union[str, Path], browser: str) -> None: + """Initialise attributes.""" + if type(file) is str: + file = Path(file).expanduser() + self.file = file + self.browser = browser + + @staticmethod + def chrome(file: TextIO) -> Iterator: + """Read a chrome database.""" + ... + + @staticmethod + def firefox(file: TextIO) -> Iterator: + """Read a firefox database.""" + pattern = re.compile(r'\s+|:|,') + for line in file: + record = pattern.split(line) + domain, htype, visits, access, expires, _, subdomains, *_ = record + expiry = int(expires) / 1000 if expires.isnumeric() else 32503680000 + yield [ + domain.split('^')[0], + int(visits), + datetime.utcfromtimestamp(0) + timedelta(int(access)), + datetime.fromtimestamp(expiry), + htype, + 'No' if subdomains == '0' else 'Yes' + ] + + def __iter__(self) -> Iterator: + """Return the relevant iterator.""" + with self.file.open() as file: + if self.browser == 'firefox': + yield from self.firefox(file) + elif self.browser == 'chrome': + yield from self.chrome(file) + else: + raise Exception diff --git a/poetry.lock b/poetry.lock index ee63bb8..228d520 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,14 +6,6 @@ optional = false python-versions = "*" version = "0.16.1" -[[package]] -category = "main" -description = "Python command-line parsing library" -name = "argparse" -optional = false -python-versions = "*" -version = "1.4.0" - [[package]] category = "dev" description = "Classes Without Boilerplate" @@ -84,6 +76,14 @@ optional = false python-versions = "*" version = "0.1.3" +[[package]] +category = "main" +description = "Pythonic argument parser, that will make you smile" +name = "docopt" +optional = false +python-versions = "*" +version = "0.6.2" + [[package]] category = "dev" description = "Dodgy: Searches for dodgy looking lines in Python code" @@ -109,7 +109,7 @@ six = "*" pipenv = ["pipenv"] [[package]] -category = "dev" +category = "main" description = "Discover and load entry points from installed packages." name = "entrypoints" optional = false @@ -117,7 +117,7 @@ python-versions = ">=2.7" version = "0.3" [[package]] -category = "dev" +category = "main" description = "the modular source code checker: pep8, pyflakes and co" name = "flake8" optional = false @@ -274,7 +274,7 @@ version = "1.8" six = "*" [[package]] -category = "dev" +category = "main" description = "McCabe checker, plugin for flake8" name = "mccabe" optional = false @@ -321,7 +321,7 @@ python-versions = "*" version = "0.9.2" [[package]] -category = "dev" +category = "main" description = "Python style guide checker" name = "pycodestyle" optional = false @@ -329,7 +329,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.5.0" [[package]] -category = "dev" +category = "main" description = "passive checker of Python programs" name = "pyflakes" optional = false @@ -448,7 +448,7 @@ secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "cer socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [metadata] -content-hash = "96c0e688a0f82ed4502bdec8c0d09caf2b4b8744bc89f5bff0f4898195084e92" +content-hash = "2bd93e4ef9c5051b7cb41e977d4535ef2a4173d7f2fe7ad9a35765e7274fcfe9" python-versions = "^3.8" [metadata.files] @@ -456,10 +456,6 @@ altgraph = [ {file = "altgraph-0.16.1-py2.py3-none-any.whl", hash = "sha256:d6814989f242b2b43025cba7161fc1b8fb487a62cd49c49245d6fd01c18ac997"}, {file = "altgraph-0.16.1.tar.gz", hash = "sha256:ddf5320017147ba7b810198e0b6619bd7b5563aa034da388cea8546b877f9b0c"}, ] -argparse = [ - {file = "argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314"}, - {file = "argparse-1.4.0.tar.gz", hash = "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4"}, -] attrs = [ {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, @@ -489,6 +485,9 @@ dis3 = [ {file = "dis3-0.1.3-py3-none-any.whl", hash = "sha256:30b6412d33d738663e8ded781b138f4b01116437f0872aa56aa3adba6aeff218"}, {file = "dis3-0.1.3.tar.gz", hash = "sha256:9259b881fc1df02ed12ac25f82d4a85b44241854330b1a651e40e0c675cb2d1e"}, ] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] dodgy = [ {file = "dodgy-0.2.1-py3-none-any.whl", hash = "sha256:51f54c0fd886fa3854387f354b19f429d38c04f984f38bc572558b703c0542a6"}, {file = "dodgy-0.2.1.tar.gz", hash = "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a"}, diff --git a/pyproject.toml b/pyproject.toml index 5c17857..1621b71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hstsparser" -version = "0.1.0" +version = "1.2.0" description = "A tool to parse Firefox and Chrome HSTS databases into forensic artifacts." authors = ["Daniel Milnes "] license = "MIT" @@ -19,8 +19,8 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.8" -argparse = "^1.4" PTable = "^0.9.2" +docopt = "^0.6.2" [tool.poetry.dev-dependencies] flake8 = "=3.7.9" diff --git a/tox.ini b/tox.ini index 351c6f8..149b51e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,3 @@ [flake8] max-line-length=120 -application_import_names=bot -exclude=__pycache__ -import-order-style=pycharm -format=junit-xml -output-file=flake8.xml +ignore=TYP101