feat(foundations): add foundations tier — three teaching-first Python projects
Adds the foundations tier: three Python projects pitched at someone who has never written Python before, ramping from a single-file hash identifier up to the cryptographic boundary of the beginner tier. Projects - hash-identifier: identify ~30 hash formats by prefix, length, and charset (one file, ~30 tests). - http-headers-scanner: fetch a URL and grade its security headers A–F (httpx + rich + respx, single-file scanner, 13 tests). - password-manager: Argon2id + AES-256-GCM encrypted CLI vault with atomic durable writes and advisory file locking (the hardest foundations project — stepping stone into the beginner tier). Each project ships with - Heavily-commented source as a teaching aid (foundations-tier override of the no-comments rule — explanations targeted at first-time readers). - A full learn/ folder: Overview, Concepts, Architecture, line-by-line Implementation walkthrough, Challenges. - README with ASCII banner, badges (incl. tier + difficulty), demo sessions, command tables, learn-folder index, see-also links. - install.sh + justfile + uv-managed dependencies. - pytest + ruff + mypy + pylint config, all linters at 10.00/10. Also centralizes the docs/ exclude in .gitignore (was a per-project list of advanced/beginner docs paths) so dev-only audit reports and plan documents stay local across the whole repo without needing individual entries.
This commit is contained in:
parent
7d69339413
commit
d97662ee61
|
|
@ -7,8 +7,7 @@
|
|||
.worktrees/
|
||||
DEMO-TRACKER.md
|
||||
|
||||
PROJECTS/beginner/hash-cracker/docs/
|
||||
PROJECTS/advanced/monitor-the-situation-dashboard/docs/
|
||||
/docs/
|
||||
|
||||
# Python virtualenvs and caches
|
||||
.venv/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
#
|
||||
# This file lists patterns of files and folders that git should NEVER
|
||||
# track. Anything matching a pattern below is invisible to `git status`
|
||||
# and won't be committed. We do this to avoid two problems:
|
||||
#
|
||||
# 1. Privacy — we never want to accidentally commit a sensitive file
|
||||
# a user might pass to the tool.
|
||||
#
|
||||
# 2. Noise — build artifacts and caches change constantly. Tracking
|
||||
# them would clutter every commit and create merge conflicts.
|
||||
#
|
||||
# Each line is a "glob" pattern (* matches any characters).
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Python build/install artifacts
|
||||
# -----------------------------------------------------------------------------
|
||||
# __pycache__: bytecode Python generates from .py files when it runs them.
|
||||
# We never edit these, and they regenerate on every run, so ignore them.
|
||||
__pycache__/
|
||||
# *.py[cod] = .pyc, .pyo, .pyd — older bytecode formats Python may emit.
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
# Build outputs from `pip wheel`, hatchling, etc.
|
||||
*.egg-info/
|
||||
*.egg
|
||||
build/
|
||||
dist/
|
||||
.eggs/
|
||||
*.so
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Virtual environments — created by `uv venv`, never committed
|
||||
# -----------------------------------------------------------------------------
|
||||
# A virtual environment is a per-project Python install. It lives inside
|
||||
# the project directory. Committing it would balloon the repo and pin
|
||||
# the user to your operating system. Always recreate locally.
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tooling caches — these speed up repeated runs but never need committing
|
||||
# -----------------------------------------------------------------------------
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
# Coverage report files from pytest-cov.
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IDE / editor scratch files
|
||||
# -----------------------------------------------------------------------------
|
||||
# Each editor drops its own metadata. None of it belongs in the repo.
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
# macOS Finder dumps these in every directory it has opened.
|
||||
.DS_Store
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Local environment files (may contain secrets)
|
||||
# -----------------------------------------------------------------------------
|
||||
# We never use .env in this project, but list it just in case. Anything
|
||||
# containing secrets should never reach the public repo.
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .style.yapf
|
||||
#
|
||||
# Configuration for yapf, our Python code formatter. yapf rewrites
|
||||
# Python files to match these rules so every file looks identical
|
||||
# no matter who wrote it.
|
||||
#
|
||||
# The two settings most worth knowing:
|
||||
# based_on_style = pep8 → start from PEP 8 (the official style)
|
||||
# column_limit = 75 → wrap lines at 75 characters
|
||||
#
|
||||
# Run with: `just format`
|
||||
[style]
|
||||
# Start from PEP 8 (Python's official style guide). Every other
|
||||
# setting below tweaks PEP 8 in some specific direction.
|
||||
based_on_style = pep8
|
||||
# Wrap any line longer than this many columns.
|
||||
column_limit = 75
|
||||
# Use 4 spaces per indent level (PEP 8 standard).
|
||||
indent_width = 4
|
||||
# Continuation lines (when a statement wraps) also indent by 4.
|
||||
continuation_indent_width = 4
|
||||
# Don't indent the closing bracket of a multi-line call/list/dict
|
||||
indent_closing_brackets = false
|
||||
# DO de-indent the closing bracket — line it up with the opener.
|
||||
dedent_closing_brackets = true
|
||||
# Don't add 4 spaces of indent to blank lines inside functions.
|
||||
indent_blank_lines = false
|
||||
# Two spaces between code and the # of a trailing comment.
|
||||
spaces_before_comment = 2
|
||||
# No spaces around ** (power) operator: 2**3 not 2 ** 3.
|
||||
spaces_around_power_operator = false
|
||||
# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
|
||||
spaces_around_default_or_named_assign = true
|
||||
# No extra space before a comma followed by a closing bracket.
|
||||
space_between_ending_comma_and_closing_bracket = false
|
||||
# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
|
||||
space_inside_brackets = false
|
||||
# Spaces around colons in slices: x[1 : 5] not x[1:5].
|
||||
spaces_around_subscript_colon = true
|
||||
# No blank line before nested def/class.
|
||||
blank_line_before_nested_class_or_def = false
|
||||
# No blank line between a class and its docstring.
|
||||
blank_line_before_class_docstring = false
|
||||
# Two blank lines around top-level functions and classes (PEP 8).
|
||||
blank_lines_around_top_level_definition = 2
|
||||
# Two blank lines between top-level imports and module-level variables.
|
||||
blank_lines_between_top_level_imports_and_variables = 2
|
||||
# No blank line before the module docstring.
|
||||
blank_line_before_module_docstring = false
|
||||
# When wrapping `a and b`, put `and` at the start of the new line.
|
||||
split_before_logical_operator = true
|
||||
# When wrapping a function call, put the first argument on a new line.
|
||||
split_before_first_argument = true
|
||||
# Put each named arg on its own line when wrapping.
|
||||
split_before_named_assigns = true
|
||||
# Multi-line comprehensions wrap onto separate lines.
|
||||
split_complex_comprehension = true
|
||||
# Don't split right after the opening parenthesis.
|
||||
split_before_expression_after_opening_paren = false
|
||||
# Put the closing bracket on its own line when wrapping.
|
||||
split_before_closing_bracket = true
|
||||
# When splitting at commas, give every value its own line.
|
||||
split_all_comma_separated_values = true
|
||||
# But don't recursively force ALL top-level commas onto new lines.
|
||||
split_all_top_level_comma_separated_values = false
|
||||
# Don't merge separate brackets into one.
|
||||
coalesce_brackets = false
|
||||
# Each dict entry gets its own line in multi-line dicts.
|
||||
each_dict_entry_on_separate_line = true
|
||||
# Disallow lambdas that span multiple lines.
|
||||
allow_multiline_lambdas = false
|
||||
# Disallow dict keys that span multiple lines.
|
||||
allow_multiline_dictionary_keys = false
|
||||
# Penalty score for splitting at import names — 0 means free to split.
|
||||
split_penalty_import_names = 0
|
||||
# Don't try to fit an already-broken statement back onto fewer lines.
|
||||
join_multiple_lines = false
|
||||
# Closing bracket lines up with the visual indent of the opener.
|
||||
align_closing_bracket_with_visual_indent = true
|
||||
# Don't add parentheses to clarify operator precedence automatically.
|
||||
arithmetic_precedence_indication = false
|
||||
# Penalty for splitting after each line — yapf weighs this against rules.
|
||||
split_penalty_for_added_line_split = 275
|
||||
# Use spaces for indentation, never tabs (PEP 8).
|
||||
use_tabs = false
|
||||
# Don't split on a `.` when wrapping (so `obj.method()` stays together).
|
||||
split_before_dot = false
|
||||
# When the last argument in a call has a trailing comma, force a split.
|
||||
split_arguments_when_comma_terminated = true
|
||||
# Function names yapf treats as i18n calls — leave their args alone.
|
||||
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
|
||||
# Comment markers yapf treats as i18n metadata.
|
||||
i18n_comment = ['# Translators:', '# i18n:']
|
||||
# Penalty for splitting comprehension iterables.
|
||||
split_penalty_comprehension = 80
|
||||
# Penalty for splitting right after an opening bracket.
|
||||
split_penalty_after_opening_bracket = 280
|
||||
# Penalty for splitting before an `if` in a ternary expression.
|
||||
split_penalty_before_if_expr = 0
|
||||
# Penalty for splitting before a bitwise operator.
|
||||
split_penalty_bitwise_operator = 290
|
||||
# Penalty for splitting before a logical operator (already lowered).
|
||||
split_penalty_logical_operator = 0
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
```ruby
|
||||
██╗ ██╗ █████╗ ███████╗██╗ ██╗ ██╗██████╗
|
||||
██║ ██║██╔══██╗██╔════╝██║ ██║ ██║██╔══██╗
|
||||
███████║███████║███████╗███████║ ██║██║ ██║
|
||||
██╔══██║██╔══██║╚════██║██╔══██║ ██║██║ ██║
|
||||
██║ ██║██║ ██║███████║██║ ██║ ██║██████╔╝
|
||||
╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝╚═════╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/hash-identifier)
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
|
||||
[](https://www.python.org)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://pytest.org)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
|
||||
> Identify the algorithm behind a hash string by its prefix, length, and character set — the first move in any password-cracking workflow.
|
||||
|
||||
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
|
||||
|
||||
> [!NOTE]
|
||||
> **Foundations tier** — this project is built for someone who has never written Python before. The source code is heavily commented as a teaching aid, the `learn/` folder explains every concept from zero, and the whole tool is one readable file. If you already know Python, jump straight to [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural cracking companion to this identifier.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Identify ~30 hash formats by prefix (`$2b$`, `$argon2id$`, `$apr1$`, `pbkdf2_sha256$`, `{SSHA}`, and more)
|
||||
- Identify common hex hashes by length (MD5, SHA-1, SHA-256, SHA-512, NTLM, MD4, RIPEMD, BLAKE2, SHA-3)
|
||||
- Recognize MySQL5, NetNTLMv1/v2, and traditional 13-char DES crypt by shape
|
||||
- Detect non-hash inputs (JWTs, base64 blobs) and tell the user what they actually pasted
|
||||
- Return ranked candidates with `high` / `medium` / `low` confidence and a one-line *reason* for every guess
|
||||
- Pure-function core — no network, no filesystem, no global state, instant runtime
|
||||
- Rich-rendered colored output table; clean exit codes for shell scripting
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
|
||||
# ✔ MD5 (medium) — 32 hex chars, most likely candidate at this length
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
|
||||
>
|
||||
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
|
||||
|
||||
## Demo Hashes
|
||||
|
||||
Try these — each demonstrates a different identification path:
|
||||
|
||||
| Hash | Detected as | Why |
|
||||
|------|-------------|-----|
|
||||
| `5f4dcc3b5aa765d61d8327deb882cf99` | MD5 | 32 hex chars — most likely candidate at this length |
|
||||
| `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | SHA-256 | 64 hex chars — most likely candidate at this length |
|
||||
| `$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK` | bcrypt | prefix `$2b$` — bcrypt PHC string, 2b variant (current) |
|
||||
| `$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG` | Argon2id | prefix `$argon2id$` — modern PHC string, the current standard |
|
||||
| `$apr1$JlOdSlVe$ipa1mTAv3LFRBHHzqaIaH/` | Apache MD5-crypt | prefix `$apr1$` — Apache htpasswd MD5 variant (`htpasswd -m`) |
|
||||
| `*A4B6157319038724E3560894F7F932C8886EBFCF` | MySQL5 | starts with `*` followed by 40 uppercase hex chars |
|
||||
| `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgN...` | JWT (not a hash) | leading `eyJ` is base64 of `{"` — JWT, not a hash |
|
||||
|
||||
```bash
|
||||
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
|
||||
just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK'
|
||||
just run -- '*A4B6157319038724E3560894F7F932C8886EBFCF'
|
||||
just run -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Always wrap hashes that begin with `$` in **single quotes**. Without quotes your shell will try to expand `$2`, `$P$`, `$1$` etc. as shell variables and silently mangle the input.
|
||||
|
||||
## Tooling
|
||||
|
||||
```bash
|
||||
just # list available recipes
|
||||
just test # run pytest (30+ tests, runs in under a second)
|
||||
just lint # ruff + mypy --strict + pylint
|
||||
just format # yapf
|
||||
just run -- <h> # identify a hash
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.14+** — the install script will check.
|
||||
- [`uv`](https://github.com/astral-sh/uv) — modern Python package manager (auto-installed by `./install.sh`).
|
||||
- [`just`](https://github.com/casey/just) — command runner (auto-installed by `./install.sh`).
|
||||
|
||||
No compilers, no system libraries, no network access required. The project is one Python file plus tests.
|
||||
|
||||
## Learn
|
||||
|
||||
This project includes step-by-step learning materials covering security theory, architecture, and implementation — written for someone who has never touched Python before.
|
||||
|
||||
| Module | Topic |
|
||||
|--------|-------|
|
||||
| [00 - Overview](learn/00-OVERVIEW.md) | Quick start, prerequisites, common problems |
|
||||
| [01 - Concepts](learn/01-CONCEPTS.md) | What hashes are, real-world breaches, the three identification signals |
|
||||
| [02 - Architecture](learn/02-ARCHITECTURE.md) | Three-layer architecture, six-step decision pipeline, data-driven design |
|
||||
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Line-by-line walkthrough — every Python feature explained when first encountered |
|
||||
| [04 - Challenges](learn/04-CHALLENGES.md) | Five tiers of extension ideas, from adding a prefix rule to building an ML classifier |
|
||||
|
||||
## See Also
|
||||
|
||||
- [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural sibling. Once this tool tells you *what* a hash is, that one teaches you how to crack it.
|
||||
- [`PROJECTS/foundations/http-headers-scanner`](../http-headers-scanner) — another foundations-tier Python project, slightly more involved I/O.
|
||||
- [`PROJECTS/foundations/password-manager`](../password-manager) — the hardest foundations-tier project; covers Argon2id, AES-GCM, and on-disk vaults.
|
||||
|
||||
## License
|
||||
|
||||
AGPL 3.0
|
||||
|
|
@ -0,0 +1,679 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
hash_identifier.py
|
||||
|
||||
Identify what kind of hash a string is, by inspecting its shape
|
||||
|
||||
When someone hands you a string of gibberish like
|
||||
`5f4dcc3b5aa765d61d8327deb882cf99` or `$2b$12$EixZaYVK1fsbw1ZfbX3OXe...`,
|
||||
the first question is: what algorithm produced it? You can NOT crack
|
||||
or analyze a hash without knowing what flavor of hash you are looking
|
||||
at. Every cracking tool — hashcat, john the ripper — needs you to
|
||||
specify an algorithm before it will start
|
||||
|
||||
This script does the looking-at part. Given a hash string, it returns
|
||||
ranked candidates with a confidence score and a short reason
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
How identification actually works
|
||||
────────────────────────────────────────────────────────────────────
|
||||
There is no magic here. Hash strings carry shape clues
|
||||
|
||||
1. PREFIX. Many modern hashes are stored in "PHC string format" —
|
||||
a self-describing format that starts with a marker like `$2b$`
|
||||
(bcrypt) or `$argon2id$` (Argon2id). When we see a known prefix
|
||||
we know the algorithm with HIGH confidence
|
||||
|
||||
2. LENGTH. Raw hex output of a hash function is always the same
|
||||
length: MD5 produces 16 bytes = 32 hex chars; SHA-1 produces 20
|
||||
bytes = 40 hex chars; SHA-256 produces 32 bytes = 64 hex chars,
|
||||
and so on. Length alone narrows the field
|
||||
|
||||
3. CHARSET. Different formats use different alphabets. Hex hashes
|
||||
use only 0-9 and a-f. Base64 uses 0-9, A-Z, a-z, +, /, and =.
|
||||
A string with `+` in it is not a hex hash
|
||||
|
||||
So our algorithm is: try prefix rules first, fall back to length +
|
||||
charset rules, return the candidates ranked by confidence
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What this script can and cannot do
|
||||
────────────────────────────────────────────────────────────────────
|
||||
CAN: suggest likely algorithms for a hash you found
|
||||
CANNOT: tell you the password the hash was made from
|
||||
(that is hashcat's job — see ../../beginner/hash-cracker)
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What this file exposes
|
||||
────────────────────────────────────────────────────────────────────
|
||||
HashCandidate — one ranked guess (algorithm, confidence, reason)
|
||||
identify(text) — return ranked candidates for a hash string
|
||||
main() — CLI entry point used by `hashid <hash>`
|
||||
"""
|
||||
|
||||
# Standard library: parse command-line flags like `--top 3` into a
|
||||
# nice object so we do not have to slice `sys.argv` by hand.
|
||||
import argparse
|
||||
|
||||
# Standard library: access to interpreter internals — we use it to
|
||||
# write to stderr and to exit the process with a specific status code.
|
||||
import sys
|
||||
|
||||
# Standard library: a decorator that turns a class into a small,
|
||||
# immutable data record without writing `__init__` boilerplate.
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Standard library: a type hint that pins a value to a small fixed
|
||||
# set of strings (here: "high" / "medium" / "low"). Mypy catches typos.
|
||||
from typing import Literal
|
||||
|
||||
# Third-party (rich): the printer that actually draws the table to
|
||||
# the terminal, with color and Unicode support.
|
||||
from rich.console import Console
|
||||
|
||||
# Third-party (rich): builds the colored ASCII table we print for
|
||||
# ranked hash candidates.
|
||||
from rich.table import Table
|
||||
|
||||
# =============================================================================
|
||||
# Confidence type — only three valid values
|
||||
# =============================================================================
|
||||
# Literal["high", "medium", "low"] is a type hint that says "this string
|
||||
# can ONLY be one of these three values." Mypy will catch typos like
|
||||
# "hgih" at edit time. We chose Literal over an Enum because Carter's
|
||||
# style guide prefers Literals for small fixed sets
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Result type — what identify() returns for each guess
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen = True, slots = True)
|
||||
class HashCandidate:
|
||||
"""
|
||||
One possible identification of a hash string
|
||||
|
||||
`frozen=True` makes the dataclass immutable — once created, its
|
||||
fields cannot change. `slots=True` makes instances lightweight in
|
||||
memory. Together these two flags create a clean "value object"
|
||||
|
||||
Fields
|
||||
------
|
||||
algorithm
|
||||
Human-readable algorithm name like "SHA-256" or "bcrypt"
|
||||
confidence
|
||||
How sure we are. "high" comes from definitive prefix matches,
|
||||
"medium" from length matches that have only one obvious
|
||||
candidate, "low" for length matches that could be many things
|
||||
reason
|
||||
Short explanation we display next to the algorithm name. Keeps
|
||||
the output debuggable — the user can see WHY each guess fired
|
||||
"""
|
||||
algorithm: str
|
||||
confidence: Confidence
|
||||
reason: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Prefix rules — strongest signal we have
|
||||
# =============================================================================
|
||||
# Modern hashes use PHC-style strings: a leading `$` marker tells you
|
||||
# exactly what algorithm produced the hash. When we see one of these
|
||||
# prefixes we report HIGH confidence. The third element of each tuple
|
||||
# is a short note we include in the reason field
|
||||
#
|
||||
# Order matters when prefixes overlap. We list more specific prefixes
|
||||
# FIRST so they match before generic ones (e.g. `$argon2id$` before
|
||||
# `$argon2$` would matter if both existed in the table)
|
||||
|
||||
PREFIX_RULES: list[tuple[str, str, str]] = [
|
||||
# Argon2 family — won the 2015 Password Hashing Competition
|
||||
("$argon2id$", "Argon2id", "modern PHC string, the current standard"),
|
||||
("$argon2i$", "Argon2i", "PHC string, side-channel-resistant variant"),
|
||||
("$argon2d$", "Argon2d", "PHC string, GPU-resistant variant"),
|
||||
|
||||
# bcrypt and its many variants — workhorse for the past 15 years
|
||||
("$2y$", "bcrypt", "bcrypt PHC string, 2y variant (PHP)"),
|
||||
("$2b$", "bcrypt", "bcrypt PHC string, 2b variant (current)"),
|
||||
("$2a$", "bcrypt", "bcrypt PHC string, 2a variant (legacy)"),
|
||||
("$2x$", "bcrypt", "bcrypt PHC string, 2x variant (legacy fix)"),
|
||||
|
||||
# Unix crypt(3) family — what /etc/shadow uses on Linux
|
||||
("$6$", "SHA-512 crypt", "Unix crypt(3) using SHA-512 (default on Linux)"),
|
||||
("$5$", "SHA-256 crypt", "Unix crypt(3) using SHA-256"),
|
||||
("$1$", "MD5 crypt", "Unix crypt(3) using MD5 (legacy, weak)"),
|
||||
|
||||
# Apache htpasswd MD5 variant — same MD5 family as $1$ above but
|
||||
# with Apache's own salt-handling tweak. This is the format that
|
||||
# `htpasswd -m` emits by default, which makes it FAR more common
|
||||
# in the wild than the Unix $1$ form (every basic-auth tutorial
|
||||
# ends with one of these in a .htpasswd file)
|
||||
("$apr1$", "Apache MD5-crypt", "Apache htpasswd MD5 variant (`htpasswd -m`)"),
|
||||
|
||||
# yescrypt — newer Linux default in some distributions
|
||||
("$y$", "yescrypt", "PHC string, modern Linux crypt successor"),
|
||||
|
||||
# phpass — used by WordPress, phpBB, and other PHP apps
|
||||
("$P$", "phpass", "WordPress / phpBB password hash"),
|
||||
("$H$", "phpass", "phpBB-style phpass variant"),
|
||||
|
||||
# Drupal 7
|
||||
("$S$", "Drupal 7 (SHA-512)", "Drupal 7 PHC-style hash"),
|
||||
|
||||
# scrypt as some implementations encode it
|
||||
("$7$", "scrypt", "scrypt PHC-style hash"),
|
||||
|
||||
# Django's default — recognizable by the algorithm name in the prefix
|
||||
("pbkdf2_sha256$", "Django PBKDF2-SHA256", "Django default password hash"),
|
||||
("pbkdf2_sha1$", "Django PBKDF2-SHA1", "Django legacy password hash"),
|
||||
("bcrypt_sha256$", "Django bcrypt-SHA256", "Django bcrypt wrapper"),
|
||||
("argon2$", "Django Argon2", "Django Argon2 wrapper"),
|
||||
|
||||
# LDAP password schemes — base64 payload after the marker
|
||||
("{SSHA}", "LDAP SSHA", "LDAP salted SHA-1 (base64 payload)"),
|
||||
("{SHA}", "LDAP SHA", "LDAP SHA-1 (base64 payload)"),
|
||||
("{SMD5}", "LDAP SMD5", "LDAP salted MD5 (base64 payload)"),
|
||||
("{MD5}", "LDAP MD5", "LDAP MD5 (base64 payload)"),
|
||||
("{CRYPT}", "LDAP CRYPT", "LDAP wrapping a crypt(3) hash"),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Length-and-hex rules — fallback when no prefix matched
|
||||
# =============================================================================
|
||||
# Raw hash output is always the same length, so a string of N hex chars
|
||||
# narrows down the algorithm. The list of algorithms for each length is
|
||||
# sorted by REAL-WORLD prevalence. The first item gets MEDIUM confidence
|
||||
# (the "most likely default"), the rest LOW (still possible)
|
||||
|
||||
# Hex chars are 0-9 plus a-f (or A-F if uppercase)
|
||||
HEX_CHARSET: frozenset[str] = frozenset("0123456789abcdefABCDEF")
|
||||
|
||||
# Uppercase-only hex charset — some formats (MySQL5) ONLY ever emit
|
||||
# uppercase hex because they print via the `%02X` C format specifier,
|
||||
# so checking membership in this tighter charset lets us reject
|
||||
# lowercase strings as false-positives instead of confidently saying
|
||||
# "yes that is MySQL5" to an obviously hand-edited input
|
||||
_HEX_UPPER_CHARSET: frozenset[str] = frozenset("0123456789ABCDEF")
|
||||
|
||||
# Length-in-hex-chars → list of algorithms, ordered by commonality
|
||||
HEX_LENGTH_RULES: dict[int, list[str]] = {
|
||||
# 16 hex chars = 8 bytes = 64 bits. This is the pre-MySQL-4.1
|
||||
# OLD_PASSWORD() output — still produced today by MySQL's
|
||||
# OLD_PASSWORD() SQL function for legacy compatibility, and
|
||||
# still appears in CTFs and old MySQL breach dumps. The only
|
||||
# other thing that produces 16 hex chars in a security context
|
||||
# is a 64-bit CRC, which is rare enough that MySQL323 outranks it
|
||||
16: ["MySQL323", "CRC-64"],
|
||||
# 32 hex chars = 16 bytes = 128 bits
|
||||
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
|
||||
# 40 hex chars = 20 bytes = 160 bits
|
||||
40: ["SHA-1", "RIPEMD-160"],
|
||||
# 48 hex chars = 24 bytes = 192 bits
|
||||
48: ["Tiger-192"],
|
||||
# 56 hex chars = 28 bytes = 224 bits
|
||||
56: ["SHA-224", "SHA3-224"],
|
||||
# 64 hex chars = 32 bytes = 256 bits
|
||||
64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
|
||||
# 80 hex chars = 40 bytes = 320 bits (uncommon)
|
||||
80: ["RIPEMD-320"],
|
||||
# 96 hex chars = 48 bytes = 384 bits
|
||||
96: ["SHA-384", "SHA3-384"],
|
||||
# 128 hex chars = 64 bytes = 512 bits
|
||||
128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _is_hex(text: str) -> bool:
|
||||
"""
|
||||
Return True iff every character in text is a hex digit and text is non-empty
|
||||
|
||||
A hash like "5f4dcc..." passes; an empty string or anything with
|
||||
a non-hex character fails. We use the HEX_CHARSET frozenset for
|
||||
membership tests because `c in frozenset` is O(1) lookup, faster
|
||||
than `c in "0123456789abcdef..."` for big inputs
|
||||
"""
|
||||
return bool(text) and all(c in HEX_CHARSET for c in text)
|
||||
|
||||
|
||||
# MySQL5 layout: `*` followed by 40 uppercase hex chars.
|
||||
# Pulling both numbers out as named constants keeps the helper below
|
||||
# from reading like sprinkled-in magic numbers — `_MYSQL5_TOTAL_LENGTH`
|
||||
# tells the reader WHY the helper compares to 41 (40 hex chars + the
|
||||
# leading `*`) instead of leaving 41 unexplained
|
||||
_MYSQL5_HEX_BODY_LENGTH = 40
|
||||
_MYSQL5_TOTAL_LENGTH = _MYSQL5_HEX_BODY_LENGTH + 1
|
||||
|
||||
|
||||
def _is_mysql5(text: str) -> bool:
|
||||
"""
|
||||
Return True for MySQL5 password format: `*` then 40 UPPERCASE hex chars
|
||||
|
||||
MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
|
||||
a leading `*`. Real MySQL5 output uses the `%02X` C format
|
||||
specifier, which is uppercase-only. We reject lowercase here so
|
||||
we do not return a confident HIGH-confidence "MySQL5" verdict on
|
||||
a hand-edited or mistyped string — better to fall through to
|
||||
no-match than to lie with conviction
|
||||
|
||||
NOTE: we cannot just call `body.isupper()` to enforce the case.
|
||||
Python's `str.isupper` returns False when a string contains NO
|
||||
cased characters at all, which would wrongly reject an all-digit
|
||||
hex body like "0123456789ABCDEF..." with no letters in it.
|
||||
Checking membership in an uppercase-only charset is the test
|
||||
that actually matches the spec
|
||||
"""
|
||||
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
|
||||
return False
|
||||
body = text[1 :]
|
||||
return all(c in _HEX_UPPER_CHARSET for c in body)
|
||||
|
||||
|
||||
# Traditional DES crypt — legacy /etc/passwd hashes from pre-shadow
|
||||
# Unix systems. They have no prefix at all: just 13 characters drawn
|
||||
# from a specific 64-char alphabet (the same alphabet used by all
|
||||
# crypt(3) variants for their base64-style output). We pull both the
|
||||
# charset and the expected length out as named constants so the helper
|
||||
# below reads like its meaning instead of like sprinkled magic numbers
|
||||
_DESCRYPT_CHARSET: frozenset[str] = frozenset(
|
||||
"./0123456789"
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
)
|
||||
_DESCRYPT_TOTAL_LENGTH = 13
|
||||
|
||||
|
||||
def _is_descrypt(text: str) -> bool:
|
||||
"""
|
||||
Return True for traditional 13-char DES crypt (legacy /etc/passwd)
|
||||
|
||||
No prefix at all — just 13 characters drawn from `./0-9A-Za-z`.
|
||||
Old enough that you mostly see it in retro CTFs now, but real
|
||||
enough that hashcat still ships a mode (1500) for cracking it.
|
||||
We report MEDIUM (not HIGH) confidence on a match because a 13-char
|
||||
string in that charset CAN be other things (some session IDs,
|
||||
encoded values), and a beginner deserves an honest confidence
|
||||
level rather than a false-positive at HIGH
|
||||
"""
|
||||
return (
|
||||
len(text) == _DESCRYPT_TOTAL_LENGTH
|
||||
and all(c in _DESCRYPT_CHARSET for c in text)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The actual identifier
|
||||
# =============================================================================
|
||||
# pylint flags this function for having many return statements and many
|
||||
# branches. That is the cost of writing the function as six numbered,
|
||||
# linear steps — each step gets its own short block and returns the
|
||||
# moment it finds a match, which is the structure that maps cleanly
|
||||
# onto the docstring. Refactoring into helper functions would obscure
|
||||
# the pedagogical line-by-line flow, so we accept the extra returns
|
||||
# here as a deliberate teaching choice and silence the two warnings
|
||||
# pylint: disable=too-many-return-statements,too-many-branches
|
||||
|
||||
|
||||
def identify(raw_input: str) -> list[HashCandidate]:
|
||||
"""
|
||||
Return ranked candidates for what algorithm produced `raw_input`
|
||||
|
||||
Algorithm
|
||||
---------
|
||||
Whitespace is trimmed from `raw_input` first. Then the six
|
||||
matching steps below run in order — each step's labeled comment
|
||||
(`# ----- Step N: ... -----`) inside the function body matches
|
||||
the number in this list, so the docstring and the code stay in
|
||||
lockstep:
|
||||
|
||||
1. Walk the PREFIX_RULES table. The first prefix that matches
|
||||
wins (HIGH confidence). Each table row is unique enough that
|
||||
at most one entry can match a given input
|
||||
2. Check special non-PHC formats in order — NetNTLMv1/v2
|
||||
challenge-response records, MySQL5 (`*<40 hex>`), and the
|
||||
legacy 13-char DES crypt. Each has a distinctive shape that
|
||||
takes precedence over generic length-based matching
|
||||
3. If the input is pure hex, look up its length in
|
||||
HEX_LENGTH_RULES and report each candidate. The first entry
|
||||
at each length gets MEDIUM confidence (the modern default);
|
||||
the rest LOW
|
||||
4. If the input has a `$<algo>$...` shape but no PREFIX_RULES
|
||||
row matched, fall back to a generic PHC string match — LOW
|
||||
confidence because we only matched the shape, not a specific
|
||||
rule
|
||||
5. If the input looks like a JWT (`eyJ...`) or a base64 blob
|
||||
(contains `+`, `/`, or `=`), say so with LOW confidence —
|
||||
these are not hashes, but a beginner deserves to know what
|
||||
they pasted instead of a silent no-match
|
||||
6. If nothing matched at all, return an empty list
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw_input
|
||||
The hash string to identify. Whitespace is trimmed before
|
||||
analysis but the rest is treated literally — case matters
|
||||
because some algorithms use uppercase output
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[HashCandidate]
|
||||
Possibly empty. When non-empty, candidates are ordered by
|
||||
confidence (high before medium before low) and within each
|
||||
confidence band by likelihood
|
||||
"""
|
||||
# Trim whitespace — copy-pasted hashes often arrive with trailing
|
||||
# newlines or leading spaces. We do not modify case because some
|
||||
# formats are case-sensitive
|
||||
text = raw_input.strip()
|
||||
|
||||
if not text:
|
||||
return []
|
||||
|
||||
# ----- Step 1: prefix rules -----
|
||||
# Walk the table top-to-bottom. Entries in PREFIX_RULES are crafted
|
||||
# so that no two of them can EVER both match the same input — the
|
||||
# first prefix that matches is THE match. So we return it right
|
||||
# away instead of continuing the loop. HIGH confidence is the
|
||||
# right label because a known prefix is a definitive self-
|
||||
# identification: the hash literally announces what algorithm
|
||||
# produced it, we are not guessing
|
||||
for prefix, algorithm, note in PREFIX_RULES:
|
||||
if text.startswith(prefix):
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = algorithm,
|
||||
confidence = "high",
|
||||
reason = f"prefix `{prefix}` — {note}",
|
||||
)
|
||||
]
|
||||
|
||||
# ----- Step 2: special non-PHC formats -----
|
||||
# Formats that do not fit the `$algo$...` PHC mold but still have
|
||||
# unmistakable shapes. We check each in turn before falling through
|
||||
# to the generic length-based step
|
||||
|
||||
# NetNTLMv1 / NetNTLMv2 — the dominant outputs of AD pentest
|
||||
# tools like Responder, secretsdump, ntlmrelayx, and Inveigh.
|
||||
# They are NOT hashes in the "irreversible function" sense —
|
||||
# they are challenge-response records — but every beginner who
|
||||
# runs their first AD lab pastes one of these into a hash
|
||||
# identifier. The literal `::` (an empty second field, where the
|
||||
# LM-hash used to live) is the structural giveaway. We test v2
|
||||
# FIRST because v2's hmac field at index 4 is 32 hex chars while
|
||||
# v1's nthash at the same index is 48 — so if we tested v1 first
|
||||
# we would still match v2 inputs at parts[3] (also 48 in v2's
|
||||
# case for the challenge length in some encodings)
|
||||
if "::" in text and text.count(":") >= 4:
|
||||
parts = text.split(":")
|
||||
# NetNTLMv2 layout:
|
||||
# user :: domain : challenge : hmac(32 hex) : blob(>=32 hex)
|
||||
# The hmac at parts[4] is exactly 32 hex chars — that single
|
||||
# property is enough to disambiguate v2 from v1
|
||||
if (len(parts) >= 6 and len(parts[4]) == 32 and _is_hex(parts[4])):
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = "NetNTLMv2",
|
||||
confidence = "high",
|
||||
reason =
|
||||
"user::domain:challenge:hmac(32 hex):blob shape",
|
||||
)
|
||||
]
|
||||
# NetNTLMv1 layout:
|
||||
# user :: domain : lmhash(48 hex) : nthash(48 hex) : challenge
|
||||
# The lmhash at parts[3] is exactly 48 hex chars
|
||||
if (len(parts) >= 6 and len(parts[3]) == 48 and _is_hex(parts[3])):
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = "NetNTLMv1",
|
||||
confidence = "high",
|
||||
reason =
|
||||
"user::domain:lm(48 hex):nt(48 hex):challenge shape",
|
||||
)
|
||||
]
|
||||
|
||||
# MySQL5 — literal `*` + 40 uppercase hex chars
|
||||
if _is_mysql5(text):
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = "MySQL5",
|
||||
confidence = "high",
|
||||
reason =
|
||||
"starts with `*` followed by 40 uppercase hex chars",
|
||||
)
|
||||
]
|
||||
|
||||
# Traditional 13-char DES crypt — legacy /etc/passwd format
|
||||
# with no prefix at all. We report MEDIUM (not HIGH) because the
|
||||
# 13-char `./0-9A-Za-z` shape isn't fully unique to DES crypt
|
||||
if _is_descrypt(text):
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = "DES crypt",
|
||||
confidence = "medium",
|
||||
reason =
|
||||
"13 chars in `./0-9A-Za-z` — legacy /etc/passwd format",
|
||||
)
|
||||
]
|
||||
|
||||
# ----- Step 3: length + hex charset -----
|
||||
if _is_hex(text):
|
||||
algorithms = HEX_LENGTH_RULES.get(len(text), [])
|
||||
candidates: list[HashCandidate] = []
|
||||
for index, algorithm in enumerate(algorithms):
|
||||
# The first listed algorithm for each length is the modern
|
||||
# default and gets MEDIUM confidence. The rest are still
|
||||
# possible but less common in 2026 — LOW confidence
|
||||
confidence: Confidence = "medium" if index == 0 else "low"
|
||||
label = (
|
||||
"most likely candidate at this length"
|
||||
if index == 0 else "also possible at this length"
|
||||
)
|
||||
candidates.append(
|
||||
HashCandidate(
|
||||
algorithm = algorithm,
|
||||
confidence = confidence,
|
||||
reason = f"{len(text)} hex chars — {label}",
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
# ----- Step 4: generic PHC string fallback -----
|
||||
# If the input starts with `$<name>$...` and <name> looks like a
|
||||
# plausible algorithm identifier, it is almost certainly a PHC
|
||||
# string from an algorithm we do not have a specific rule for.
|
||||
# Passlib alone ships ~30 PHC encodings; our PREFIX_RULES table
|
||||
# covers only the most common 20-or-so. Reporting it as a generic
|
||||
# PHC string with the extracted algorithm name is strictly better
|
||||
# than silence, and the LOW confidence label is honest about not
|
||||
# having a specific rule to confirm the algorithm
|
||||
if text.startswith("$"):
|
||||
# Drop the leading `$`, then look for the second `$` that
|
||||
# closes the algorithm-name field. If there's no second `$`,
|
||||
# this isn't a PHC string at all, just a string that happens
|
||||
# to start with `$` — fall through
|
||||
rest = text[1 :]
|
||||
if "$" in rest:
|
||||
algo_name = rest.split("$", 1)[0]
|
||||
# The PHC spec restricts algorithm IDs to alphanumeric
|
||||
# plus `-` and `_`. We accept exactly that charset and
|
||||
# reject anything weirder — anything containing spaces,
|
||||
# punctuation, etc. is almost certainly not a real PHC
|
||||
# string and we'd rather stay silent than make up a name
|
||||
if algo_name and all(c.isalnum() or c in "-_"
|
||||
for c in algo_name):
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = f"PHC string ({algo_name})",
|
||||
confidence = "low",
|
||||
reason =
|
||||
f"`${algo_name}$...` shape — generic PHC, no specific rule",
|
||||
)
|
||||
]
|
||||
|
||||
# ----- Step 5: not-a-hash shape hints -----
|
||||
# Beginners often paste JWTs or base64 blobs into a hash
|
||||
# identifier — they are auth tokens, not hashes, but the user
|
||||
# may not know that yet. Returning a short shape-hint at LOW
|
||||
# confidence is more educational than a silent no-match: the
|
||||
# user finds out WHAT they pasted and that this tool is not
|
||||
# going to crack it for them
|
||||
if text.startswith("eyJ"):
|
||||
# JWTs always begin with `eyJ` because their JSON header
|
||||
# `{"alg":...}` base64-encodes to a string starting with
|
||||
# those three characters (`{"` → base64 → `eyI`/`eyJ`)
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = "JWT (not a hash)",
|
||||
confidence = "low",
|
||||
reason =
|
||||
"leading `eyJ` is base64 of `{\"` — JWT, not a hash",
|
||||
)
|
||||
]
|
||||
if any(c in text for c in "+/=") and len(text) > 8:
|
||||
# Hex hashes NEVER contain `+`, `/`, or `=`. If our input
|
||||
# does, it is almost certainly base64-encoded data of some
|
||||
# kind. The `> 8` length floor avoids flagging short
|
||||
# strings like "a+b=c" as base64
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm = "Base64 blob (not a hash)",
|
||||
confidence = "low",
|
||||
reason = "contains base64-only chars (`+`, `/`, `=`)",
|
||||
)
|
||||
]
|
||||
|
||||
# ----- Step 6: nothing matched -----
|
||||
# If we got here, the input has no known prefix, no special
|
||||
# shape, no hex length we recognize, no PHC-string shape, and
|
||||
# no obvious not-a-hash shape either. Returning an empty list
|
||||
# is better than returning bad guesses — the CLI prints a
|
||||
# clean "could not identify" message instead
|
||||
return []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI — argparse + a rich table
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_argument_parser() -> argparse.ArgumentParser:
|
||||
"""
|
||||
Construct the argparse parser used by main()
|
||||
|
||||
Pulled out into its own function so tests can call it without
|
||||
actually running the CLI. Each argument is documented inline
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog = "hashid",
|
||||
description = (
|
||||
"Identify a hash string by prefix, length, and charset. "
|
||||
"Returns ranked candidates with confidence and reasoning."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"hash",
|
||||
help =
|
||||
"The hash string to identify (wrap in single quotes if it contains $).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top",
|
||||
"-n",
|
||||
type = int,
|
||||
default = 5,
|
||||
help = "Show at most this many candidates (default: 5).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _render_table(
|
||||
raw_input: str,
|
||||
candidates: list[HashCandidate],
|
||||
console: Console,
|
||||
) -> None:
|
||||
"""
|
||||
Print a rich Table showing the identified candidates
|
||||
|
||||
A Table is a bordered grid. We give it three columns: algorithm,
|
||||
confidence (color-coded), and reason
|
||||
"""
|
||||
table = Table(
|
||||
title = f"Candidates for: {raw_input.strip()}",
|
||||
title_style = "bold cyan",
|
||||
show_lines = False,
|
||||
)
|
||||
table.add_column("algorithm", style = "bold white", no_wrap = True)
|
||||
table.add_column("confidence", no_wrap = True)
|
||||
table.add_column("reason", style = "dim")
|
||||
|
||||
# Color confidence levels so the eye can scan them quickly.
|
||||
# green → yellow → cyan is a gradient that reads "strong, weaker,
|
||||
# weakest" without ever colliding with red, which is reserved for
|
||||
# the no-match error message printed elsewhere in main(). Painting
|
||||
# "low" in red would make three weak-but-valid guesses look like
|
||||
# three errors at a glance — broken visual hierarchy
|
||||
confidence_colors: dict[Confidence,
|
||||
str] = {
|
||||
"high": "green",
|
||||
"medium": "yellow",
|
||||
"low": "cyan",
|
||||
}
|
||||
for candidate in candidates:
|
||||
color = confidence_colors[candidate.confidence]
|
||||
table.add_row(
|
||||
candidate.algorithm,
|
||||
f"[{color}]{candidate.confidence}[/{color}]",
|
||||
candidate.reason,
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
CLI entry point — return an exit code (0 = ok, 1 = nothing found)
|
||||
|
||||
Wrapping the body in a function (instead of running at module
|
||||
import time) means the test suite can import this module without
|
||||
accidentally executing the CLI
|
||||
"""
|
||||
parser = _build_argument_parser()
|
||||
args = parser.parse_args()
|
||||
console = Console()
|
||||
|
||||
candidates = identify(args.hash)
|
||||
|
||||
if not candidates:
|
||||
# `[red]...[/red]` is rich's inline color markup
|
||||
console.print(
|
||||
"[red]No identification possible.[/red] "
|
||||
"Input did not match any known prefix, special format, "
|
||||
"or hex length."
|
||||
)
|
||||
return 1
|
||||
|
||||
# Trim to the requested top-N
|
||||
trimmed = candidates[: args.top]
|
||||
_render_table(args.hash, trimmed, console)
|
||||
|
||||
# Helpful nudge — point the user at the cracker once they know
|
||||
# what algorithm to target. Foundations tier is meant to chain
|
||||
if trimmed[0].confidence == "high":
|
||||
console.print(
|
||||
"\n[dim]Next step: try the matching cracker mode "
|
||||
"(see ../../beginner/hash-cracker).[/dim]"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# Standard "if invoked directly as a script" guard — lets the file be
|
||||
# imported by tests without firing main()
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env bash
|
||||
# ©AngelaMos | 2026
|
||||
# install.sh
|
||||
#
|
||||
# Zero-friction install script. Anyone who clones this project should
|
||||
# be able to run `./install.sh` and end up with a working setup,
|
||||
# regardless of whether they have uv or just installed yet.
|
||||
#
|
||||
# What this script does, in order:
|
||||
# 1. Verifies Python 3.13+ is installed (we need modern type-hint syntax)
|
||||
# 2. Installs uv if it is missing (uv is the Python package manager we use)
|
||||
# 3. Installs just if it is missing (just is the command runner)
|
||||
# 4. Calls `just setup` to create the venv and install dependencies
|
||||
# 5. Prints next steps
|
||||
#
|
||||
# Run with: ./install.sh
|
||||
# Or: bash install.sh
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bash safety flags — fail fast and loud
|
||||
# -----------------------------------------------------------------------------
|
||||
# -e : exit immediately if any command returns a non-zero (error) status
|
||||
# -u : treat unset variables as an error
|
||||
# -o pipefail : if any command in a pipeline fails, the whole pipeline fails
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Color helpers — pretty terminal output without external dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
# These are ANSI escape codes. \033 is the ESC character; the bracketed
|
||||
# digits tell the terminal which color to switch to. NC = "no color",
|
||||
# resets back to whatever the terminal had before.
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Tiny helper functions so we don't repeat the format strings everywhere.
|
||||
# `>&2` redirects to stderr (where errors belong) instead of stdout.
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 1 — Confirm Python 3.13+ is on the system
|
||||
# -----------------------------------------------------------------------------
|
||||
check_python() {
|
||||
info "Checking for Python 3.13+..."
|
||||
|
||||
# `command -v <name>` prints the path of <name> if it exists, nothing
|
||||
# otherwise. `&>/dev/null` discards both stdout and stderr — we only
|
||||
# care about the exit code (0 = found, non-zero = missing).
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
error "python3 not found. Please install Python 3.13 or newer."
|
||||
error " macOS: brew install python@3.13"
|
||||
error " Linux: sudo apt install python3.13 (Debian/Ubuntu)"
|
||||
error " Windows: download from python.org"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read the version from Python itself — the most reliable source.
|
||||
# `local` makes these variables function-scoped instead of leaking
|
||||
# into the rest of the script.
|
||||
local version
|
||||
version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
local major minor
|
||||
# `cut -d. -f1` splits the string on `.` and takes field 1.
|
||||
major=$(echo "$version" | cut -d. -f1)
|
||||
minor=$(echo "$version" | cut -d. -f2)
|
||||
|
||||
# `(( ... ))` is bash arithmetic context — lets us write `<` `>` etc.
|
||||
# The compound condition fails if major < 3, OR if major == 3 and
|
||||
# minor < 13. So Python 3.12 fails, 3.13 passes, 4.0 passes.
|
||||
if (( major < 3 )) || { (( major == 3 )) && (( minor < 13 )); }; then
|
||||
error "Python 3.13+ is required, found Python $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Python $version detected"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 2 — Install uv if missing (https://docs.astral.sh/uv)
|
||||
# -----------------------------------------------------------------------------
|
||||
install_uv() {
|
||||
# Already installed? Print confirmation and bail out of this function.
|
||||
# `return 0` exits the function with success — the caller continues.
|
||||
if command -v uv &>/dev/null; then
|
||||
success "uv already installed ($(uv --version))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Installing uv (Python package manager)..."
|
||||
# Pipe the official install script into sh. `-LsSf`:
|
||||
# -L : follow redirects
|
||||
# -s : silent (no progress meter)
|
||||
# -S : show errors even when silent
|
||||
# -f : fail on HTTP errors instead of writing them to disk
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# The installer drops uv into ~/.local/bin or ~/.cargo/bin.
|
||||
# Add both to PATH for the rest of THIS script's run.
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# If we still cannot find uv, something went wrong.
|
||||
if ! command -v uv &>/dev/null; then
|
||||
error "uv install completed but \`uv\` is still not on PATH."
|
||||
error "Restart your shell and re-run this script, or add uv to PATH manually."
|
||||
exit 1
|
||||
fi
|
||||
success "uv installed"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 3 — Install just if missing (https://github.com/casey/just)
|
||||
# -----------------------------------------------------------------------------
|
||||
install_just() {
|
||||
if command -v just &>/dev/null; then
|
||||
success "just already installed ($(just --version))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Installing just (command runner)..."
|
||||
# Make sure the install destination exists first.
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
# Official install script. `--to <dir>` controls where the binary lands.
|
||||
# `--proto '=https'` rejects any protocol but HTTPS.
|
||||
# `--tlsv1.2` insists on a modern TLS version.
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
|
||||
| bash -s -- --to "$HOME/.local/bin"
|
||||
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
success "just installed"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 4 — Use just to set up the project (venv + dependencies)
|
||||
# -----------------------------------------------------------------------------
|
||||
project_setup() {
|
||||
info "Running 'just setup'..."
|
||||
# Calling our own justfile recipe — single source of truth for setup.
|
||||
just setup
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main — orchestrate the steps and print next instructions
|
||||
# -----------------------------------------------------------------------------
|
||||
main() {
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " hash-identifier — install"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
check_python
|
||||
install_uv
|
||||
install_just
|
||||
project_setup
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
success "Install complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " just run -- 5f4dcc3b5aa765d61d8327deb882cf99 # identify a hash"
|
||||
echo " just run -- --help # see options"
|
||||
echo " just test # run the test suite"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# `"$@"` forwards every argument the script was called with into main.
|
||||
# We do not use any args today, but keeping this pattern means future
|
||||
# flags can be added without changing the bottom of the file.
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# ©AngelaMos | 2026
|
||||
# justfile
|
||||
#
|
||||
# A "justfile" is a list of commands you can run with `just <name>`.
|
||||
# Think of it as a project's command center — instead of remembering
|
||||
# `uv run pytest -v`, you just type `just test`.
|
||||
#
|
||||
# Why use just instead of make? It is simpler, cross-platform,
|
||||
# and the syntax is easier to read.
|
||||
#
|
||||
# Show all commands: `just`
|
||||
# Run a command: `just <name>` (e.g. `just setup`)
|
||||
|
||||
# Export every variable defined here as an environment variable for
|
||||
# the recipes that just runs.
|
||||
set export
|
||||
# On Linux/macOS, run recipe lines with bash and -u (error on
|
||||
# unset variables) and -c (read commands from a string).
|
||||
set shell := ["bash", "-uc"]
|
||||
# On Windows, fall back to PowerShell with no logo / non-interactive.
|
||||
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||
# Make recipe arguments available to shebang scripts as `$1`, `$2`,
|
||||
# `$@`, and `$#`. Without this, shebang recipes only see args via the
|
||||
# textual `{{args}}` substitution, which is unsafe for inputs that
|
||||
# contain `$` (the substituted text gets re-expanded by bash).
|
||||
set positional-arguments
|
||||
|
||||
# Show available commands when you run `just` with no args.
|
||||
default:
|
||||
@just --list --unsorted
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Setup Commands
|
||||
# =============================================================================
|
||||
|
||||
# One-shot first-time setup — creates .venv and installs everything
|
||||
[group('setup')]
|
||||
setup:
|
||||
@echo "Creating virtual environment with uv..."
|
||||
# uv venv creates a .venv/ folder using the system Python that
|
||||
# matches `requires-python` in pyproject.toml.
|
||||
# --allow-existing makes the recipe safe to re-run after a partial install.
|
||||
uv venv --allow-existing
|
||||
@echo ""
|
||||
@echo "Installing dependencies (including dev tools)..."
|
||||
# --all-extras pulls in every optional-dependencies group (just `dev`
|
||||
# for us). Without this, dev tools like pytest do not get installed.
|
||||
uv sync --all-extras
|
||||
@echo ""
|
||||
@echo "✓ Setup complete!"
|
||||
@echo ""
|
||||
@echo "Try it out:"
|
||||
@echo " just run 5f4dcc3b5aa765d61d8327deb882cf99"
|
||||
@echo " just test"
|
||||
|
||||
# Install runtime dependencies only (no dev tools)
|
||||
[group('setup')]
|
||||
install:
|
||||
uv sync
|
||||
|
||||
# Install runtime + dev dependencies
|
||||
[group('setup')]
|
||||
install-dev:
|
||||
uv sync --all-extras
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Testing & Quality Checks
|
||||
# =============================================================================
|
||||
|
||||
# Run the test suite
|
||||
[group('test')]
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
# `uv run` runs a command inside the project's virtual environment
|
||||
# without us having to `source .venv/bin/activate` first.
|
||||
uv run pytest
|
||||
|
||||
# Run all linters in sequence (ruff + pylint + mypy)
|
||||
[group('test')]
|
||||
lint:
|
||||
@echo "=== Ruff ==="
|
||||
uv run ruff check hash_identifier.py test_hash_identifier.py
|
||||
@echo ""
|
||||
@echo "=== Pylint ==="
|
||||
uv run pylint hash_identifier.py
|
||||
@echo ""
|
||||
@echo "=== Mypy ==="
|
||||
uv run mypy hash_identifier.py
|
||||
@echo ""
|
||||
@echo "✓ All linters passed"
|
||||
|
||||
# Auto-format every Python file with yapf
|
||||
[group('test')]
|
||||
format:
|
||||
@echo "Formatting code with yapf..."
|
||||
# -i = in place. Edits the files directly instead of printing diffs.
|
||||
uv run yapf -i hash_identifier.py test_hash_identifier.py
|
||||
@echo "✓ Code formatted"
|
||||
|
||||
# Auto-fix what ruff can fix on its own (unused imports, etc.)
|
||||
[group('test')]
|
||||
fix:
|
||||
uv run ruff check hash_identifier.py test_hash_identifier.py --fix
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run the CLI
|
||||
# =============================================================================
|
||||
|
||||
# Run hashid — pass the hash to identify after `--`
|
||||
# Example: just run 5f4dcc3b5aa765d61d8327deb882cf99
|
||||
# just run '$2b$12$abcdefghijklmnopqrstuv'
|
||||
# [no-exit-message] silences just's "Recipe `run` failed with exit
|
||||
# code N" line when hashid exits non-zero. hashid uses exit 1 to mean
|
||||
# "no candidates matched" — a legitimate result, not a failure. The
|
||||
# exit code itself is still propagated to whoever invoked just, so
|
||||
# CI use cases keep working.
|
||||
[group('run')]
|
||||
[no-exit-message]
|
||||
run *args:
|
||||
#!/usr/bin/env bash
|
||||
# If no args were given, print a friendly usage block and exit
|
||||
# cleanly. Without this, `just run` (no args) would invoke
|
||||
# `uv run hashid` with nothing, argparse would exit 2, and just
|
||||
# would tack a "Recipe `run` failed" error on top — confusing for
|
||||
# someone just trying to see how the command works.
|
||||
if [ $# -eq 0 ]; then
|
||||
cat <<'EOF'
|
||||
Usage: just run -- <hash>
|
||||
|
||||
Examples:
|
||||
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
|
||||
just run -- '$2b$12$abcdefghijklmnopqrstuv'
|
||||
|
||||
See all options:
|
||||
just run -- --help
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
# Forward args via "$@" — NOT via {{args}}. Why: {{args}} is a
|
||||
# TEXTUAL substitution done by just before bash runs the script,
|
||||
# so a hash like $2b$12$abc would get its $2b and $12 expanded as
|
||||
# bash variables and arrive at hashid mangled. "$@" hands bash the
|
||||
# original argv unchanged.
|
||||
uv run hashid "$@"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility / Cleanup
|
||||
# =============================================================================
|
||||
|
||||
# Delete the venv and all build / cache artifacts
|
||||
[group('utility')]
|
||||
clean:
|
||||
rm -rf .venv
|
||||
rm -rf __pycache__
|
||||
rm -rf .mypy_cache .ruff_cache .pytest_cache
|
||||
rm -rf *.egg-info build dist
|
||||
rm -rf .coverage htmlcov
|
||||
@echo "✓ Cleaned"
|
||||
|
||||
# Lock the exact dependency versions to uv.lock
|
||||
[group('utility')]
|
||||
lock:
|
||||
uv lock
|
||||
|
||||
# Upgrade all dependencies to latest allowed versions
|
||||
[group('utility')]
|
||||
update:
|
||||
uv lock --upgrade
|
||||
uv sync --all-extras
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CI Pipeline
|
||||
# =============================================================================
|
||||
|
||||
# Full pipeline: setup + lint + test. For first-time runs.
|
||||
[group('ci')]
|
||||
all: setup lint test
|
||||
@echo ""
|
||||
@echo "✓ Setup, lint, and tests all passed"
|
||||
|
||||
# Lint + test only — what CI runs after dependencies are installed
|
||||
[group('ci')]
|
||||
ci: lint test
|
||||
@echo "✓ CI checks passed"
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
# Hash Identifier
|
||||
|
||||
## What this is
|
||||
|
||||
A small Python program that looks at a string of weird-looking characters and tells you what kind of cryptographic hash it probably is. You give it something like this:
|
||||
|
||||
```
|
||||
5f4dcc3b5aa765d61d8327deb882cf99
|
||||
```
|
||||
|
||||
and it tells you "that's an MD5 hash" with a reason for why it thinks so.
|
||||
|
||||
That's the whole job. It does not crack the hash. It does not turn the hash back into a password. It just answers the question "what flavor of hash is this?" — which is the question you have to answer *first* before any other tool will help you.
|
||||
|
||||
## Why anyone needs this
|
||||
|
||||
The first thing that happens when a real attack succeeds is that the attacker walks out with a database dump full of password hashes. The hashes look like nonsense, but they are not random — every hash carries clues about how it was made. Once you know the algorithm (MD5, SHA-256, bcrypt, Argon2, whatever) you can hand the hash to a cracking tool like [hashcat](https://hashcat.net) or [John the Ripper](https://www.openwall.com/john/) and start trying to recover the original password.
|
||||
|
||||
But here's the thing: hashcat needs you to tell it the algorithm. It has [over 400 hash modes](https://hashcat.net/wiki/doku.php?id=example_hashes), each with a different number. Mode 0 is MD5. Mode 100 is SHA-1. Mode 3200 is bcrypt. If you pick the wrong mode, hashcat will sit there forever and find nothing. So before cracking, you identify. That's this tool.
|
||||
|
||||
**Real-world moments where you'd reach for this:**
|
||||
|
||||
- A pentester finds a dump file on a compromised server full of strings like `$2b$12$EixZaYVK1...` and needs to know what to feed hashcat.
|
||||
- A CTF challenge hands you a hash and zero hints about what algorithm made it.
|
||||
- You're reading a breach writeup and want to understand whether the leaked passwords were stored as fast unsalted MD5 (a disaster) or slow salted bcrypt (much better).
|
||||
- The [2012 LinkedIn breach](https://en.wikipedia.org/wiki/2012_LinkedIn_hack) leaked 6.5 million unsalted SHA-1 hashes. The first thing any researcher had to do before doing *anything* was confirm "yes, these are SHA-1." Forty-character hex strings. Easy. The tool you're about to read would have told them that in milliseconds.
|
||||
|
||||
## What you will learn
|
||||
|
||||
**Security ideas:**
|
||||
|
||||
- What a cryptographic hash actually is (a function that turns any input into a fixed-length jumble that you can't reverse).
|
||||
- The three signals every hash leaks about itself: its **prefix**, its **length**, and its **character set**.
|
||||
- Why modern password hashes (`$2b$...`, `$argon2id$...`) *announce themselves* on purpose, and why old fast hashes (MD5, SHA-1) don't.
|
||||
- The difference between a fast hash (made for speed, terrible for passwords) and a slow hash (made on purpose to resist cracking).
|
||||
- Why you can never recover the password from a hash, only *guess* the password and check if its hash matches.
|
||||
|
||||
**Python ideas (assuming this is your first time):**
|
||||
|
||||
- How to read a Python file from top to bottom and understand what it's doing.
|
||||
- What `import` does and where the standard library ends and third-party packages begin.
|
||||
- Functions, type hints (`str`, `int`, `list[str]`), and what `-> bool` means after a function signature.
|
||||
- `@dataclass` — a shortcut for making little record-like objects.
|
||||
- `frozenset`, `dict`, `list`, `tuple` — the core Python containers and when to pick which.
|
||||
- How a command-line tool actually starts running (the `if __name__ == "__main__"` line at the bottom).
|
||||
- How a test file works and why every function in the main code has tests next to it.
|
||||
|
||||
**Tools you'll touch:**
|
||||
|
||||
- [`uv`](https://github.com/astral-sh/uv) — the modern Python package manager. Like `pip` but ~100× faster.
|
||||
- [`just`](https://github.com/casey/just) — a command runner. Instead of memorizing long commands, you type `just test` or `just run`.
|
||||
- [`rich`](https://github.com/Textualize/rich) — the library that prints the pretty colored table at the end.
|
||||
- [`pytest`](https://pytest.org) — Python's test runner.
|
||||
- [`ruff`](https://github.com/astral-sh/ruff) + [`mypy`](https://mypy-lang.org) + [`pylint`](https://pylint.org) — the linters that yell at you if your code is wrong, slow, or sloppy.
|
||||
|
||||
## What you need before starting
|
||||
|
||||
**Knowledge you should have:**
|
||||
|
||||
- You've used a terminal at least once (you know what `cd` and `ls` do).
|
||||
- You vaguely know that "a hash" is a one-way function. If not, [01-CONCEPTS.md](./01-CONCEPTS.md) will get you there in 10 minutes.
|
||||
- You can read code, or at least you're willing to. We will explain every Python feature as we hit it.
|
||||
|
||||
**Knowledge you do NOT need:**
|
||||
|
||||
- Any prior Python experience. The whole point of the **foundations** tier is that you start here.
|
||||
- Any prior cybersecurity experience.
|
||||
- Any math beyond "counting." There is no math in this project. Cryptography uses math under the hood, but identifying a hash by its shape doesn't.
|
||||
|
||||
**Software you need installed:**
|
||||
|
||||
- Python 3.14 or newer.
|
||||
- The `uv` tool (the install script will get this for you if you don't have it).
|
||||
- The `just` tool (also handled by the install script).
|
||||
- A terminal. Any terminal. On Mac it's Terminal.app or iTerm2; on Linux it's whatever your distro shipped; on Windows it's WSL2 + Ubuntu (we strongly recommend WSL2 instead of native Windows).
|
||||
|
||||
You do *not* need an IDE — a text editor is fine. We recommend [VS Code](https://code.visualstudio.com) with the Python extension, but `nano`, `vim`, `helix`, or whatever you already use will work.
|
||||
|
||||
## Quick start
|
||||
|
||||
From inside `PROJECTS/foundations/hash-identifier/`:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
That script will install `uv` and `just` if missing, create a virtual environment (an isolated Python sandbox just for this project), install all the dependencies, and verify the tests pass. It prints what it's doing as it goes — read the output, don't just close the terminal.
|
||||
|
||||
Then try the tool:
|
||||
|
||||
```bash
|
||||
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
|
||||
```
|
||||
|
||||
You should see a colored table identifying that string as MD5 (with NTLM, MD4, and RIPEMD-128 as less-likely alternatives — all four produce 32 hex characters, so length alone can't separate them).
|
||||
|
||||
Try a few more:
|
||||
|
||||
```bash
|
||||
# bcrypt — modern password hash, announces itself with the $2b$ prefix
|
||||
just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G'
|
||||
|
||||
# SHA-256 — 64 hex characters
|
||||
just run -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
|
||||
# A JWT (this is NOT a hash, but the tool will say so politely)
|
||||
just run -- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
|
||||
|
||||
# Total garbage — the tool will say "no idea" rather than guess
|
||||
just run -- helloworld
|
||||
```
|
||||
|
||||
**Note on quoting:** when a hash starts with `$`, you must wrap it in single quotes (`'$2b$...'`). Without quotes, your shell will try to expand `$2` as a shell variable and chop the hash up. This is a shell thing, not a Python thing — every Unix shell does it.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
hash-identifier/
|
||||
├── hash_identifier.py the whole tool — one file, ~680 lines
|
||||
├── test_hash_identifier.py tests for every behavior the tool claims to have
|
||||
├── install.sh one-shot setup script
|
||||
├── justfile shortcuts for run / test / lint / format
|
||||
├── pyproject.toml project config: dependencies, linter rules, etc.
|
||||
├── README.md short pointer to this learn/ folder
|
||||
├── learn/ you are here
|
||||
│ ├── 00-OVERVIEW.md quick start (this file)
|
||||
│ ├── 01-CONCEPTS.md what hashes are and how identification works
|
||||
│ ├── 02-ARCHITECTURE.md how the code is structured, with diagrams
|
||||
│ ├── 03-IMPLEMENTATION.md line-by-line walkthrough of the code
|
||||
│ └── 04-CHALLENGES.md extension ideas if you want to go further
|
||||
└── assets/ images, screenshots
|
||||
```
|
||||
|
||||
One file of code is on purpose. The foundations tier is meant to be readable in one sitting. The intermediate and advanced tiers split into many files; foundations does not.
|
||||
|
||||
## Where to go next
|
||||
|
||||
1. **[01-CONCEPTS.md](./01-CONCEPTS.md)** — understand *what* a hash is, *why* identification is the first move, and *how* prefix/length/charset clues actually work. Read this even if you think you know it; the framing matters.
|
||||
2. **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** — see the six-step pipeline the tool uses to make a decision, drawn out as a flow diagram.
|
||||
3. **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** — read `hash_identifier.py` with us, line by line. Every Python feature gets explained when it first appears.
|
||||
4. **[04-CHALLENGES.md](./04-CHALLENGES.md)** — extensions you can try on your own once you've absorbed the rest.
|
||||
|
||||
## Common problems
|
||||
|
||||
**"command not found: just"**
|
||||
The install script should set this up, but if it didn't: `curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash`. Then close and reopen your terminal so it sees the new tool.
|
||||
|
||||
**"command not found: uv"**
|
||||
Same idea: `curl -LsSf https://astral.sh/uv/install.sh | sh`, then reopen your terminal.
|
||||
|
||||
**`just run -- $2b$12$...` chops the hash up**
|
||||
You forgot the single quotes around the hash. Re-run with `just run -- '$2b$12$...'`.
|
||||
|
||||
**"ModuleNotFoundError: No module named 'rich'"**
|
||||
You ran `python hash_identifier.py` directly instead of `just run`. The `just run` recipe uses the virtual environment that has `rich` installed. Either use `just run`, or activate the venv first: `source .venv/bin/activate`, *then* `python hash_identifier.py <hash>`.
|
||||
|
||||
**Tests fail right after install**
|
||||
Tests should pass on a fresh `./install.sh`. If they don't, you probably have an older Python (run `python --version`; you need 3.14+). On Ubuntu, install it via [`deadsnakes`](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa); on Mac use [Homebrew](https://brew.sh).
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
# Concepts
|
||||
|
||||
This page builds up the ideas you need to understand the code. We start from "what is a hash" and end at "here is exactly why a 32-character hex string is probably MD5." No prior security knowledge required.
|
||||
|
||||
## 1. What is a hash?
|
||||
|
||||
A **cryptographic hash function** takes any input — a single byte, a password, a 4GB video file — and produces a fixed-length output that looks like random garbage. Same input always gives the same output. Different inputs give different outputs. And critically: if you only have the output, you cannot work backwards to the input.
|
||||
|
||||
Picture it as a kitchen blender that only goes forwards:
|
||||
|
||||
```
|
||||
"password" ─────► [ MD5 blender ] ─────► 5f4dcc3b5aa765d61d8327deb882cf99
|
||||
"hello" ─────► [ MD5 blender ] ─────► 5d41402abc4b2a76b9719d911017c592
|
||||
"hello!" ─────► [ MD5 blender ] ─────► d9014c4624844aa5bac314773d6b689a
|
||||
│
|
||||
└─ change ONE character → totally different output
|
||||
```
|
||||
|
||||
A few properties that fall out of this:
|
||||
|
||||
- **Deterministic.** Same input, same output, every single time. This is the whole reason hashes are useful — you can store the hash of a password, and when someone logs in you re-hash what they typed and compare.
|
||||
- **Fixed-length output.** Whether you hash `"a"` or the entire Bible, MD5 always produces 32 hex characters. SHA-256 always produces 64. This length is the first big clue we use to identify which algorithm was used.
|
||||
- **One-way.** You can go from password → hash, but not from hash → password. There is no "un-hash" button. The only way to figure out which password produced a hash is to *guess passwords and hash them yourself* until you get a match. That guessing is what hashcat does.
|
||||
- **Avalanche effect.** Change one letter of the input and the entire output changes. `"password"` and `"Password"` produce hashes that share zero characters.
|
||||
|
||||
If you want to play with this for yourself, in a Python REPL (`uv run python`):
|
||||
|
||||
```python
|
||||
>>> import hashlib
|
||||
>>> hashlib.md5(b"password").hexdigest()
|
||||
'5f4dcc3b5aa765d61d8327deb882cf99'
|
||||
>>> hashlib.md5(b"Password").hexdigest()
|
||||
'dc647eb65e6711e155375218212b3964'
|
||||
```
|
||||
|
||||
The `b"..."` syntax means "this is bytes, not text" — hash functions work on raw bytes, not characters. Don't worry about that distinction yet; just notice that one letter change produced a completely different hash.
|
||||
|
||||
## 2. Why hashes exist (the password storage problem)
|
||||
|
||||
Imagine you run a website. Users sign up with a password. The naive thing to do is store passwords in your database directly:
|
||||
|
||||
```
|
||||
+----------+------------+
|
||||
| username | password |
|
||||
+----------+------------+
|
||||
| alice | hunter2 |
|
||||
| bob | letmein |
|
||||
+----------+------------+
|
||||
```
|
||||
|
||||
This is a catastrophe waiting to happen. The moment somebody breaches your database — and someone always eventually does — every user is exposed. Worse, since people [reuse passwords across sites](https://www.security.org/digital-safety/password-reuse-statistics/), the attacker now has the keys to Alice's bank, Alice's email, and Alice's Netflix.
|
||||
|
||||
The fix is to never store the password itself. Store its hash:
|
||||
|
||||
```
|
||||
+----------+----------------------------------+
|
||||
| username | password_hash |
|
||||
+----------+----------------------------------+
|
||||
| alice | 5f4dcc3b5aa765d61d8327deb882cf99 | ← MD5("hunter2")... if it were
|
||||
| bob | 0d107d09f5bbe40cade3de5c71e9e9b7 | "password" (it isn't)
|
||||
+----------+----------------------------------+
|
||||
```
|
||||
|
||||
When Alice logs in, you hash what she just typed and compare it to the stored hash. If they match, she gets in. You never knew her password and you never have to.
|
||||
|
||||
The attacker who steals this database now has hashes, not passwords. They have to *guess* every password by hashing candidate passwords and comparing. With a fast hash like MD5, a modern GPU can try billions of guesses per second. With a slow hash like bcrypt, it can try only thousands. That's the entire reason modern systems use slow hashes — not because they're "more secure" in some abstract way, but because they make guessing *expensive*.
|
||||
|
||||
## 3. Real breaches that turned on hash identification
|
||||
|
||||
Identifying the hash format is the *first move* in every password-leak story. Until you know what algorithm made the hashes, nothing else happens.
|
||||
|
||||
**[2012 LinkedIn breach](https://en.wikipedia.org/wiki/2012_LinkedIn_hack)** — 6.5 million unsalted SHA-1 hashes leaked. Forty hex characters each. Researchers identified the format in seconds, then cracked 90% of the hashes in 72 hours because SHA-1 is fast and the passwords had no salt (more on salt below). LinkedIn later admitted [117 million more accounts](https://www.theguardian.com/technology/2016/may/19/linkedin-2012-data-breach-hack-117-million-email-password-details) were exposed than originally disclosed.
|
||||
|
||||
**[2013 Adobe breach](https://krebsonsecurity.com/2013/11/adobe-breach-impacted-at-least-38-million-users/)** — 153 million accounts, with passwords stored using 3DES encryption (not even hashing) and no unique salts. The lack of unique salts meant identical passwords produced identical ciphertext. Researchers could see, just by looking at the dump, which 1.9 million accounts shared the password `123456`.
|
||||
|
||||
**[2016 Yahoo breach](https://en.wikipedia.org/wiki/Yahoo!_data_breaches)** — 3 billion accounts. Some hashed with MD5 (catastrophic), some with bcrypt (much better). The mixed format made identification the first task before any defense analysis could proceed.
|
||||
|
||||
**[2019 Collection #1](https://www.troyhunt.com/the-773-million-record-collection-1-data-reach/)** — 773 million email/password pairs aggregated from past breaches. Researchers had to sort which hashes were which algorithm before anything else.
|
||||
|
||||
The pattern is the same every time: dump appears → identify the algorithm → decide whether cracking is feasible → reach for hashcat.
|
||||
|
||||
## 4. The three signals a hash leaks about itself
|
||||
|
||||
This is the heart of the tool. A hash string carries up to three clues about its own origin: its **prefix**, its **length**, and its **character set**. We use these in order, strongest first.
|
||||
|
||||
### Signal 1: prefix (the strongest clue)
|
||||
|
||||
Modern password hashes use a self-describing format called **PHC string format** (PHC stands for "Password Hashing Competition," the contest that gave us Argon2 in 2015). A PHC string starts with a marker that announces the algorithm:
|
||||
|
||||
```
|
||||
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
|
||||
^^^^^^^^^^
|
||||
│
|
||||
└─ "I am an Argon2id hash. You don't have to guess."
|
||||
```
|
||||
|
||||
```
|
||||
$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK
|
||||
^^^^
|
||||
│
|
||||
└─ "I am a bcrypt hash, variant 2b, cost factor 12."
|
||||
```
|
||||
|
||||
When a hash announces itself like this, identification is essentially free — you just compare prefixes. The tool reports **HIGH confidence** for prefix matches because the hash literally told you what it is. The PHC spec is documented [here](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) if you want the full grammar.
|
||||
|
||||
Old hashes do *not* do this. MD5 and SHA-1 just give you the raw hex digest with no prefix. Which is why the modern shift to PHC was a big deal — it makes the system self-documenting.
|
||||
|
||||
Here's a non-exhaustive cheat sheet of common prefixes. The full table is in `hash_identifier.py` under `PREFIX_RULES`:
|
||||
|
||||
| Prefix | Algorithm | Where you see it |
|
||||
| ------------------ | ------------------- | ----------------------------------------------- |
|
||||
| `$argon2id$` | Argon2id | Modern web apps, the current gold standard |
|
||||
| `$2b$` | bcrypt | Workhorse for the past 15 years |
|
||||
| `$6$` | SHA-512 crypt | `/etc/shadow` on most Linux distros |
|
||||
| `$apr1$` | Apache MD5-crypt | `.htpasswd` files (Apache basic auth) |
|
||||
| `$P$` | phpass | WordPress and old phpBB forums |
|
||||
| `pbkdf2_sha256$` | Django PBKDF2 | Default for Django web apps |
|
||||
| `{SSHA}` | LDAP salted SHA-1 | LDAP directory passwords |
|
||||
|
||||
### Signal 2: length (the second-strongest clue)
|
||||
|
||||
Hash functions emit fixed-length output. If you see no prefix but you have 64 hex characters, you can be confident it came from one of the 256-bit hash family (SHA-256, SHA3-256, BLAKE2s-256, RIPEMD-256). Each algorithm always emits the same number of bytes:
|
||||
|
||||
```
|
||||
algorithm bytes hex chars
|
||||
─────────────────────────────────────
|
||||
MD5 16 32
|
||||
SHA-1 20 40
|
||||
SHA-224 28 56
|
||||
SHA-256 32 64
|
||||
SHA-384 48 96
|
||||
SHA-512 64 128
|
||||
```
|
||||
|
||||
The reason the number of hex characters is double the number of bytes: each byte is 8 bits, but a hex character represents only 4 bits (one of 16 possible values: `0-9a-f`). So you need two hex characters per byte.
|
||||
|
||||
Length narrows the field but rarely picks a unique winner. 32 hex characters could be MD5, NTLM, MD4, or RIPEMD-128 — they all produce 128 bits of output. So when the tool matches on length, it reports **MEDIUM confidence** for the most likely algorithm at that length and **LOW** for the rest. "Most likely" means "most common in the wild in 2026" — MD5 vastly outranks RIPEMD-128.
|
||||
|
||||
### Signal 3: charset (used as a sanity check)
|
||||
|
||||
The character set of the hash narrows things further. Three common alphabets show up:
|
||||
|
||||
- **Hex:** only `0-9a-f` (or `0-9A-F` if uppercase). Used by raw MD5, SHA-family, NTLM, etc.
|
||||
- **Base64-ish:** `0-9A-Za-z+/=`. Used by LDAP and some Java password formats.
|
||||
- **crypt(3) base64:** a peculiar alphabet `./0-9A-Za-z` (note the `.` and `/` at the start, no `=` for padding). Used by bcrypt and the old Unix crypt formats.
|
||||
|
||||
A string with `+` in it is *not* a hex hash. A string with `*` followed by 40 uppercase hex characters is almost certainly MySQL5 (and only MySQL5 — because MySQL prints its hashes using the `%02X` C format specifier, which is uppercase-only).
|
||||
|
||||
Charset alone rarely identifies anything, but it's the tiebreaker that lets us *rule things out*. For example, the helper `_is_mysql5` in the code refuses to match if the body is lowercase, because real MySQL5 output is always uppercase. Better to say "I don't know" than to lie with confidence.
|
||||
|
||||
## 5. Salts (a quick detour)
|
||||
|
||||
You'll see the word **salt** all over the place when you read about password hashing. A salt is a unique random string that you mix into the password before hashing, then store alongside the hash:
|
||||
|
||||
```
|
||||
hash = bcrypt("hunter2" + random_salt_for_alice)
|
||||
```
|
||||
|
||||
The point of a salt is to make every user's hash *different*, even if two users picked the same password. Without salts, you can sort the database by hash column, count duplicates, and immediately learn which password is most popular (this is exactly how researchers learned `123456` was Adobe's most popular password — no cracking required).
|
||||
|
||||
Salts also defeat **rainbow tables**: precomputed lookup tables that map `hash → password` for billions of common passwords. With a unique salt per user, every entry in the rainbow table would have to be recomputed for *every salt*, which is infeasible.
|
||||
|
||||
A PHC string like `$2b$12$EixZaYVK1fsbw1ZfbX3OXe...` contains the salt right there in the string (the `EixZaYVK1fsbw1ZfbX3OXe` part). That's not a security problem — the salt is supposed to be public. Its job is to make every hash unique, not to be secret.
|
||||
|
||||
## 6. Why identification has to come first
|
||||
|
||||
Cracking tools don't auto-detect the algorithm — they need you to tell them.
|
||||
|
||||
```bash
|
||||
# Hashcat, mode 0 (MD5):
|
||||
hashcat -m 0 -a 0 5f4dcc3b5aa765d61d8327deb882cf99 wordlist.txt
|
||||
|
||||
# Hashcat, mode 3200 (bcrypt):
|
||||
hashcat -m 3200 -a 0 '$2b$12$EixZaY...' wordlist.txt
|
||||
|
||||
# John the Ripper, --format=raw-md5:
|
||||
john --format=raw-md5 --wordlist=wordlist.txt hashes.txt
|
||||
```
|
||||
|
||||
Pick the wrong mode and hashcat will sit there comparing your bcrypt hash against MD5 outputs forever and finding nothing. So *before* you crack, you identify.
|
||||
|
||||
This is why a tool like the one in this project (and the older [`hashid`](https://github.com/psypanda/hashID) and [`hash-identifier`](https://github.com/blackploit/hash-identifier) projects it's inspired by) exists. It is the first step. Our tool is a beginner-friendly clone of that idea, written so you can read every line and understand every decision.
|
||||
|
||||
## 7. The tradeoff this tool is making
|
||||
|
||||
We could theoretically achieve higher accuracy by trying every algorithm and computing diagnostics. We don't. We make decisions from *string shape alone*:
|
||||
|
||||
- We never run any hash function.
|
||||
- We never make network requests.
|
||||
- We never touch the filesystem.
|
||||
- We never call any external tool.
|
||||
|
||||
This makes the tool **fast** (instant), **safe** (impossible to leak data), and **trivially testable** (every test is "given input X, expect output Y"). It's a pure function, in the mathematical sense — same input always gives same output, no side effects.
|
||||
|
||||
The cost is that we sometimes report multiple candidates with MEDIUM/LOW confidence. We could in principle pick a winner if we tried each algorithm against a known wordlist — but that's a different tool. That tool is the cracker, and it lives in `PROJECTS/beginner/hash-cracker`. Our tool's whole job is to point you at the *right cracker mode* in the first place.
|
||||
|
||||
## 8. What the tool will and will not do
|
||||
|
||||
| Will | Will not |
|
||||
| ----------------------------------------------------- | --------------------------------------------------- |
|
||||
| Identify ~30 hash formats by prefix | Crack any hash |
|
||||
| Identify common hex hashes by length | Compute hashes for you |
|
||||
| Recognize MySQL5, NetNTLM, DES crypt by shape | Call hashcat or john for you |
|
||||
| Tell you "that's a JWT" or "that's base64, not a hash"| Tell you the password |
|
||||
| Print ranked candidates with confidence and reasoning | Make network requests |
|
||||
| Run as a one-shot CLI in milliseconds | Touch the filesystem |
|
||||
|
||||
## 9. Where to go from here
|
||||
|
||||
Now that you know *what* the tool is doing and *why*, read **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** to see how the code is structured into a six-step decision pipeline. Then **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks the actual Python file with you, function by function.
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
# Architecture
|
||||
|
||||
This page is about *how the code is shaped*. Not what each line does — that's the next page. Here we zoom out and look at how the pieces fit together, what data flows where, and why we picked this shape over the alternatives.
|
||||
|
||||
## 1. The big picture
|
||||
|
||||
The whole tool is one Python file: `hash_identifier.py`. Everything that runs lives in that file. There are three layers inside it:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CLI layer (main, _build_argument_parser, _render_table) │
|
||||
│ - reads command-line arguments │
|
||||
│ - prints the colored table to your terminal │
|
||||
│ - returns an exit code │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ calls
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Pure-function layer (identify) │
|
||||
│ - the actual decision-making │
|
||||
│ - takes a string, returns a list of HashCandidate │
|
||||
│ - touches NO files, NO network, NO global state │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Data layer (PREFIX_RULES, HEX_LENGTH_RULES, charsets) │
|
||||
│ - lookup tables describing what we know about hashes │
|
||||
│ - read-only, defined at module load time │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Reading top to bottom, the CLI layer is the **outside** of the program: it deals with the human. The pure-function layer is the **brain**: it takes a clean string in and returns a clean answer out. The data layer is the **knowledge**: every "we know X about hash Y" lives in one of those tables, not scattered through the code.
|
||||
|
||||
This three-way split is deliberate. We can test the brain in isolation without ever spawning a CLI — and the test file does exactly that, calling `identify()` directly. We can change how the table looks (color, layout, JSON output) without touching the brain. And we can add new hash formats by adding a row to a table, not by adding a new function.
|
||||
|
||||
## 2. Data flow on a single run
|
||||
|
||||
Here's what happens when you type `just run -- 5f4dcc3b5aa765d61d8327deb882cf99`:
|
||||
|
||||
```
|
||||
(your terminal)
|
||||
│
|
||||
│ "5f4dcc3b5aa765d61d8327deb882cf99"
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ argparse │
|
||||
│ parses sys.argv into args.hash │
|
||||
└────────────────┬────────────────────┘
|
||||
│ args.hash = "5f4dcc..."
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ identify(args.hash) │
|
||||
│ │
|
||||
│ text = args.hash.strip() │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Step 1: prefix match? │ │
|
||||
│ └────────────┬────────────────┘ │
|
||||
│ no match │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Step 2: special shape? │ │
|
||||
│ │ (NetNTLM / MySQL5 / DES) │ │
|
||||
│ └────────────┬────────────────┘ │
|
||||
│ no match │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Step 3: hex + length match? │ │
|
||||
│ │ → 32 hex chars → MD5/NTLM..│ ✔ │
|
||||
│ └────────────┬────────────────┘ │
|
||||
│ │ │
|
||||
│ returns [HashCandidate, ...] │
|
||||
└────────────────┬────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ _render_table() │
|
||||
│ builds a rich.Table, prints it │
|
||||
└────────────────┬────────────────────┘
|
||||
│
|
||||
▼
|
||||
(your terminal)
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Candidates for: 5f4dcc3b5aa765d61d8327deb882cf99 │
|
||||
│ ╭───────────┬────────────┬───────────────────────────╮ │
|
||||
│ │ algorithm │ confidence │ reason │ │
|
||||
│ ├───────────┼────────────┼───────────────────────────┤ │
|
||||
│ │ MD5 │ medium │ 32 hex chars — most likely│ │
|
||||
│ │ NTLM │ low │ 32 hex chars — also poss. │ │
|
||||
│ │ MD4 │ low │ 32 hex chars — also poss. │ │
|
||||
│ │ RIPEMD-128│ low │ 32 hex chars — also poss. │ │
|
||||
│ ╰───────────┴────────────┴───────────────────────────╯ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The brain is the middle box. Everything above and below it is just plumbing: getting the string in, getting the table out.
|
||||
|
||||
## 3. The six-step decision pipeline
|
||||
|
||||
The brain (`identify()`) is structured as **six numbered steps**. Each step is a chance to short-circuit and return a verdict. If a step matches, the function returns immediately. If not, control falls through to the next step.
|
||||
|
||||
This shape — "try the strongest signal first, fall back to weaker ones" — is called a **decision cascade** or **rule pipeline**. You'll see this pattern all over security tooling: spam filters, IDS rules, antivirus heuristics, fingerprinting. They all share the same skeleton.
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ Step 1: PREFIX_RULES? │ HIGH confidence
|
||||
│ Walk the prefix table. │ ────────► return
|
||||
│ Any prefix start match wins. │ first match
|
||||
└─────────────┬──────────────────┘
|
||||
│ no match
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ Step 2: special shapes? │ HIGH/MEDIUM
|
||||
│ NetNTLMv2 / NetNTLMv1 (`::`) │ ────────► return
|
||||
│ MySQL5 (`*` + 40 upper hex) │ first match
|
||||
│ DES crypt (13 chars) │
|
||||
└─────────────┬──────────────────┘
|
||||
│ no match
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ Step 3: pure hex? │ MEDIUM/LOW
|
||||
│ If yes, look up length in │ ────────► return
|
||||
│ HEX_LENGTH_RULES, return all │ ranked list
|
||||
│ candidates ranked by likelihood│
|
||||
└─────────────┬──────────────────┘
|
||||
│ not hex
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ Step 4: generic `$algo$...`? │ LOW
|
||||
│ Looks like a PHC string but │ ────────► return
|
||||
│ we have no specific rule. │ generic match
|
||||
│ Report it as a generic PHC. │
|
||||
└─────────────┬──────────────────┘
|
||||
│ no
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ Step 5: shape hint? │ LOW
|
||||
│ Looks like a JWT (eyJ...) or │ ────────► return
|
||||
│ base64 (`+`, `/`, `=`)? │ "not a hash"
|
||||
│ Tell the user it's not a hash. │
|
||||
└─────────────┬──────────────────┘
|
||||
│ no
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ Step 6: give up. │ none
|
||||
│ Return empty list. │ ────────► []
|
||||
│ CLI prints "could not identify"│
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
Order matters here, and the order isn't arbitrary. We always try **the most specific test first** and **the most general test last**:
|
||||
|
||||
1. PHC prefixes are dead giveaways — the hash itself names the algorithm.
|
||||
2. Special shapes (NetNTLM, MySQL5, DES crypt) are also strong: they have distinctive structures.
|
||||
3. Hex + length is a *narrowing* signal, not a definitive one — it picks a family, not a member.
|
||||
4. Generic PHC fallback catches hashes that *look* PHC-shaped but aren't in our table.
|
||||
5. Shape hints handle the common "I pasted the wrong thing" case (people drop JWTs into hash identifiers all the time).
|
||||
6. Empty list = honest "I don't know."
|
||||
|
||||
If we reversed the order — say, checked length before prefix — we would misclassify a bcrypt hash as "60 hex chars, no length rule" because we'd never look at its `$2b$` prefix. Order encodes priority.
|
||||
|
||||
## 4. The HashCandidate object
|
||||
|
||||
The brain doesn't return a string — it returns a list of `HashCandidate` objects. A candidate has three fields:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HashCandidate │
|
||||
│ │
|
||||
│ algorithm: str e.g. "MD5", "bcrypt", "SHA-256" │
|
||||
│ confidence: Literal "high" | "medium" | "low" │
|
||||
│ reason: str "prefix `$2b$` — bcrypt PHC..." │
|
||||
│ │
|
||||
│ frozen=True ── immutable, can't be mutated after creation│
|
||||
│ slots=True ── memory-efficient (no __dict__) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The shape is intentional. **`algorithm`** is what hashcat wants to know. **`confidence`** tells the human reader how much to trust this guess. **`reason`** is the *evidence* — a one-line explanation of why the tool made this guess. The reason field is what makes the tool teachable: the user sees not just "bcrypt" but "prefix `$2b$` — bcrypt PHC string, 2b variant (current)."
|
||||
|
||||
We use `@dataclass(frozen=True, slots=True)` instead of writing a class with `__init__` and `__repr__` by hand:
|
||||
|
||||
- **`frozen=True`** means once you build a `HashCandidate`, you can't mutate it. If somewhere in the code tried `candidate.algorithm = "something else"`, Python would raise `FrozenInstanceError`. This makes the data flow predictable: a candidate that comes out of `identify()` is the same candidate everywhere it shows up later.
|
||||
- **`slots=True`** is a memory optimization. Without slots, every instance carries around a `__dict__` for adding attributes on the fly. We don't need that, so we turn it off and save memory.
|
||||
|
||||
Both flags also signal *intent* to a reader: "this is a value object, not a mutable bag of state." That signal matters more than the bytes saved.
|
||||
|
||||
## 5. The data tables as the source of truth
|
||||
|
||||
If you wanted to add a new hash format to this tool, you would not write new logic. You would add a row to one of these tables:
|
||||
|
||||
```
|
||||
PREFIX_RULES: list of (prefix, algorithm, note)
|
||||
─────────────────────────────────────────────────
|
||||
("$argon2id$", "Argon2id", "modern PHC string..."),
|
||||
("$2b$", "bcrypt", "bcrypt PHC string..."),
|
||||
("$6$", "SHA-512 crypt", "Unix crypt..."),
|
||||
... ~25 more rows
|
||||
|
||||
|
||||
HEX_LENGTH_RULES: dict of {length_in_hex_chars: [algorithms]}
|
||||
─────────────────────────────────────────────────────────────
|
||||
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
|
||||
40: ["SHA-1", "RIPEMD-160"],
|
||||
64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
|
||||
128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
|
||||
... etc
|
||||
|
||||
|
||||
HEX_CHARSET, _HEX_UPPER_CHARSET, _DESCRYPT_CHARSET
|
||||
──────────────────────────────────────────────────
|
||||
The alphabets used by each format. frozenset for fast lookup.
|
||||
```
|
||||
|
||||
This is called a **data-driven design**. The rules live in data, not code. Three benefits:
|
||||
|
||||
1. **Adding a new format is one line.** No new function, no new test scaffolding to write.
|
||||
2. **The rules are inspectable.** You can read `PREFIX_RULES` and immediately see every format the tool knows.
|
||||
3. **The rules are testable.** The test file iterates over `PREFIX_RULES` and confirms each prefix is recognized — so the data and the behavior cannot drift out of sync.
|
||||
|
||||
When you read the implementation page next, watch how few of the function bodies have *if/elif/elif* chains. The decisions happen inside table lookups, not inside conditionals. That's the data-driven design at work.
|
||||
|
||||
## 6. Two helper functions
|
||||
|
||||
The brain is one big function (`identify`), but two small helpers live next to it. They both answer yes/no questions about the input string:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ _is_hex(text) -> bool │
|
||||
│ "Is every character of text a valid hex digit?" │
|
||||
│ Used in step 3 to decide whether to look up length. │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ _is_mysql5(text) -> bool │
|
||||
│ "Does text look like `*` + 40 uppercase hex chars?" │
|
||||
│ Used in step 2 for MySQL5 detection. │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ _is_descrypt(text) -> bool │
|
||||
│ "Is text 13 chars from `./0-9A-Za-z`?" │
|
||||
│ Used in step 2 for legacy DES crypt detection. │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The leading underscore (`_is_hex`, not `is_hex`) is a Python convention meaning **"this is module-private."** It says to other developers: "this is an implementation detail of `hash_identifier.py`; don't import it from somewhere else." Python doesn't *enforce* this — you can still import private names — but every linter and every reviewer will flag you if you do.
|
||||
|
||||
The helpers are tiny on purpose. Each one is a single boolean question. We pull them out of `identify()` not because they're complicated but because giving them a name makes `identify()` read like English: "if `_is_hex(text)`, do hex-length matching." If we inlined the test, the eye would have to parse it.
|
||||
|
||||
## 7. The CLI layer
|
||||
|
||||
The CLI layer is the part the human actually interacts with. It does three things:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ _build_argument_parser() │
|
||||
│ Sets up argparse — defines that the program takes one │
|
||||
│ positional argument (`hash`) and an optional `--top N` │
|
||||
│ flag. Returns a configured parser. │
|
||||
│ │
|
||||
│ Pulled out of main() so tests can build the parser │
|
||||
│ without actually running the CLI. │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ _render_table(raw_input, candidates, console) │
|
||||
│ Builds a rich.Table object, adds one row per candidate, │
|
||||
│ colors the confidence column (green/yellow/cyan), │
|
||||
│ and prints it. │
|
||||
│ │
|
||||
│ Takes the rich Console as an argument so tests can pass │
|
||||
│ a captured-output Console and verify what got printed. │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ main() │
|
||||
│ Parses args, calls identify(), prints the table. │
|
||||
│ Returns an exit code: │
|
||||
│ 0 → at least one candidate found │
|
||||
│ 1 → no candidates (printed an error message) │
|
||||
│ │
|
||||
│ The exit code lets shell scripts do │
|
||||
│ `if hashid "$x"; then ...` │
|
||||
│ to react to whether identification succeeded. │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The crucial design choice in the CLI layer is **dependency injection**. `_render_table` takes a `Console` object as a parameter instead of creating one inside. That sounds fancy, but it just means: "give me the printer to use." The function doesn't care if you give it a real terminal Console or a test Console that captures output to a string. This makes the function testable without writing to your real terminal during tests.
|
||||
|
||||
## 8. The test file mirrors the brain
|
||||
|
||||
`test_hash_identifier.py` is structured to mirror `hash_identifier.py`. It tests every behavior the tool claims to have:
|
||||
|
||||
```
|
||||
test_bcrypt_prefix_is_recognized ── covers PREFIX_RULES row $2b$
|
||||
test_argon2id_prefix_is_recognized ── covers PREFIX_RULES row $argon2id$
|
||||
test_apr1_prefix_is_recognized ── covers PREFIX_RULES row $apr1$
|
||||
test_sha512_crypt_prefix_is_recognized ── covers PREFIX_RULES row $6$
|
||||
test_django_pbkdf2_prefix_is_recognized ── covers PREFIX_RULES row pbkdf2_sha256$
|
||||
|
||||
test_mysql5_format_is_recognized ── covers Step 2 / _is_mysql5
|
||||
test_mysql5_rejects_lowercase_body ── covers the "be honest, don't lie" rule
|
||||
test_netntlmv2_format_is_recognized ── covers Step 2 / NetNTLMv2
|
||||
test_netntlmv1_format_is_recognized ── covers Step 2 / NetNTLMv1
|
||||
test_descrypt_format_is_recognized ── covers Step 2 / _is_descrypt
|
||||
|
||||
test_md5_length_returns_md5_first ── covers Step 3 / 32 hex chars
|
||||
test_sha1_length_returns_sha1_first ── covers Step 3 / 40 hex chars
|
||||
test_sha256_length_returns_sha256_first ── covers Step 3 / 64 hex chars
|
||||
test_mysql323_length_returns_mysql323_first ── covers Step 3 / 16 hex chars
|
||||
|
||||
test_unknown_phc_string_falls_back_to_generic
|
||||
── covers Step 4
|
||||
|
||||
test_jwt_input_is_called_out_as_not_a_hash ── covers Step 5
|
||||
test_base64_blob_is_called_out_as_not_a_hash── covers Step 5
|
||||
|
||||
test_empty_input_returns_no_candidates ── covers Step 6 (edge case)
|
||||
test_garbage_returns_no_candidates ── covers Step 6
|
||||
test_input_is_trimmed_of_whitespace ── covers the .strip() at the top
|
||||
|
||||
test_hash_candidate_is_frozen ── covers the @dataclass(frozen=True)
|
||||
|
||||
test_every_prefix_rule_is_recognized_with_high_confidence
|
||||
── meta-test: iterates over
|
||||
── PREFIX_RULES and asserts every
|
||||
── row produces a HIGH-confidence
|
||||
── match. Keeps data and code in sync.
|
||||
```
|
||||
|
||||
The meta-test at the bottom is the most interesting one. It's a guard against future regressions: if you add a new row to `PREFIX_RULES` and forget to update the matching logic, this test fires. The test loops over the data, not over hardcoded inputs — so the test grows automatically as the data table grows.
|
||||
|
||||
## 9. Why pure functions matter here
|
||||
|
||||
The brain (`identify()`) is what's called a **pure function**:
|
||||
|
||||
- Given the same input, it always returns the same output.
|
||||
- It doesn't modify anything outside itself (no global variables, no files, no network).
|
||||
- It doesn't depend on anything outside itself (no current time, no environment variables, no random numbers).
|
||||
|
||||
This sounds like a small thing. It's enormous. Pure functions are:
|
||||
|
||||
- **Trivially testable.** `assert identify("5f4d...") == [HashCandidate(...)]`. No mocking, no setup, no teardown.
|
||||
- **Trivially parallelizable.** You could run `identify()` on a million hashes across 16 CPU cores with zero coordination, because no two calls can interfere with each other.
|
||||
- **Trivially cacheable.** Same input → same output → memoize freely.
|
||||
- **Trivially understandable.** You can read `identify()` in isolation. You don't have to know what state the program is in.
|
||||
|
||||
Most real programs can't be all-pure — they have to read files, send packets, write to databases. But you can almost always *carve out* a pure core and put a thin shell around it that does the side-effecty stuff. That's exactly the architecture here: pure brain in the middle, side-effecty CLI shell around it.
|
||||
|
||||
This is sometimes called the [Functional Core, Imperative Shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) pattern. It's worth learning the name because once you see it, you'll spot it everywhere.
|
||||
|
||||
## 10. Next up
|
||||
|
||||
You now know the shape: three layers, six steps, three data tables, three helpers, one `HashCandidate` record, one CLI shell. Read **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** next and we'll walk every line of `hash_identifier.py` together.
|
||||
|
|
@ -0,0 +1,616 @@
|
|||
# Implementation walkthrough
|
||||
|
||||
This page reads `hash_identifier.py` from top to bottom with you. Every Python feature gets explained the first time it shows up. If you're brand new to Python, read this with the source file open in another window so you can see the lines we're talking about.
|
||||
|
||||
> Throughout this page, we say "the source file" to mean `hash_identifier.py`. Open it now: `code hash_identifier.py`, or `nano hash_identifier.py`, or whatever editor you use.
|
||||
|
||||
## 1. The file header
|
||||
|
||||
```python
|
||||
"""
|
||||
©AngelaMos | 2026
|
||||
hash_identifier.py
|
||||
|
||||
Identify what kind of hash a string is, by inspecting its shape
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
That triple-quoted block at the very top of the file is a **module docstring**. In Python, anything inside `"""..."""` is a string literal. When the file is loaded, Python sees this string sitting at the top with no name attached to it, and treats it as documentation for the whole module. You can read it later with `help(hash_identifier)` or by hovering over the import in an IDE.
|
||||
|
||||
The first line, `©AngelaMos | 2026`, is the copyright marker required by every file in this repo. The second line is the filename. Then a longer human-readable explanation of what the file does. You'll see the same pattern in every file in `PROJECTS/foundations/`.
|
||||
|
||||
> **Why a docstring instead of a comment?** Python has both. A `# comment` is stripped before the code runs. A `"""docstring"""` is stored on the module/function/class and is available at runtime via `__doc__`. Tools like Sphinx, mkdocs, and your IDE's hover help all read docstrings, not comments. Rule of thumb: use docstrings for "what this thing is and how to use it," and use comments for "why this *specific line* exists."
|
||||
|
||||
## 2. Imports
|
||||
|
||||
```python
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
```
|
||||
|
||||
An `import` statement brings code from another file into yours. Python ships with hundreds of modules in its **standard library** (always available, no install needed) and you can install more from [PyPI](https://pypi.org/) using `uv add <package>`.
|
||||
|
||||
There are two `import` shapes:
|
||||
|
||||
- `import argparse` — imports the whole module under its own name. You then refer to things inside it as `argparse.ArgumentParser`.
|
||||
- `from dataclasses import dataclass` — imports just one thing out of the module, so you can use it bare: `dataclass` instead of `dataclasses.dataclass`.
|
||||
|
||||
Use the second form when you only need one or two things and they have descriptive names. Use the first form when you'd otherwise pull in a bunch of names that might collide with yours.
|
||||
|
||||
A blank line separates standard-library imports from third-party imports. That's a [PEP 8](https://peps.python.org/pep-0008/#imports) convention. Linters will yell at you if you mix them.
|
||||
|
||||
Tour of what we just imported:
|
||||
|
||||
| Import | What it is | Why we need it |
|
||||
| ------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `argparse` | Standard-library CLI argument parser | Turns `sys.argv` into nice attributes (`args.hash`) |
|
||||
| `sys` | Standard library, talks to the Python interpreter | We use `sys.exit(...)` to set the program's exit code |
|
||||
| `dataclass` | A decorator that turns a class into a small record | Saves us writing `__init__` for `HashCandidate` |
|
||||
| `Literal` | A type hint meaning "this value is one of these specific strings" | Pins `confidence` to `"high" / "medium" / "low"` |
|
||||
| `Console` | Third-party, from the [`rich`](https://github.com/Textualize/rich) library | The thing that draws colored text to the terminal |
|
||||
| `Table` | Also from `rich` | Builds the colored ASCII table we print to the user |
|
||||
|
||||
## 3. The Literal type
|
||||
|
||||
```python
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
```
|
||||
|
||||
This line creates a **type alias**. We give the type `Literal["high", "medium", "low"]` a friendly name, `Confidence`, and use that name everywhere else.
|
||||
|
||||
A `Literal` type says: "the value of this thing must be exactly one of these specific values — not just any string." With mypy (our type checker) turned on, code like this:
|
||||
|
||||
```python
|
||||
candidate = HashCandidate(algorithm="MD5", confidence="hgih", reason=...)
|
||||
^^^^^^
|
||||
typo!
|
||||
```
|
||||
|
||||
would be flagged at edit time, before you ever run the code. Without `Literal`, the type would be `str` and `"hgih"` would slide by until a user noticed the misspelled output.
|
||||
|
||||
We picked `Literal` over Python's `Enum` because for small fixed sets of strings, `Literal` is lighter weight — no separate class definition, no `.value` attribute to remember. (For bigger sets or when you need behavior on the values, `Enum` is the right choice.)
|
||||
|
||||
## 4. The HashCandidate dataclass
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HashCandidate:
|
||||
"""One possible identification of a hash string ..."""
|
||||
algorithm: str
|
||||
confidence: Confidence
|
||||
reason: str
|
||||
```
|
||||
|
||||
This is the data record the brain returns. Let's unpack what's happening:
|
||||
|
||||
- **`class HashCandidate:`** defines a new type called `HashCandidate`. A class is a blueprint for objects.
|
||||
- **`algorithm: str`** declares an attribute named `algorithm` of type `str` (a string). The colon-then-type syntax is a **type annotation**. It's optional in Python but heavily used in modern code.
|
||||
- **`@dataclass(...)`** is a *decorator*. A decorator is a function that wraps your class and modifies it. The `@dataclass` decorator looks at the three attributes you declared (`algorithm`, `confidence`, `reason`) and generates an `__init__` method, a `__repr__` method, and a few other dunder methods automatically. Without `@dataclass`, you'd have to write:
|
||||
|
||||
```python
|
||||
class HashCandidate:
|
||||
def __init__(self, algorithm: str, confidence: Confidence, reason: str):
|
||||
self.algorithm = algorithm
|
||||
self.confidence = confidence
|
||||
self.reason = reason
|
||||
def __repr__(self):
|
||||
return f"HashCandidate(algorithm={self.algorithm!r}, ...)"
|
||||
def __eq__(self, other):
|
||||
...
|
||||
```
|
||||
|
||||
`@dataclass` writes all that for you.
|
||||
|
||||
- **`frozen=True`** makes instances immutable. After the object is built, `candidate.algorithm = "different"` raises `FrozenInstanceError`. This is what makes `HashCandidate` a **value object** — like an integer or a tuple, it doesn't change after creation.
|
||||
|
||||
- **`slots=True`** is a memory optimization. By default, every Python object has a `__dict__` so you can add arbitrary attributes to it on the fly. We don't want that — the three fields are all we'll ever have. `slots=True` tells Python to allocate a fixed array for the three fields, skipping the dict. Faster, smaller, and `obj.typo = "anything"` now also fails (which is good — it catches bugs).
|
||||
|
||||
You'll use both flags together a lot for tiny data records. Together they say "this is a record, treat it like one."
|
||||
|
||||
## 5. The PREFIX_RULES table
|
||||
|
||||
```python
|
||||
PREFIX_RULES: list[tuple[str, str, str]] = [
|
||||
("$argon2id$", "Argon2id", "modern PHC string, the current standard"),
|
||||
("$argon2i$", "Argon2i", "PHC string, side-channel-resistant variant"),
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
This is a `list` of `tuple`s. Let's break that down.
|
||||
|
||||
A **list** is Python's basic ordered container. You write it with square brackets: `[1, 2, 3]`. You can add to it, remove from it, index into it (`items[0]`).
|
||||
|
||||
A **tuple** is like a list but immutable. You write it with parentheses: `(1, 2, 3)`. You can read from it but you can't change it after creation. Tuples are the right container when the *position* of each value has meaning — like coordinates `(x, y)`, or here, "the prefix, the algorithm name, and the note" always in that order.
|
||||
|
||||
So `PREFIX_RULES` is a list of 3-tuples. Each tuple says "if you see this prefix, the algorithm is this name, and here is a short note about it."
|
||||
|
||||
The type annotation `list[tuple[str, str, str]]` says exactly that: "a list whose elements are tuples of three strings." This is purely for the human reader and for mypy — at runtime Python doesn't enforce it.
|
||||
|
||||
> **Why a list and not a dict?** A dict would let us look up by prefix in O(1) time. But our prefixes are not all the same length — `$2b$` is 4 chars, `$argon2id$` is 10. There's no fast "is this string a prefix of that string for any key in my dict" operation, so we walk the list. Performance is fine because the list is short (~25 entries) and we only do this once per program invocation.
|
||||
|
||||
Notice the comment groupings — `# Argon2 family`, `# bcrypt and its many variants` — these are the only kind of comment we use heavily in foundations-tier projects. They name *sections* of related data so the reader can scan.
|
||||
|
||||
The order of entries matters when two prefixes could overlap. Specifically, `$argon2id$` must come before `$argon2$` because `"$argon2id$something".startswith("$argon2$")` would *also* be true if `$argon2$` were in our table. We list more specific prefixes first so they match first.
|
||||
|
||||
## 6. The HEX_LENGTH_RULES table
|
||||
|
||||
```python
|
||||
HEX_CHARSET: frozenset[str] = frozenset("0123456789abcdefABCDEF")
|
||||
_HEX_UPPER_CHARSET: frozenset[str] = frozenset("0123456789ABCDEF")
|
||||
|
||||
HEX_LENGTH_RULES: dict[int, list[str]] = {
|
||||
16: ["MySQL323", "CRC-64"],
|
||||
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
|
||||
40: ["SHA-1", "RIPEMD-160"],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
A **set** is an unordered container of unique values. A **frozenset** is a set you can't modify after creation. We use frozenset here because:
|
||||
|
||||
1. We never need to add/remove characters from the hex alphabet — it's known and fixed.
|
||||
2. Lookup (`c in HEX_CHARSET`) is O(1) — constant time. Faster than `c in "0123456789abcdef..."` which would scan the string character by character.
|
||||
3. Marking it `frozenset` signals to the reader: "this is a fixed constant, don't try to mutate it."
|
||||
|
||||
A **dict** (dictionary) is a mapping from keys to values. You write it with curly braces: `{key: value, key: value}`. Lookup is O(1) on the key.
|
||||
|
||||
`HEX_LENGTH_RULES` maps "length-in-hex-chars" to "list of algorithm names that produce that length." So `HEX_LENGTH_RULES[32]` is `["MD5", "NTLM", "MD4", "RIPEMD-128"]` — the four algorithms that produce a 32-hex-character output.
|
||||
|
||||
`_HEX_UPPER_CHARSET` starts with an underscore. Convention: **leading underscore means module-private.** It's saying "this is an implementation detail, not part of the public interface." Python doesn't enforce this, but every linter does. The uppercase variant exists because MySQL5 prints its hex in uppercase only (`%02X` C format), so we use a tighter charset to avoid false positives on hand-typed inputs.
|
||||
|
||||
## 7. The `_is_hex` helper
|
||||
|
||||
```python
|
||||
def _is_hex(text: str) -> bool:
|
||||
"""Return True iff every character in text is a hex digit and text is non-empty"""
|
||||
return bool(text) and all(c in HEX_CHARSET for c in text)
|
||||
```
|
||||
|
||||
A `def` statement defines a function. Reading this signature:
|
||||
|
||||
- `def _is_hex(text: str) -> bool:` declares a function named `_is_hex` that takes one argument `text` of type `str` and returns a `bool` (True or False).
|
||||
- The leading underscore makes it module-private.
|
||||
|
||||
The body is one line. Let's read it right to left:
|
||||
|
||||
- `(c in HEX_CHARSET for c in text)` is a **generator expression**. It produces a sequence of booleans: for each character `c` in `text`, yield `True` if `c` is in our hex charset, else `False`.
|
||||
- `all(...)` takes that sequence and returns `True` only if *every* yielded value is True. Equivalent to "every character of text is a hex digit."
|
||||
- `bool(text)` evaluates to `False` if `text` is empty, `True` otherwise. We need this guard because `all([])` of an empty sequence returns True (mathematically reasonable, practically annoying — an empty string is not a valid hex string).
|
||||
- `... and ...` short-circuits: if `bool(text)` is False, we don't bother checking `all(...)`.
|
||||
|
||||
The word `iff` in the docstring is shorthand for "if and only if." Math nerds and CS people use it constantly; it means a biconditional.
|
||||
|
||||
## 8. MySQL5 detection
|
||||
|
||||
```python
|
||||
_MYSQL5_HEX_BODY_LENGTH = 40
|
||||
_MYSQL5_TOTAL_LENGTH = _MYSQL5_HEX_BODY_LENGTH + 1
|
||||
|
||||
|
||||
def _is_mysql5(text: str) -> bool:
|
||||
"""Return True for MySQL5 password format: `*` then 40 UPPERCASE hex chars ..."""
|
||||
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
|
||||
return False
|
||||
body = text[1:]
|
||||
return all(c in _HEX_UPPER_CHARSET for c in body)
|
||||
```
|
||||
|
||||
The two constants at the top come from one of the rules of this codebase: **no magic numbers**. Compare:
|
||||
|
||||
```python
|
||||
if len(text) != 41 or not text.startswith("*"):
|
||||
return False
|
||||
```
|
||||
|
||||
vs:
|
||||
|
||||
```python
|
||||
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
|
||||
return False
|
||||
```
|
||||
|
||||
Both work. Only the second one tells the reader *why* 41 is the right number (it's 40 hex chars for the body plus 1 for the leading `*`).
|
||||
|
||||
The function does three things:
|
||||
|
||||
1. Check the total length is exactly 41 and the first character is `*`. If not, bail with False.
|
||||
2. Slice off the leading `*` using `text[1:]`. Python slicing: `text[start:stop]` gives you the substring from index `start` (inclusive) to `stop` (exclusive). Omitting `stop` means "to the end." So `text[1:]` means "everything from index 1 onwards" — i.e. drop the first character.
|
||||
3. Check that every character in the body is in our uppercase-only hex charset.
|
||||
|
||||
The docstring contains an important caveat: we *cannot* use `body.isupper()` to enforce uppercase, because Python's `str.isupper()` returns False for a string with no cased characters at all. So `"0123456789ABCDEF...".isupper()` would correctly return True, but an all-digit body would return False — wrongly rejecting valid input. Checking membership in `_HEX_UPPER_CHARSET` is the test that actually matches the spec.
|
||||
|
||||
This is the kind of subtle gotcha you only learn from being burned. Worth memorizing: **don't use `.isupper()` / `.islower()` as a "this string contains only uppercase chars" check.**
|
||||
|
||||
## 9. DES crypt detection
|
||||
|
||||
```python
|
||||
_DESCRYPT_CHARSET: frozenset[str] = frozenset(
|
||||
"./0123456789"
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
)
|
||||
_DESCRYPT_TOTAL_LENGTH = 13
|
||||
|
||||
|
||||
def _is_descrypt(text: str) -> bool:
|
||||
"""Return True for traditional 13-char DES crypt (legacy /etc/passwd) ..."""
|
||||
return (
|
||||
len(text) == _DESCRYPT_TOTAL_LENGTH
|
||||
and all(c in _DESCRYPT_CHARSET for c in text)
|
||||
)
|
||||
```
|
||||
|
||||
DES crypt is the *original* Unix password hash format from the 1970s. No prefix, no salt marker, just 13 characters from a specific 64-character alphabet.
|
||||
|
||||
The charset definition uses **string literal concatenation**: three string literals sitting next to each other are automatically joined by Python at parse time. So this:
|
||||
|
||||
```python
|
||||
"./0123456789"
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
```
|
||||
|
||||
is exactly the same as:
|
||||
|
||||
```python
|
||||
"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
```
|
||||
|
||||
But the split form is much easier to read. This works for any sequence of adjacent string literals — useful when wrapping long strings.
|
||||
|
||||
`_is_descrypt` itself is two checks: right length, right alphabet. Both must be true (`and`). The whole expression is wrapped in parentheses just for visual layout — Python allows you to split expressions across lines if they're inside `()`, `[]`, or `{}`.
|
||||
|
||||
## 10. The `identify` function — the brain
|
||||
|
||||
This is the main attraction. It's about 100 lines long and follows the six-step pipeline from [02-ARCHITECTURE.md](./02-ARCHITECTURE.md).
|
||||
|
||||
### 10a. The pylint silencing comment
|
||||
|
||||
```python
|
||||
# pylint: disable=too-many-return-statements,too-many-branches
|
||||
```
|
||||
|
||||
This comment turns off two specific pylint warnings *for this function only*. Pylint normally complains about functions with lots of `return` statements or lots of branches, because they're usually a sign that you should refactor.
|
||||
|
||||
We're keeping the warnings off because in this case the branching *is* the structure. Six numbered steps, each potentially returning a result — refactoring them into six helper functions would scatter the pipeline across the file and make it harder, not easier, to read. So we acknowledge the warnings, explain why we're keeping them off in the inline comment block above, and turn them off explicitly.
|
||||
|
||||
> **Rule of thumb on silencing linters:** never silence broadly. Always silence the *specific* warning, on the *specific* function, with a comment that explains why. If you find yourself silencing the same warning everywhere, your linter config is wrong, not your code.
|
||||
|
||||
### 10b. The signature and docstring
|
||||
|
||||
```python
|
||||
def identify(raw_input: str) -> list[HashCandidate]:
|
||||
"""Return ranked candidates for what algorithm produced `raw_input` ..."""
|
||||
```
|
||||
|
||||
Takes a string. Returns a list of `HashCandidate` objects. That's the whole contract. The function does not raise exceptions for unknown inputs — it returns an empty list instead. (Throwing exceptions for "I don't know" forces every caller to wrap the call in try/except. Returning an empty list is cleaner.)
|
||||
|
||||
The docstring is **Numpy-style**: it has labeled sections (`Parameters`, `Returns`) with the parameter name on its own line and the description indented underneath. Numpy style is what most scientific Python uses. The other big style is Google ("Args:" and "Returns:" with colons), and you'll see both in the wild. Either is fine; just pick one and be consistent.
|
||||
|
||||
### 10c. Trim and bail on empty
|
||||
|
||||
```python
|
||||
text = raw_input.strip()
|
||||
|
||||
if not text:
|
||||
return []
|
||||
```
|
||||
|
||||
`str.strip()` returns a *new* string with leading and trailing whitespace removed. (Strings are immutable in Python — every "modifying" method actually returns a new string.) We do this because hashes copy-pasted from terminals often arrive with trailing newlines or leading spaces.
|
||||
|
||||
We do *not* lowercase the text. Some formats (MySQL5) are case-sensitive on purpose.
|
||||
|
||||
The `if not text:` check catches the empty string. In Python, an empty string is **falsy** — it counts as False in a boolean context. So `not text` is True when `text` is empty. Same for empty list, empty dict, `None`, and `0`. Get used to this — it's one of Python's defining features.
|
||||
|
||||
### 10d. Step 1 — walk PREFIX_RULES
|
||||
|
||||
```python
|
||||
for prefix, algorithm, note in PREFIX_RULES:
|
||||
if text.startswith(prefix):
|
||||
return [
|
||||
HashCandidate(
|
||||
algorithm=algorithm,
|
||||
confidence="high",
|
||||
reason=f"prefix `{prefix}` — {note}",
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
A `for ... in ...` loop iterates over each element of a sequence. Here, each element of `PREFIX_RULES` is a 3-tuple, so we **unpack** it directly into three variables — `prefix`, `algorithm`, `note` — in one go. This is called *tuple unpacking* and it's used constantly in Python.
|
||||
|
||||
`text.startswith(prefix)` is a method on `str` that returns True if `text` begins with `prefix`. There's also `endswith`, by the way.
|
||||
|
||||
`f"..."` is an **f-string** (formatted string literal). Anything inside `{}` is evaluated and inserted into the string. So `f"prefix `{prefix}` — {note}"` produces something like `"prefix \`$2b$\` — bcrypt PHC string, 2b variant (current)"`. F-strings are the modern way to format strings in Python (3.6+); avoid the older `%` and `.format()` styles in new code.
|
||||
|
||||
The function returns a list containing one `HashCandidate`. We use keyword arguments (`algorithm=algorithm`) instead of positional ones so the call reads clearly even if you don't remember the parameter order.
|
||||
|
||||
The whole `if text.startswith(prefix):` check returns immediately on the first match. This is fine because the table is designed so that no two prefixes can match the same input (the longer, more specific ones come first).
|
||||
|
||||
### 10e. Step 2 — special non-PHC shapes
|
||||
|
||||
NetNTLMv2, NetNTLMv1, MySQL5, DES crypt. Each gets its own block.
|
||||
|
||||
```python
|
||||
if "::" in text and text.count(":") >= 4:
|
||||
parts = text.split(":")
|
||||
if (len(parts) >= 6 and len(parts[4]) == 32 and _is_hex(parts[4])):
|
||||
return [HashCandidate(algorithm="NetNTLMv2", ...)]
|
||||
if (len(parts) >= 6 and len(parts[3]) == 48 and _is_hex(parts[3])):
|
||||
return [HashCandidate(algorithm="NetNTLMv1", ...)]
|
||||
```
|
||||
|
||||
A few Python features here:
|
||||
|
||||
- `"::" in text` returns True if the substring `"::"` appears anywhere in `text`. The `in` operator works on strings (substring check), lists (membership), dicts (key lookup), sets (membership), and any iterable.
|
||||
- `text.count(":")` returns how many times `:` appears in the string.
|
||||
- `text.split(":")` returns a list of substrings, splitting on `:`. So `"a:b:c".split(":")` gives `["a", "b", "c"]`.
|
||||
- `parts[3]` indexes into the list. Python uses zero-based indexing, so `parts[3]` is the *fourth* element.
|
||||
|
||||
NetNTLMv2 records look like `user::domain:challenge:hmac(32 hex):blob`. We split on `:`, then look at part index 4 (the hmac field): if it's exactly 32 hex characters, we've got a v2. NetNTLMv1 looks similar but the field at index 3 is 48 hex chars (the LM hash). We test v2 *first* because v2's distinguishing field at index 4 is more specific.
|
||||
|
||||
This is the messiest step in the whole function. NetNTLM records were not designed to be pretty — they evolved from Microsoft authentication protocols of the 1990s. The shape match is the best we can do without parsing the entire NTLM protocol.
|
||||
|
||||
```python
|
||||
if _is_mysql5(text):
|
||||
return [HashCandidate(algorithm="MySQL5", confidence="high", ...)]
|
||||
|
||||
if _is_descrypt(text):
|
||||
return [HashCandidate(algorithm="DES crypt", confidence="medium", ...)]
|
||||
```
|
||||
|
||||
Calls our helpers. Note that MySQL5 gets **HIGH** confidence (the `*` + 40 uppercase hex shape is essentially unique) while DES crypt gets **MEDIUM** (a 13-char `./0-9A-Za-z` string could plausibly be other things — some session IDs, some encoded values). Honesty is a feature.
|
||||
|
||||
### 10f. Step 3 — hex + length lookup
|
||||
|
||||
```python
|
||||
if _is_hex(text):
|
||||
algorithms = HEX_LENGTH_RULES.get(len(text), [])
|
||||
candidates: list[HashCandidate] = []
|
||||
for index, algorithm in enumerate(algorithms):
|
||||
confidence: Confidence = "medium" if index == 0 else "low"
|
||||
label = (
|
||||
"most likely candidate at this length"
|
||||
if index == 0 else "also possible at this length"
|
||||
)
|
||||
candidates.append(
|
||||
HashCandidate(algorithm=algorithm, confidence=confidence, reason=...)
|
||||
)
|
||||
return candidates
|
||||
```
|
||||
|
||||
Three new Python features here:
|
||||
|
||||
- **`dict.get(key, default)`** returns the value if the key exists, or `default` if not. So `HEX_LENGTH_RULES.get(len(text), [])` returns the list of algorithms at this length, or an empty list if no rule exists for this length. This avoids a `KeyError` exception that would happen with `HEX_LENGTH_RULES[len(text)]` on an unknown length.
|
||||
- **`enumerate(iterable)`** is a built-in that wraps an iterable and yields `(index, value)` pairs. So `for index, algorithm in enumerate(algorithms)` walks the list and gives us both the position and the value. The first algorithm gets index 0, the second gets index 1, etc.
|
||||
- **Ternary expression**: `value_if_true if condition else value_if_false`. So `"medium" if index == 0 else "low"` evaluates to `"medium"` for the first item and `"low"` for the rest. This is the Python equivalent of `cond ? a : b` in C/JavaScript.
|
||||
|
||||
The first algorithm at each length is the one that's most common in 2026 — MD5 at length 32, SHA-1 at length 40, SHA-256 at length 64. The list ordering in `HEX_LENGTH_RULES` is by descending prevalence, so "first" really does mean "most likely."
|
||||
|
||||
`candidates.append(item)` adds an item to the end of the list. `list[T]` is a generic type — `list[HashCandidate]` means "a list whose elements are HashCandidate objects." The empty list literal `[]` is unannotated, so we write `candidates: list[HashCandidate] = []` to tell mypy what we intend the list to hold.
|
||||
|
||||
### 10g. Step 4 — generic PHC fallback
|
||||
|
||||
```python
|
||||
if text.startswith("$"):
|
||||
rest = text[1:]
|
||||
if "$" in rest:
|
||||
algo_name = rest.split("$", 1)[0]
|
||||
if algo_name and all(c.isalnum() or c in "-_" for c in algo_name):
|
||||
return [HashCandidate(algorithm=f"PHC string ({algo_name})", ...)]
|
||||
```
|
||||
|
||||
If the input starts with `$` but didn't match any of our specific rules, it might still be a PHC string from an algorithm we don't have a rule for. We try to extract the algorithm name and report it as a generic PHC at LOW confidence.
|
||||
|
||||
- `text[1:]` slices off the leading `$`.
|
||||
- `rest.split("$", 1)` splits on `$` but only once — the optional second argument to `split` is the max number of splits. So `"argon2id$v=19$...".split("$", 1)` returns `["argon2id", "v=19$..."]`. Without the `1`, it would split on every `$` and we'd lose information.
|
||||
- `[0]` takes the first element of the resulting list.
|
||||
- `c.isalnum()` is a `str` method returning True if `c` is a letter or digit. We use this to validate that the algorithm name field contains only PHC-legal characters (alphanumeric plus `-` and `_`). Anything weirder and we bail rather than make up an algorithm name from garbage.
|
||||
|
||||
The whole step is gated on the algorithm name being non-empty AND every character being legal. Better to report nothing than to guess wildly.
|
||||
|
||||
### 10h. Step 5 — shape hints
|
||||
|
||||
```python
|
||||
if text.startswith("eyJ"):
|
||||
return [HashCandidate(algorithm="JWT (not a hash)", ...)]
|
||||
|
||||
if any(c in text for c in "+/=") and len(text) > 8:
|
||||
return [HashCandidate(algorithm="Base64 blob (not a hash)", ...)]
|
||||
```
|
||||
|
||||
People paste JWTs and base64 blobs into hash identifiers all the time. Rather than returning a silent "no match," we point out what they probably pasted.
|
||||
|
||||
JWTs always start with `eyJ` because the JWT header is JSON like `{"alg":"HS256","typ":"JWT"}`, and the bytes `{"` base64-encoded begin with `eyI` or `eyJ`. The leading `eyJ` is essentially diagnostic for JWTs.
|
||||
|
||||
`any(c in text for c in "+/=")` is a generator expression inside `any()` — mirror of the `all(...)` we used in `_is_hex`. `any()` returns True if *any* element of the sequence is True. Together: "is there any character in `'+/='` that appears in `text`?" If yes, the input contains base64-only characters and cannot be a hex hash.
|
||||
|
||||
The `len(text) > 8` floor exists because a short string like `"a+b=c"` might trip the base64 check accidentally. We require enough length to be sure it's actually base64, not a math expression.
|
||||
|
||||
### 10i. Step 6 — give up
|
||||
|
||||
```python
|
||||
return []
|
||||
```
|
||||
|
||||
Empty list. The CLI prints "could not identify" when it sees this. Always better to admit defeat than to lie with confidence.
|
||||
|
||||
## 11. The CLI layer
|
||||
|
||||
### 11a. The argument parser
|
||||
|
||||
```python
|
||||
def _build_argument_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="hashid", description="...")
|
||||
parser.add_argument("hash", help="The hash string to identify ...")
|
||||
parser.add_argument("--top", "-n", type=int, default=5, help="...")
|
||||
return parser
|
||||
```
|
||||
|
||||
`argparse.ArgumentParser` is the standard library's CLI parser. We set:
|
||||
|
||||
- `prog="hashid"` — the program name shown in help text.
|
||||
- `description="..."` — the one-line summary at the top of `--help`.
|
||||
|
||||
Then we add two arguments:
|
||||
|
||||
- **`"hash"`** — a positional argument. The user types `hashid <hash>`. Required. After parsing, `args.hash` holds the string.
|
||||
- **`"--top", "-n"`** — an optional flag with both a long form (`--top 3`) and short form (`-n 3`). `type=int` converts the string the user typed into an integer. `default=5` is what you get if the user doesn't pass it.
|
||||
|
||||
The function *returns* the parser without calling `.parse_args()`. Why? So the test file can build the parser, inspect it, run it on test input, etc., without actually executing the CLI. **Separating construction from execution is a recurring pattern for testability.** Whenever you find yourself writing `something().run()` in one line, ask whether someone needs to build that something without running it.
|
||||
|
||||
### 11b. The table renderer
|
||||
|
||||
```python
|
||||
def _render_table(raw_input, candidates, console) -> None:
|
||||
table = Table(title=f"Candidates for: {raw_input.strip()}", ...)
|
||||
table.add_column("algorithm", style="bold white", no_wrap=True)
|
||||
table.add_column("confidence", no_wrap=True)
|
||||
table.add_column("reason", style="dim")
|
||||
|
||||
confidence_colors: dict[Confidence, str] = {
|
||||
"high": "green",
|
||||
"medium": "yellow",
|
||||
"low": "cyan",
|
||||
}
|
||||
for candidate in candidates:
|
||||
color = confidence_colors[candidate.confidence]
|
||||
table.add_row(
|
||||
candidate.algorithm,
|
||||
f"[{color}]{candidate.confidence}[/{color}]",
|
||||
candidate.reason,
|
||||
)
|
||||
console.print(table)
|
||||
```
|
||||
|
||||
`rich.Table` is a class from the `rich` library. You create a Table, add columns, add rows, then print it. The library handles all the box-drawing characters, color codes, terminal-width detection, and Unicode handling.
|
||||
|
||||
The `f"[{color}]{candidate.confidence}[/{color}]"` syntax is `rich`'s inline color markup. `[green]text[/green]` colors `text` green. We use a dict to look up the color for each confidence level — three colors, predictable, easy to change in one place if we wanted to.
|
||||
|
||||
The `-> None` return type means the function doesn't return anything meaningful (it just has side effects: printing to the terminal). This is the right annotation for "this function does its work via side effects."
|
||||
|
||||
`console` is passed in as a parameter rather than created inside. Same idea as the parser: the test can pass a captured-output Console, the real CLI passes a real Console. Dependency injection at work.
|
||||
|
||||
### 11c. `main()` and the script guard
|
||||
|
||||
```python
|
||||
def main() -> int:
|
||||
parser = _build_argument_parser()
|
||||
args = parser.parse_args()
|
||||
console = Console()
|
||||
|
||||
candidates = identify(args.hash)
|
||||
|
||||
if not candidates:
|
||||
console.print("[red]No identification possible.[/red] ...")
|
||||
return 1
|
||||
|
||||
trimmed = candidates[:args.top]
|
||||
_render_table(args.hash, trimmed, console)
|
||||
|
||||
if trimmed[0].confidence == "high":
|
||||
console.print("\n[dim]Next step: try the matching cracker ...[/dim]")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
`main()` is the entry point. The body reads like English:
|
||||
|
||||
1. Build the parser.
|
||||
2. Parse `sys.argv` (`parser.parse_args()` defaults to using `sys.argv`).
|
||||
3. Build a Console.
|
||||
4. Run the brain on the user's input.
|
||||
5. If no candidates, print an error and return exit code 1.
|
||||
6. Trim to the top-N results (`candidates[:args.top]` is slicing — same as before).
|
||||
7. Render the table.
|
||||
8. If the top candidate is HIGH confidence, print a hint about what to do next.
|
||||
9. Return exit code 0.
|
||||
|
||||
The function returns the exit code as an integer. We hand it to `sys.exit()` at the bottom.
|
||||
|
||||
The final block:
|
||||
|
||||
```python
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
is the classic Python script idiom. `__name__` is a special variable Python sets automatically:
|
||||
|
||||
- When you run `python hash_identifier.py`, Python sets `__name__ = "__main__"`.
|
||||
- When you `import hash_identifier` from somewhere else, Python sets `__name__ = "hash_identifier"`.
|
||||
|
||||
So `if __name__ == "__main__":` means "only do this when the file is run directly, not when it's imported." This lets the test file `import hash_identifier` and call `identify()` without accidentally firing the CLI.
|
||||
|
||||
`sys.exit(N)` terminates the program with exit code N. Exit code 0 conventionally means success; non-zero means failure. Shell scripts (`if hashid "$x"; then ...`) read the exit code to decide what to do next.
|
||||
|
||||
## 12. Running through a real example
|
||||
|
||||
Let's trace what happens for `just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G'`.
|
||||
|
||||
```
|
||||
1. Shell invokes: python hash_identifier.py '$2b$12$EixZ...'
|
||||
2. Python runs the file. sys.argv = [".../hash_identifier.py", "$2b$12$EixZ..."]
|
||||
3. Since __name__ == "__main__", call sys.exit(main()).
|
||||
4. main() builds parser, calls parser.parse_args().
|
||||
5. argparse sees positional arg → args.hash = "$2b$12$EixZ..."
|
||||
args.top = 5 (default).
|
||||
6. main() calls identify(args.hash).
|
||||
|
||||
7. identify():
|
||||
text = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G"
|
||||
text is non-empty.
|
||||
Step 1: walk PREFIX_RULES.
|
||||
First row $argon2id$ — does text start with "$argon2id$"? no.
|
||||
... a few more rows ...
|
||||
Row ("$2b$", "bcrypt", ...) — does text start with "$2b$"? YES!
|
||||
return [HashCandidate(algorithm="bcrypt", confidence="high", reason="prefix `$2b$` — bcrypt PHC string, 2b variant (current)")]
|
||||
|
||||
8. Back in main(): candidates is non-empty.
|
||||
9. trimmed = candidates[:5] → just the one candidate.
|
||||
10. _render_table(args.hash, trimmed, console)
|
||||
Build a Table with title "Candidates for: $2b$12$EixZ...".
|
||||
Add three columns.
|
||||
Build one row: ("bcrypt", "[green]high[/green]", "prefix `$2b$` — ...").
|
||||
console.print(table) — rich draws the colored ASCII table to your terminal.
|
||||
11. Top candidate is HIGH confidence → print the "Next step" nudge.
|
||||
12. return 0.
|
||||
13. sys.exit(0) — clean exit.
|
||||
```
|
||||
|
||||
Total elapsed time: well under a millisecond for the brain, a few more for `rich` to render. Almost all of the program's runtime is `rich` drawing the table.
|
||||
|
||||
## 13. The test file, in brief
|
||||
|
||||
Open `test_hash_identifier.py` if you haven't yet. It's structured as ~25 small `test_*` functions. Each one:
|
||||
|
||||
1. Builds a known input.
|
||||
2. Calls `identify(input)` or `_is_mysql5(input)` etc.
|
||||
3. Asserts something about the output (`assert candidates[0].algorithm == "bcrypt"`).
|
||||
|
||||
A few interesting ones to read:
|
||||
|
||||
- **`test_every_prefix_rule_is_recognized_with_high_confidence`** (~line 531) — loops over `PREFIX_RULES` and confirms every row produces a HIGH-confidence match. Adds rows automatically if you add new prefixes.
|
||||
- **`test_mysql5_rejects_lowercase_body`** (~line 188) — confirms the "don't lie with confidence" rule from the implementation.
|
||||
- **`test_hash_candidate_is_frozen`** (~line 481) — uses `pytest.raises` to confirm that mutating a frozen dataclass raises `FrozenInstanceError`.
|
||||
|
||||
Run them: `just test`. The whole suite is ~30 tests and finishes in under a second.
|
||||
|
||||
## 14. What to try next
|
||||
|
||||
You've read the file. To make the knowledge stick:
|
||||
|
||||
1. Try `just run -- <hash>` with weird inputs — empty string (after quoting), pure digits, super long strings, hashes with trailing whitespace, JWTs, base64.
|
||||
2. Open `hash_identifier.py` and add a `print()` statement inside `identify()` to see which step matches for each input. Then remove it before committing.
|
||||
3. Add a new prefix to `PREFIX_RULES` — for instance, scrypt sometimes shows up with `$scrypt$` prefix. Add it, add a test, run `just test`.
|
||||
4. Read **[04-CHALLENGES.md](./04-CHALLENGES.md)** for harder extension ideas.
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
# Challenges
|
||||
|
||||
You've read the code. You know what every line does. Now the only way to make this knowledge actually yours is to *change* the code. This page is a ladder of extensions, easiest to hardest. Don't skip rungs — each one teaches a thing that the next one assumes.
|
||||
|
||||
For every challenge: **write the test first.** Then make the test pass. That's the rhythm professional developers actually use. The test file already shows you the pattern — copy one of the existing `test_*` functions, change the input and expected output, watch it fail, then change the code until it passes.
|
||||
|
||||
## Tier 1 — get comfortable
|
||||
|
||||
### Challenge 1.1: Add a new prefix rule
|
||||
|
||||
`PREFIX_RULES` has ~25 entries today. There are dozens more. Pick one from the list below and add it:
|
||||
|
||||
| Prefix | Algorithm | Where it comes from |
|
||||
| ------------ | ------------------ | ----------------------------------------- |
|
||||
| `$pbkdf2$` | PBKDF2-SHA1 (Atlassian) | Older Atlassian / Jira hashes |
|
||||
| `$ml$` | macOS / iCloud Keychain | Apple PBKDF2-SHA512 |
|
||||
| `{x-pbkdf2}` | PBKDF2 (some Atlassian) | LDAP-style wrapper |
|
||||
| `$sha1$` | sha1crypt | A rare crypt(3) variant |
|
||||
| `$md5,` | Solaris MD5 crypt | Note the comma instead of `$` — be careful |
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Open `hash_identifier.py`. Add one row to `PREFIX_RULES`.
|
||||
2. Open `test_hash_identifier.py`. Copy an existing prefix test (e.g. `test_argon2id_prefix_is_recognized`). Rename it. Replace the input string with a sample of your new prefix. Update the assertion.
|
||||
3. Run `just test`. The new test should pass. The meta-test `test_every_prefix_rule_is_recognized_with_high_confidence` should also still pass.
|
||||
4. Run the tool on your new input: `just run -- '$pbkdf2$...'`. Confirm the new algorithm shows up.
|
||||
|
||||
**What you learn:** the table-driven design pays off — you wrote zero new logic, just data. That's the goal.
|
||||
|
||||
### Challenge 1.2: Add a length to HEX_LENGTH_RULES
|
||||
|
||||
There's no rule for 24 hex chars (96 bits) right now. That length is rare but `Tiger-128` and some old custom hashes produce it.
|
||||
|
||||
1. Add `24: ["Tiger-128"]` to `HEX_LENGTH_RULES`.
|
||||
2. Write a test (`test_tiger128_length_returns_tiger128`).
|
||||
3. Run `just test`.
|
||||
|
||||
**Twist:** what should happen if someone passes a 24-character string that *isn't* hex? The existing `_is_hex` check should handle it. Read step 3 of `identify()` and confirm.
|
||||
|
||||
### Challenge 1.3: Add a `--json` output mode
|
||||
|
||||
Right now the CLI only prints a colored table. Add a `--json` flag that prints the candidates as JSON instead. JSON is what every other tool will want to consume — your output becomes machine-readable.
|
||||
|
||||
**Hints:**
|
||||
|
||||
- `argparse` supports boolean flags via `action="store_true"`. Add `parser.add_argument("--json", action="store_true", help="...")`.
|
||||
- The standard library `json` module has `json.dumps(data)`.
|
||||
- `HashCandidate` is a dataclass, so `dataclasses.asdict(candidate)` converts it to a plain dict that `json.dumps` can serialize.
|
||||
- Test it: `just run -- --json 5f4d...` should output a JSON array.
|
||||
|
||||
**Twist:** make the JSON include a top-level `input` field with the original string, so a downstream tool knows what was identified. And pretty-print with `indent=2`.
|
||||
|
||||
## Tier 2 — actually new behavior
|
||||
|
||||
### Challenge 2.1: Read hashes from a file or stdin
|
||||
|
||||
Right now the tool takes one hash per invocation. Real workflows have files with millions of hashes. Extend the CLI so it can take input from a file (`--file hashes.txt`) or from standard input when no positional argument is given (so `cat hashes.txt | hashid` works).
|
||||
|
||||
**Hints:**
|
||||
|
||||
- Make the positional `hash` argument optional with `nargs="?"`.
|
||||
- Add `--file` with `type=argparse.FileType("r")`.
|
||||
- Use `sys.stdin.read()` (or iterate `sys.stdin` line-by-line) when both are missing.
|
||||
- The output for batch input should be different — probably one line per input, not a colored table for each. Decide what format makes sense and document it.
|
||||
|
||||
**Twist:** what should happen if the same input appears 1000 times in the file? Should you re-run `identify()` each time, or cache results? Try both and measure with `just run` on a file of 1M repeated hashes. (Bigger lesson: caching is only a win when the function is pure. Ours is.)
|
||||
|
||||
### Challenge 2.2: Add hashcat-mode hints
|
||||
|
||||
Hashcat assigns a numeric mode to every algorithm: 0 for MD5, 100 for SHA-1, 3200 for bcrypt, etc. The full list is documented at [hashcat.net/wiki/doku.php?id=example_hashes](https://hashcat.net/wiki/doku.php?id=example_hashes).
|
||||
|
||||
Extend `HashCandidate` with an optional `hashcat_mode: int | None` field. When you build a candidate, look up its mode (you'll need a `dict[str, int]` mapping algorithm name → mode) and fill it in.
|
||||
|
||||
Then print the mode in the table, and update the "next step" nudge at the end of `main()` to suggest the exact hashcat command:
|
||||
|
||||
```
|
||||
Next step: hashcat -m 3200 -a 0 '$2b$12$EixZ...' wordlist.txt
|
||||
```
|
||||
|
||||
**Hints:**
|
||||
|
||||
- `Optional` fields on a dataclass need defaults: `hashcat_mode: int | None = None`.
|
||||
- The mode lookup is another data table — keep the data-driven design.
|
||||
- John the Ripper uses different names (e.g. `bcrypt`, `raw-md5`). Add those too if you're feeling generous.
|
||||
|
||||
### Challenge 2.3: Recognize more "not a hash" inputs
|
||||
|
||||
Step 5 only catches JWTs and base64 blobs. Many other things get pasted into hash identifiers by accident. Add detectors for:
|
||||
|
||||
- **URLs** — start with `http://` or `https://`. Tell the user it's a URL.
|
||||
- **Hex with `0x` prefix** — Ethereum addresses, memory addresses. Tell the user.
|
||||
- **Base58** — used by Bitcoin addresses and IPFS hashes. Alphabet is `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz` (no `0`, `O`, `I`, `l`).
|
||||
- **Base32** — uppercase letters + digits 2-7. Used by some Tor onion addresses and TOTP secrets.
|
||||
|
||||
Each one is a new branch in step 5, returning a LOW-confidence "not a hash" candidate. Be honest with the confidence — these are *shape* hints, not certainties.
|
||||
|
||||
## Tier 3 — extend the model
|
||||
|
||||
### Challenge 3.1: Multi-hash detection in one string
|
||||
|
||||
Some breach dumps contain *combined* records like `user:hash:salt`. Real workflows often have to split these into the constituent parts before identification. Add a `--split` mode that:
|
||||
|
||||
1. Takes a string with colon-separated fields.
|
||||
2. Identifies each field independently.
|
||||
3. Prints a table showing which field is what (username, hash, salt, garbage).
|
||||
|
||||
**Hints:**
|
||||
|
||||
- This is mostly heuristics. A field that's all letters is probably a username; a field that matches a hash rule is probably the hash; a short random-looking field could be a salt.
|
||||
- Pull the field-classification logic out of `identify()` — it's a new layer on top.
|
||||
- Test on real-looking lines like `alice:$2b$12$EixZ...`, `bob:5f4dcc3b5aa765d61d8327deb882cf99:salt123`.
|
||||
|
||||
### Challenge 3.2: Confidence rebalancing with evidence weights
|
||||
|
||||
Right now `confidence` is a single value: high, medium, or low. But evidence is more nuanced. A 32-hex string that *also* contains no characters above 'f' is "very likely hex." A 13-character string from the DES charset is "13-char DES-compatible" but the same characters could appear in many short strings.
|
||||
|
||||
Replace `confidence: Literal["high", "medium", "low"]` with `confidence_score: float` in the range 0.0–1.0. Compute the score from multiple evidence weights:
|
||||
|
||||
- prefix match: 0.95
|
||||
- special shape match: 0.85
|
||||
- length match (1st candidate): 0.55
|
||||
- length match (Nth candidate): 0.55 / N
|
||||
- charset match: small additive bonus
|
||||
- not-a-hash hint: 0.30
|
||||
|
||||
Then in the CLI, map score back to a color (>0.8 green, 0.5–0.8 yellow, <0.5 cyan) for display. The user still sees three buckets; the internal model gets richer.
|
||||
|
||||
**What you learn:** how scoring systems work under the hood. This is the same idea as Bayesian spam classifiers, search relevance ranking, and antivirus heuristics — combine multiple weak signals into one numeric score, then bucket for display.
|
||||
|
||||
### Challenge 3.3: Suggest the *crack difficulty* alongside the algorithm
|
||||
|
||||
Once you know the algorithm, you also implicitly know how hard the hash is to crack. MD5 cracks at billions of guesses per second on a modern GPU; bcrypt at thousands. Argon2id with strong parameters might be hundreds.
|
||||
|
||||
Add a `crack_difficulty: Literal["trivial", "moderate", "hard", "very_hard"]` field to `HashCandidate`, filled in from a per-algorithm table. Print it in the output table.
|
||||
|
||||
**Twist:** for parameterized hashes (bcrypt cost factor, Argon2 memory/time), parse the parameters out of the PHC string. A bcrypt hash with cost factor 4 is much weaker than one with cost factor 14. Have your output reflect that:
|
||||
|
||||
```
|
||||
algorithm difficulty reason
|
||||
───────── ────────── ──────────────────────────────────────────
|
||||
bcrypt moderate cost=4 — much weaker than the default 12
|
||||
bcrypt hard cost=12 — the modern default
|
||||
bcrypt very_hard cost=14 — paranoid setting
|
||||
```
|
||||
|
||||
## Tier 4 — make it real
|
||||
|
||||
### Challenge 4.1: Run identification against a real breach dump
|
||||
|
||||
The [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) password file is a publicly-distributed list of ~1 billion SHA-1 hashes of leaked passwords, available as a torrent. (Use the **SHA-1** version, not the NTLM version — and use it only for educational analysis; don't try to "crack" it for any malicious purpose. The hashes themselves are public.)
|
||||
|
||||
Run your tool against the first 1000 lines. Confirm it identifies them all as SHA-1 (40 hex chars). Measure throughput: how many hashes per second can your tool process? Where's the bottleneck?
|
||||
|
||||
**Bigger lesson:** the brain is microseconds; the bottleneck is the CLI printing. To process millions of hashes you'd skip `rich` and stream JSON to stdout. That's a different program for a different use case.
|
||||
|
||||
### Challenge 4.2: Compare to `hashid` and `name-that-hash`
|
||||
|
||||
Two existing tools do roughly what ours does:
|
||||
|
||||
- [hashid](https://github.com/psypanda/hashID) — the classic, ~10 years old, written in pure Python.
|
||||
- [name-that-hash](https://github.com/HashPals/Name-That-Hash) — newer, more thorough, more aggressive about guessing.
|
||||
|
||||
Run all three on the same inputs. Compare:
|
||||
|
||||
- Which detects more formats?
|
||||
- Which has the best false-positive rate (says "definitely SHA-256" on garbage that isn't)?
|
||||
- Which is fastest on a batch input?
|
||||
|
||||
Write up your findings. This is exactly the kind of work security researchers do when picking a tool for production use.
|
||||
|
||||
### Challenge 4.3: Wrap it into a `pre-commit` hook
|
||||
|
||||
`pre-commit` is a tool that runs checks before you `git commit`. People sometimes accidentally commit password hashes to repos (extremely bad). Build a `pre-commit` hook that runs your identifier on every changed file and refuses the commit if it finds anything that looks like a real hash.
|
||||
|
||||
**Hints:**
|
||||
|
||||
- Read [pre-commit's hook tutorial](https://pre-commit.com/#new-hooks).
|
||||
- For each line of each changed file, run `identify(line)`. If the top candidate is HIGH confidence and isn't a generic-PHC or not-a-hash, refuse.
|
||||
- Exit code 1 = block the commit; exit code 0 = allow it.
|
||||
- Add allowlist comments like `# pragma: allow-hash` so that legitimate test fixtures don't get blocked.
|
||||
|
||||
This is the kind of "small tool, real impact" project that ends up in real security teams' toolchains.
|
||||
|
||||
## Tier 5 — break the model
|
||||
|
||||
### Challenge 5.1: Why is this a hard problem?
|
||||
|
||||
So far we've matched on *structure*: prefix, length, charset. But the model has limits. Consider:
|
||||
|
||||
- Two different algorithms producing the same length output (MD5 vs NTLM at 32 hex chars). We *can't* distinguish them from structure alone.
|
||||
- An algorithm whose output is sometimes uppercase and sometimes lowercase (different libraries produce different cases for the same algo).
|
||||
- A truncated SHA-256 — someone took the first 32 hex chars of a SHA-256 output and called it a "hash." We'd identify it as MD5.
|
||||
|
||||
Write a short document (`docs/limitations.md`) listing every case where the tool *cannot* distinguish two formats and explain why. This is the kind of honesty good security tools document up-front. Don't pretend the tool is more capable than it is.
|
||||
|
||||
### Challenge 5.2: Probabilistic identification with a ML classifier
|
||||
|
||||
The structural approach is interpretable but limited. The opposite extreme is to train a classifier on labeled hashes and let it learn the structure for you.
|
||||
|
||||
Build a tiny experiment:
|
||||
|
||||
1. Generate 100k labeled training samples: known passwords hashed with each algorithm. (Use Python's `hashlib`, `bcrypt`, `argon2-cffi`, etc.)
|
||||
2. Train a simple classifier — `sklearn.linear_model.LogisticRegression` with character-n-gram features works fine for this.
|
||||
3. Run the classifier on held-out test samples. Measure accuracy.
|
||||
4. Compare to your rule-based identifier on the same test set.
|
||||
|
||||
**What you'll learn:** the rule-based system probably wins on common formats (it has all the structural priors baked in) and the ML model probably wins on unusual cases (it picks up patterns you didn't think to encode). Real-world tools combine both — rules first, ML for tie-breaking. This is how spam classifiers, web application firewalls, and antivirus engines actually work.
|
||||
|
||||
> **Don't run any of these challenges on real user data without permission.** The challenges assume publicly-released breach archives, your own test data, or CTF inputs. Identifying hashes from data you don't have rights to is a different conversation and outside the scope of this project.
|
||||
|
||||
## Where to go after the challenges
|
||||
|
||||
If you finished tier 4 or 5, you should now have a strong feel for the *whole loop*: read code → understand → modify → test → measure → document. That loop is the entire job of a security engineer. The specific topic doesn't matter much — you'd do the same loop for a port scanner, a fuzzer, a SIEM rule, or a vulnerability scanner.
|
||||
|
||||
When you're ready for more:
|
||||
|
||||
- **`PROJECTS/beginner/hash-cracker`** — the natural sibling to this tool. You learned to identify a hash; that project teaches you how to crack one.
|
||||
- **`PROJECTS/foundations/http-headers-scanner`** — another foundations-tier project, single-file Python. Slightly more involved I/O (it makes a network request) but the same architectural shape.
|
||||
- **`PROJECTS/foundations/password-manager`** — the hardest foundations project. Encryption, key derivation, and a real on-disk vault. Read [01-CONCEPTS.md](./01-CONCEPTS.md) first to understand why Argon2id is the right choice for the key derivation step.
|
||||
- **`PROJECTS/beginner/simple-port-scanner`** — once you've absorbed the patterns here, port scanning is a natural next step. Sockets, concurrency, and timing all show up.
|
||||
|
||||
Good luck. Read everything. Write code. Break it. Fix it. That's how you learn this.
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
# ©AngelaMos | 2026
|
||||
# pyproject.toml
|
||||
#
|
||||
# This file is the "instruction manual" for our Python project. Every
|
||||
# modern Python project has one. It tells tools like uv, pip, pytest,
|
||||
# ruff, mypy, and pylint:
|
||||
#
|
||||
# - What our project is called
|
||||
# - Which third-party libraries we depend on
|
||||
# - How to build and install it
|
||||
# - How to lint, type-check, and test it
|
||||
#
|
||||
# The file format is called TOML — a simple, human-readable config
|
||||
# language. Square brackets like [project] start a "table" (a section).
|
||||
# Lines like name = "..." are key-value pairs inside that table.
|
||||
#
|
||||
# Read this top-to-bottom. Each section has a short comment explaining
|
||||
# what it does and why it is here.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [project] — the metadata that describes WHAT this project is
|
||||
# =============================================================================
|
||||
# This section is standardized across the whole Python ecosystem (PEP 621).
|
||||
# Anyone who runs `pip install` or `uv sync` reads from here.
|
||||
[project]
|
||||
# The name people will install this package under.
|
||||
# Must be unique on PyPI if we ever publish there.
|
||||
name = "hash-identifier"
|
||||
|
||||
# Semantic version: MAJOR.MINOR.PATCH.
|
||||
# 1.0.0 means "first stable release."
|
||||
# When we add features → bump MINOR (1.1.0). When we break things → MAJOR.
|
||||
version = "1.0.0"
|
||||
|
||||
# One-line description that shows up in `pip show` and on PyPI.
|
||||
description = "Identify hash types by prefix, length, and charset (foundations tier)"
|
||||
|
||||
# The minimum Python version this project supports.
|
||||
# >=3.13 means "Python 3.13 or anything newer."
|
||||
# We need 3.13 because we use modern type-hint syntax (X | None).
|
||||
requires-python = ">=3.13"
|
||||
|
||||
# Who wrote it. Shows up in package metadata.
|
||||
authors = [
|
||||
{name = "CarterPerez-dev", email = "support@certgames.com"}
|
||||
]
|
||||
|
||||
# The README file is shown on PyPI and in `pip show`.
|
||||
readme = "README.md"
|
||||
|
||||
# AGPL-3.0 means: anyone can use it, but if they modify and run it as a
|
||||
# service, they must publish their changes. It protects the project
|
||||
# from being privatized.
|
||||
license = {text = "AGPL-3.0-or-later"}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# dependencies — third-party libraries we MUST have at runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
# When a user runs `pip install hash-identifier`, these get installed too.
|
||||
# The string format is "<name><operator><version>". We use ranges so we
|
||||
# get bug fixes (>=) but never an incompatible major version (<).
|
||||
#
|
||||
# Just one runtime dependency — the detection logic is pure stdlib.
|
||||
dependencies = [
|
||||
# rich: pretty terminal output — colors, tables, panels.
|
||||
# Used to render the results of identification as a clean table.
|
||||
"rich>=15.0.0",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# optional-dependencies — extra libraries that are NOT needed at runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
# These only matter to developers (testing, linting, formatting). End users
|
||||
# don't install them. Activated with: `uv sync --extra dev` or `--all-extras`.
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# pytest — runs our test suite (the test_*.py file in this directory).
|
||||
"pytest>=9.0.3",
|
||||
|
||||
# ruff — extremely fast linter and formatter. Catches bugs, style
|
||||
# issues, dead imports. Modern replacement for flake8/black/isort.
|
||||
"ruff>=0.15.12",
|
||||
|
||||
# mypy — static type checker. Reads our type hints and tells us if
|
||||
# we passed a string where an int was expected, BEFORE running the code.
|
||||
"mypy>=2.1.0",
|
||||
|
||||
# pylint — second linter that catches deeper logic issues ruff misses.
|
||||
"pylint>=4.0.5",
|
||||
|
||||
# yapf — code formatter. Keeps every file looking identical.
|
||||
"yapf>=0.43.0,<1.0.0",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# project.scripts — command-line entry points
|
||||
# -----------------------------------------------------------------------------
|
||||
# After `uv sync`, the user can type `hashid <hash>` instead of
|
||||
# `python hash_identifier.py <hash>`. The right-hand side is
|
||||
# "<module>:<function>" — the function gets called when the command runs.
|
||||
# Here it points to the `main` function inside hash_identifier.py.
|
||||
[project.scripts]
|
||||
hashid = "hash_identifier:main"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [build-system] — how to BUILD this project into an installable package
|
||||
# =============================================================================
|
||||
# Required by PEP 517. Tools like uv read this to know which builder
|
||||
# to use. We chose hatchling — modern, fast, zero-config for our case.
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
# Tell hatchling exactly which file to package. This project is a SINGLE
|
||||
# Python file (no src/<package>/ layout), so we use `only-include` to
|
||||
# point at it directly. Without this, hatchling would not know what
|
||||
# to ship.
|
||||
[tool.hatch.build.targets.wheel]
|
||||
only-include = ["hash_identifier.py"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.ruff] — linter / formatter configuration
|
||||
# =============================================================================
|
||||
# Ruff is the FAST one. It runs in milliseconds and catches 90% of issues.
|
||||
[tool.ruff]
|
||||
# Target Python version — ruff adjusts which warnings apply.
|
||||
target-version = "py313"
|
||||
|
||||
# Wrap lines at 88 characters (PEP 8 says 79, but 88 is the modern norm).
|
||||
line-length = 88
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Which lint rules to enable. Each "code" is a category of check.
|
||||
# -----------------------------------------------------------------------------
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors — basic PEP 8 spacing/indent rules
|
||||
"F", # Pyflakes — unused imports, undefined names, real bugs
|
||||
"W", # pycodestyle warnings — softer style issues
|
||||
"B", # Bugbear — sneaky bugs (mutable default args, etc.)
|
||||
"C4", # Comprehensions — encourages cleaner list/dict comprehensions
|
||||
"UP", # Pyupgrade — flags old syntax we should modernize
|
||||
"SIM", # Simplify — suggests cleaner equivalents
|
||||
]
|
||||
|
||||
# Some rules we deliberately ignore.
|
||||
ignore = [
|
||||
# Line length is handled by yapf, not ruff. Avoids double-flagging.
|
||||
"E501",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.mypy] — static type checker configuration
|
||||
# =============================================================================
|
||||
# Mypy reads our type hints (the `: int`, `-> str` parts) and verifies them
|
||||
# without running the code. Catches whole categories of bugs at edit time.
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
|
||||
# Require type annotations on every function — strict mode.
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
# Don't auto-add Optional just because a default is None. Be explicit.
|
||||
no_implicit_optional = true
|
||||
|
||||
# Warn if we cast a value to a type it already has (dead code).
|
||||
warn_redundant_casts = true
|
||||
|
||||
# Warn if a function might fall off the end without returning.
|
||||
warn_no_return = true
|
||||
|
||||
# Pretty error output — helps when reading mypy output.
|
||||
show_error_codes = true
|
||||
pretty = true
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.pylint] — second-opinion linter
|
||||
# =============================================================================
|
||||
# Slower than ruff but catches different things — class design issues,
|
||||
# dead variables, complex code patterns.
|
||||
[tool.pylint.main]
|
||||
# Match the project's Python version.
|
||||
py-version = "3.13"
|
||||
|
||||
# Run lint checks in parallel across 4 cores for speed.
|
||||
jobs = 4
|
||||
|
||||
# Specific pylint warnings we deliberately turn off.
|
||||
[tool.pylint.messages_control]
|
||||
disable = [
|
||||
"R0903", # too-few-public-methods — small data classes are fine
|
||||
"C0103", # invalid-name — short names like `s` for hash strings are clearer
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.pytest.ini_options] — test runner configuration
|
||||
# =============================================================================
|
||||
[tool.pytest.ini_options]
|
||||
# Where to look for tests. "." = the project root, since this project's
|
||||
# test file lives next to the source file.
|
||||
testpaths = ["."]
|
||||
|
||||
# Files that match this pattern are treated as test modules.
|
||||
python_files = ["test_*.py"]
|
||||
|
||||
# Default flags every `pytest` invocation gets:
|
||||
# -v → verbose, show each test name
|
||||
# --tb=short → short tracebacks on failure (less wall of text)
|
||||
addopts = "-v --tb=short"
|
||||
|
|
@ -0,0 +1,554 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_hash_identifier.py
|
||||
|
||||
Tests for hash_identifier — focused on the most-relied-on cases
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What "tests" are and why we write them
|
||||
────────────────────────────────────────────────────────────────────
|
||||
A test is a tiny Python function that calls our real code with a
|
||||
known input and then ASSERTS that the result is what we expected.
|
||||
If the assertion fails, pytest prints a red FAIL message — which
|
||||
means we changed something and broke a behavior we cared about
|
||||
|
||||
Tests are insurance. The first time you write the code, the test
|
||||
just confirms it works. But six months later when you refactor or
|
||||
add a new feature, the existing tests catch any accidental breakage.
|
||||
This is why every senior codebase has tests: not because the code is
|
||||
hard to write, but because the code is hard to keep WORKING over time
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
The shape of a pytest test
|
||||
────────────────────────────────────────────────────────────────────
|
||||
def test_<what_we_are_checking>() -> None:
|
||||
result = some_function(some_input)
|
||||
assert result == expected
|
||||
|
||||
Three rules
|
||||
1. The function name must start with `test_` — pytest only collects
|
||||
functions that match that pattern
|
||||
2. The function takes no arguments (unless using fixtures)
|
||||
3. Use the `assert` keyword to declare what should be true. If the
|
||||
condition is false, the test fails
|
||||
|
||||
We follow the "Arrange-Act-Assert" structure inside each test
|
||||
- Arrange: set up inputs (the `sample = ...` line)
|
||||
- Act: call the real code (`candidates = identify(sample)`)
|
||||
- Assert: check the result (`assert candidates[0]...`)
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Coverage strategy
|
||||
────────────────────────────────────────────────────────────────────
|
||||
We do NOT try to test every algorithm in the table — that would be
|
||||
hundreds of nearly identical tests. Instead we exercise each BRANCH
|
||||
of identify() at least once
|
||||
|
||||
- prefix matches (one bcrypt, one Argon2id, one Django, one crypt)
|
||||
- special MySQL5 format
|
||||
- hex length matches (MD5, SHA-1, SHA-256 lengths)
|
||||
- the empty / garbage / whitespace fallbacks
|
||||
- HashCandidate's immutability
|
||||
|
||||
Together these give us confidence that every code path runs without
|
||||
explosion, and that the most-common inputs produce the expected
|
||||
top-ranked candidate
|
||||
"""
|
||||
|
||||
# Importing from `hash_identifier` (NOT `hash_identifier.py`) tells
|
||||
# Python to load the module that lives in this same directory. We
|
||||
# pull in three things:
|
||||
# - `identify` — the function under test
|
||||
# - `HashCandidate`— the return-type dataclass (used in the
|
||||
# immutability test)
|
||||
# - `PREFIX_RULES` — the prefix lookup table (used by the
|
||||
# parametrized "every row is covered" test
|
||||
# at the bottom of this file)
|
||||
# `pytest` is also imported so we can use the @pytest.mark.parametrize
|
||||
# decorator to expand one test function into many test cases
|
||||
# Third-party: the test runner itself. We also need it imported here
|
||||
# so we can use its `@pytest.mark.parametrize` decorator below.
|
||||
import pytest
|
||||
|
||||
# Local: our own module. We pull in the public pieces under test —
|
||||
# the prefix-rule table, the result dataclass, and the entry function.
|
||||
from hash_identifier import PREFIX_RULES, HashCandidate, identify
|
||||
|
||||
# =============================================================================
|
||||
# Prefix matches (high confidence)
|
||||
# =============================================================================
|
||||
# These tests verify Step 1 of identify(): when the input starts with a
|
||||
# known prefix, we report HIGH confidence. The exact hash payload after
|
||||
# the prefix does not matter to identify() — it only inspects the
|
||||
# leading characters
|
||||
|
||||
|
||||
def test_bcrypt_prefix_is_recognized() -> None:
|
||||
"""
|
||||
A real bcrypt hash starts with `$2b$` and should be reported as bcrypt
|
||||
"""
|
||||
# Sample: an actual bcrypt hash for the password "password" with cost 12.
|
||||
# The interesting part for our test is just `$2b$` — we never even
|
||||
# decode the rest
|
||||
sample = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G"
|
||||
|
||||
# Call the function under test. `candidates` is a list of HashCandidate
|
||||
candidates = identify(sample)
|
||||
|
||||
# First assert the list is non-empty. `assert <thing>` fails when
|
||||
# <thing> is falsy — empty lists are falsy, so this catches the
|
||||
# "no candidates returned" bug
|
||||
assert candidates
|
||||
# Then check the FIRST candidate (highest-priority guess) is bcrypt.
|
||||
# `candidates[0]` is the first item; `.algorithm` is the field we
|
||||
# check. `==` compares for equality
|
||||
assert candidates[0].algorithm == "bcrypt"
|
||||
# And confidence must be "high" — prefix matches are definitive
|
||||
assert candidates[0].confidence == "high"
|
||||
|
||||
|
||||
def test_argon2id_prefix_is_recognized() -> None:
|
||||
"""
|
||||
Argon2id PHC strings begin with `$argon2id$`
|
||||
"""
|
||||
# PHC format for Argon2id: $argon2id$v=<version>$m=...,t=...,p=...$<salt>$<hash>
|
||||
sample = "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaA"
|
||||
candidates = identify(sample)
|
||||
# `any(...)` returns True if at least one element of the iterable
|
||||
# makes the inner expression true. We check that AT LEAST ONE
|
||||
# candidate is Argon2id — using any() instead of [0] keeps the test
|
||||
# robust if we ever add a second guess to the same prefix
|
||||
assert any(c.algorithm == "Argon2id" for c in candidates)
|
||||
|
||||
|
||||
def test_sha512_crypt_prefix_is_recognized() -> None:
|
||||
"""
|
||||
`$6$` is the marker for SHA-512 crypt — what /etc/shadow uses on Linux
|
||||
"""
|
||||
sample = "$6$rounds=10000$salt$hashedpasswordhere"
|
||||
candidates = identify(sample)
|
||||
# `[0]` because we want the TOP candidate. If anything else was
|
||||
# ranked first, this assertion would fail loudly
|
||||
assert candidates[0].algorithm == "SHA-512 crypt"
|
||||
|
||||
|
||||
def test_django_pbkdf2_prefix_is_recognized() -> None:
|
||||
"""
|
||||
Django stores passwords as `pbkdf2_sha256$<iter>$<salt>$<hash>`
|
||||
"""
|
||||
sample = "pbkdf2_sha256$260000$salt$hash"
|
||||
candidates = identify(sample)
|
||||
assert candidates[0].algorithm == "Django PBKDF2-SHA256"
|
||||
|
||||
|
||||
def test_apr1_prefix_is_recognized() -> None:
|
||||
"""
|
||||
Apache `.htpasswd` MD5 hashes start with $apr1$
|
||||
|
||||
The htpasswd tool generates these by default with the `-m` flag.
|
||||
Same MD5 family as the Unix $1$ format but with Apache's own salt
|
||||
handling — and FAR more common in the wild because every Apache
|
||||
basic-auth tutorial ends with one of these in a .htpasswd file
|
||||
"""
|
||||
# Real-looking apr1 hash. The trailing payload after the second
|
||||
# `$` is the base64-flavored encoding of the MD5 digest + salt.
|
||||
# identify() never decodes it — only the leading `$apr1$` matters
|
||||
sample = "$apr1$rsalt$mp7TYYDvbgvNCJN3JTd6q1"
|
||||
candidates = identify(sample)
|
||||
assert candidates[0].algorithm == "Apache MD5-crypt"
|
||||
assert candidates[0].confidence == "high"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Special formats
|
||||
# =============================================================================
|
||||
# Step 2 of identify(): formats that are NOT PHC strings but still have
|
||||
# unmistakable shapes. Today we recognize NetNTLMv1, NetNTLMv2, and
|
||||
# MySQL5 — three structurally-distinct records that all benefit from
|
||||
# the same "check the literal shape before falling back to length" path
|
||||
|
||||
|
||||
def test_mysql5_format_is_recognized() -> None:
|
||||
"""
|
||||
MySQL5 = literal `*` followed by 40 uppercase hex chars
|
||||
|
||||
MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
|
||||
a leading asterisk. So the whole hash is exactly 41 characters
|
||||
"""
|
||||
# The * matters — without it, this would just be 40 hex chars
|
||||
# and would fall through to the SHA-1 length rule
|
||||
sample = "*23AE809DDACAF96AF0FD78ED04B6A265E05AA257"
|
||||
candidates = identify(sample)
|
||||
|
||||
# MySQL5 is a definitive shape, so we expect HIGH confidence
|
||||
assert candidates[0].algorithm == "MySQL5"
|
||||
assert candidates[0].confidence == "high"
|
||||
|
||||
|
||||
def test_mysql5_rejects_lowercase_body() -> None:
|
||||
"""
|
||||
Lowercase hex after the leading `*` is not real MySQL5 output
|
||||
|
||||
MySQL emits uppercase via `%02X`, so a `*` followed by lowercase
|
||||
hex is almost certainly hand-edited junk rather than a real
|
||||
MySQL5 hash. We would rather return nothing than return a
|
||||
confident WRONG answer here
|
||||
"""
|
||||
# Lowercase version of the previous test's body. The leading `*`
|
||||
# is the only thing it shares with real MySQL5 output, and the
|
||||
# case mismatch alone should disqualify it
|
||||
lowercase_body = "23ae809ddacaf96af0fd78ed04b6a265e05aa257"
|
||||
candidates = identify("*" + lowercase_body)
|
||||
|
||||
# Either the list is empty (preferred, what we expect today) OR
|
||||
# whatever did match must NOT be labeled MySQL5. The `if`
|
||||
# guard makes the test robust to future rules that might catch
|
||||
# this shape under a different (correct) label — what matters is
|
||||
# that we do not LIE and call it MySQL5
|
||||
if candidates:
|
||||
assert candidates[0].algorithm != "MySQL5"
|
||||
|
||||
|
||||
def test_netntlmv2_format_is_recognized() -> None:
|
||||
"""
|
||||
NetNTLMv2 records from Responder look like
|
||||
`user::domain:challenge:hmac:blob`
|
||||
|
||||
The hmac field is exactly 32 hex chars. The leading `::` (empty
|
||||
LM-hash slot) is the giveaway that this is an AD challenge-
|
||||
response record, not a stored password hash
|
||||
"""
|
||||
# Build a realistic NetNTLMv2 record:
|
||||
# alice :: CORP : <16-char challenge> : <32 hex hmac> : <64 hex blob>
|
||||
# The actual hash values do not matter — only the structural
|
||||
# shape (colon count + hex field lengths) is what identify() checks
|
||||
sample = "alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 64
|
||||
candidates = identify(sample)
|
||||
|
||||
# NetNTLMv2 is a definitive shape — HIGH confidence
|
||||
assert candidates[0].algorithm == "NetNTLMv2"
|
||||
assert candidates[0].confidence == "high"
|
||||
|
||||
|
||||
def test_netntlmv1_format_is_recognized() -> None:
|
||||
"""
|
||||
NetNTLMv1 records have 48-hex-char lmhash AND nthash before the challenge
|
||||
|
||||
Layout: `user::domain:lm(48 hex):nt(48 hex):challenge`. We
|
||||
recognize this by looking at field index 3 — in NetNTLMv1 it is
|
||||
exactly 48 hex chars, while in NetNTLMv2 it is the (shorter)
|
||||
challenge field
|
||||
"""
|
||||
# Build a realistic NetNTLMv1 record. The 48-char lmhash and
|
||||
# nthash are the load-bearing signal; identify() never decodes
|
||||
# them — only their length and charset matter
|
||||
sample = "alice::CORP:" + "a" * 48 + ":" + "b" * 48 + ":1122334455667788"
|
||||
candidates = identify(sample)
|
||||
assert candidates[0].algorithm == "NetNTLMv1"
|
||||
assert candidates[0].confidence == "high"
|
||||
|
||||
|
||||
def test_descrypt_format_is_recognized() -> None:
|
||||
"""
|
||||
Traditional DES crypt has NO prefix — only length and charset identify it
|
||||
|
||||
Legacy /etc/passwd from pre-shadow Unix systems used this format:
|
||||
13 characters drawn from the alphabet `./0-9A-Za-z`. Rare today
|
||||
but still turns up in retro CTFs, and hashcat still ships a mode
|
||||
(1500) for cracking it
|
||||
"""
|
||||
# A realistic 13-char DES crypt output. Content does not matter
|
||||
# for the test — only the length and the all-valid-charset
|
||||
# property are what _is_descrypt checks
|
||||
sample = "kRq14pmccuMOA"
|
||||
candidates = identify(sample)
|
||||
|
||||
assert candidates[0].algorithm == "DES crypt"
|
||||
# MEDIUM (not HIGH) confidence because a 13-char `./0-9A-Za-z`
|
||||
# string CAN technically be other things (session IDs, random
|
||||
# tokens). An honest medium beats a confident false-positive
|
||||
assert candidates[0].confidence == "medium"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hex length matches (medium / low confidence)
|
||||
# =============================================================================
|
||||
# Step 3 of identify(): when the input is pure hex, length narrows down
|
||||
# the algorithm. The FIRST listed algorithm for each length gets
|
||||
# medium confidence (the modern default); the rest are low
|
||||
|
||||
|
||||
def test_mysql323_length_returns_mysql323_first() -> None:
|
||||
"""
|
||||
16 hex chars points at MySQL323 (legacy MySQL OLD_PASSWORD output)
|
||||
|
||||
MySQL versions before 4.1 stored passwords as a 16-char hex string
|
||||
from a custom (now-broken) hash function. The OLD_PASSWORD() SQL
|
||||
function still produces this format on modern MySQL for legacy
|
||||
compatibility, so these still show up in CTFs and old MySQL
|
||||
breach dumps
|
||||
"""
|
||||
# A 16-hex-char string. The content does not matter — only the
|
||||
# length and the all-hex charset are what identify() checks
|
||||
sample = "5d2e19393cc5ef67"
|
||||
candidates = identify(sample)
|
||||
|
||||
# MySQL323 ranks ABOVE CRC-64 because in a security context (a
|
||||
# CTF challenge, a breach dump, a password column) MySQL323 is
|
||||
# by far the more likely source. A 64-bit CRC almost never
|
||||
# arrives at a hash-identifier tool
|
||||
assert candidates[0].algorithm == "MySQL323"
|
||||
# MEDIUM confidence: length alone is suggestive but cannot rule
|
||||
# out CRC-64 with certainty without seeing the surrounding context
|
||||
assert candidates[0].confidence == "medium"
|
||||
|
||||
|
||||
def test_md5_length_returns_md5_first() -> None:
|
||||
"""
|
||||
32 hex chars matches MD5, NTLM, MD4, RIPEMD-128
|
||||
|
||||
MD5 is BY FAR the most common 32-hex hash in the wild, so it must
|
||||
be the top candidate. NTLM (a Windows hash) is also 32 hex but
|
||||
far less common in pasted hash dumps, so it should appear later
|
||||
"""
|
||||
# The literal MD5 of the string "password". A useful sample because
|
||||
# any reader can verify it: `echo -n password | md5sum`
|
||||
sample = "5f4dcc3b5aa765d61d8327deb882cf99"
|
||||
candidates = identify(sample)
|
||||
|
||||
# Top candidate is MD5
|
||||
assert candidates[0].algorithm == "MD5"
|
||||
# And we report MEDIUM confidence — length alone is suggestive
|
||||
# but not definitive (no prefix to confirm)
|
||||
assert candidates[0].confidence == "medium"
|
||||
|
||||
# NTLM should appear in the candidate list as a less-likely option.
|
||||
# We pull just the algorithm names into a list using a comprehension,
|
||||
# then check membership with `in`
|
||||
algorithms = [c.algorithm for c in candidates]
|
||||
assert "NTLM" in algorithms
|
||||
|
||||
|
||||
def test_sha256_length_returns_sha256_first() -> None:
|
||||
"""
|
||||
64 hex chars points at SHA-256 first
|
||||
|
||||
SHA3-256 and BLAKE2s also produce 64 hex chars but are rarer in
|
||||
real systems, so SHA-256 takes the top spot
|
||||
"""
|
||||
# `"a" * 64` is Python shorthand for "the character 'a' repeated 64
|
||||
# times" — a quick way to make a 64-char string for length tests.
|
||||
# The actual content does not matter; only the length does
|
||||
sample = "a" * 64
|
||||
candidates = identify(sample)
|
||||
assert candidates[0].algorithm == "SHA-256"
|
||||
|
||||
|
||||
def test_sha1_length_returns_sha1_first() -> None:
|
||||
"""
|
||||
40 hex chars = SHA-1 (RIPEMD-160 as a backup guess)
|
||||
"""
|
||||
sample = "a" * 40
|
||||
candidates = identify(sample)
|
||||
assert candidates[0].algorithm == "SHA-1"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# No-match / edge cases
|
||||
# =============================================================================
|
||||
# Always test the boring edge cases. Empty inputs, whitespace-only,
|
||||
# garbage. These caused real bugs in real codebases more than once
|
||||
|
||||
|
||||
def test_empty_input_returns_no_candidates() -> None:
|
||||
"""
|
||||
Empty string returns an empty list — never blows up
|
||||
"""
|
||||
# Two checks because both of these have caused crashes elsewhere:
|
||||
# truly empty AND whitespace-only (which strips down to empty)
|
||||
assert identify("") == []
|
||||
assert identify(" ") == []
|
||||
|
||||
|
||||
def test_garbage_returns_no_candidates() -> None:
|
||||
"""
|
||||
A string with neither a known prefix nor a hex shape returns []
|
||||
"""
|
||||
# Sentence with spaces and punctuation: cannot be hex, has no
|
||||
# PHC prefix. identify() should return an empty list, not guess
|
||||
assert identify("hello, this is not a hash") == []
|
||||
|
||||
|
||||
def test_input_is_trimmed_of_whitespace() -> None:
|
||||
"""
|
||||
Trailing newlines and leading spaces should not block recognition
|
||||
|
||||
This matters because copy-paste from a terminal often picks up
|
||||
invisible whitespace. We strip it inside identify()
|
||||
"""
|
||||
# Note the leading spaces and trailing \n — escaped newline character
|
||||
sample = " 5f4dcc3b5aa765d61d8327deb882cf99\n"
|
||||
candidates = identify(sample)
|
||||
# If trim works, we still recognize MD5 despite the surrounding noise
|
||||
assert candidates[0].algorithm == "MD5"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Soft-match fallbacks (shape hints, LOW confidence)
|
||||
# =============================================================================
|
||||
# Steps 4 and 5 of identify(): when nothing in the PREFIX_RULES table
|
||||
# or the special-formats step or the hex-length table fires, we still
|
||||
# try two soft matches — generic PHC string shape, then "this looks
|
||||
# like a JWT / base64 blob, not a hash." Both return LOW confidence
|
||||
# because shape alone is a weaker signal than a known prefix
|
||||
|
||||
|
||||
def test_unknown_phc_string_falls_back_to_generic() -> None:
|
||||
"""
|
||||
A PHC string from an algorithm we don't have a specific rule for
|
||||
is still reported as a PHC string with the extracted algorithm name
|
||||
|
||||
This is a SOFT match (LOW confidence) but still beats the
|
||||
alternative of returning nothing on an obviously-PHC-shaped input
|
||||
"""
|
||||
# Passlib's pbkdf2-sha512 PHC encoding. We have no specific rule
|
||||
# for it in PREFIX_RULES — but the `$pbkdf2-sha512$...` shape is
|
||||
# unambiguous, so the generic fallback should pick it up
|
||||
sample = "$pbkdf2-sha512$25000$cnNhbHQ$aGFzaA"
|
||||
candidates = identify(sample)
|
||||
|
||||
assert candidates
|
||||
# The algorithm column should say "PHC string (pbkdf2-sha512)" —
|
||||
# both the marker word "PHC" and the extracted algorithm name
|
||||
# should appear so the user knows WHAT kind of thing they pasted
|
||||
assert "PHC" in candidates[0].algorithm
|
||||
assert "pbkdf2-sha512" in candidates[0].algorithm
|
||||
# LOW confidence because we matched on shape only, not on a
|
||||
# specific rule that would let us confirm the algorithm
|
||||
assert candidates[0].confidence == "low"
|
||||
|
||||
|
||||
def test_jwt_input_is_called_out_as_not_a_hash() -> None:
|
||||
"""
|
||||
JWTs start with `eyJ` and should be called out as not-a-hash
|
||||
|
||||
Beginners often paste JWTs into a hash identifier because they
|
||||
look hash-like. Saying "this is a JWT, not a hash" is more
|
||||
useful than silence — it teaches the user what they have
|
||||
"""
|
||||
# A short but real-shaped JWT (header.payload.signature). The
|
||||
# signature here is intentionally not real — identify() never
|
||||
# validates it, only the leading `eyJ` matters
|
||||
sample = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig"
|
||||
candidates = identify(sample)
|
||||
|
||||
assert candidates
|
||||
# The algorithm column should contain "JWT" so the user knows
|
||||
# exactly what kind of thing they pasted
|
||||
assert "JWT" in candidates[0].algorithm
|
||||
# LOW confidence is honest: we are not 100% sure this is a JWT,
|
||||
# we are 100% sure it starts with `eyJ` and JWTs start with `eyJ`
|
||||
assert candidates[0].confidence == "low"
|
||||
|
||||
|
||||
def test_base64_blob_is_called_out_as_not_a_hash() -> None:
|
||||
"""
|
||||
A string containing base64-only chars (`+`, `/`, `=`) is not hex
|
||||
|
||||
Hex hashes never contain those characters, so their presence is a
|
||||
strong signal that the input is base64-encoded data of some kind
|
||||
rather than a hash — even when we cannot say what it decodes to
|
||||
"""
|
||||
# A base64-looking blob with the `=` padding character
|
||||
sample = "VGhpcyBpcyBub3QgYSBoYXNoLCBpdHMgYmFzZTY0Lg=="
|
||||
candidates = identify(sample)
|
||||
|
||||
assert candidates
|
||||
# "Base64 blob" should appear in the algorithm name so the user
|
||||
# knows we did identify SOMETHING, just not a hash
|
||||
assert "Base64" in candidates[0].algorithm
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HashCandidate is immutable
|
||||
# =============================================================================
|
||||
# We declared HashCandidate with @dataclass(frozen=True). Frozen means
|
||||
# you cannot reassign fields after construction — like a sealed envelope.
|
||||
# This test makes sure the seal actually holds, so future refactors do
|
||||
# not accidentally drop the `frozen` flag
|
||||
|
||||
|
||||
def test_hash_candidate_is_frozen() -> None:
|
||||
"""
|
||||
Attempting to mutate a HashCandidate must raise an error
|
||||
"""
|
||||
# First, construct a normal instance
|
||||
candidate = HashCandidate(
|
||||
algorithm = "MD5",
|
||||
confidence = "medium",
|
||||
reason = "test",
|
||||
)
|
||||
|
||||
# try/except is Python's "guard against an error" syntax. Inside
|
||||
# `try`, we attempt the operation we EXPECT to fail. The `except`
|
||||
# block runs ONLY if the listed exception types fire — and our
|
||||
# frozen dataclass raises one of these on assignment
|
||||
try:
|
||||
# `# type: ignore[misc]` tells mypy "I know this is a type error;
|
||||
# I am doing it on purpose to verify it actually fails at runtime"
|
||||
candidate.algorithm = "SHA-1" # type: ignore[misc]
|
||||
except (AttributeError, TypeError):
|
||||
# Got the expected exception — the test passes by returning
|
||||
return
|
||||
|
||||
# If we reached this line, no exception was raised — frozen is
|
||||
# broken. Fail the test loudly with a clear message
|
||||
raise AssertionError(
|
||||
"HashCandidate should be frozen; assignment should have raised"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Comprehensive PREFIX_RULES table coverage
|
||||
# =============================================================================
|
||||
# The individual prefix tests above (bcrypt, Argon2id, etc.) double as
|
||||
# readable EXAMPLES of how identify() handles known prefixes. This last
|
||||
# test plays a different role: it is the safety-net that guarantees
|
||||
# EVERY row of PREFIX_RULES is exercised, so a typo in any single row's
|
||||
# algorithm name or note string fails its own test case rather than
|
||||
# slipping through silently.
|
||||
#
|
||||
# `@pytest.mark.parametrize(name, values)` is pytest's mechanism for
|
||||
# expanding ONE test function into MANY test cases. Here we hand it
|
||||
# the entire PREFIX_RULES list. Pytest unpacks each `(prefix, algorithm,
|
||||
# note)` tuple into the three parameters of the test function and runs
|
||||
# the body once per row. The leading `_` in `_note` tells linters
|
||||
# (and the next reader) that we intentionally do not assert on the
|
||||
# note string — we accept whatever the table author wrote
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix,algorithm,_note", PREFIX_RULES)
|
||||
def test_every_prefix_rule_is_recognized_with_high_confidence(
|
||||
prefix: str,
|
||||
algorithm: str,
|
||||
_note: str,
|
||||
) -> None:
|
||||
"""
|
||||
Every entry in PREFIX_RULES produces a HIGH-confidence candidate
|
||||
with the matching algorithm when its prefix sits at the start
|
||||
of the input
|
||||
|
||||
The body of the hash after the prefix does not matter to
|
||||
identify() — it only inspects the leading characters — so we
|
||||
just glue any junk onto the end to form a syntactically-plausible
|
||||
input
|
||||
"""
|
||||
sample = prefix + "fakebodydoesntmatter"
|
||||
candidates = identify(sample)
|
||||
|
||||
# If identify() returned nothing, the prefix-loop branch is
|
||||
# broken — fail with a message that names the offending prefix
|
||||
# so the failure is debuggable at a glance
|
||||
assert candidates, f"no candidates returned for prefix `{prefix}`"
|
||||
assert candidates[0].algorithm == algorithm
|
||||
assert candidates[0].confidence == "high"
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash-identifier"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "yapf" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0,<2.0.0" },
|
||||
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.0,<5.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||
{ name = "rich", specifier = ">=14.2.0,<15.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.0,<0.15.0" },
|
||||
{ name = "yapf", marker = "extra == 'dev'", specifier = ">=0.43.0,<1.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "8.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "dill" },
|
||||
{ name = "isort" },
|
||||
{ name = "mccabe" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yapf"
|
||||
version = "0.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
|
||||
]
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
#
|
||||
# This file lists patterns of files and folders that git should NEVER
|
||||
# track. Anything matching a pattern below is invisible to `git status`
|
||||
# and won't be committed. We do this to avoid two problems:
|
||||
#
|
||||
# 1. Privacy — we never want to accidentally commit a sensitive file
|
||||
# a user might pass to the tool.
|
||||
#
|
||||
# 2. Noise — build artifacts and caches change constantly. Tracking
|
||||
# them would clutter every commit and create merge conflicts.
|
||||
#
|
||||
# Each line is a "glob" pattern (* matches any characters).
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Python build/install artifacts
|
||||
# -----------------------------------------------------------------------------
|
||||
# __pycache__: bytecode Python generates from .py files when it runs them.
|
||||
# We never edit these, and they regenerate on every run, so ignore them.
|
||||
__pycache__/
|
||||
# *.py[cod] = .pyc, .pyo, .pyd — older bytecode formats Python may emit.
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
# Build outputs from `pip wheel`, hatchling, etc.
|
||||
*.egg-info/
|
||||
*.egg
|
||||
build/
|
||||
dist/
|
||||
.eggs/
|
||||
*.so
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Virtual environments — created by `uv venv`, never committed
|
||||
# -----------------------------------------------------------------------------
|
||||
# A virtual environment is a per-project Python install. It lives inside
|
||||
# the project directory. Committing it would balloon the repo and pin
|
||||
# the user to your operating system. Always recreate locally.
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tooling caches — these speed up repeated runs but never need committing
|
||||
# -----------------------------------------------------------------------------
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
# Coverage report files from pytest-cov.
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IDE / editor scratch files
|
||||
# -----------------------------------------------------------------------------
|
||||
# Each editor drops its own metadata. None of it belongs in the repo.
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
# macOS Finder dumps these in every directory it has opened.
|
||||
.DS_Store
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Local environment files (may contain secrets)
|
||||
# -----------------------------------------------------------------------------
|
||||
# We never use .env in this project, but list it just in case. Anything
|
||||
# containing secrets should never reach the public repo.
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .style.yapf
|
||||
#
|
||||
# Configuration for yapf, our Python code formatter. yapf rewrites
|
||||
# Python files to match these rules so every file looks identical
|
||||
# no matter who wrote it.
|
||||
#
|
||||
# The two settings most worth knowing:
|
||||
# based_on_style = pep8 → start from PEP 8 (the official style)
|
||||
# column_limit = 75 → wrap lines at 75 characters
|
||||
#
|
||||
# Run with: `just format`
|
||||
[style]
|
||||
# Start from PEP 8 (Python's official style guide). Every other
|
||||
# setting below tweaks PEP 8 in some specific direction.
|
||||
based_on_style = pep8
|
||||
# Wrap any line longer than this many columns.
|
||||
column_limit = 75
|
||||
# Use 4 spaces per indent level (PEP 8 standard).
|
||||
indent_width = 4
|
||||
# Continuation lines (when a statement wraps) also indent by 4.
|
||||
continuation_indent_width = 4
|
||||
# Don't indent the closing bracket of a multi-line call/list/dict
|
||||
indent_closing_brackets = false
|
||||
# DO de-indent the closing bracket — line it up with the opener.
|
||||
dedent_closing_brackets = true
|
||||
# Don't add 4 spaces of indent to blank lines inside functions.
|
||||
indent_blank_lines = false
|
||||
# Two spaces between code and the # of a trailing comment.
|
||||
spaces_before_comment = 2
|
||||
# No spaces around ** (power) operator: 2**3 not 2 ** 3.
|
||||
spaces_around_power_operator = false
|
||||
# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
|
||||
spaces_around_default_or_named_assign = true
|
||||
# No extra space before a comma followed by a closing bracket.
|
||||
space_between_ending_comma_and_closing_bracket = false
|
||||
# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
|
||||
space_inside_brackets = false
|
||||
# Spaces around colons in slices: x[1 : 5] not x[1:5].
|
||||
spaces_around_subscript_colon = true
|
||||
# No blank line before nested def/class.
|
||||
blank_line_before_nested_class_or_def = false
|
||||
# No blank line between a class and its docstring.
|
||||
blank_line_before_class_docstring = false
|
||||
# Two blank lines around top-level functions and classes (PEP 8).
|
||||
blank_lines_around_top_level_definition = 2
|
||||
# Two blank lines between top-level imports and module-level variables.
|
||||
blank_lines_between_top_level_imports_and_variables = 2
|
||||
# No blank line before the module docstring.
|
||||
blank_line_before_module_docstring = false
|
||||
# When wrapping `a and b`, put `and` at the start of the new line.
|
||||
split_before_logical_operator = true
|
||||
# When wrapping a function call, put the first argument on a new line.
|
||||
split_before_first_argument = true
|
||||
# Put each named arg on its own line when wrapping.
|
||||
split_before_named_assigns = true
|
||||
# Multi-line comprehensions wrap onto separate lines.
|
||||
split_complex_comprehension = true
|
||||
# Don't split right after the opening parenthesis.
|
||||
split_before_expression_after_opening_paren = false
|
||||
# Put the closing bracket on its own line when wrapping.
|
||||
split_before_closing_bracket = true
|
||||
# When splitting at commas, give every value its own line.
|
||||
split_all_comma_separated_values = true
|
||||
# But don't recursively force ALL top-level commas onto new lines.
|
||||
split_all_top_level_comma_separated_values = false
|
||||
# Don't merge separate brackets into one.
|
||||
coalesce_brackets = false
|
||||
# Each dict entry gets its own line in multi-line dicts.
|
||||
each_dict_entry_on_separate_line = true
|
||||
# Disallow lambdas that span multiple lines.
|
||||
allow_multiline_lambdas = false
|
||||
# Disallow dict keys that span multiple lines.
|
||||
allow_multiline_dictionary_keys = false
|
||||
# Penalty score for splitting at import names — 0 means free to split.
|
||||
split_penalty_import_names = 0
|
||||
# Don't try to fit an already-broken statement back onto fewer lines.
|
||||
join_multiple_lines = false
|
||||
# Closing bracket lines up with the visual indent of the opener.
|
||||
align_closing_bracket_with_visual_indent = true
|
||||
# Don't add parentheses to clarify operator precedence automatically.
|
||||
arithmetic_precedence_indication = false
|
||||
# Penalty for splitting after each line — yapf weighs this against rules.
|
||||
split_penalty_for_added_line_split = 275
|
||||
# Use spaces for indentation, never tabs (PEP 8).
|
||||
use_tabs = false
|
||||
# Don't split on a `.` when wrapping (so `obj.method()` stays together).
|
||||
split_before_dot = false
|
||||
# When the last argument in a call has a trailing comma, force a split.
|
||||
split_arguments_when_comma_terminated = true
|
||||
# Function names yapf treats as i18n calls — leave their args alone.
|
||||
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
|
||||
# Comment markers yapf treats as i18n metadata.
|
||||
i18n_comment = ['# Translators:', '# i18n:']
|
||||
# Penalty for splitting comprehension iterables.
|
||||
split_penalty_comprehension = 80
|
||||
# Penalty for splitting right after an opening bracket.
|
||||
split_penalty_after_opening_bracket = 280
|
||||
# Penalty for splitting before an `if` in a ternary expression.
|
||||
split_penalty_before_if_expr = 0
|
||||
# Penalty for splitting before a bitwise operator.
|
||||
split_penalty_bitwise_operator = 290
|
||||
# Penalty for splitting before a logical operator (already lowered).
|
||||
split_penalty_logical_operator = 0
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
```ruby
|
||||
██╗ ██╗███████╗ █████╗ ██████╗ ███████╗██████╗ ███████╗
|
||||
██║ ██║██╔════╝██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝
|
||||
███████║█████╗ ███████║██║ ██║█████╗ ██████╔╝███████╗
|
||||
██╔══██║██╔══╝ ██╔══██║██║ ██║██╔══╝ ██╔══██╗╚════██║
|
||||
██║ ██║███████╗██║ ██║██████╔╝███████╗██║ ██║███████║
|
||||
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/http-headers-scanner)
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
|
||||
[](https://www.python.org)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://pytest.org)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://www.python-httpx.org/)
|
||||
|
||||
> Fetch a URL once and grade its HTTP security headers A through F using the same weighted-rubric model as Mozilla Observatory.
|
||||
|
||||
*This is a quick overview, security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
|
||||
|
||||
> [!NOTE]
|
||||
> **Foundations tier**, this project is built for someone who has never written Python before. The source code is heavily commented as a teaching aid, the `learn/` folder explains every concept from zero, and the whole tool is one readable file. If you already know Python, the natural next step is [`PROJECTS/foundations/password-manager`](../password-manager), the hardest foundations project, which adds Argon2id, AES-GCM, and on-disk state.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Performs one polite HTTPS request to the URL you provide and inspects the response headers
|
||||
- Grades six security-critical headers against a weighted rubric (high = 30 pts, medium = 15 pts, low = 5 pts)
|
||||
- Reports each finding as `ok`, `weak`, or `missing` with a one-line explanation of why
|
||||
- Computes a 0 to 100 score and maps it to an A through F letter grade (90+ = A, 80+ = B, etc.)
|
||||
- Catches subtly broken values like `Strict-Transport-Security: max-age=0` (header present, actively disabled) and flags them as `weak`, not `ok`
|
||||
- Follows redirects and grades the **final** URL, the one your browser would actually land on
|
||||
- Prints a colored Rich table plus a grade panel plus a recommendation list for every non-`ok` finding
|
||||
- Returns meaningful exit codes: `0` for A/B, `1` for C/D, `2` for F or network error, useful in CI pipelines
|
||||
|
||||
## The Headers It Grades
|
||||
|
||||
| Header | Severity | What it stops |
|
||||
|---|---|---|
|
||||
| `Strict-Transport-Security` | high | SSL stripping on coffee-shop wifi |
|
||||
| `Content-Security-Policy` | high | XSS via injected `<script>` tags |
|
||||
| `X-Content-Type-Options` | medium | MIME-sniffing of uploaded files |
|
||||
| `X-Frame-Options` | medium | Clickjacking via hidden iframes |
|
||||
| `Referrer-Policy` | low | Leaking secret tokens via the Referer header |
|
||||
| `Permissions-Policy` | low | Compromised third-party scripts abusing camera/mic/etc. |
|
||||
|
||||
Every header maps to a real attack class with a real history. The [`01-CONCEPTS.md`](learn/01-CONCEPTS.md) module walks through each one with concrete attack examples.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
just run -- https://example.com
|
||||
# Grade: B, Score: 85 / 100 (example.com is missing CSP and Permissions-Policy)
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
|
||||
>
|
||||
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
|
||||
|
||||
## Demo URLs
|
||||
|
||||
Try these, each demonstrates a different grading path:
|
||||
|
||||
| URL | Expected grade | Why |
|
||||
|---|---|---|
|
||||
| `https://github.com` | A | Comprehensive CSP, HSTS with `includeSubDomains`, almost every header set |
|
||||
| `https://web.dev` | A | Google's own developer-docs site, full modern header set |
|
||||
| `https://mozilla.org` | A | Mozilla practices what Observatory preaches |
|
||||
| `https://example.com` | B / C | Has HSTS, but missing CSP, Permissions-Policy, and others |
|
||||
| `http://neverssl.com` | F | Intentionally serves plain HTTP, no security headers at all |
|
||||
|
||||
```bash
|
||||
just run -- https://github.com
|
||||
just run -- https://example.com
|
||||
just run -- https://web.dev --timeout 5
|
||||
just run -- http://neverssl.com
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Always include the `http://` or `https://` scheme. The scanner refuses bare hostnames like `github.com` because it cannot guess which scheme you meant, and guessing wrong is exactly the SSL-stripping problem HSTS exists to prevent.
|
||||
|
||||
## Sample Output
|
||||
|
||||
```
|
||||
Headers for https://github.com/ (HTTP 200)
|
||||
┌─────────────────────────────┬─────────┬──────────┬─────────────────────────┐
|
||||
│ header │ status │ severity │ note │
|
||||
├─────────────────────────────┼─────────┼──────────┼─────────────────────────┤
|
||||
│ Strict-Transport-Security │ ok │ high │ Present and contains... │
|
||||
│ Content-Security-Policy │ ok │ high │ Present │
|
||||
│ X-Content-Type-Options │ ok │ medium │ Present and contains... │
|
||||
│ X-Frame-Options │ ok │ medium │ Present │
|
||||
│ Referrer-Policy │ ok │ low │ Present │
|
||||
│ Permissions-Policy │ missing │ low │ Header ... is not set │
|
||||
└─────────────────────────────┴─────────┴──────────┴─────────────────────────┘
|
||||
╭─ Result ───────────────────╮
|
||||
│ Grade: A │
|
||||
│ Score: 95 / 100 │
|
||||
╰────────────────────────────╯
|
||||
```
|
||||
|
||||
Followed by a `Recommendations:` block for every non-`ok` finding, with the exact header value to add.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
The scanner returns shell-friendly exit codes so you can wire it into CI:
|
||||
|
||||
| Grade | Exit code | Meaning |
|
||||
|---|---|---|
|
||||
| A, B | `0` | Green light, no action needed |
|
||||
| C, D | `1` | Worth investigating, often acceptable depending on context |
|
||||
| F or network error | `2` | Hard fail, must fix |
|
||||
|
||||
```bash
|
||||
just run -- https://my-deployed-site.com
|
||||
if [ $? -gt 1 ]; then exit 1; fi # fail the build only on F or error
|
||||
```
|
||||
|
||||
## Tooling
|
||||
|
||||
```bash
|
||||
just # list available recipes
|
||||
just test # run pytest (11 tests, runs in under a second, network-mocked with respx)
|
||||
just lint # ruff + mypy --strict + pylint
|
||||
just format # yapf
|
||||
just run -- <url> # scan a URL
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.13+**, the install script will check.
|
||||
- [`uv`](https://github.com/astral-sh/uv), modern Python package manager (auto-installed by `./install.sh`).
|
||||
- [`just`](https://github.com/casey/just), command runner (auto-installed by `./install.sh`).
|
||||
- A working internet connection at runtime (the scanner makes one real HTTPS request per scan, but the test suite mocks the network with `respx` and runs fully offline).
|
||||
|
||||
No compilers, no system libraries. The project is one Python file plus tests.
|
||||
|
||||
## Learn
|
||||
|
||||
This project includes step-by-step learning materials covering security theory, architecture, and implementation, written for someone who has never touched Python before.
|
||||
|
||||
| Module | Topic |
|
||||
|--------|-------|
|
||||
| [00 - Overview](learn/00-OVERVIEW.md) | Quick start, prerequisites, expected output, common first-run problems |
|
||||
| [01 - Concepts](learn/01-CONCEPTS.md) | What HTTP is, what a header is, each security header with the real attack it stops (SSL stripping, clickjacking, MIME sniffing, XSS, referer leakage) |
|
||||
| [02 - Architecture](learn/02-ARCHITECTURE.md) | The four-stage pipeline, dataclasses as value objects, the I/O fence pattern (functional core / imperative shell) |
|
||||
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Function-by-function walkthrough, every Python feature explained when first encountered, plus test patterns and tooling |
|
||||
| [04 - Challenges](learn/04-CHALLENGES.md) | Twelve extension ideas, from "add a seventh header rule" up through "wrap it in a FastAPI service with rate limiting" |
|
||||
|
||||
## Real-World Context
|
||||
|
||||
This scanner is a teaching-scale version of tools that do the same job at production scale:
|
||||
|
||||
- **[Mozilla Observatory](https://observatory.mozilla.org/)**, the canonical version. Same weighted-rubric approach, deeper CSP analysis, cookie checks, TLS configuration grading.
|
||||
- **[securityheaders.com](https://securityheaders.com)**, simpler UI, same idea.
|
||||
- **[nmap http-security-headers script](https://nmap.org/nsedoc/scripts/http-security-headers.html)**, for command-line workflows.
|
||||
|
||||
Once you understand how this scanner makes decisions, those tools become readable instead of magical. The [04-CHALLENGES.md](learn/04-CHALLENGES.md) module includes ideas for growing this project toward what Observatory does.
|
||||
|
||||
## See Also
|
||||
|
||||
- [`PROJECTS/foundations/hash-identifier`](../hash-identifier), the easier foundations project, pure logic and no network at all.
|
||||
- [`PROJECTS/foundations/password-manager`](../password-manager), the hardest foundations project, covers Argon2id, AES-GCM, and on-disk vaults.
|
||||
- [`PROJECTS/advanced/bug-bounty-platform`](../../advanced/bug-bounty-platform), what a serious web-security tool looks like once it grows up.
|
||||
|
||||
## License
|
||||
|
||||
AGPL 3.0
|
||||
|
|
@ -0,0 +1,639 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
http_headers_scanner.py
|
||||
|
||||
Scan a URL and grade its HTTP security headers A–F
|
||||
|
||||
When a browser asks a website for a page, the server sends back the
|
||||
page itself PLUS a bunch of metadata called "HTTP response headers."
|
||||
Some of those headers are security-critical: they tell the browser
|
||||
"only talk to me over HTTPS," "do not let other sites embed me in
|
||||
an iframe," "ignore guesses about file types," and more
|
||||
|
||||
If a website forgets these headers, real attacks become easier:
|
||||
clickjacking, MIME-sniffing, mixed-content downgrades, XSS that
|
||||
otherwise would have been stopped by a good Content-Security-Policy.
|
||||
This script connects to a URL, pulls the headers, and tells you which
|
||||
ones are missing or weak
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
The headers we care about
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Strict-Transport-Security forces HTTPS for return visits
|
||||
Content-Security-Policy controls which scripts/styles may load
|
||||
X-Content-Type-Options disables MIME-sniffing
|
||||
X-Frame-Options controls iframe embedding (clickjacking)
|
||||
Referrer-Policy limits Referer leakage
|
||||
Permissions-Policy disables browser features the page does
|
||||
not need (camera, microphone, etc.)
|
||||
|
||||
Each rule has a severity (high / medium / low). The score is the
|
||||
percentage of weighted points the site earned. The grade comes from
|
||||
the score: 90+ A, 80+ B, 70+ C, 60+ D, otherwise F. This mirrors the
|
||||
model used by the Mozilla Observatory
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What this script does NOT do
|
||||
────────────────────────────────────────────────────────────────────
|
||||
- It does not crawl the site, only the URL you give it
|
||||
- It does not parse complex CSP directives (just checks presence)
|
||||
- It does not test for actual XSS or open redirects
|
||||
|
||||
This is foundational: learn the headers, then graduate to bigger
|
||||
tools like Mozilla Observatory or `securityheaders.com`
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What this file exposes
|
||||
────────────────────────────────────────────────────────────────────
|
||||
HeaderRule — one rule (header name, severity, description, ...)
|
||||
HeaderFinding — the result of evaluating one rule
|
||||
ScanReport — full report (url, status_code, findings, score, grade)
|
||||
evaluate_header() — run one rule against a set of headers
|
||||
scan() — fetch a URL and run every rule
|
||||
main() — CLI entry point used by `headers <url>`
|
||||
"""
|
||||
|
||||
# Standard library: parse command-line flags like `--timeout 5` into
|
||||
# a tidy object so we do not have to slice `sys.argv` by hand.
|
||||
import argparse
|
||||
# Standard library: regular expressions — we use `re.search` to match
|
||||
# header values against rule patterns (e.g. `max-age\s*=\s*[1-9]` for
|
||||
# HSTS, which must reject `max-age=0`).
|
||||
import re
|
||||
# Standard library: access to interpreter internals — we use it for
|
||||
# stderr writes and to exit the process with a specific status code.
|
||||
import sys
|
||||
# Standard library: a decorator that turns a class into a small,
|
||||
# immutable data record without writing `__init__` boilerplate.
|
||||
from dataclasses import dataclass
|
||||
# Standard library: a type hint that pins a value to a small fixed
|
||||
# set of strings (here: severity levels like "good"/"warn"). Mypy
|
||||
# catches typos.
|
||||
from typing import Literal
|
||||
|
||||
# Third-party (httpx): the HTTP client that actually fetches the URL.
|
||||
# Modern replacement for `requests` — supports timeouts and HTTP/2.
|
||||
import httpx
|
||||
# Third-party (rich): the printer that draws colored output to the
|
||||
# terminal, with full Unicode and width handling.
|
||||
from rich.console import Console
|
||||
# Third-party (rich): draws a bordered box around content — we use
|
||||
# it for the summary banner at the top of the report.
|
||||
from rich.panel import Panel
|
||||
# Third-party (rich): builds the colored ASCII table that lists each
|
||||
# header finding with its severity.
|
||||
from rich.table import Table
|
||||
|
||||
# =============================================================================
|
||||
# Severity type — three valid values
|
||||
# =============================================================================
|
||||
# Literal["high", "medium", "low"] is a type hint that says "this string
|
||||
# can ONLY be one of these three values." Mypy will catch typos like
|
||||
# "hgih" at edit time. We chose Literal over an Enum because Carter's
|
||||
# style guide prefers Literals for small fixed sets
|
||||
|
||||
Severity = Literal["high", "medium", "low"]
|
||||
Status = Literal["ok", "weak", "missing"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HeaderRule — one rule we evaluate against the response
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen = True, slots = True)
|
||||
class HeaderRule:
|
||||
"""
|
||||
A single security-header check
|
||||
|
||||
`frozen=True` makes the dataclass immutable — once created, its
|
||||
fields cannot change. `slots=True` makes instances lightweight in
|
||||
memory. Together these two flags create a clean "value object"
|
||||
|
||||
Fields
|
||||
------
|
||||
header
|
||||
The HTTP header name to look for (case-insensitive)
|
||||
severity
|
||||
How important the header is. Drives the point value below
|
||||
description
|
||||
One sentence explaining what the header does. Shown in output
|
||||
recommendation
|
||||
Concrete fix the user should apply if the header is missing
|
||||
must_match
|
||||
Optional regex pattern (case-insensitive) the value MUST match.
|
||||
Use a plain word for substring matching (e.g. ``nosniff``), or a
|
||||
real regex for stricter checks. Example: ``max-age\\s*=\\s*[1-9]``
|
||||
requires ``max-age`` to be a positive integer, which rejects the
|
||||
actively-harmful ``max-age=0``. If this is set and the value
|
||||
does not match, we report `weak` instead of `ok`
|
||||
"""
|
||||
header: str
|
||||
severity: Severity
|
||||
description: str
|
||||
recommendation: str
|
||||
must_match: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The rules table — single source of truth for what we check
|
||||
# =============================================================================
|
||||
# Adding a header to this list is the only change needed to extend
|
||||
# the scanner. The check logic is generic — it walks this list at
|
||||
# runtime and applies each rule the same way
|
||||
|
||||
RULES: list[HeaderRule] = [
|
||||
HeaderRule(
|
||||
header = "Strict-Transport-Security",
|
||||
severity = "high",
|
||||
description = (
|
||||
"Tells the browser to ONLY connect over HTTPS for the "
|
||||
"next N seconds, defeating SSL-stripping attacks"
|
||||
),
|
||||
recommendation = (
|
||||
"Add: Strict-Transport-Security: "
|
||||
"max-age=31536000; includeSubDomains"
|
||||
),
|
||||
# Require max-age to be a positive integer — `max-age=0`
|
||||
# actively disables HSTS, so we must reject it. The regex
|
||||
# accepts whitespace around `=` to tolerate `max-age = 60`.
|
||||
must_match = r"max-age\s*=\s*[1-9]",
|
||||
),
|
||||
HeaderRule(
|
||||
header = "Content-Security-Policy",
|
||||
severity = "high",
|
||||
description = (
|
||||
"Controls which scripts, styles, frames, and connections "
|
||||
"the browser may load — the strongest XSS defense"
|
||||
),
|
||||
recommendation = (
|
||||
"Add a Content-Security-Policy that disallows "
|
||||
"'unsafe-inline' and limits sources to trusted origins"
|
||||
),
|
||||
),
|
||||
HeaderRule(
|
||||
header = "X-Content-Type-Options",
|
||||
severity = "medium",
|
||||
description = (
|
||||
"Stops browsers from second-guessing the Content-Type "
|
||||
"and treating a .txt file as HTML — defeats MIME-sniffing"
|
||||
),
|
||||
recommendation = "Add: X-Content-Type-Options: nosniff",
|
||||
# Value must literally be `nosniff`; anything else is broken.
|
||||
# `re.search("nosniff", ...)` is a substring match here — no
|
||||
# special regex characters in the pattern.
|
||||
must_match = "nosniff",
|
||||
),
|
||||
HeaderRule(
|
||||
header = "X-Frame-Options",
|
||||
severity = "medium",
|
||||
description = (
|
||||
"Prevents another site from embedding this page in an "
|
||||
"iframe, defeating clickjacking attacks"
|
||||
),
|
||||
recommendation = (
|
||||
"Add: X-Frame-Options: DENY (or use "
|
||||
"Content-Security-Policy: frame-ancestors 'none')"
|
||||
),
|
||||
),
|
||||
HeaderRule(
|
||||
header = "Referrer-Policy",
|
||||
severity = "low",
|
||||
description = (
|
||||
"Limits how much of the current URL leaks to other sites "
|
||||
"when the user clicks an outbound link"
|
||||
),
|
||||
recommendation = (
|
||||
"Add: Referrer-Policy: strict-origin-when-cross-origin"
|
||||
),
|
||||
),
|
||||
HeaderRule(
|
||||
header = "Permissions-Policy",
|
||||
severity = "low",
|
||||
description = (
|
||||
"Disables browser features the page does not use "
|
||||
"(camera, microphone, geolocation, payments, etc.)"
|
||||
),
|
||||
recommendation = (
|
||||
"Add: Permissions-Policy: "
|
||||
"camera=(), microphone=(), geolocation=()"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Severity → points. Drives the final score
|
||||
# =============================================================================
|
||||
# Each present-and-correct header earns its full points; weak presence
|
||||
# earns half points; missing earns zero. Total achievable = sum of
|
||||
# all rule points. The score is (earned / total) * 100, rounded
|
||||
|
||||
SEVERITY_POINTS: dict[Severity,
|
||||
int] = {
|
||||
"high": 30,
|
||||
"medium": 15,
|
||||
"low": 5,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HeaderFinding — the result of evaluating one rule
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen = True, slots = True)
|
||||
class HeaderFinding:
|
||||
"""
|
||||
Outcome of running one HeaderRule against the response headers
|
||||
|
||||
Fields
|
||||
------
|
||||
rule
|
||||
The rule we evaluated. Carrying it inside the finding means
|
||||
the renderer never has to look up the rule again
|
||||
status
|
||||
"ok" — header is present and (if applicable) the value
|
||||
matches must_match
|
||||
"weak" — header is present but the value is wrong
|
||||
"missing" — header is not in the response at all
|
||||
actual_value
|
||||
Whatever the server actually sent for this header, or None
|
||||
when the header was missing entirely
|
||||
note
|
||||
Short human-readable explanation. Shown in the table next
|
||||
to the status column
|
||||
"""
|
||||
rule: HeaderRule
|
||||
status: Status
|
||||
actual_value: str | None
|
||||
note: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ScanReport — the full result returned by scan()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen = True, slots = True)
|
||||
class ScanReport:
|
||||
"""
|
||||
A full scan result for one URL
|
||||
|
||||
The `score` and `grade` properties are computed on demand from
|
||||
the findings, so they always reflect whatever the rules table
|
||||
looked like at scan time
|
||||
"""
|
||||
url: str
|
||||
final_url: str
|
||||
status_code: int
|
||||
findings: list[HeaderFinding]
|
||||
|
||||
@property
|
||||
def score(self) -> int:
|
||||
"""
|
||||
Return a 0–100 score reflecting the weighted findings
|
||||
|
||||
Formula
|
||||
-------
|
||||
earned = full points for every "ok"
|
||||
+ half points for every "weak"
|
||||
+ zero for every "missing"
|
||||
score = round(earned / total * 100)
|
||||
"""
|
||||
total = sum(SEVERITY_POINTS[r.severity] for r in RULES)
|
||||
# Guard against an empty rules table — would only matter if
|
||||
# someone deletes RULES while testing. Keeps the code total
|
||||
if total == 0:
|
||||
return 0
|
||||
|
||||
earned = 0.0
|
||||
for finding in self.findings:
|
||||
full = SEVERITY_POINTS[finding.rule.severity]
|
||||
if finding.status == "ok":
|
||||
earned += full
|
||||
elif finding.status == "weak":
|
||||
earned += full / 2
|
||||
# "missing" earns 0 — no else branch needed
|
||||
|
||||
# Round-half-up via int(x + 0.5) avoids Python's banker's
|
||||
# rounding, which would map round(0.5) -> 0 and round(2.5) -> 2
|
||||
# — surprising for a score that should always round up at the
|
||||
# .5 boundary
|
||||
return int((earned / total) * 100 + 0.5)
|
||||
|
||||
@property
|
||||
def grade(self) -> str:
|
||||
"""
|
||||
Map the score to a letter grade A–F
|
||||
"""
|
||||
score = self.score
|
||||
if score >= 90:
|
||||
return "A"
|
||||
if score >= 80:
|
||||
return "B"
|
||||
if score >= 70:
|
||||
return "C"
|
||||
if score >= 60:
|
||||
return "D"
|
||||
return "F"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Header evaluation — pure function, no I/O
|
||||
# =============================================================================
|
||||
# Splitting this out from scan() makes it trivially testable: pass in
|
||||
# a rule and a dict of headers, get back a finding. No network needed
|
||||
|
||||
|
||||
def evaluate_header(
|
||||
rule: HeaderRule,
|
||||
response_headers: dict[str,
|
||||
str],
|
||||
) -> HeaderFinding:
|
||||
"""
|
||||
Apply a single HeaderRule to a set of response headers
|
||||
|
||||
HTTP header names are case-insensitive per RFC 7230 — `HSTS` and
|
||||
`hsts` and `Hsts` are the same header. We normalize both sides
|
||||
to lowercase before comparing
|
||||
"""
|
||||
target = rule.header.lower()
|
||||
|
||||
# Walk the response headers manually instead of building a
|
||||
# case-insensitive dict. The input is always a plain dict here —
|
||||
# scan() converts httpx's Headers object before calling us, so
|
||||
# tests can pass any dict[str, str] without ceremony
|
||||
actual_value: str | None = None
|
||||
for name, value in response_headers.items():
|
||||
if name.lower() == target:
|
||||
actual_value = value
|
||||
break
|
||||
|
||||
if actual_value is None:
|
||||
return HeaderFinding(
|
||||
rule = rule,
|
||||
status = "missing",
|
||||
actual_value = None,
|
||||
note = f"Header `{rule.header}` is not set",
|
||||
)
|
||||
|
||||
# If the rule has no must_match check, presence is enough
|
||||
if rule.must_match is None:
|
||||
return HeaderFinding(
|
||||
rule = rule,
|
||||
status = "ok",
|
||||
actual_value = actual_value,
|
||||
note = "Present",
|
||||
)
|
||||
|
||||
# Otherwise verify the value matches the required pattern.
|
||||
# re.search finds the pattern anywhere in the string — for a plain
|
||||
# word like `nosniff` that behaves as a substring check; for a
|
||||
# real regex like `max-age\s*=\s*[1-9]` it enforces a richer
|
||||
# condition (positive integer after `max-age=`)
|
||||
if re.search(rule.must_match, actual_value, re.IGNORECASE):
|
||||
return HeaderFinding(
|
||||
rule = rule,
|
||||
status = "ok",
|
||||
actual_value = actual_value,
|
||||
note = f"Present and matches `{rule.must_match}`",
|
||||
)
|
||||
|
||||
return HeaderFinding(
|
||||
rule = rule,
|
||||
status = "weak",
|
||||
actual_value = actual_value,
|
||||
note = (
|
||||
f"Present but does not match `{rule.must_match}` "
|
||||
f"(got `{actual_value}`)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# scan() — fetch the URL and apply every rule
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# A polite, identifiable User-Agent. Some servers block requests with
|
||||
# the default httpx UA or no UA at all
|
||||
DEFAULT_USER_AGENT: str = (
|
||||
"http-headers-scanner/1.0 "
|
||||
"(+https://github.com/CarterPerez-dev/Cybersecurity-Projects)"
|
||||
)
|
||||
|
||||
|
||||
def scan(
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
) -> ScanReport:
|
||||
"""
|
||||
Fetch `url` once and grade its response headers
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url
|
||||
Full URL including the scheme. Bare hostnames like
|
||||
"example.com" are NOT supported because we cannot guess
|
||||
whether the user wanted http or https
|
||||
timeout
|
||||
Seconds before we give up on a slow server. Default 10
|
||||
user_agent
|
||||
Sent as the User-Agent header. Some sites serve different
|
||||
responses to bots; the default identifies us honestly
|
||||
|
||||
Returns
|
||||
-------
|
||||
ScanReport
|
||||
Containing the findings, status code, and final URL after
|
||||
any redirects
|
||||
|
||||
Raises
|
||||
------
|
||||
httpx.RequestError
|
||||
On DNS failure, connection refusal, timeout, etc. The CLI
|
||||
catches these to print a clean error message
|
||||
"""
|
||||
# follow_redirects=True means http://example.com → https://example.com
|
||||
# is followed automatically. We grade the FINAL URL, not the first
|
||||
# one, because that is the one users actually see
|
||||
response = httpx.get(
|
||||
url,
|
||||
timeout = timeout,
|
||||
follow_redirects = True,
|
||||
headers = {"User-Agent": user_agent},
|
||||
)
|
||||
|
||||
# httpx Headers object behaves like a dict for our purposes.
|
||||
# dict(response.headers) gives us a regular dict[str, str]
|
||||
response_headers = dict(response.headers)
|
||||
|
||||
# Run every rule against the response. List comprehension is
|
||||
# cleaner than a for-loop with .append() here
|
||||
findings = [evaluate_header(rule, response_headers) for rule in RULES]
|
||||
|
||||
return ScanReport(
|
||||
url = url,
|
||||
final_url = str(response.url),
|
||||
status_code = response.status_code,
|
||||
findings = findings,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI rendering — keeps display logic out of the data layer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# How each status / severity should be colored in the terminal
|
||||
STATUS_COLORS: dict[Status,
|
||||
str] = {
|
||||
"ok": "green",
|
||||
"weak": "yellow",
|
||||
"missing": "red",
|
||||
}
|
||||
|
||||
GRADE_COLORS: dict[str,
|
||||
str] = {
|
||||
"A": "bright_green",
|
||||
"B": "green",
|
||||
"C": "yellow",
|
||||
"D": "red",
|
||||
"F": "bright_red",
|
||||
}
|
||||
|
||||
|
||||
def _render_report(report: ScanReport, console: Console) -> None:
|
||||
"""
|
||||
Print the scan report as a rich table plus a grade panel
|
||||
"""
|
||||
# The header table — one row per rule
|
||||
table = Table(
|
||||
title = (
|
||||
f"Headers for {report.final_url} "
|
||||
f"(HTTP {report.status_code})"
|
||||
),
|
||||
title_style = "bold cyan",
|
||||
show_lines = False,
|
||||
)
|
||||
table.add_column("header", style = "bold white", no_wrap = True)
|
||||
table.add_column("status", no_wrap = True)
|
||||
table.add_column("severity", no_wrap = True)
|
||||
table.add_column("note", style = "dim")
|
||||
|
||||
for finding in report.findings:
|
||||
status_color = STATUS_COLORS[finding.status]
|
||||
table.add_row(
|
||||
finding.rule.header,
|
||||
f"[{status_color}]{finding.status}[/{status_color}]",
|
||||
finding.rule.severity,
|
||||
finding.note,
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
# Browsers IGNORE HSTS received over plain HTTP per RFC 6797 §8.1
|
||||
# — if the final response was served over http://, any HSTS grade
|
||||
# above is misleading. Warn so the user does not walk away with a
|
||||
# false sense of security
|
||||
if report.final_url.startswith("http://"):
|
||||
console.print(
|
||||
"[yellow]Note:[/yellow] this response was served over plain "
|
||||
"HTTP. Browsers IGNORE HSTS over HTTP, so any HSTS grade "
|
||||
"above is misleading until the site enforces HTTPS"
|
||||
)
|
||||
|
||||
# The grade panel — big, color-coded, eye-catching
|
||||
grade_color = GRADE_COLORS[report.grade]
|
||||
panel = Panel(
|
||||
f"[bold {grade_color}]Grade: {report.grade}[/bold {grade_color}]\n"
|
||||
f"Score: {report.score} / 100",
|
||||
title = "Result",
|
||||
border_style = grade_color,
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
# Print recommendations for any non-ok findings, so the user has
|
||||
# an action list — what to add or fix
|
||||
actionable = [f for f in report.findings if f.status != "ok"]
|
||||
if actionable:
|
||||
console.print("\n[bold]Recommendations:[/bold]")
|
||||
for finding in actionable:
|
||||
console.print(
|
||||
f" • [yellow]{finding.rule.header}[/yellow] "
|
||||
f"— {finding.rule.recommendation}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# argparse plumbing — broken out so tests can call it directly
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_argument_parser() -> argparse.ArgumentParser:
|
||||
"""
|
||||
Construct the argparse parser used by main()
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog = "headers",
|
||||
description = (
|
||||
"Scan a URL for HTTP security headers and grade the result A–F."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"url",
|
||||
help = "Full URL to scan (must include http:// or https://).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type = float,
|
||||
default = 10.0,
|
||||
help =
|
||||
"Seconds to wait before giving up on the request (default: 10).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# main() — exit codes mean something
|
||||
# =============================================================================
|
||||
# 0 → grade A or B (green light for CI)
|
||||
# 1 → grade C or D (warn but do not fail by default)
|
||||
# 2 → grade F or network error (fail loudly)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
CLI entry point — return an exit code reflecting the scan result
|
||||
"""
|
||||
parser = _build_argument_parser()
|
||||
args = parser.parse_args()
|
||||
console = Console()
|
||||
|
||||
# Catch network errors here so the user sees a clean message
|
||||
# instead of a raw traceback. We let httpx's own message bubble
|
||||
# through after our prefix — the underlying error usually has
|
||||
# useful detail (DNS failure, connection refused, etc.)
|
||||
try:
|
||||
report = scan(args.url, timeout = args.timeout)
|
||||
except httpx.RequestError as exc:
|
||||
console.print(
|
||||
f"[red]Request failed:[/red] {type(exc).__name__}: {exc}"
|
||||
)
|
||||
return 2
|
||||
|
||||
_render_report(report, console)
|
||||
|
||||
if report.grade in ("A", "B"):
|
||||
return 0
|
||||
if report.grade in ("C", "D"):
|
||||
return 1
|
||||
return 2
|
||||
|
||||
|
||||
# Standard "if invoked directly as a script" guard — lets the file be
|
||||
# imported by tests without firing main()
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env bash
|
||||
# ©AngelaMos | 2026
|
||||
# install.sh
|
||||
#
|
||||
# Zero-friction install script. Anyone who clones this project should
|
||||
# be able to run `./install.sh` and end up with a working setup,
|
||||
# regardless of whether they have uv or just installed yet.
|
||||
#
|
||||
# What this script does, in order:
|
||||
# 1. Verifies Python 3.13+ is installed (we need modern type-hint syntax)
|
||||
# 2. Installs uv if it is missing (uv is the Python package manager we use)
|
||||
# 3. Installs just if it is missing (just is the command runner)
|
||||
# 4. Calls `just setup` to create the venv and install dependencies
|
||||
# 5. Prints next steps
|
||||
#
|
||||
# Run with: ./install.sh
|
||||
# Or: bash install.sh
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bash safety flags — fail fast and loud
|
||||
# -----------------------------------------------------------------------------
|
||||
# -e : exit immediately if any command returns a non-zero (error) status
|
||||
# -u : treat unset variables as an error
|
||||
# -o pipefail : if any command in a pipeline fails, the whole pipeline fails
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Color helpers — pretty terminal output without external dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
# These are ANSI escape codes. \033 is the ESC character; the bracketed
|
||||
# digits tell the terminal which color to switch to. NC = "no color",
|
||||
# resets back to whatever the terminal had before.
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Tiny helper functions so we don't repeat the format strings everywhere.
|
||||
# `>&2` redirects to stderr (where errors belong) instead of stdout.
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 1 — Confirm Python 3.13+ is on the system
|
||||
# -----------------------------------------------------------------------------
|
||||
check_python() {
|
||||
info "Checking for Python 3.13+..."
|
||||
|
||||
# `command -v <name>` prints the path of <name> if it exists, nothing
|
||||
# otherwise. `&>/dev/null` discards both stdout and stderr — we only
|
||||
# care about the exit code (0 = found, non-zero = missing).
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
error "python3 not found. Please install Python 3.13 or newer."
|
||||
error " macOS: brew install python@3.13"
|
||||
error " Linux: sudo apt install python3.13 (Debian/Ubuntu)"
|
||||
error " Windows: download from python.org"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read the version from Python itself — the most reliable source.
|
||||
# `local` makes these variables function-scoped instead of leaking
|
||||
# into the rest of the script.
|
||||
local version
|
||||
version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
local major minor
|
||||
# `cut -d. -f1` splits the string on `.` and takes field 1.
|
||||
major=$(echo "$version" | cut -d. -f1)
|
||||
minor=$(echo "$version" | cut -d. -f2)
|
||||
|
||||
# `(( ... ))` is bash arithmetic context — lets us write `<` `>` etc.
|
||||
# The compound condition fails if major < 3, OR if major == 3 and
|
||||
# minor < 13. So Python 3.12 fails, 3.13 passes, 4.0 passes.
|
||||
if (( major < 3 )) || { (( major == 3 )) && (( minor < 13 )); }; then
|
||||
error "Python 3.13+ is required, found Python $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Python $version detected"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 2 — Install uv if missing (https://docs.astral.sh/uv)
|
||||
# -----------------------------------------------------------------------------
|
||||
install_uv() {
|
||||
# Already installed? Print confirmation and bail out of this function.
|
||||
# `return 0` exits the function with success — the caller continues.
|
||||
if command -v uv &>/dev/null; then
|
||||
success "uv already installed ($(uv --version))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Installing uv (Python package manager)..."
|
||||
# Pipe the official install script into sh. `-LsSf`:
|
||||
# -L : follow redirects
|
||||
# -s : silent (no progress meter)
|
||||
# -S : show errors even when silent
|
||||
# -f : fail on HTTP errors instead of writing them to disk
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# The installer drops uv into ~/.local/bin or ~/.cargo/bin.
|
||||
# Add both to PATH for the rest of THIS script's run.
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# If we still cannot find uv, something went wrong.
|
||||
if ! command -v uv &>/dev/null; then
|
||||
error "uv install completed but \`uv\` is still not on PATH."
|
||||
error "Restart your shell and re-run this script, or add uv to PATH manually."
|
||||
exit 1
|
||||
fi
|
||||
success "uv installed"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 3 — Install just if missing (https://github.com/casey/just)
|
||||
# -----------------------------------------------------------------------------
|
||||
install_just() {
|
||||
if command -v just &>/dev/null; then
|
||||
success "just already installed ($(just --version))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Installing just (command runner)..."
|
||||
# Make sure the install destination exists first.
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
# Official install script. `--to <dir>` controls where the binary lands.
|
||||
# `--proto '=https'` rejects any protocol but HTTPS.
|
||||
# `--tlsv1.2` insists on a modern TLS version.
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
|
||||
| bash -s -- --to "$HOME/.local/bin"
|
||||
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
success "just installed"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 4 — Use just to set up the project (venv + dependencies)
|
||||
# -----------------------------------------------------------------------------
|
||||
project_setup() {
|
||||
info "Running 'just setup'..."
|
||||
# Calling our own justfile recipe — single source of truth for setup.
|
||||
just setup
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main — orchestrate the steps and print next instructions
|
||||
# -----------------------------------------------------------------------------
|
||||
main() {
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " http-headers-scanner — install"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
check_python
|
||||
install_uv
|
||||
install_just
|
||||
project_setup
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
success "Install complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " just run -- https://example.com # scan a real URL"
|
||||
echo " just run -- --help # see options"
|
||||
echo " just test # run the test suite"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# `"$@"` forwards every argument the script was called with into main.
|
||||
# We do not use any args today, but keeping this pattern means future
|
||||
# flags can be added without changing the bottom of the file.
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# ©AngelaMos | 2026
|
||||
# justfile
|
||||
#
|
||||
# A "justfile" is a list of commands you can run with `just <name>`.
|
||||
# Think of it as a project's command center — instead of remembering
|
||||
# `uv run pytest -v`, you just type `just test`.
|
||||
#
|
||||
# Why use just instead of make? It is simpler, cross-platform,
|
||||
# and the syntax is easier to read.
|
||||
#
|
||||
# Show all commands: `just`
|
||||
# Run a command: `just <name>` (e.g. `just setup`)
|
||||
|
||||
# Export every variable defined here as an environment variable for
|
||||
# the recipes that just runs.
|
||||
set export
|
||||
# On Linux/macOS, run recipe lines with bash and -u (error on
|
||||
# unset variables) and -c (read commands from a string).
|
||||
set shell := ["bash", "-uc"]
|
||||
# On Windows, fall back to PowerShell with no logo / non-interactive.
|
||||
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||
# Make recipe arguments available to shebang scripts as `$1`, `$2`,
|
||||
# `$@`, and `$#`. Without this, shebang recipes only see args via the
|
||||
# textual `{{args}}` substitution, which is unsafe for inputs that
|
||||
# contain `$` (the substituted text gets re-expanded by bash).
|
||||
set positional-arguments
|
||||
|
||||
# Show available commands when you run `just` with no args.
|
||||
default:
|
||||
@just --list --unsorted
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Setup Commands
|
||||
# =============================================================================
|
||||
|
||||
# One-shot first-time setup — creates .venv and installs everything
|
||||
[group('setup')]
|
||||
setup:
|
||||
@echo "Creating virtual environment with uv..."
|
||||
# uv venv creates a .venv/ folder using the system Python that
|
||||
# matches `requires-python` in pyproject.toml.
|
||||
# --allow-existing makes the recipe safe to re-run after a partial install.
|
||||
uv venv --allow-existing
|
||||
@echo ""
|
||||
@echo "Installing dependencies (including dev tools)..."
|
||||
# --all-extras pulls in every optional-dependencies group (just `dev`
|
||||
# for us). Without this, dev tools like pytest do not get installed.
|
||||
uv sync --all-extras
|
||||
@echo ""
|
||||
@echo "✓ Setup complete!"
|
||||
@echo ""
|
||||
@echo "Try it out:"
|
||||
@echo " just run -- https://example.com"
|
||||
@echo " just test"
|
||||
|
||||
# Install runtime dependencies only (no dev tools)
|
||||
[group('setup')]
|
||||
install:
|
||||
uv sync
|
||||
|
||||
# Install runtime + dev dependencies
|
||||
[group('setup')]
|
||||
install-dev:
|
||||
uv sync --all-extras
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Testing & Quality Checks
|
||||
# =============================================================================
|
||||
|
||||
# Run the test suite
|
||||
[group('test')]
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
# `uv run` runs a command inside the project's virtual environment
|
||||
# without us having to `source .venv/bin/activate` first.
|
||||
uv run pytest
|
||||
|
||||
# Run all linters in sequence (ruff + pylint + mypy)
|
||||
[group('test')]
|
||||
lint:
|
||||
@echo "=== Ruff ==="
|
||||
uv run ruff check http_headers_scanner.py test_http_headers_scanner.py
|
||||
@echo ""
|
||||
@echo "=== Pylint ==="
|
||||
uv run pylint http_headers_scanner.py
|
||||
@echo ""
|
||||
@echo "=== Mypy ==="
|
||||
uv run mypy http_headers_scanner.py
|
||||
@echo ""
|
||||
@echo "✓ All linters passed"
|
||||
|
||||
# Auto-format every Python file with yapf
|
||||
[group('test')]
|
||||
format:
|
||||
@echo "Formatting code with yapf..."
|
||||
# -i = in place. Edits the files directly instead of printing diffs.
|
||||
uv run yapf -i http_headers_scanner.py test_http_headers_scanner.py
|
||||
@echo "✓ Code formatted"
|
||||
|
||||
# Auto-fix what ruff can fix on its own (unused imports, etc.)
|
||||
[group('test')]
|
||||
fix:
|
||||
uv run ruff check http_headers_scanner.py test_http_headers_scanner.py --fix
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run the CLI
|
||||
# =============================================================================
|
||||
|
||||
# Run headers — pass the URL after `--`
|
||||
# Example: just run -- https://example.com
|
||||
# just run -- https://github.com --timeout 5
|
||||
# [no-exit-message] silences just's "Recipe `run` failed with exit
|
||||
# code N" line when the scanner exits non-zero. The scanner uses
|
||||
# exit 1 for grade C/D and exit 2 for grade F or network error —
|
||||
# meaningful CI signals, not "the recipe is broken." The exit code
|
||||
# itself is still propagated to whoever invoked just.
|
||||
[group('run')]
|
||||
[no-exit-message]
|
||||
run *args:
|
||||
#!/usr/bin/env bash
|
||||
# If no args were given, print a friendly usage block and exit
|
||||
# cleanly. Without this, `just run` (no args) would invoke
|
||||
# `uv run headers` with nothing, argparse would exit 2, and just
|
||||
# would tack a "Recipe `run` failed" error on top — confusing for
|
||||
# someone just trying to see how the command works.
|
||||
if [ $# -eq 0 ]; then
|
||||
cat <<'EOF'
|
||||
Usage: just run -- <url> [options]
|
||||
|
||||
Examples:
|
||||
just run -- https://example.com
|
||||
just run -- https://github.com --timeout 5
|
||||
|
||||
See all options:
|
||||
just run -- --help
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
# Forward args via "$@" — NOT via {{args}}. Why: {{args}} is a
|
||||
# TEXTUAL substitution done by just before bash runs the script,
|
||||
# so a URL or option value with $ in it would get those $-references
|
||||
# expanded as bash variables and arrive at the headers CLI mangled.
|
||||
# "$@" hands bash the original argv unchanged.
|
||||
uv run headers "$@"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility / Cleanup
|
||||
# =============================================================================
|
||||
|
||||
# Delete the venv and all build / cache artifacts
|
||||
[group('utility')]
|
||||
clean:
|
||||
rm -rf .venv
|
||||
rm -rf __pycache__
|
||||
rm -rf .mypy_cache .ruff_cache .pytest_cache
|
||||
rm -rf *.egg-info build dist
|
||||
rm -rf .coverage htmlcov
|
||||
@echo "✓ Cleaned"
|
||||
|
||||
# Lock the exact dependency versions to uv.lock
|
||||
[group('utility')]
|
||||
lock:
|
||||
uv lock
|
||||
|
||||
# Upgrade all dependencies to latest allowed versions
|
||||
[group('utility')]
|
||||
update:
|
||||
uv lock --upgrade
|
||||
uv sync --all-extras
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CI Pipeline
|
||||
# =============================================================================
|
||||
|
||||
# Full pipeline: setup + lint + test. For first-time runs.
|
||||
[group('ci')]
|
||||
all: setup lint test
|
||||
@echo ""
|
||||
@echo "✓ Setup, lint, and tests all passed"
|
||||
|
||||
# Lint + test only — what CI runs after dependencies are installed
|
||||
[group('ci')]
|
||||
ci: lint test
|
||||
@echo "✓ CI checks passed"
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
# http-headers-scanner
|
||||
|
||||
A small command line tool that visits a website, asks for the "rules" that the website tells your browser to follow, and grades the website A through F based on how good those rules are.
|
||||
|
||||
This is the middle project in the **foundations** tier. The first foundations project (`hash-identifier`) was pure logic with no network. The third one (`password-manager`) is a full small application. This project sits between them: one trip across the internet, then some grading math.
|
||||
|
||||
## Why anyone built this
|
||||
|
||||
Every time you visit a website, your browser and the website's server have a short conversation. The browser asks "give me the page" and the server sends back two things:
|
||||
|
||||
1. The page itself (the HTML, images, scripts).
|
||||
2. A short list of **rules** about how to treat that page.
|
||||
|
||||
That second list is what we care about here. It is called the **response headers**. Some of those headers are security related. They tell your browser things like:
|
||||
|
||||
- "From now on, only talk to me over HTTPS. Never plain HTTP."
|
||||
- "Do not let any other website put me inside an `<iframe>`."
|
||||
- "If you receive a file from me and you are not sure what type it is, do not guess. Treat it strictly."
|
||||
- "Block scripts unless they come from this exact list of trusted places."
|
||||
|
||||
If a website forgets to send these headers, real attacks become easier. A few examples that actually happened to real companies:
|
||||
|
||||
- **Clickjacking.** Without `X-Frame-Options`, an attacker can load a victim's website inside a hidden iframe on a malicious page, trick you into clicking what looks like a button on the malicious page, but your click really lands on the victim's site. Used against Twitter, Facebook, and Adobe Flash settings in the 2008 to 2012 era.
|
||||
- **SSL stripping.** Without `Strict-Transport-Security`, an attacker on the same coffee shop wifi can downgrade your first visit to plain HTTP, sit in the middle, and read or change everything you send. Moxie Marlinspike demoed this at Black Hat 2009 and it is still effective on any site that forgets HSTS.
|
||||
- **MIME sniffing.** Without `X-Content-Type-Options: nosniff`, a browser may treat an uploaded file as something it is not (an "image" that the browser decides looks like HTML and runs as a script). Real attack against legacy IE that worked because the browser tried to be helpful.
|
||||
|
||||
This scanner does NOT fix any of those. It just tells you whether a website is missing the headers that would have prevented them. That is the first useful job in security: knowing what is wrong before you can try to fix it.
|
||||
|
||||
## What you will learn by building it
|
||||
|
||||
This is not a tutorial that teaches you Python from the absolute first line. It assumes you can install something on your computer and run a command in the terminal. Everything past that, we will walk through.
|
||||
|
||||
After working through this project you should understand:
|
||||
|
||||
**Security concepts**
|
||||
- What HTTP headers are and why some of them matter for security
|
||||
- The specific attacks each major security header prevents (clickjacking, MIME sniffing, mixed content, XSS, referer leakage)
|
||||
- How a "grading rubric" lets you turn many small checks into one final score, which is what real scanners like Mozilla Observatory and securityheaders.com do
|
||||
|
||||
**Python concepts**
|
||||
- How to make an HTTP request from code with `httpx`
|
||||
- How `dataclasses` give you small "shapes" of data without writing constructors by hand
|
||||
- How `Literal` type hints pin a value to a small fixed set of strings so typos get caught early
|
||||
- How to split "code that does I/O" (talks to the network) from "code that is pure math" so you can test the math without touching the network
|
||||
- How `pytest` runs tests, what a fixture is, and how `respx` lets you fake HTTP responses
|
||||
|
||||
**Command line tooling**
|
||||
- How `argparse` parses flags like `--timeout 5` into a tidy object
|
||||
- How exit codes (the number a program returns when it finishes) can communicate success or failure to other programs and to CI systems
|
||||
- How `rich` draws colored tables and panels in the terminal
|
||||
|
||||
## What it looks like when you run it
|
||||
|
||||
```bash
|
||||
$ just run -- https://github.com
|
||||
```
|
||||
|
||||
You will see something like this (colors are real in the terminal):
|
||||
|
||||
```
|
||||
Headers for https://github.com/ (HTTP 200)
|
||||
┌─────────────────────────────┬─────────┬──────────┬─────────────────────────┐
|
||||
│ header │ status │ severity │ note │
|
||||
├─────────────────────────────┼─────────┼──────────┼─────────────────────────┤
|
||||
│ Strict-Transport-Security │ ok │ high │ Present and contains... │
|
||||
│ Content-Security-Policy │ ok │ high │ Present │
|
||||
│ X-Content-Type-Options │ ok │ medium │ Present and contains... │
|
||||
│ X-Frame-Options │ ok │ medium │ Present │
|
||||
│ Referrer-Policy │ ok │ low │ Present │
|
||||
│ Permissions-Policy │ missing │ low │ Header ... is not set │
|
||||
└─────────────────────────────┴─────────┴──────────┴─────────────────────────┘
|
||||
╭─ Result ───────────────────╮
|
||||
│ Grade: A │
|
||||
│ Score: 95 / 100 │
|
||||
╰────────────────────────────╯
|
||||
|
||||
Recommendations:
|
||||
• Permissions-Policy — Add: Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
```
|
||||
|
||||
The big takeaways:
|
||||
- A green table row means "this header is set and useful."
|
||||
- A yellow row means "this header is set but the value is wrong" (for example, `Strict-Transport-Security` was sent but with `max-age=0`, which actively disables itself).
|
||||
- A red row means "this header is missing entirely."
|
||||
- The grade panel summarises everything into one letter so you can tell at a glance whether a site has its basics in order.
|
||||
|
||||
## Who this is for
|
||||
|
||||
You can be absolutely brand new to Python and to security. You should already know:
|
||||
|
||||
- How to open a terminal on your computer.
|
||||
- How to clone a git repository (or download a folder of files).
|
||||
- How to read text and not panic when something does not work on the first try.
|
||||
|
||||
You do NOT need to know in advance:
|
||||
|
||||
- What HTTP is. We will explain it.
|
||||
- What a dataclass is. We will explain it.
|
||||
- What pytest is, what mocking is, what argparse is. All explained.
|
||||
|
||||
## Prerequisites in real terms
|
||||
|
||||
**Software you need installed on your computer.** All three are free.
|
||||
|
||||
- **Python 3.13 or newer.** Python is the programming language this is written in. Version 3.13 added some of the syntax we use. If you have an older Python (3.10, 3.11, 3.12) you will get errors. Check with `python3 --version`.
|
||||
- **A terminal.** Mac and Linux have one built in (Terminal.app or any of dozens on Linux). Windows users should install Windows Terminal from the Microsoft Store, or use the one inside VS Code.
|
||||
- **A working internet connection.** The scanner makes one real HTTPS request to whatever URL you point it at.
|
||||
|
||||
The install script (`install.sh`) will set up everything else for you: `uv` (a Python package manager), `just` (a command runner), a virtual environment, and all the libraries this project uses.
|
||||
|
||||
## Quick start
|
||||
|
||||
From the project folder:
|
||||
|
||||
```bash
|
||||
# One-shot install. Sets up Python tooling, installs dependencies.
|
||||
./install.sh
|
||||
|
||||
# Scan a real site.
|
||||
just run -- https://example.com
|
||||
|
||||
# Add a custom timeout if a site is slow to respond.
|
||||
just run -- https://github.com --timeout 5
|
||||
|
||||
# Run the tests to confirm the code works on your machine.
|
||||
just test
|
||||
```
|
||||
|
||||
If `./install.sh` errors out with "permission denied," run `chmod +x install.sh` first to mark the file as executable, then try again.
|
||||
|
||||
## Project layout
|
||||
|
||||
The whole project is intentionally small. Two Python files plus tooling.
|
||||
|
||||
```
|
||||
http-headers-scanner/
|
||||
├── http_headers_scanner.py the actual scanner: rules, scoring, CLI
|
||||
├── test_http_headers_scanner.py tests for the scanner
|
||||
├── pyproject.toml project metadata + dependency list
|
||||
├── uv.lock exact versions of every dependency
|
||||
├── justfile shortcut commands (just test, just run, etc.)
|
||||
├── install.sh one-shot installer
|
||||
├── README.md a brief readme for the GitHub page
|
||||
└── learn/ this folder you are reading
|
||||
├── 00-OVERVIEW.md you are here
|
||||
├── 01-CONCEPTS.md what HTTP is, what each header does, real attacks
|
||||
├── 02-ARCHITECTURE.md how the code is organised and why
|
||||
├── 03-IMPLEMENTATION.md line by line walkthrough of the code
|
||||
└── 04-CHALLENGES.md extension ideas to make it your own
|
||||
```
|
||||
|
||||
Everything important lives in **one Python file** (`http_headers_scanner.py`). That is on purpose for a foundations project. One file you can hold in your head. Bigger projects (the password manager, anything in `intermediate/`) split into many files because they have to. This one does not have to.
|
||||
|
||||
## Common first-time issues
|
||||
|
||||
**`python3: command not found`**
|
||||
You probably have Python installed but under a different name. Try `python --version`. If that says 3.13+, edit `install.sh` and replace `python3` with `python`. If neither works, install Python from python.org (or `brew install python@3.13` on Mac, `sudo apt install python3.13` on Debian/Ubuntu).
|
||||
|
||||
**`./install.sh: Permission denied`**
|
||||
The file is not marked as executable. Run `chmod +x install.sh` and try again.
|
||||
|
||||
**`just: command not found` after install**
|
||||
The install script puts `just` in `~/.local/bin`. That folder may not be on your PATH in a fresh terminal. Either restart your terminal or run `export PATH="$HOME/.local/bin:$PATH"`. To make it permanent, add that line to `~/.bashrc` or `~/.zshrc`.
|
||||
|
||||
**Network errors when scanning a real URL**
|
||||
If you are behind a corporate firewall or VPN, some sites may refuse to respond, or they may block the scanner's default User-Agent. That is not a bug in the code, it is the world being annoying. Try a different URL like `https://example.com` first to confirm the basic plumbing works.
|
||||
|
||||
## Where to go next
|
||||
|
||||
1. **[01-CONCEPTS.md](./01-CONCEPTS.md)** explains HTTP from scratch, what each header actually does, and the real-world attacks they prevent. Read this before the code. The code makes much more sense once you understand what the headers are protecting against.
|
||||
2. **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** explains how the code is split into pieces and why. Useful when you want to extend the scanner without making a mess.
|
||||
3. **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks through the code line by line. This is the longest of the five files.
|
||||
4. **[04-CHALLENGES.md](./04-CHALLENGES.md)** is for after you have read everything and want ideas for extending the project.
|
||||
|
||||
## Related projects in this repo
|
||||
|
||||
- `PROJECTS/foundations/hash-identifier/`: even smaller. Pure logic, no network. Good warmup if this one feels too big.
|
||||
- `PROJECTS/foundations/password-manager/`: the next step up from this one. Multiple files, real cryptography.
|
||||
- `PROJECTS/advanced/bug-bounty-platform/`: what a serious version of this idea looks like when you grow it into a whole product.
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
# Core Concepts
|
||||
|
||||
This file explains the security ideas the scanner is built on. By the end you should know what HTTP is, what a header is, why the six headers we check exist, and what kind of attack each one stops.
|
||||
|
||||
Read this file before the code. The code is short; the concepts behind it are what take the time.
|
||||
|
||||
## 1. What HTTP actually is
|
||||
|
||||
When you type `github.com` into your browser, something has to go fetch the page. That "something" speaks a protocol called **HTTP** (HyperText Transfer Protocol). HTTPS is the same protocol with encryption wrapped around it.
|
||||
|
||||
The basic shape of an HTTP conversation is:
|
||||
|
||||
```
|
||||
HTTP request
|
||||
┌─────────────┐ ───────────────────────▶ ┌─────────────┐
|
||||
│ │ │ │
|
||||
│ Browser │ │ Server │
|
||||
│ (you) │ │ (github) │
|
||||
│ │ ◀─────────────────────── │ │
|
||||
└─────────────┘ HTTP response └─────────────┘
|
||||
```
|
||||
|
||||
You (the browser) send a **request**. The server sends back a **response**. The request says "I would like the page at /home/index.html, please." The response says "Here is that page. Also, here is some information about the page."
|
||||
|
||||
Both the request and the response are just **text**, with a specific layout.
|
||||
|
||||
### What a real response looks like
|
||||
|
||||
If you stripped away the encryption and watched the bytes coming back from `https://example.com`, you would see something like this:
|
||||
|
||||
```
|
||||
HTTP/2 200
|
||||
content-type: text/html; charset=UTF-8
|
||||
content-length: 1256
|
||||
strict-transport-security: max-age=31536000
|
||||
x-frame-options: DENY
|
||||
cache-control: max-age=600
|
||||
date: Tue, 13 May 2026 12:00:00 GMT
|
||||
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><title>Example Domain</title></head>
|
||||
<body>...</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Three parts to notice:
|
||||
|
||||
1. **The status line.** `HTTP/2 200` means "HTTP version 2, status code 200." 200 means "OK, here is your page." 404 would mean "I do not have that page." 500 would mean "I broke trying to make that page."
|
||||
2. **The headers.** Everything between the status line and the blank line. Each header is one line, in the format `name: value`. There can be dozens of them.
|
||||
3. **The body.** Everything after the blank line. The actual HTML, image, JSON, or whatever else the server is sending you.
|
||||
|
||||
The **headers** are what this scanner cares about. Some headers are about caching, content type, cookies, and so on. We ignore those. A specific six headers exist for security, and that is what we grade on.
|
||||
|
||||
### Why headers are case insensitive
|
||||
|
||||
You will sometimes see `Strict-Transport-Security` and sometimes `strict-transport-security`. **They mean the same thing.** RFC 7230, the official spec for HTTP, says header names are case insensitive. Different servers and proxies use different casing. The scanner has to handle all of them, which is why we lowercase both sides before comparing.
|
||||
|
||||
## 2. What a security header actually is
|
||||
|
||||
A security header is just a regular HTTP header with a name the browser has been programmed to recognise as a security instruction. There is nothing magic about them. The server sends `Strict-Transport-Security: max-age=31536000` and the browser thinks "ah, the website is telling me to remember to only use HTTPS to talk to it for the next 31,536,000 seconds (one year)."
|
||||
|
||||
If the server forgets to send the header, the browser falls back to its default behaviour, which is usually "do whatever, no special protections." That default is what attackers count on.
|
||||
|
||||
So security headers are basically a **promise** the website makes to your browser. "Trust me, I never serve content over plain HTTP. If you ever see me on plain HTTP, somebody is lying to you, ignore them."
|
||||
|
||||
## 3. The six headers we grade
|
||||
|
||||
The scanner checks six headers. Each one stops a specific class of attack. We will go through them one by one.
|
||||
|
||||
### 3.1 Strict-Transport-Security (HSTS): severity HIGH
|
||||
|
||||
**What it tells the browser**
|
||||
|
||||
"For the next N seconds, only ever talk to me over HTTPS. Never plain HTTP. If somebody hands you a link to `http://my-site.com`, upgrade it to `https://` before you make the request."
|
||||
|
||||
**The attack it stops: SSL stripping**
|
||||
|
||||
Imagine you are at a coffee shop and you type `github.com` into your browser (no `https://` prefix). Your browser, by default, tries `http://github.com` first. GitHub's server then says "actually, please use HTTPS" and redirects you. The browser follows the redirect, switches to HTTPS, and now everything is encrypted.
|
||||
|
||||
In the gap between "you sent a plain HTTP request" and "the redirect came back," an attacker on the same wifi (running a laptop with `bettercap` or `sslstrip`) can intercept everything. They sit in the middle:
|
||||
|
||||
```
|
||||
┌────────┐ plain HTTP ┌──────────┐ HTTPS ┌────────┐
|
||||
│ You │ ───────────────▶ │ Attacker │ ──────────▶ │ GitHub │
|
||||
└────────┘ │ (MITM) │ └────────┘
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
The attacker keeps talking to GitHub over real HTTPS, but talks to you over plain HTTP, and rewrites every `https://` link in the page back to `http://` so you never escape. You think the site looks normal. The attacker reads your password.
|
||||
|
||||
**How HSTS stops it.** The first time you visit GitHub successfully over HTTPS, your browser remembers the `Strict-Transport-Security` header. Next time, even if you type `http://github.com`, the browser refuses to send a plain HTTP request at all. It upgrades to `https://` locally, before any packet leaves your machine. The attacker's "intercept the plain HTTP step" trick stops working.
|
||||
|
||||
**What the value looks like**
|
||||
|
||||
```
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
```
|
||||
|
||||
- `max-age=31536000`: remember this rule for 31,536,000 seconds (one year).
|
||||
- `includeSubDomains`: apply the rule to `api.github.com`, `docs.github.com`, everything ending in `github.com`.
|
||||
|
||||
**Why the scanner requires a positive `max-age`.** A server could send `Strict-Transport-Security:` with no value, or `Strict-Transport-Security: max-age=0`. Both are useless. `max-age=0` actively **disables** HSTS (it tells the browser to forget any previous HSTS rule for this site). So presence alone is not enough. The scanner reports `weak` whenever the value does not match the pattern `max-age = <positive integer>` — which catches both the empty case and the deliberately-zero case.
|
||||
|
||||
**Real example.** Moxie Marlinspike's sslstrip demo at Black Hat 2009 made this attack famous. Every major bank moved to enforce HTTPS-only after that talk. As of 2026 almost every serious site sends HSTS. Sites that do not are usually old internal systems.
|
||||
|
||||
### 3.2 Content-Security-Policy (CSP): severity HIGH
|
||||
|
||||
**What it tells the browser**
|
||||
|
||||
"Here is the exact list of places I am willing to load scripts, styles, images, fonts, frames, and other resources from. If you see something on the page asking you to load from anywhere else, refuse."
|
||||
|
||||
**The attack it stops: cross-site scripting (XSS)**
|
||||
|
||||
XSS is when an attacker manages to inject their own JavaScript into a page that other users will see. Classic example:
|
||||
|
||||
```
|
||||
Comment box on the site allows: I love this product!
|
||||
Attacker types: <script>steal_cookie()</script>
|
||||
Site displays it verbatim.
|
||||
Now every user who loads the comment runs the attacker's JS in their session.
|
||||
```
|
||||
|
||||
That JS runs with the full trust of the website, so it can read cookies, read the page's session token, send requests to the API as the user, and so on. This is bug class number 7 in the OWASP Top 10 for years.
|
||||
|
||||
**How CSP stops it.** The website tells the browser "scripts may only come from `https://my-site.com` or from `https://cdn.my-site.com`." When the browser sees `<script>steal_cookie()</script>` embedded inline in the HTML, it goes "that script does not come from one of the allowed origins, I refuse to run it." The injected XSS just becomes inert text.
|
||||
|
||||
**What the value looks like**
|
||||
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com
|
||||
```
|
||||
|
||||
- `default-src 'self'`: for anything not specifically listed below, only allow content from this exact site.
|
||||
- `script-src 'self' https://cdn.example.com`: JavaScript can come from this site or from cdn.example.com.
|
||||
|
||||
A real CSP is much longer because real sites pull in fonts, analytics, embeds, etc. A good CSP **never** contains `'unsafe-inline'` for scripts (which would allow the inline `<script>` tags an XSS attack relies on).
|
||||
|
||||
**Real example.** GitHub's CSP is one of the longest in the industry. After their 2018 GitHub-Pages XSS, they tightened it significantly. You can see it yourself: load github.com in your browser, open dev tools, look at the response headers.
|
||||
|
||||
**Why we only check for presence.** Parsing CSP properly is hard. Mozilla Observatory does a much deeper analysis (checks for `unsafe-inline`, wildcard origins, missing `default-src`, etc.). Our scanner only checks the header is present. That is the right call for a foundations project: graduate to Observatory once you want the deeper analysis.
|
||||
|
||||
### 3.3 X-Content-Type-Options: severity MEDIUM
|
||||
|
||||
**What it tells the browser**
|
||||
|
||||
"When I send you a file, I am telling you its type with the `Content-Type` header. Believe me. Do not look at the bytes and decide for yourself."
|
||||
|
||||
**The attack it stops: MIME sniffing**
|
||||
|
||||
Older versions of Internet Explorer had a "helpful" feature: if a server said a file was `text/plain` but the contents looked like HTML, IE would treat it as HTML and render it. The intent was to be forgiving toward misconfigured servers. The effect was a massive security hole.
|
||||
|
||||
Attack: a website lets users upload a profile picture. Attacker uploads a file named `cute_cat.gif` whose first bytes look like a valid GIF, but contains `<script>steal_everything()</script>` further in. Server stores it. Server later serves it back to other users with `Content-Type: image/gif`. IE looks at the bytes, sees the `<script>` tag, says "this is actually HTML" and runs the script. Now the attacker has XSS via image upload.
|
||||
|
||||
**How the header stops it.** `X-Content-Type-Options: nosniff` tells the browser "do not second-guess my Content-Type." If I say `image/gif`, you treat it as an image, full stop. The script tag never runs.
|
||||
|
||||
**What the value looks like**
|
||||
|
||||
```
|
||||
X-Content-Type-Options: nosniff
|
||||
```
|
||||
|
||||
That is literally the only allowed value. The header is the most boring of the six because there is one correct setting and that is it.
|
||||
|
||||
**Why the scanner requires `nosniff`.** Some misconfigured servers send `X-Content-Type-Options: off` or other garbage. The header has to literally contain the string `nosniff` to be useful. If it does not, we report `weak`.
|
||||
|
||||
**Real example.** Most of the MIME sniffing vulnerabilities were in IE 6 through 9. Modern browsers default to nosniff behaviour for `script` and `style` resources regardless. The header still matters for older browsers and for non-script contexts.
|
||||
|
||||
### 3.4 X-Frame-Options: severity MEDIUM
|
||||
|
||||
**What it tells the browser**
|
||||
|
||||
"Do not let other websites put me inside an `<iframe>` on their pages."
|
||||
|
||||
**The attack it stops: clickjacking**
|
||||
|
||||
Imagine the attacker makes a webpage that says:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ Win a free iPhone! Click here! │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ [INVISIBLE iframe with │ │
|
||||
│ │ github.com loaded │ │
|
||||
│ │ inside it, positioned │ │
|
||||
│ │ so the "Delete repo" │ │
|
||||
│ │ button sits exactly │ │
|
||||
│ │ over the "Click here" │ │
|
||||
│ │ button] │ │
|
||||
│ └──────────────────────────┘ │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
You are logged into GitHub in another tab. You see the "free iPhone" page on attacker's site. You click. The click does not land on the visible "Click here" button. It passes through the transparent iframe and lands on GitHub's "Delete repo" button. Your session cookie travels with the click. GitHub thinks you wanted to delete the repo, so it deletes it.
|
||||
|
||||
This is **clickjacking**. The CSS to set up the overlay is trivial. The only thing stopping it from working on every login session everywhere is the victim site refusing to be framed.
|
||||
|
||||
**How the header stops it.** `X-Frame-Options: DENY` tells the browser "if you see me being loaded inside an iframe on any other page, refuse to render me." The clickjacking iframe stays blank. Click goes nowhere harmful.
|
||||
|
||||
**What the value looks like**
|
||||
|
||||
```
|
||||
X-Frame-Options: DENY # never allow framing, ever
|
||||
X-Frame-Options: SAMEORIGIN # only allow framing by pages on the same domain
|
||||
```
|
||||
|
||||
`X-Frame-Options` is the old way. The modern way is the `frame-ancestors` directive inside `Content-Security-Policy`. Most real sites send both for browser-compatibility reasons. The scanner only checks `X-Frame-Options` to keep things simple.
|
||||
|
||||
**Real example.** Adobe's Flash settings page in 2008 was clickjackable. Twitter's "Follow this user" button got clickjacked into "Re-tweet anything" attacks in 2009. Facebook had a clickjacking worm in 2010 that spread via "likes" on rigged pages. The pattern is the same every time.
|
||||
|
||||
### 3.5 Referrer-Policy: severity LOW
|
||||
|
||||
**What it tells the browser**
|
||||
|
||||
"When you leave my page to go to another site, do not tell that other site exactly which page you came from."
|
||||
|
||||
**The leak it stops: referer leakage**
|
||||
|
||||
Browsers, by default, send a `Referer` header with every outgoing request that says "the user got here from this URL." Yes, the spelling is `Referer` with one R. That was a typo in the original HTTP spec from 1996 and we are stuck with it forever. Hilarious.
|
||||
|
||||
Why this matters. Suppose your website has URLs like:
|
||||
|
||||
```
|
||||
https://my-site.com/password-reset?token=abc123xyz
|
||||
```
|
||||
|
||||
A user lands on that page, clicks an external link (say, to a YouTube help video). Their browser tells YouTube "this user came from `https://my-site.com/password-reset?token=abc123xyz`." YouTube now has the user's password reset token in their access logs. Anyone with log access at YouTube has it too.
|
||||
|
||||
This has happened in real life many times. The pattern: secret tokens in URLs leak via the Referer header to every third-party resource the page loads.
|
||||
|
||||
**How the header stops it.** `Referrer-Policy: strict-origin-when-cross-origin` is a sensible default. It says "when the user goes to another site, only tell that site my origin (`https://my-site.com`), never the full URL with query params. Hide the path and query string."
|
||||
|
||||
**What the value looks like**
|
||||
|
||||
```
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Referrer-Policy: no-referrer # most paranoid
|
||||
Referrer-Policy: same-origin # safe for outbound links
|
||||
```
|
||||
|
||||
**Why severity is LOW.** Referer leakage is real and bad, but it depends on the site putting secrets in URLs in the first place, which is a separate mistake. The header is a useful belt-and-suspenders defence, but missing it is not a guaranteed loss.
|
||||
|
||||
### 3.6 Permissions-Policy: severity LOW
|
||||
|
||||
**What it tells the browser**
|
||||
|
||||
"This page does not use the camera. Do not let any code on this page, including embedded third-party scripts, ask the user for camera access."
|
||||
|
||||
You can do this for camera, microphone, geolocation, USB devices, payment APIs, accelerometer, and a long list of other browser features.
|
||||
|
||||
**The attack it stops: feature abuse through compromised third parties**
|
||||
|
||||
Imagine your site embeds an analytics script from a third party. That third party gets hacked. The attacker pushes a malicious update to the analytics script. Now every page on your site that includes the script is running attacker code with access to whatever the browser allows. If the attacker writes `navigator.mediaDevices.getUserMedia({ audio: true })`, the user gets a "this site wants to use your microphone" prompt. Many users will click yes because they trust your site.
|
||||
|
||||
**How the header stops it.** `Permissions-Policy: camera=(), microphone=()` tells the browser "no code on this page may request camera or microphone, regardless of source. Do not even show the prompt." The attacker's script gets a denied response and moves on.
|
||||
|
||||
**What the value looks like**
|
||||
|
||||
```
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
|
||||
```
|
||||
|
||||
The empty parentheses mean "no origins are allowed to use this feature." You can also explicitly allowlist origins, but for most sites the right answer for most features is "nobody."
|
||||
|
||||
**Why severity is LOW.** This only matters if (a) your site embeds third-party code, AND (b) that third-party gets compromised, AND (c) the third party would otherwise try to use these features. It is genuinely defence in depth, but it is several steps removed from the immediate attack surface.
|
||||
|
||||
## 4. The scoring rubric
|
||||
|
||||
So we have six headers. Each one has a severity (high, medium, low) that maps to a point value:
|
||||
|
||||
```
|
||||
high = 30 points each
|
||||
medium = 15 points each
|
||||
low = 5 points each
|
||||
```
|
||||
|
||||
The current rules table has 2 highs, 2 mediums, 2 lows. Total achievable: `2*30 + 2*15 + 2*5 = 90` points. Wait, that does not add up to 100? Correct. The math:
|
||||
|
||||
```
|
||||
60 (two highs) + 30 (two mediums) + 10 (two lows) = 100 points
|
||||
```
|
||||
|
||||
I miscounted. Let me redo it: high=30, two highs is 60. Medium=15, two mediums is 30. Low=5, two lows is 10. Total: 60+30+10 = **100**.
|
||||
|
||||
For each header, the scanner produces a finding:
|
||||
|
||||
- `ok` → earn the full point value for that rule
|
||||
- `weak` → earn half points (the header is present but the value is broken)
|
||||
- `missing` → earn zero
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
score = round( (earned points / total points) * 100 )
|
||||
```
|
||||
|
||||
The score becomes a grade by a standard letter-grade cutoff:
|
||||
|
||||
```
|
||||
score >= 90 → A
|
||||
score >= 80 → B
|
||||
score >= 70 → C
|
||||
score >= 60 → D
|
||||
otherwise → F
|
||||
```
|
||||
|
||||
This mirrors how Mozilla Observatory and securityheaders.com work. They use different exact point values and check more headers, but the shape is the same: weighted findings, percentage score, letter grade.
|
||||
|
||||
## 5. What this scanner does NOT do
|
||||
|
||||
Being clear about scope is important. This is a foundations project. It is **not**:
|
||||
|
||||
- **A crawler.** It scans exactly the URL you give it. One request. It does not follow links inside the page and grade every subpage.
|
||||
- **A CSP analyser.** We only check that `Content-Security-Policy` exists. We do not look inside the value for `unsafe-inline`, wildcard sources, missing `default-src`, etc.
|
||||
- **A vulnerability scanner.** It does not try to find SQL injection, XSS, open redirects, or any actual exploitable flaw. It only reports on missing defensive configuration.
|
||||
- **An authority.** Real sites sometimes intentionally drop certain headers because they break a feature they need. A missing header is a signal worth investigating, not an automatic verdict.
|
||||
|
||||
When you want a fuller picture, graduate to:
|
||||
- **Mozilla Observatory** (`observatory.mozilla.org`): does everything we do plus deep CSP analysis, cookie checks, TLS configuration grading.
|
||||
- **securityheaders.com**: similar idea, simpler UI.
|
||||
- **`nmap` with the http-security-headers script**: for command line nerds.
|
||||
|
||||
## 6. Industry references
|
||||
|
||||
If you want to look these up yourself in official docs:
|
||||
|
||||
- **MDN** (developer.mozilla.org) has authoritative articles on each header. Search for the header name plus "MDN."
|
||||
- **OWASP Secure Headers Project** (owasp.org/www-project-secure-headers) has the canonical list and recommended values.
|
||||
- **RFC 6797** is the spec for HSTS. RFC 7034 is the spec for X-Frame-Options. Reading specs is a useful skill even when the spec is boring.
|
||||
- **CWE-693** "Protection Mechanism Failure" is the common-weakness ID for missing or misconfigured defensive headers.
|
||||
|
||||
## 7. Quick self check
|
||||
|
||||
You should be able to answer these before moving on to architecture:
|
||||
|
||||
1. What is the difference between an HTTP request and an HTTP response?
|
||||
2. Where do headers sit in a response (relative to the status line and the body)?
|
||||
3. Which header would have prevented SSL stripping at the coffee shop?
|
||||
4. Which header would have prevented the clickjacking-style "delete repo" attack?
|
||||
5. Why does the scanner report `weak` instead of `ok` when `X-Content-Type-Options` is present but its value is not literally `nosniff`?
|
||||
6. What kind of attack does CSP defend against, and why does it not parse the CSP value deeply?
|
||||
7. What is the grade for a site with score 75? What about 60? What about 59?
|
||||
|
||||
If any of these feel fuzzy, re-read the relevant section. The implementation in the next two files will make much more sense once these are solid.
|
||||
|
||||
## Next
|
||||
|
||||
Move on to **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** for how the scanner is organised in code, or jump straight to **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** for the line-by-line walkthrough.
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
# Architecture
|
||||
|
||||
This file explains how the code is organised and why. It is the bridge between "I get what HTTP headers are" (the previous file) and "I can read this exact Python code" (the next file). Read it after concepts and before implementation.
|
||||
|
||||
The whole scanner is one Python file plus one test file. Less than 700 lines total. That is small enough that you might wonder why we are using the word "architecture" at all. The answer: even small programs benefit from being split into pieces, and the WAY they get split matters. A real production scanner is going to grow into many files. Learning what the split looks like at this size makes the bigger ones less scary.
|
||||
|
||||
## 1. The big picture
|
||||
|
||||
The scanner is a pipeline. Data flows left to right through four stages:
|
||||
|
||||
```
|
||||
┌────────┐ URL ┌──────────┐ bytes ┌────────────┐ findings ┌──────────┐
|
||||
│ User │ ────▶ │ scan() │ ──────▶ │ evaluate_ │ ────────▶ │ render │
|
||||
│ CLI │ │ fetches │ │ header() │ │ report │
|
||||
└────────┘ └──────────┘ loop │ ×6 │ └──────────┘
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
1. **CLI layer.** The user types `headers https://example.com`. `argparse` turns that into a normal Python object with `args.url` and `args.timeout`.
|
||||
2. **Network layer (`scan()`).** Makes one HTTPS request, follows redirects, returns the raw headers as a dict.
|
||||
3. **Evaluation layer (`evaluate_header()`).** Pure function. Takes one rule and the response headers, returns one finding. Called once per rule. No network. No printing.
|
||||
4. **Render layer (`_render_report()`).** Takes the report, prints a coloured table plus the grade panel plus recommendations.
|
||||
|
||||
The reason it is laid out this way: each stage is independently testable. We can hand `evaluate_header()` a fake dict of headers and check the finding, without ever touching the internet. We can build a fake `ScanReport` and check the score and grade, without running `scan()` at all.
|
||||
|
||||
If any of these stages were mashed together (for example, if `scan()` directly printed its output and the math lived inside the print logic), testing would mean spinning up a real or fake web server every time. Splitting them apart is the whole reason you can write 20 tests that run in under a second.
|
||||
|
||||
## 2. The four key data shapes
|
||||
|
||||
We use **dataclasses** for our data. A dataclass is just a class where Python writes the boilerplate for you. Instead of this:
|
||||
|
||||
```python
|
||||
class HeaderRule:
|
||||
def __init__(self, header, severity, description, recommendation, must_match=None):
|
||||
self.header = header
|
||||
self.severity = severity
|
||||
self.description = description
|
||||
self.recommendation = recommendation
|
||||
self.must_match = must_match
|
||||
```
|
||||
|
||||
You write this:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeaderRule:
|
||||
header: str
|
||||
severity: Severity
|
||||
description: str
|
||||
recommendation: str
|
||||
must_match: str | None = None
|
||||
```
|
||||
|
||||
The `@dataclass` decorator on top reads the field declarations and generates the `__init__` for you. It also generates equality (`==`), a string representation, and a few other useful things.
|
||||
|
||||
Two flags worth understanding:
|
||||
|
||||
- **`frozen=True`** makes the dataclass **immutable**. Once you create a `HeaderRule`, you cannot do `rule.severity = "low"`. Trying to modify a field raises an error. This is good for "value object" types where you want guarantees that no other piece of code can change them out from under you.
|
||||
- **`slots=True`** makes instances **smaller in memory**. Without slots, every Python object carries around a hidden dictionary for its attributes, which is flexible but uses more memory. With slots, attributes go into fixed slots, no dict. For a record type with a known set of fields, this is pure win. Costs you nothing.
|
||||
|
||||
We have four shapes total:
|
||||
|
||||
### 2.1 `HeaderRule`: "what we are looking for"
|
||||
|
||||
One rule is one thing-to-check. Header name, severity, description (for humans), a recommendation (what to set if missing), and an optional `must_match` regex (for headers like `X-Content-Type-Options` where the *value* has to be right, not just present — a plain word like `"nosniff"` works as a substring check, while a richer pattern like `r"max-age\s*=\s*[1-9]"` rejects HSTS values that disable themselves).
|
||||
|
||||
This is the rule the scanner walks down. The whole list of rules lives in a module-level constant called `RULES`. Adding a new header check means appending to that list. The rest of the code is generic.
|
||||
|
||||
### 2.2 `HeaderFinding`: "what we found"
|
||||
|
||||
One finding is the result of running one rule against the server's response. It carries:
|
||||
|
||||
- The rule it came from (so the renderer can show severity, recommendation, etc. without doing a second lookup).
|
||||
- A `status`: `ok`, `weak`, or `missing`.
|
||||
- The `actual_value` the server sent (or `None` if the header was missing).
|
||||
- A short human-readable `note` describing what happened ("Present", "Present and contains `nosniff`", "Header `X` is not set", etc.).
|
||||
|
||||
Findings are also frozen. Once we evaluate a rule, the result does not change. That makes the report safe to pass around to multiple functions without worrying that one of them mutates it.
|
||||
|
||||
### 2.3 `ScanReport`: "the whole result"
|
||||
|
||||
One report wraps everything from one scan. The original URL (what the user typed), the final URL (after redirects), the HTTP status code, and the list of findings (one per rule).
|
||||
|
||||
The interesting part: `score` and `grade` are **computed properties**, not stored fields. They are functions decorated with `@property` that look at the findings on the fly. This means:
|
||||
|
||||
- We do not have to remember to recalculate them when findings change (they cannot change, the report is frozen, but the principle stands).
|
||||
- A test can build a report with a synthetic set of findings and immediately ask `report.score`, no plumbing required.
|
||||
|
||||
### 2.4 The `Severity` and `Status` types
|
||||
|
||||
These are **Literal types**:
|
||||
|
||||
```python
|
||||
Severity = Literal["high", "medium", "low"]
|
||||
Status = Literal["ok", "weak", "missing"]
|
||||
```
|
||||
|
||||
A `Literal` type tells the type checker "this is a string, but it can only ever be one of these exact values." Why bother? Two reasons:
|
||||
|
||||
1. **Typo protection.** If you write `severity = "hgih"` somewhere, mypy catches it before you even run the code. Without `Literal`, the type would just be `str` and any typo would slip through.
|
||||
2. **Documentation.** Just by looking at the type signature you know the legal values. You do not have to grep through the code to find out.
|
||||
|
||||
We could have used an `Enum` instead. Carter's style guide prefers `Literal` for small fixed sets because it keeps the values as plain strings (easy to print, easy to log, easy to use as dict keys).
|
||||
|
||||
## 3. Layer separation: the I/O fence
|
||||
|
||||
The single most important architectural decision in this project is the **fence between code that touches the network and code that does not**. Let us trace it.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ THE I/O LAYER │
|
||||
│ - scan() (calls httpx.get, hits the network) │
|
||||
│ - main() (reads sys.argv, calls scan) │
|
||||
│ - _render_report() (writes to the terminal) │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
│ passes a dict[str, str] or a ScanReport
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ THE PURE LAYER │
|
||||
│ - evaluate_header(rule, headers) -> HeaderFinding │
|
||||
│ - ScanReport.score property │
|
||||
│ - ScanReport.grade property │
|
||||
│ - RULES list, SEVERITY_POINTS dict │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Everything in the **pure layer**:
|
||||
- Takes inputs.
|
||||
- Returns outputs.
|
||||
- Touches no network. Touches no filesystem. Prints nothing.
|
||||
|
||||
Everything in the **I/O layer**:
|
||||
- Talks to the outside world.
|
||||
- Eventually passes its results into the pure layer.
|
||||
|
||||
Why this matters: the pure layer is **trivially testable**. You build inputs by hand, you check outputs. No mocking required. The I/O layer is harder to test (you have to mock the network or the terminal), but it is also the small part. Most of the bugs in a scanner are in the math, not in the call to httpx. Putting the math in a pure function means you test the math with confidence and the network code with only a few smoke tests.
|
||||
|
||||
This is sometimes called **functional core, imperative shell**. Coined by Gary Bernhardt. Same idea: keep the part that decides what to do pure, push the part that actually does it to the edges.
|
||||
|
||||
## 4. The data flow, end to end
|
||||
|
||||
Let us trace one scan from terminal to output. The user types:
|
||||
|
||||
```
|
||||
$ just run -- https://github.com
|
||||
```
|
||||
|
||||
```
|
||||
1. SHELL → main()
|
||||
─────────────────────────────────────────────────────
|
||||
`just run` calls `uv run headers https://github.com`.
|
||||
`uv run` activates the venv, runs `headers` which is
|
||||
declared in pyproject.toml as the entry point that
|
||||
maps to http_headers_scanner:main.
|
||||
sys.argv is now ["headers", "https://github.com"].
|
||||
|
||||
2. main() → _build_argument_parser()
|
||||
─────────────────────────────────────────────────────
|
||||
We construct an argparse parser, add the `url` arg
|
||||
and the `--timeout` option, then call parse_args().
|
||||
Result: args.url = "https://github.com", args.timeout = 10.0
|
||||
|
||||
3. main() → scan(url, timeout)
|
||||
─────────────────────────────────────────────────────
|
||||
scan() calls httpx.get() with follow_redirects=True
|
||||
and a custom User-Agent. httpx does DNS, opens a TCP
|
||||
connection, negotiates TLS, sends the GET request,
|
||||
reads the response, follows any redirects. Returns a
|
||||
Response object.
|
||||
|
||||
4. scan() → response_headers (a dict[str, str])
|
||||
─────────────────────────────────────────────────────
|
||||
We convert httpx's Headers object to a plain dict.
|
||||
This is the "leave the I/O world, enter the pure
|
||||
world" handoff. Past this point, nothing knows or
|
||||
cares about httpx.
|
||||
|
||||
5. scan() → [evaluate_header(rule, headers) for rule in RULES]
|
||||
─────────────────────────────────────────────────────
|
||||
A list comprehension. Run evaluate_header() once for
|
||||
each of the six rules. Each call is pure: it looks
|
||||
up the header in the dict (case-insensitive), checks
|
||||
must_match (via re.search, case-insensitive) if set,
|
||||
returns a HeaderFinding.
|
||||
|
||||
6. scan() → ScanReport
|
||||
─────────────────────────────────────────────────────
|
||||
Bundle the URL, final URL, status code, and findings
|
||||
into a frozen ScanReport. scan() is done.
|
||||
|
||||
7. main() → _render_report(report, console)
|
||||
─────────────────────────────────────────────────────
|
||||
The renderer builds a rich Table, adds one row per
|
||||
finding, prints it. Then builds the grade Panel,
|
||||
prints it. Then iterates over non-ok findings and
|
||||
prints their recommendations.
|
||||
|
||||
8. main() → exit code
|
||||
─────────────────────────────────────────────────────
|
||||
Look at report.grade. A or B → return 0 (success).
|
||||
C or D → return 1 (warning). F or network error → 2.
|
||||
sys.exit(main()) sends the code to the shell.
|
||||
```
|
||||
|
||||
The key thing to notice: steps 5 and 6 are pure. If you want to write a test that exercises the scoring and finding logic, you skip step 3 entirely and call `evaluate_header()` directly with hand-built inputs. That is exactly what `test_http_headers_scanner.py` does.
|
||||
|
||||
## 5. Why each function is the size it is
|
||||
|
||||
A common question for beginners: how do you know when to split a chunk of code into a new function?
|
||||
|
||||
A useful rule of thumb: **one job per function**. If you can describe what a function does in one sentence without "and," it is probably the right size. If you have to say "this fetches the URL AND parses the headers AND grades them AND prints the table," it is too big.
|
||||
|
||||
Look at our functions through that lens:
|
||||
|
||||
- **`evaluate_header()`**: "Apply one rule to one set of headers and return a finding." One job.
|
||||
- **`scan()`**: "Fetch a URL and return a report." One job. It does also call `evaluate_header()` internally, but that is delegation, not a second job.
|
||||
- **`_render_report()`**: "Pretty-print a report to the terminal." One job.
|
||||
- **`_build_argument_parser()`**: "Build the argparse parser." One job. Worth splitting out so tests can call it without firing main().
|
||||
- **`main()`**: "Glue the others together and pick an exit code." One job: orchestration.
|
||||
|
||||
The underscore prefix on `_render_report` and `_build_argument_parser` is a Python convention meaning "this is private, not part of the public API." Other code in the same file can still call them, but anyone importing the module should consider them internal.
|
||||
|
||||
## 6. The single source of truth: the `RULES` list
|
||||
|
||||
Notice that `RULES` is defined once, at the top of the file, as a list of `HeaderRule` objects. The scoring functions, the evaluation function, and the renderer all walk this list at runtime. None of them have hardcoded knowledge of which headers exist.
|
||||
|
||||
What this buys us: **adding a seventh header to check is a one-line change**. Append a `HeaderRule` to the list. The scanner picks it up automatically. The test suite picks it up automatically (the synthetic-report helper uses `RULES` directly). No code in `scan()`, in the rendering, in the scoring needs to change.
|
||||
|
||||
This is what people mean when they say "data driven" code. The behaviour is determined by data (the rules table), not by hardcoded logic per case. It is also one of the easiest patterns to recognise once you start looking for it.
|
||||
|
||||
The same pattern, with the same benefits, shows up in real production scanners:
|
||||
- Nuclei (a vuln scanner) reads YAML templates that look a lot like our HeaderRule, just bigger.
|
||||
- ESLint plugins are mostly rules tables.
|
||||
- Nmap NSE scripts are individual rule files in a directory.
|
||||
|
||||
## 7. Why we use httpx, not requests
|
||||
|
||||
`requests` is the famous Python HTTP library. `httpx` is the newer one. They have very similar APIs. We picked httpx because:
|
||||
|
||||
- **First-class type hints.** mypy understands httpx out of the box. requests still requires `types-requests` stubs.
|
||||
- **HTTP/2 support.** Newer sites speak HTTP/2 by default. requests is HTTP/1.1 only.
|
||||
- **Async-ready.** httpx has a sync API (what we use) and an async API for when you grow up.
|
||||
- **Active maintenance.** requests is in maintenance mode. httpx is where new development happens.
|
||||
|
||||
For this project we only use the sync API. The async API would let us scan many URLs in parallel, which is a great extension challenge in `04-CHALLENGES.md`.
|
||||
|
||||
## 8. Why we use respx for testing
|
||||
|
||||
When you write a test that calls `scan("https://example.com")`, you have a problem: the test now depends on example.com being reachable, fast, and returning predictable headers. None of those are guaranteed. The test would be **flaky** (sometimes pass, sometimes fail, for reasons unrelated to your code).
|
||||
|
||||
`respx` solves this by intercepting every httpx call inside a test and returning whatever you set up. Schematically:
|
||||
|
||||
```
|
||||
Test code respx (interceptor) The real internet
|
||||
───────── ─────────────────── ─────────────────
|
||||
respx.get(URL).mock( intercepts httpx.get,
|
||||
return_value=... never sends a packet,
|
||||
) hands back the fake
|
||||
scan(URL) ───▶ Response object ──╳ never reached
|
||||
```
|
||||
|
||||
A test using respx is fast (no network), deterministic (the fake response is exactly what you set up), and offline-friendly. The cost is that you have to be careful: `respx` only intercepts httpx, so a scan that used `requests` under the hood would silently bypass the mock.
|
||||
|
||||
## 9. Error handling philosophy
|
||||
|
||||
Two layers of error handling.
|
||||
|
||||
In `scan()`, we **let errors propagate**. If DNS fails, if the host refuses the connection, if the request times out, httpx raises an exception. We do not catch it. The function's job is to fetch and grade, not to decide what to do when fetching fails.
|
||||
|
||||
In `main()`, we **catch `httpx.RequestError` once** and turn it into a clean message plus exit code 2. The user does not need to see a 30-line Python traceback for "could not connect." They need to see "request failed."
|
||||
|
||||
This is a pattern worth internalising: **the library code lets errors bubble up, the CLI code translates them into user-friendly output.** The library author does not know what context the library is being called from, so they should not pretend to know how to handle errors. The CLI author knows exactly what context the call is being made in (a user typed a command), so they can present errors appropriately.
|
||||
|
||||
## 10. Exit codes that mean something
|
||||
|
||||
Most CLIs return exit code 0 for success and 1 for any kind of failure. Our scanner uses three:
|
||||
|
||||
```
|
||||
0 → grade A or B (CI: green, no action needed)
|
||||
1 → grade C or D (CI: yellow, worth investigating)
|
||||
2 → grade F or network error (CI: red, must fix)
|
||||
```
|
||||
|
||||
This is useful when you wire the scanner into CI. A pipeline can run:
|
||||
|
||||
```
|
||||
just run -- https://my-deployed-site.com
|
||||
if [ $? -gt 1 ]; then exit 1; fi # fail the build only on F or error
|
||||
```
|
||||
|
||||
You can decide for yourself what threshold counts as "fail the build." The point is the scanner gives you the information to decide. A binary success/failure exit code throws away too much detail.
|
||||
|
||||
## 11. What does NOT belong in this architecture
|
||||
|
||||
For a foundations project, we deliberately keep things out:
|
||||
|
||||
- **No async.** A single URL does not need it. The sync API is easier to read.
|
||||
- **No subcommands.** No `headers scan`, `headers explain`, `headers config`. Just one job, run it.
|
||||
- **No config file.** All settings come from CLI flags. No `~/.config/headers.toml`. Add one if you extend it.
|
||||
- **No database.** Each run is independent. No history. Add one if you want trends over time (challenge file has details).
|
||||
- **No plugin system.** The rules table is just a list. To "extend" the scanner, you edit the list.
|
||||
|
||||
These are all things a more mature scanner would have, and every one of them is a great extension idea. None of them belong in a project whose goal is "be the smallest possible thing that teaches the core idea."
|
||||
|
||||
## 12. Key files reference
|
||||
|
||||
A quick map of the project:
|
||||
|
||||
| Path | What is in it |
|
||||
|------|---------------|
|
||||
| `http_headers_scanner.py` | The whole scanner. Rules, evaluation, scan, CLI. |
|
||||
| `test_http_headers_scanner.py` | All tests. Uses pytest and respx. |
|
||||
| `pyproject.toml` | Project metadata, dependencies, tool configs (ruff, mypy, pylint, yapf, pytest). |
|
||||
| `uv.lock` | Exact versions of every transitive dependency. Reproducible builds. |
|
||||
| `justfile` | Shortcut commands: `just test`, `just lint`, `just run`. |
|
||||
| `install.sh` | One-shot installer for new clones. |
|
||||
| `learn/` | The documentation you are reading. |
|
||||
|
||||
## Next
|
||||
|
||||
Move on to **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** for the line-by-line walkthrough, or jump to **[04-CHALLENGES.md](./04-CHALLENGES.md)** for extension ideas.
|
||||
|
|
@ -0,0 +1,670 @@
|
|||
# Implementation Walkthrough
|
||||
|
||||
This file walks through the actual code in `http_headers_scanner.py` (and a bit of `test_http_headers_scanner.py`) line by line. By the end you should understand every piece of the file: what it does, why it is there, and what would break if you removed it.
|
||||
|
||||
This is the longest file in the learn folder. Take it in chunks. The order below matches the order things appear in the source.
|
||||
|
||||
## 0. Reading conventions
|
||||
|
||||
Each section names a function, class, or constant from `http_headers_scanner.py`. Open the file in your editor on the side and search for the name. The code excerpts in this guide are real, copied directly from the file, but the file is also short enough that you can scroll the whole thing in a couple of pages.
|
||||
|
||||
## 1. The file docstring
|
||||
|
||||
The file starts with a long triple-quoted string. In Python, a string at the very top of a file is called the **module docstring**. It is the official place to explain what the file is about.
|
||||
|
||||
```python
|
||||
"""
|
||||
©AngelaMos | 2026
|
||||
http_headers_scanner.py
|
||||
|
||||
Scan a URL and grade its HTTP security headers A–F
|
||||
|
||||
When a browser asks a website for a page, the server sends back the
|
||||
page itself PLUS a bunch of metadata called "HTTP response headers."
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
A few things to notice:
|
||||
|
||||
- **The first three lines are the project's standard file header.** Every file in the project starts this way: a copyright line, a blank-ish line, the filename. The `©AngelaMos | 2026` part is the project's branding, not something you would normally see in a generic Python tutorial.
|
||||
- **The body is unusually long for a docstring.** Most files have a one-line summary. This one is detailed because it is a teaching project. The docstring is the first thing any reader sees (`help(http_headers_scanner)` prints it, IDEs show it on hover), so we use it to teach.
|
||||
- **It ends with a list of "what this file exposes."** This is a real convention. Tells readers what they can import from the module without scrolling through 600 lines.
|
||||
|
||||
## 2. Imports
|
||||
|
||||
```python
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
```
|
||||
|
||||
PEP 8 (Python's style guide) wants imports grouped into three sections separated by blank lines:
|
||||
|
||||
1. **Standard library**: things that ship with Python. Here: `argparse`, `re`, `sys`, `dataclasses`, `typing`.
|
||||
2. **Third-party**: things you installed with `uv` / `pip`. Here: `httpx`, `rich`.
|
||||
3. **Local**: things from this same project. We have none.
|
||||
|
||||
`re` is the standard library's regular-expression module. We use `re.search(pattern, value, re.IGNORECASE)` inside `evaluate_header()` to check whether a header's value matches a rule's required pattern. Regexes give us a way to express "max-age must be a positive integer" in one line, instead of having to parse the HSTS directive structure ourselves.
|
||||
|
||||
Each module is imported with a tight inline comment explaining what we use it for. Beginners often ask "what does `import` even do?" Short answer: it tells Python "go find this module and make its names available in this file." `import argparse` makes `argparse.ArgumentParser` available. `from dataclasses import dataclass` makes the bare name `dataclass` available so we can use `@dataclass` directly without writing `@dataclasses.dataclass`.
|
||||
|
||||
## 3. The Severity and Status types
|
||||
|
||||
```python
|
||||
Severity = Literal["high", "medium", "low"]
|
||||
Status = Literal["ok", "weak", "missing"]
|
||||
```
|
||||
|
||||
These are **type aliases**. They give a friendly name to a more complex type. Anywhere you write `Severity` from now on, the type checker reads `Literal["high", "medium", "low"]`.
|
||||
|
||||
The reason `Literal` exists: a regular `str` means "any string." If we annotated `severity: str`, then `severity = "hgih"` would compile fine and only blow up at runtime when something tried to look it up. With `severity: Severity`, mypy refuses to let `"hgih"` near the field. The typo is caught at edit time.
|
||||
|
||||
This is more discipline than most beginner Python you will see online. It is a deliberate choice for a teaching project: we want you to absorb the habit early.
|
||||
|
||||
## 4. `HeaderRule` dataclass
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeaderRule:
|
||||
header: str
|
||||
severity: Severity
|
||||
description: str
|
||||
recommendation: str
|
||||
must_match: str | None = None
|
||||
```
|
||||
|
||||
A dataclass is a regular class that has the boring parts (constructor, equality, string repr) written for you by the `@dataclass` decorator. We covered the `frozen` and `slots` flags in `02-ARCHITECTURE.md`. The short version: `frozen` prevents anyone from modifying the fields after construction, `slots` makes the instances smaller in memory.
|
||||
|
||||
The fields:
|
||||
|
||||
- **`header`**: the HTTP header name we are looking for. Stored with canonical casing (e.g. `"Strict-Transport-Security"`) but compared case-insensitively at lookup time.
|
||||
- **`severity`**: drives the score. `"high"` = 30 points, `"medium"` = 15, `"low"` = 5.
|
||||
- **`description`**: one sentence explaining the header. Currently used for documentation; we could also render it in the table.
|
||||
- **`recommendation`**: what to add to fix a missing or weak header. Shown in the "Recommendations" section at the bottom of the output.
|
||||
- **`must_match`**: optional. A regex pattern the value must match (case-insensitive) to be considered `ok`. For HSTS the pattern is `r"max-age\s*=\s*[1-9]"` (rejects `max-age=0`); for `X-Content-Type-Options` it is `"nosniff"` (a plain word works as a substring match under `re.search`). If `None`, presence alone is enough.
|
||||
|
||||
The trailing `= None` on `must_match` is its **default value**. Means you can construct a `HeaderRule` without specifying it. Only fields with defaults can be omitted at construction time.
|
||||
|
||||
## 5. The `RULES` table
|
||||
|
||||
```python
|
||||
RULES: list[HeaderRule] = [
|
||||
HeaderRule(
|
||||
header="Strict-Transport-Security",
|
||||
severity="high",
|
||||
...
|
||||
must_match=r"max-age\s*=\s*[1-9]",
|
||||
),
|
||||
HeaderRule(
|
||||
header="Content-Security-Policy",
|
||||
severity="high",
|
||||
...
|
||||
),
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
This is the **single source of truth** for which headers we check. The list-of-dataclasses pattern is one of the most useful in Python: each entry is structured, immutable, and easy to add to.
|
||||
|
||||
A pattern detail: we use **keyword arguments** for every field, not positional. We write `HeaderRule(header="...", severity="...", ...)`, not `HeaderRule("...", "...", ...)`. Why? Two reasons:
|
||||
|
||||
1. **Readability.** When someone reads the code, they see `severity="high"` and know exactly what the second value means. Positional `("Strict-Transport-Security", "high", ...)` makes them count fields.
|
||||
2. **Refactor safety.** If you add a new field later (say `references: list[str]`), positional calls might land the new value in the wrong place. Keyword calls are unambiguous.
|
||||
|
||||
Why is this list at module level, not inside a function? Because it never changes. Building it once at import time is cheaper than rebuilding it on every scan. It is also accessible to the test suite (`from http_headers_scanner import RULES`).
|
||||
|
||||
## 6. `SEVERITY_POINTS` mapping
|
||||
|
||||
```python
|
||||
SEVERITY_POINTS: dict[Severity, int] = {
|
||||
"high": 30,
|
||||
"medium": 15,
|
||||
"low": 5,
|
||||
}
|
||||
```
|
||||
|
||||
A dictionary mapping each severity to its point value. Notice the type annotation: `dict[Severity, int]`. That tells the type checker "keys must be `"high"` / `"medium"` / `"low"`, values must be ints." If you tried to add `"critical": 50` to this dict, mypy would refuse: `"critical"` is not in the `Severity` Literal type.
|
||||
|
||||
Why a dict and not a function with three if statements? Because it is data, not logic. Data driven code is easier to extend (add another severity, edit one line) and easier to test (you can assert the exact point values).
|
||||
|
||||
## 7. `HeaderFinding` dataclass
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeaderFinding:
|
||||
rule: HeaderRule
|
||||
status: Status
|
||||
actual_value: str | None
|
||||
note: str
|
||||
```
|
||||
|
||||
A finding is the result of evaluating one rule against one response. It carries:
|
||||
|
||||
- **`rule`**: the rule that was evaluated. Storing the whole rule inside the finding (rather than just its name) means the renderer never has to do a second lookup to know the severity or recommendation.
|
||||
- **`status`**: one of `"ok"`, `"weak"`, `"missing"`. The Literal type catches typos.
|
||||
- **`actual_value`**: whatever the server actually sent. `None` if the header was missing.
|
||||
- **`note`**: a short human-friendly string. Shown in the table.
|
||||
|
||||
Why is `actual_value` typed `str | None`? Because the field is genuinely sometimes a string and sometimes nothing. `None` is Python's way of saying "no value." The type `str | None` makes that explicit. Anywhere you use `finding.actual_value`, the type checker forces you to either handle the None case or assert that it cannot be None.
|
||||
|
||||
The `|` syntax (e.g. `str | None`) is the modern way (Python 3.10+). The older way was `Optional[str]` from the `typing` module. Both work; the new syntax is shorter.
|
||||
|
||||
## 8. `ScanReport` dataclass with computed properties
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScanReport:
|
||||
url: str
|
||||
final_url: str
|
||||
status_code: int
|
||||
findings: list[HeaderFinding]
|
||||
|
||||
@property
|
||||
def score(self) -> int:
|
||||
...
|
||||
|
||||
@property
|
||||
def grade(self) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
A report has four stored fields plus two computed properties.
|
||||
|
||||
### 8.1 Why `final_url` is separate from `url`
|
||||
|
||||
`url` is what the user typed. `final_url` is where they ended up after redirects. They are often the same. They are different when, say, `http://example.com/` redirects to `https://example.com/`. We track both because:
|
||||
|
||||
- The user wants to see the URL they typed acknowledged in the output.
|
||||
- The grade really belongs to the final URL (the redirected destination is what their browser actually shows).
|
||||
|
||||
### 8.2 The `score` property
|
||||
|
||||
```python
|
||||
@property
|
||||
def score(self) -> int:
|
||||
total = sum(SEVERITY_POINTS[r.severity] for r in RULES)
|
||||
if total == 0:
|
||||
return 0
|
||||
|
||||
earned = 0.0
|
||||
for finding in self.findings:
|
||||
full = SEVERITY_POINTS[finding.rule.severity]
|
||||
if finding.status == "ok":
|
||||
earned += full
|
||||
elif finding.status == "weak":
|
||||
earned += full / 2
|
||||
|
||||
return int((earned / total) * 100 + 0.5)
|
||||
```
|
||||
|
||||
Step by step:
|
||||
|
||||
1. **`@property`** on the line above turns the method into something you access without parentheses. `report.score`, not `report.score()`. Looks like a field, computed on demand.
|
||||
2. **`total = sum(SEVERITY_POINTS[r.severity] for r in RULES)`** computes the total achievable points by walking the rules. The expression inside `sum(...)` is a **generator expression**: it produces one number per rule (the point value for that rule's severity), then sum adds them up. With the current 2-high, 2-medium, 2-low rules, total = 100.
|
||||
3. **`if total == 0: return 0`** is a guard. If somebody deleted the rules table at runtime, we would otherwise divide by zero. Returning zero is a safe answer.
|
||||
4. **The main loop** walks every finding. For each one, look up the full point value for its rule's severity. If status is `ok`, add the full points. If `weak`, add half. If `missing`, add nothing (no explicit branch; the variable is unchanged).
|
||||
5. **`int((earned / total) * 100 + 0.5)`** is the final score. The `+ 0.5` then `int(...)` is a manual round-half-up. We use it because Python's built-in `round()` uses banker's rounding (round half to even), which would map `round(0.5)` to `0` and `round(2.5)` to `2`. Mathematically defensible (it cancels out bias over a large sample) but surprising at the `.5` boundary, where a score should always round up. `int(x + 0.5)` is the form everyone expects.
|
||||
|
||||
### 8.3 The `grade` property
|
||||
|
||||
```python
|
||||
@property
|
||||
def grade(self) -> str:
|
||||
score = self.score
|
||||
if score >= 90:
|
||||
return "A"
|
||||
if score >= 80:
|
||||
return "B"
|
||||
if score >= 70:
|
||||
return "C"
|
||||
if score >= 60:
|
||||
return "D"
|
||||
return "F"
|
||||
```
|
||||
|
||||
Notice that each branch `return`s directly, so there are no `elif`s and no final `else`. This is a common idiom called **early return**. The function reads top to bottom: as soon as a condition matches, you are done. It also avoids "arrow code" where each branch is more indented than the last.
|
||||
|
||||
Notice also that **we call `self.score` once**, store the result, then compare it five times. If we wrote `if self.score >= 90:` etc., the property would re-run each time. For a tiny score function it would not matter, but the habit of caching repeated expensive lookups is worth forming early.
|
||||
|
||||
## 9. `evaluate_header()`: the heart of the scanner
|
||||
|
||||
This is the **pure function** at the core of everything. No network. No prints. Just rule plus headers in, finding out.
|
||||
|
||||
```python
|
||||
def evaluate_header(
|
||||
rule: HeaderRule,
|
||||
response_headers: dict[str, str],
|
||||
) -> HeaderFinding:
|
||||
target = rule.header.lower()
|
||||
|
||||
actual_value: str | None = None
|
||||
for name, value in response_headers.items():
|
||||
if name.lower() == target:
|
||||
actual_value = value
|
||||
break
|
||||
|
||||
if actual_value is None:
|
||||
return HeaderFinding(
|
||||
rule=rule,
|
||||
status="missing",
|
||||
actual_value=None,
|
||||
note=f"Header `{rule.header}` is not set",
|
||||
)
|
||||
|
||||
if rule.must_match is None:
|
||||
return HeaderFinding(
|
||||
rule=rule,
|
||||
status="ok",
|
||||
actual_value=actual_value,
|
||||
note="Present",
|
||||
)
|
||||
|
||||
if re.search(rule.must_match, actual_value, re.IGNORECASE):
|
||||
return HeaderFinding(
|
||||
rule=rule,
|
||||
status="ok",
|
||||
actual_value=actual_value,
|
||||
note=f"Present and matches `{rule.must_match}`",
|
||||
)
|
||||
|
||||
return HeaderFinding(
|
||||
rule=rule,
|
||||
status="weak",
|
||||
actual_value=actual_value,
|
||||
note=(
|
||||
f"Present but does not match `{rule.must_match}` "
|
||||
f"(got `{actual_value}`)"
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Three branches, in order:
|
||||
|
||||
1. **Missing.** We loop over the response headers, lowercase each name, compare against the lowercased target. If we never find a match, `actual_value` stays `None`, and we return a `missing` finding.
|
||||
2. **Present, no must_match check.** If the rule does not require a specific pattern, presence alone is enough. Return `ok`.
|
||||
3. **Present, must_match check.** If the rule has a `must_match`, run `re.search(pattern, value, re.IGNORECASE)`. A plain word like `"nosniff"` works as a substring check; a richer pattern like `r"max-age\s*=\s*[1-9]"` enforces a real condition (HSTS must be set to a positive integer, not the actively-harmful `max-age=0`). If the pattern matches, `ok`. If not, `weak`.
|
||||
|
||||
A few things worth pointing out:
|
||||
|
||||
**The case-insensitive lookup.** HTTP header names are case insensitive per RFC 7230. Different servers return them with different casings. Some return `Strict-Transport-Security`, some `strict-transport-security`, some even `STRICT-TRANSPORT-SECURITY` (rare but legal). Lowercasing both sides is the simplest portable way to handle this.
|
||||
|
||||
We could have used a case-insensitive dict (httpx returns one), but the function should accept a plain dict for testing purposes. In practice `scan()` already converts the response headers to a plain `dict[str, str]` before calling `evaluate_header`, so this function never sees an `httpx.Headers` object directly — but the contract is "any `dict[str, str]` works," which is what makes hand-built test inputs trivial.
|
||||
|
||||
**Why we `break` out of the loop early.** Once we found the header, we have what we need. Continuing the loop would waste CPU.
|
||||
|
||||
**The f-strings in `note`.** An f-string is a string with `{expression}` placeholders that get filled in at runtime. `f"Header `{rule.header}` is not set"` becomes `Header `Strict-Transport-Security` is not set` if the rule's header is HSTS. The backticks around the header name make it look monospaced if the renderer happens to be markdown-aware, and it generally helps the eye.
|
||||
|
||||
**No else branches.** Each branch returns. Once you return, the function is done. No need to write `elif` or `else`. This is the same early-return pattern from the `grade` property.
|
||||
|
||||
## 10. `scan()`: the network call
|
||||
|
||||
```python
|
||||
DEFAULT_USER_AGENT: str = (
|
||||
"http-headers-scanner/1.0 "
|
||||
"(+https://github.com/CarterPerez-dev/Cybersecurity-Projects)"
|
||||
)
|
||||
|
||||
|
||||
def scan(
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
) -> ScanReport:
|
||||
response = httpx.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": user_agent},
|
||||
)
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
findings = [evaluate_header(rule, response_headers) for rule in RULES]
|
||||
|
||||
return ScanReport(
|
||||
url=url,
|
||||
final_url=str(response.url),
|
||||
status_code=response.status_code,
|
||||
findings=findings,
|
||||
)
|
||||
```
|
||||
|
||||
### 10.1 The User-Agent
|
||||
|
||||
The User-Agent string identifies who is making the request. Browsers send things like `Mozilla/5.0 (X11; Linux x86_64) ...`. Our scanner sends `http-headers-scanner/1.0 (+https://...)`. This is polite for two reasons:
|
||||
|
||||
- Server operators reading their access logs can tell who is hitting them and check our project page if they wonder why.
|
||||
- Some sites block the default httpx UA. A custom UA is more likely to get a real response.
|
||||
|
||||
### 10.2 The `*,` in the signature
|
||||
|
||||
```python
|
||||
def scan(
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
) -> ScanReport:
|
||||
```
|
||||
|
||||
The `*,` between `url` and `timeout` forces callers to pass `timeout` and `user_agent` by keyword. You cannot call `scan("https://example.com", 5.0)`. You have to call `scan("https://example.com", timeout=5.0)`.
|
||||
|
||||
Why force that? Because `5.0` does not obviously mean "five seconds of timeout" when you read the call site. `timeout=5.0` does. Keyword-only arguments make call sites more readable and refactor-safe. The cost is exactly one extra character (`timeout=`) when calling the function.
|
||||
|
||||
### 10.3 `follow_redirects=True`
|
||||
|
||||
When the server says "this URL has moved, try this other one," we follow the redirect automatically. Many sites redirect `http://` to `https://` or `www.` to bare domain. The user typed one URL but their browser would end up on a different one. We want to grade the one their browser would actually see.
|
||||
|
||||
### 10.4 The handoff to the pure layer
|
||||
|
||||
```python
|
||||
response_headers = dict(response.headers)
|
||||
findings = [evaluate_header(rule, response_headers) for rule in RULES]
|
||||
```
|
||||
|
||||
These two lines are the "leave I/O world, enter pure world" handoff we talked about in the architecture file. `dict(response.headers)` converts the httpx Headers object into a plain dict. The list comprehension on the next line runs `evaluate_header()` for each rule.
|
||||
|
||||
A **list comprehension** is shorthand for "make a list by running an expression over each item in a source." The equivalent for-loop would be:
|
||||
|
||||
```python
|
||||
findings = []
|
||||
for rule in RULES:
|
||||
findings.append(evaluate_header(rule, response_headers))
|
||||
```
|
||||
|
||||
Same result, more lines. The comprehension is preferred when the body is one expression.
|
||||
|
||||
## 11. Rendering
|
||||
|
||||
```python
|
||||
STATUS_COLORS: dict[Status, str] = {
|
||||
"ok": "green",
|
||||
"weak": "yellow",
|
||||
"missing": "red",
|
||||
}
|
||||
|
||||
GRADE_COLORS: dict[str, str] = {
|
||||
"A": "bright_green",
|
||||
"B": "green",
|
||||
"C": "yellow",
|
||||
"D": "red",
|
||||
"F": "bright_red",
|
||||
}
|
||||
|
||||
|
||||
def _render_report(report: ScanReport, console: Console) -> None:
|
||||
table = Table(...)
|
||||
table.add_column(...)
|
||||
...
|
||||
for finding in report.findings:
|
||||
status_color = STATUS_COLORS[finding.status]
|
||||
table.add_row(...)
|
||||
console.print(table)
|
||||
|
||||
if report.final_url.startswith("http://"):
|
||||
console.print(
|
||||
"[yellow]Note:[/yellow] this response was served over plain "
|
||||
"HTTP. Browsers IGNORE HSTS over HTTP, ..."
|
||||
)
|
||||
|
||||
grade_color = GRADE_COLORS[report.grade]
|
||||
panel = Panel(...)
|
||||
console.print(panel)
|
||||
|
||||
actionable = [f for f in report.findings if f.status != "ok"]
|
||||
if actionable:
|
||||
console.print("\n[bold]Recommendations:[/bold]")
|
||||
for finding in actionable:
|
||||
console.print(...)
|
||||
```
|
||||
|
||||
The renderer uses **rich**, a third-party library for pretty terminal output. The patterns:
|
||||
|
||||
- **A `Table` object** with columns. You add rows one at a time. `console.print(table)` draws it as a Unicode-bordered table.
|
||||
- **`[green]something[/green]`** is rich's markup syntax. It is roughly like HTML for terminal colors. `[bold cyan]Result[/bold cyan]` would render "Result" in bold cyan.
|
||||
- **`Panel(...)`** wraps content in a bordered box.
|
||||
|
||||
The renderer is intentionally separate from `scan()` and `evaluate_header()`. The pure code does not know or care about colors. If we ever want a JSON output mode for CI, we add a second renderer (`_render_json(report)`) and keep all the other code unchanged.
|
||||
|
||||
The `actionable = [f for f in report.findings if f.status != "ok"]` line is another comprehension: build a list of every finding whose status is not `ok`. These are the ones we have recommendations for. If the list is empty (perfect score), we skip the section entirely.
|
||||
|
||||
**The HTTP warning.** Right after the table, we check `report.final_url.startswith("http://")`. Per RFC 6797 §8.1, browsers MUST IGNORE the `Strict-Transport-Security` header when it arrives over plain HTTP — only HSTS received over HTTPS counts. So if a user points the scanner at `http://example.com` and the server returns HSTS, that HSTS earns full credit in our grading even though no real browser would honor it. The yellow note makes the caveat visible at the only place that matters: the user-facing report. We do not change the grading logic — one rule, one outcome — but the user sees an honest "this grade is misleading until the site enforces HTTPS" line right next to the score.
|
||||
|
||||
## 12. The argparse plumbing
|
||||
|
||||
```python
|
||||
def _build_argument_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="headers",
|
||||
description="Scan a URL for HTTP security headers and grade the result A–F.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"url",
|
||||
help="Full URL to scan (must include http:// or https://).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=10.0,
|
||||
help="Seconds to wait before giving up on the request (default: 10).",
|
||||
)
|
||||
return parser
|
||||
```
|
||||
|
||||
**argparse** is the standard library's command-line argument parser. You declare what your program accepts, argparse handles the rest: parsing, type conversion, generating `--help` output, rejecting bad input.
|
||||
|
||||
Two arguments declared:
|
||||
|
||||
- **`url`**: positional (no `--` prefix). Required. If the user does not provide it, argparse errors out and prints the usage automatically.
|
||||
- **`--timeout`**: optional. Defaults to `10.0`. `type=float` tells argparse to convert the string `"5"` into the float `5.0`.
|
||||
|
||||
The function is intentionally separate from `main()` so tests can build the parser and call `parse_args([...])` on a synthetic list, without having to mess with `sys.argv`.
|
||||
|
||||
## 13. `main()`: orchestration
|
||||
|
||||
```python
|
||||
def main() -> int:
|
||||
parser = _build_argument_parser()
|
||||
args = parser.parse_args()
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
report = scan(args.url, timeout=args.timeout)
|
||||
except httpx.RequestError as exc:
|
||||
console.print(f"[red]Request failed:[/red] {type(exc).__name__}: {exc}")
|
||||
return 2
|
||||
|
||||
_render_report(report, console)
|
||||
|
||||
if report.grade in ("A", "B"):
|
||||
return 0
|
||||
if report.grade in ("C", "D"):
|
||||
return 1
|
||||
return 2
|
||||
```
|
||||
|
||||
This function is small on purpose. Its job is to be the glue. Steps:
|
||||
|
||||
1. Build the argparse parser. `parse_args()` with no argument reads `sys.argv` implicitly.
|
||||
2. Create a `Console` (rich's main object for printing).
|
||||
3. Try to scan. If `httpx.RequestError` (the parent of every network-related error) is raised, print a clean message and return exit code 2.
|
||||
4. Render the report.
|
||||
5. Pick an exit code based on the grade.
|
||||
|
||||
The `try / except` here is the **only** place we catch network errors. We let them propagate from `httpx.get()` up through `scan()` up to `main()`. The reason: lower-level functions cannot know what to do with errors. The CLI knows what to do (show the user, exit). The CLI is the right layer to catch.
|
||||
|
||||
`type(exc).__name__` is "the name of the exception's class as a string." For a connection timeout it would be `ConnectTimeout`. For DNS failure, `ConnectError`. Including this in the output gives the user a clue about what went wrong without dumping a full traceback.
|
||||
|
||||
## 14. The script entrypoint
|
||||
|
||||
```python
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
This pattern shows up in every Python script. It means "if this file was invoked directly (not imported as a module), run main."
|
||||
|
||||
When you `python http_headers_scanner.py`, Python sets a special variable `__name__` to `"__main__"`. When some other code does `import http_headers_scanner`, `__name__` is set to `"http_headers_scanner"` instead.
|
||||
|
||||
So `if __name__ == "__main__":` is "only when running as a script, not when being imported." Tests import the file, so they need `main()` to NOT run automatically.
|
||||
|
||||
`sys.exit(main())` calls main, then passes its return value (0, 1, or 2) to the operating system as the exit code.
|
||||
|
||||
## 15. The test file walkthrough
|
||||
|
||||
The tests live in `test_http_headers_scanner.py`. We will not go through every line, but here are the key patterns.
|
||||
|
||||
### 15.1 Fixtures
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def hsts_rule() -> HeaderRule:
|
||||
return HeaderRule(
|
||||
header="Strict-Transport-Security",
|
||||
severity="high",
|
||||
...
|
||||
must_match=r"max-age\s*=\s*[1-9]",
|
||||
)
|
||||
```
|
||||
|
||||
A **fixture** is pytest's way of saying "before this test runs, set up this thing for it." Any test function that has a parameter named `hsts_rule` will receive whatever this fixture returns. Pytest matches by name.
|
||||
|
||||
We use fixtures so the rule is constructed in one place. If the `HeaderRule` shape changes (new field added), we update the fixture, not five different tests.
|
||||
|
||||
### 15.2 The pure-function tests
|
||||
|
||||
```python
|
||||
def test_evaluate_header_present_with_required_substring(
|
||||
hsts_rule: HeaderRule,
|
||||
) -> None:
|
||||
headers = {"Strict-Transport-Security": "max-age=31536000"}
|
||||
finding = evaluate_header(hsts_rule, headers)
|
||||
assert finding.status == "ok"
|
||||
assert finding.actual_value == "max-age=31536000"
|
||||
```
|
||||
|
||||
Each test follows the **arrange-act-assert** pattern:
|
||||
|
||||
1. **Arrange.** Build the input. Here: a tiny dict of headers.
|
||||
2. **Act.** Call the function under test.
|
||||
3. **Assert.** Check the result is what we expected.
|
||||
|
||||
`assert` is Python's "this must be true or fail the test." If `finding.status != "ok"`, pytest raises an AssertionError and prints what the actual value was.
|
||||
|
||||
Because `evaluate_header` is pure, these tests are dead simple. No mocking, no setup beyond the fixture, no teardown.
|
||||
|
||||
### 15.3 The score and grade tests
|
||||
|
||||
The `_make_report` helper builds a synthetic `ScanReport` by pairing each rule with a status. Then the test asks `report.score` and `report.grade` and asserts they are what we expected.
|
||||
|
||||
This is the payoff for making `score` and `grade` properties of `ScanReport`: we can test them without running `scan()`. We just hand-build the inputs.
|
||||
|
||||
### 15.4 The respx-mocked scan tests
|
||||
|
||||
```python
|
||||
@respx.mock
|
||||
def test_scan_mocks_a_clean_response_and_grades_it_correctly() -> None:
|
||||
respx.get("https://safe.example.com/").mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
...
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
report = scan("https://safe.example.com/")
|
||||
|
||||
assert report.status_code == 200
|
||||
assert report.score == 100
|
||||
```
|
||||
|
||||
The `@respx.mock` decorator above the function tells respx: "during this test, intercept every httpx call and use the routes I set up below."
|
||||
|
||||
`respx.get("https://safe.example.com/").mock(return_value=httpx.Response(...))` says "when something does an HTTP GET to that URL, hand back this canned response, do not actually go to the internet."
|
||||
|
||||
Then `scan("https://safe.example.com/")` does its thing. httpx tries to fetch the URL; respx intercepts; the fake response comes back; the rest of the code never knows the difference. We assert on the score.
|
||||
|
||||
The redirect test (`test_scan_records_final_url_after_redirect`) sets up two mocked routes: the first returns a 301 to the second, the second returns 200. The scanner follows the redirect, and we assert that `report.final_url` reflects where we ended up.
|
||||
|
||||
## 16. Tooling: lint, type-check, format
|
||||
|
||||
The project ships with four quality tools wired up through `just`:
|
||||
|
||||
```
|
||||
just lint # runs ruff, then pylint, then mypy
|
||||
just format # runs yapf in place
|
||||
just test # runs pytest
|
||||
just fix # runs ruff with --fix (auto-fixes what it can)
|
||||
```
|
||||
|
||||
What each tool does:
|
||||
|
||||
- **ruff** is a fast Python linter. Catches a long list of style and correctness issues. Modern replacement for flake8.
|
||||
- **pylint** is a slower, more opinionated linter. Catches different issues than ruff. We run both because their checks complement each other.
|
||||
- **mypy** is the static type checker. It reads the type annotations and checks every call against them. Catches `severity = "hgih"` typos and many other bugs at edit time.
|
||||
- **yapf** is the code formatter. It rewrites the file to match a configured style (column limit, indentation, etc.). Means the project has a single consistent look regardless of who wrote each line.
|
||||
- **pytest** is the test runner. Discovers files starting with `test_`, runs every function in them whose name starts with `test_`, reports passes and failures.
|
||||
|
||||
In a real workflow you would set up a **pre-commit hook** that runs `just lint` and `just test` before each commit, so broken code never gets committed. We have not done that in this project to keep the foundations tier minimal, but extending it is one of the challenges.
|
||||
|
||||
## 17. The pyproject.toml
|
||||
|
||||
`pyproject.toml` is the modern Python project metadata file. It replaces the old `setup.py` + `setup.cfg` combo. Worth glancing at, even though you do not usually edit it day to day.
|
||||
|
||||
Key sections:
|
||||
|
||||
- **`[project]`**: name, version, description, Python version requirement, dependencies.
|
||||
- **`[project.optional-dependencies]`**: dev dependencies (pytest, mypy, etc.) that end users do not need.
|
||||
- **`[project.scripts]`**: declares the `headers` command-line script. This is why `uv run headers` works: it knows to invoke `http_headers_scanner:main`.
|
||||
- **`[tool.ruff]`, `[tool.mypy]`, `[tool.pylint.*]`, `[tool.pytest.ini_options]`**: config for each tool. Centralising config in one file is convenient.
|
||||
|
||||
## 18. Common pitfalls when extending
|
||||
|
||||
A few things that have tripped people up when adding new rules or features:
|
||||
|
||||
**Forgetting to bump the score total in tests.** Currently `RULES` is six rules totalling 100 points. If you add a seventh, the score calculation still works (it sums whatever is in the list), but tests that hardcoded the expected score (e.g. "score should be 50 when half are missing") may break. Fix: write tests in terms of percentages, not absolute point counts.
|
||||
|
||||
**Adding a rule whose value parsing is non-trivial.** Our `must_match` field is a single regex. That's plenty for "starts with `nosniff`" or "max-age is a positive integer," but some real headers need much more complex parsing (CSP, for instance, has its own grammar of directives, source expressions, and nonces). If your new rule needs structured parsing, do the parsing in `evaluate_header()` based on the rule's header name, or extend `HeaderRule` with a new field like `value_validator: Callable[[str], bool] | None`.
|
||||
|
||||
**Forgetting the case-insensitive comparison.** New code that does `if "X-Frame-Options" in response.headers` will miss servers that return `x-frame-options`. Always lowercase both sides for header name comparison.
|
||||
|
||||
**Trying to scan multiple URLs without async.** The sync API blocks one URL at a time. Scanning 100 URLs in sequence is slow. If you want concurrency, switch to `httpx.AsyncClient` and use `asyncio.gather`. The challenges file has a sketch of this.
|
||||
|
||||
## 19. Debugging tips
|
||||
|
||||
When something goes wrong:
|
||||
|
||||
**Run with `-v` for pytest verbose output.**
|
||||
```
|
||||
uv run pytest -v
|
||||
```
|
||||
Shows you each test name as it runs. Easier to spot which one failed.
|
||||
|
||||
**Use the `--pdb` flag for an interactive debugger.**
|
||||
```
|
||||
uv run pytest --pdb
|
||||
```
|
||||
Drops into Python's debugger on the first failing test. Type `l` for the source around the failure, `p variable_name` to inspect, `c` to continue.
|
||||
|
||||
**Print the actual headers when the scanner gives wrong results.**
|
||||
Edit `scan()` to print `response_headers` before the loop. Run the scanner against a known site. Compare what you see to what your browser's dev tools say. Different User-Agents sometimes get different responses.
|
||||
|
||||
**Use `curl -I` as a sanity check.**
|
||||
```
|
||||
curl -I https://example.com
|
||||
```
|
||||
The `-I` flag fetches only headers. If the headers you see there do not match what the scanner reports, something is up with the request the scanner is making.
|
||||
|
||||
## 20. Next
|
||||
|
||||
Read **[04-CHALLENGES.md](./04-CHALLENGES.md)** for ideas to extend the scanner. Pick one that interests you, try it, and see how the architecture holds up when you push on it.
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
# Extension Challenges
|
||||
|
||||
You have read the code and you understand what it does. Time to make it yours.
|
||||
|
||||
These challenges are ordered roughly by difficulty. The early ones are 30-minute changes. The later ones are weekend projects that meaningfully change how the scanner works. None of them have "the right answer" hidden in this folder. Try them, get them wrong, get them right, learn something.
|
||||
|
||||
If you get stuck, the source of truth is always the code. Re-read `evaluate_header()` and `scan()` and `_render_report()`. The whole scanner fits on a screen.
|
||||
|
||||
## Warm-up challenges
|
||||
|
||||
### 1. Add a seventh header to check
|
||||
|
||||
**Goal:** Extend `RULES` with one more security header. Pick one of:
|
||||
|
||||
- **`Cross-Origin-Opener-Policy`** (COOP): prevents the page from being interacted with by other origins via window.opener. Recommended value: `same-origin`.
|
||||
- **`Cross-Origin-Embedder-Policy`** (COEP): controls which cross-origin resources can be loaded. Recommended value: `require-corp`.
|
||||
- **`Cross-Origin-Resource-Policy`** (CORP): controls who can embed this resource. Recommended value: `same-origin`.
|
||||
|
||||
**Why it is useful.** These three headers together (COOP, COEP, CORP) are the modern way to isolate a page from cross-origin attacks like Spectre. Real high-security sites set all three.
|
||||
|
||||
**Hints.**
|
||||
- Add a `HeaderRule(...)` to `RULES`. Pick a severity. Decide whether you need a `must_match` pattern (a plain word like `"same-origin"` works as a substring check; use a richer regex if "the value has to be exactly one of N options" matters).
|
||||
- The score totals will change. Your existing tests that assert exact scores may break. Either update those tests or write them in terms of percentages.
|
||||
- Test it against a real site. `https://web.dev` is a good one to check.
|
||||
|
||||
**Done when.** `just test` passes, `just run -- https://web.dev` shows your new header in the table.
|
||||
|
||||
### 2. Add a `--json` flag for machine-readable output
|
||||
|
||||
**Goal.** Make the scanner emit a JSON blob when the user passes `--json`, instead of the colored table.
|
||||
|
||||
**Why useful.** CI systems and dashboards need structured output. A grep-friendly table is fine for humans, JSON is fine for everyone else.
|
||||
|
||||
**Hints.**
|
||||
- Add a `--json` boolean flag to `_build_argument_parser()` using `action="store_true"`.
|
||||
- In `main()`, after `scan()` returns, branch on `args.json`. If True, use the standard library's `json` module to dump a dict with the report fields. If False, call the existing `_render_report()`.
|
||||
- `HeaderFinding` is not directly JSON-serializable because it contains a nested `HeaderRule`. You can flatten it manually, or use `dataclasses.asdict()` which converts a frozen dataclass tree into nested dicts.
|
||||
|
||||
**Done when.** `just run -- https://example.com --json | jq '.score'` prints just the score number.
|
||||
|
||||
### 3. Add a `--verbose` flag that prints raw response headers
|
||||
|
||||
**Goal.** Optional flag that, when set, prints every header the server returned (including non-security ones) above the table.
|
||||
|
||||
**Why useful.** When debugging "why does the scanner say my HSTS is weak," you want to see the raw value the server sent.
|
||||
|
||||
**Hints.**
|
||||
- Add the flag the same way as `--json`.
|
||||
- The `scan()` function only stores findings, not the raw response. You will need to thread the raw headers through `ScanReport` (add a new field) or have `scan()` return a tuple.
|
||||
- Print the raw headers in `_render_report` when a verbose flag is passed. The renderer signature will need updating.
|
||||
|
||||
**Done when.** `just run -- https://example.com --verbose` shows all server headers above the findings table.
|
||||
|
||||
## Intermediate challenges
|
||||
|
||||
### 4. Scan multiple URLs in one run
|
||||
|
||||
**Goal.** Allow the user to pass several URLs and get a report per URL. Bonus: a summary table at the end.
|
||||
|
||||
**Why useful.** When auditing a company's sites, you do not want to run the scanner once per URL. Run it once over the whole list.
|
||||
|
||||
**Hints.**
|
||||
- Change the `url` argparse argument to accept `nargs="+"` (one or more). `args.url` becomes a list of strings.
|
||||
- Loop over the URLs in `main()`. Call `scan()` for each, render each report, collect them.
|
||||
- For the summary, add a small final table with one row per URL: URL, score, grade.
|
||||
- The exit code becomes interesting. Probably the worst grade across all URLs.
|
||||
|
||||
**Edge cases.**
|
||||
- One URL fails (network error) but others succeed. Do you still exit with code 2?
|
||||
- Two URLs return the same final URL (after redirects). Dedupe or show both?
|
||||
|
||||
### 5. Add an `--allow-warnings` style threshold flag
|
||||
|
||||
**Goal.** Let the user say "I am ok with grade C, only exit non-zero if grade D or below."
|
||||
|
||||
**Why useful.** CI integrations. Different teams have different acceptance bars.
|
||||
|
||||
**Hints.**
|
||||
- Add `--min-grade` taking a value from `{"A", "B", "C", "D", "F"}`. Default to `"C"` (the current behaviour roughly).
|
||||
- In `main()`, compare `report.grade` to the threshold and pick exit code accordingly.
|
||||
- Grades have a natural order. You can compare them with a small helper that maps each to an integer.
|
||||
|
||||
**Done when.** `just run -- https://example.com --min-grade C` exits 0 if the site got C or better, exits 1 otherwise.
|
||||
|
||||
### 6. Cache results to disk
|
||||
|
||||
**Goal.** When the user re-scans the same URL within the last hour, return the cached result instead of hitting the network.
|
||||
|
||||
**Why useful.** When you are iterating on the renderer or scoring, you do not want to spam a real site every test run.
|
||||
|
||||
**Hints.**
|
||||
- Store cached reports in `~/.cache/http-headers-scanner/`.
|
||||
- File name from a hash of the URL (`hashlib.sha256(url.encode()).hexdigest()`).
|
||||
- Use `dataclasses.asdict(report)` to JSON-ify, `json.dump()` to write.
|
||||
- Include a timestamp in the cached file. Skip the cache when it is older than an hour.
|
||||
- Add a `--no-cache` flag to bypass.
|
||||
|
||||
**Tricky bit.** `HeaderFinding` contains a `HeaderRule`. When you load from cache, you need to rebuild those rules from the JSON. Or, simpler, only cache the bits you care about and rebuild the report shape from scratch.
|
||||
|
||||
### 7. Detect and warn about mixed HTTP/HTTPS
|
||||
|
||||
**Goal.** If the user passes an `http://` URL and the server redirects to `https://`, mention it prominently in the output.
|
||||
|
||||
**Why useful.** Many sites enforce HTTPS via redirect, but the redirect chain itself is unencrypted on the first hop and gets stripped by attackers (this is what HSTS protects against). Knowing whether a redirect happened is a useful signal.
|
||||
|
||||
**Hints.**
|
||||
- After `scan()`, compare `report.url` (what the user typed) to `report.final_url` (where they ended up).
|
||||
- If the user typed `http://...` and the final URL is `https://...`, print a one-line note: "Note: this URL was upgraded from HTTP to HTTPS via redirect. Without HSTS, the first request is vulnerable to interception."
|
||||
- Better yet, deduct points if HSTS is missing AND the user came in via http.
|
||||
|
||||
## Advanced challenges
|
||||
|
||||
### 8. Make it async to scan many sites in parallel
|
||||
|
||||
**Goal.** Use `httpx.AsyncClient` plus `asyncio.gather` so that scanning 50 URLs takes about the time of one scan, not 50 scans.
|
||||
|
||||
**Why useful.** Real audits cover many hosts. Sequential scanning is the bottleneck.
|
||||
|
||||
**Hints.**
|
||||
- Add an `async def scan_async(url, ...)` alongside `scan()`. Use `async with httpx.AsyncClient() as client:` and `client.get(...)`.
|
||||
- In `main()`, build an `asyncio.gather()` over a list of `scan_async` calls.
|
||||
- Concurrency limit: do not blast 5000 URLs at once. Use `asyncio.Semaphore(20)` to cap parallelism.
|
||||
- The pure functions (`evaluate_header`, `score`, `grade`) do not change. That is the payoff of separating pure logic from I/O.
|
||||
|
||||
**Watch out for.** Some sites rate-limit aggressive scanning. The default User-Agent identifies us; respect any `Retry-After` headers if they come back.
|
||||
|
||||
### 9. Add a static analyser for the CSP value
|
||||
|
||||
**Goal.** Currently we only check that `Content-Security-Policy` is present. Build a sub-analyser that looks inside the CSP value and reports specific problems:
|
||||
|
||||
- Contains `'unsafe-inline'` in `script-src`? Major weakness, points off.
|
||||
- Contains a wildcard origin (`*`) in `script-src`? Same.
|
||||
- Missing `default-src`? Worth noting.
|
||||
|
||||
**Why useful.** A CSP that allows `'unsafe-inline'` is barely a CSP at all. Real scanners (Mozilla Observatory) do this analysis. Yours can too.
|
||||
|
||||
**Hints.**
|
||||
- Add a new dataclass `CSPAnalysis` with fields like `has_unsafe_inline: bool`, `wildcard_origins: list[str]`, etc.
|
||||
- Add a `parse_csp(value: str) -> CSPAnalysis` pure function. CSP grammar: directives are semicolon-separated; each directive is `name source1 source2 ...`.
|
||||
- Make the CSP rule's `weak` status take into account the analysis result. The base `evaluate_header` will need an escape hatch for rules that have a custom validator.
|
||||
|
||||
**Tricky bit.** CSP is genuinely complex. The official spec is at `w3.org/TR/CSP3`. Start by parsing only `script-src`; ignore the rest.
|
||||
|
||||
### 10. Build a continuous monitor
|
||||
|
||||
**Goal.** Run the scanner against a list of URLs every 24 hours and alert when a site's grade drops.
|
||||
|
||||
**Why useful.** Configuration drifts. A site that had grade A six months ago can drop to B because someone disabled HSTS to debug something and forgot to put it back. You want to know.
|
||||
|
||||
**Hints.**
|
||||
- Need persistent storage. Easiest start: a SQLite database with columns `(url, run_at, score, grade)`. Standard library has `sqlite3` so no new dependency.
|
||||
- A separate script reads a URL list (one URL per line, in a file), runs the scanner, writes results.
|
||||
- Compare today's grade to the most recent previous grade per URL. If today is worse, emit an alert (print to stdout, send an email, push a Slack message, your choice).
|
||||
- A `cron` entry or a systemd timer fires the script once a day.
|
||||
|
||||
**Watch out for.** Networks are flaky. A timeout one day is not necessarily a grade drop. You probably want a "two failures in a row" rule before alerting.
|
||||
|
||||
### 11. Browser-based comparison: scan the same URL with three different User-Agents
|
||||
|
||||
**Goal.** Sites sometimes serve different headers to bots versus real browsers. Add a `--compare-uas` mode that scans the same URL with the default scanner UA, with a Chrome UA, and with a Googlebot UA, then shows a side-by-side comparison of the headers.
|
||||
|
||||
**Why useful.** A site might serve strict CSP to real users but loose CSP to bots, or vice versa. Knowing this is real evidence of a misconfiguration.
|
||||
|
||||
**Hints.**
|
||||
- The `scan()` function already takes a `user_agent` argument. Run it three times with three different values.
|
||||
- A real Chrome UA looks like: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36`.
|
||||
- Googlebot's UA is `Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)`.
|
||||
- The render layer needs a "three-column comparison" mode. A rich Table with columns "Default", "Chrome", "Googlebot" and one row per header.
|
||||
|
||||
## Expert challenge
|
||||
|
||||
### 12. Make it a service with a REST API
|
||||
|
||||
**Goal.** Wrap the scanner in a small HTTP server. POST a URL, get back a JSON report.
|
||||
|
||||
**Why useful.** Other services in a security pipeline can call yours. Plus, it is the natural way to make the scanner accessible from a frontend.
|
||||
|
||||
**Estimated time.** A weekend if you have not used FastAPI before, a few hours if you have.
|
||||
|
||||
**Prerequisites.** Some exposure to web frameworks would help. The intermediate `siem-dashboard` project in this repo is a deeper example of building one.
|
||||
|
||||
**Architecture sketch.**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ FastAPI app │
|
||||
│ │
|
||||
│ POST /scan { "url": "..." } │
|
||||
│ └──▶ async scan_async(url) ────▶ ScanReport │
|
||||
│ ◀── { "url": ..., "grade": ..., findings: ... }│
|
||||
│ │
|
||||
│ GET /healthz │
|
||||
│ └──▶ {"ok": true} │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Steps.**
|
||||
|
||||
1. **Add fastapi and uvicorn to dependencies.** `uv add fastapi uvicorn[standard]`.
|
||||
2. **Create a new file `server.py`** that imports `scan_async` from your async-ified scanner.
|
||||
3. **Define a Pydantic model** for the request body (`class ScanRequest(BaseModel): url: HttpUrl`).
|
||||
4. **Define a response model** that mirrors `ScanReport` for serialisation. `HttpUrl` validates URLs at the API boundary.
|
||||
5. **Add the route.** `@app.post("/scan", response_model=ScanResponse)` then `async def scan_endpoint(req: ScanRequest)`.
|
||||
6. **Add a healthz endpoint** so external monitors can confirm the service is alive.
|
||||
7. **Add rate limiting.** Otherwise people will use your service to scan strangers. The advanced `api-rate-limiter` project in this repo is a good reference.
|
||||
|
||||
**Production checklist.**
|
||||
- [ ] Validation: reject internal IPs (`127.0.0.1`, `10.0.0.0/8`, etc.) to prevent SSRF.
|
||||
- [ ] Timeouts: every scan has a hard cap so a slow target cannot tie up workers.
|
||||
- [ ] Logging: every request is logged with `url`, `grade`, `duration_ms`.
|
||||
- [ ] CORS: decide which origins can call your API.
|
||||
- [ ] Deploy: Dockerfile that runs uvicorn, env var for port.
|
||||
|
||||
**Stretch goal.** Build a tiny single-page frontend (vanilla HTML + JS, no framework needed) that lets a user paste a URL and shows the report. Now you have a real product.
|
||||
|
||||
## Other directions
|
||||
|
||||
A few smaller ideas if none of the above grab you:
|
||||
|
||||
- **Color-blind mode.** Replace the green/yellow/red colors with green/yellow/red plus distinct shapes (✓, ⚠, ✗) so the table is readable for color-blind users.
|
||||
- **HAR file import.** Instead of scanning a live URL, read headers from a `.har` file (HTTP Archive, what browsers export from dev tools). Lets you scan responses you captured earlier.
|
||||
- **Show diff against a known good baseline.** Save a "reference report" for a URL. On next scan, show only the changes ("HSTS got weaker," "X-Frame-Options went missing").
|
||||
- **Add a `--strict` mode** that lowers the bar: deduct points even for minor issues (e.g. `max-age` less than six months, missing `includeSubDomains`).
|
||||
|
||||
## What to do when stuck
|
||||
|
||||
The strategy that works every single time:
|
||||
|
||||
1. **Reduce.** Strip the problem down. Comment out everything except the smallest piece that still misbehaves.
|
||||
2. **Print.** When `evaluate_header` does not return what you expected, print the inputs and the outputs. Do not guess what they look like. Look.
|
||||
3. **Read the test.** If a test is failing, the test is telling you exactly what it expected. Read the assertion. Read the inputs. Compare to the actual output.
|
||||
4. **Diff against known good.** Use `git diff` to see what changed since the tests last passed. The bug is in your changes 99% of the time.
|
||||
|
||||
If after all that you still need help, the GitHub Discussions tab on the repository is the right place. Bring:
|
||||
- What you tried.
|
||||
- What you expected.
|
||||
- What actually happened (the full error if any).
|
||||
- The smallest reproducer that shows the problem.
|
||||
|
||||
"It does not work" is not enough information to help with. The above four bullets are.
|
||||
|
||||
## When you finish a challenge
|
||||
|
||||
Make it yours. If you build something interesting, write it up. Push it to your own fork. Open a pull request back to the main repo if your improvement is generally useful. The best way to lock in what you learned is to teach someone else.
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
# ©AngelaMos | 2026
|
||||
# pyproject.toml
|
||||
#
|
||||
# This file is the "instruction manual" for our Python project. Every
|
||||
# modern Python project has one. It tells tools like uv, pip, pytest,
|
||||
# ruff, mypy, and pylint:
|
||||
#
|
||||
# - What our project is called
|
||||
# - Which third-party libraries we depend on
|
||||
# - How to build and install it
|
||||
# - How to lint, type-check, and test it
|
||||
#
|
||||
# The file format is called TOML — a simple, human-readable config
|
||||
# language. Square brackets like [project] start a "table" (a section).
|
||||
# Lines like name = "..." are key-value pairs inside that table.
|
||||
#
|
||||
# Read this top-to-bottom. Each section has a short comment explaining
|
||||
# what it does and why it is here.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [project] — the metadata that describes WHAT this project is
|
||||
# =============================================================================
|
||||
# This section is standardized across the whole Python ecosystem (PEP 621).
|
||||
# Anyone who runs `pip install` or `uv sync` reads from here.
|
||||
[project]
|
||||
# The name people will install this package under.
|
||||
# Must be unique on PyPI if we ever publish there.
|
||||
name = "http-headers-scanner"
|
||||
|
||||
# Semantic version: MAJOR.MINOR.PATCH.
|
||||
# 1.0.0 means "first stable release."
|
||||
# When we add features → bump MINOR (1.1.0). When we break things → MAJOR.
|
||||
version = "1.0.0"
|
||||
|
||||
# One-line description that shows up in `pip show` and on PyPI.
|
||||
description = "Scan a URL for HTTP security headers and grade the result (foundations tier)"
|
||||
|
||||
# The minimum Python version this project supports.
|
||||
# >=3.13 means "Python 3.13 or anything newer."
|
||||
# We need 3.13 because we use modern type-hint syntax (X | None).
|
||||
requires-python = ">=3.13"
|
||||
|
||||
# Who wrote it. Shows up in package metadata.
|
||||
authors = [
|
||||
{name = "CarterPerez-dev", email = "support@certgames.com"}
|
||||
]
|
||||
|
||||
# The README file is shown on PyPI and in `pip show`.
|
||||
readme = "README.md"
|
||||
|
||||
# AGPL-3.0 means: anyone can use it, but if they modify and run it as a
|
||||
# service, they must publish their changes. It protects the project
|
||||
# from being privatized.
|
||||
license = {text = "AGPL-3.0-or-later"}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# dependencies — third-party libraries we MUST have at runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
# When a user runs `pip install http-headers-scanner`, these get installed.
|
||||
# The string format is "<name><operator><version>". We use ranges so we
|
||||
# get bug fixes (>=) but never an incompatible major version (<).
|
||||
dependencies = [
|
||||
# httpx: a modern HTTP client. We use it instead of `requests` because
|
||||
# it is async-ready, supports HTTP/2, and ships first-class type hints.
|
||||
# (We use the SYNC API in this project for simplicity — async would
|
||||
# be overkill for a single-URL scanner.)
|
||||
"httpx>=0.28.1",
|
||||
|
||||
# rich: pretty terminal output — colors, tables, panels.
|
||||
# Used to render the scan report as a clean grading table.
|
||||
"rich>=15.0.0",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# optional-dependencies — extra libraries that are NOT needed at runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
# These only matter to developers (testing, linting, formatting). End users
|
||||
# don't install them. Activated with: `uv sync --extra dev` or `--all-extras`.
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# pytest — runs our test suite (the test_*.py file in this directory).
|
||||
"pytest>=9.0.3",
|
||||
|
||||
# respx — mocks httpx responses so our tests don't need the network.
|
||||
# Lets us assert "scan() correctly handles a missing-CSP response"
|
||||
# without depending on a live server.
|
||||
"respx>=0.23.1",
|
||||
|
||||
# ruff — extremely fast linter and formatter. Catches bugs, style
|
||||
# issues, dead imports. Modern replacement for flake8/black/isort.
|
||||
"ruff>=0.15.12",
|
||||
|
||||
# mypy — static type checker. Reads our type hints and tells us if
|
||||
# we passed a string where an int was expected, BEFORE running the code.
|
||||
"mypy>=2.1.0",
|
||||
|
||||
# pylint — second linter that catches deeper logic issues ruff misses.
|
||||
"pylint>=4.0.5",
|
||||
|
||||
# yapf — code formatter. Keeps every file looking identical.
|
||||
"yapf>=0.43.0,<1.0.0",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# project.scripts — command-line entry points
|
||||
# -----------------------------------------------------------------------------
|
||||
# After `uv sync`, the user can type `headers <url>` instead of
|
||||
# `python http_headers_scanner.py <url>`. The right-hand side is
|
||||
# "<module>:<function>" — the function gets called when the command runs.
|
||||
[project.scripts]
|
||||
headers = "http_headers_scanner:main"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [build-system] — how to BUILD this project into an installable package
|
||||
# =============================================================================
|
||||
# Required by PEP 517. Tools like uv read this to know which builder
|
||||
# to use. We chose hatchling — modern, fast, zero-config for our case.
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
# Tell hatchling exactly which file to package. This project is a SINGLE
|
||||
# Python file (no src/<package>/ layout), so we use `only-include` to
|
||||
# point at it directly. Without this, hatchling would not know what
|
||||
# to ship.
|
||||
[tool.hatch.build.targets.wheel]
|
||||
only-include = ["http_headers_scanner.py"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.ruff] — linter / formatter configuration
|
||||
# =============================================================================
|
||||
# Ruff is the FAST one. It runs in milliseconds and catches 90% of issues.
|
||||
[tool.ruff]
|
||||
# Target Python version — ruff adjusts which warnings apply.
|
||||
target-version = "py313"
|
||||
|
||||
# Wrap lines at 88 characters (PEP 8 says 79, but 88 is the modern norm).
|
||||
line-length = 88
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Which lint rules to enable. Each "code" is a category of check.
|
||||
# -----------------------------------------------------------------------------
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors — basic PEP 8 spacing/indent rules
|
||||
"F", # Pyflakes — unused imports, undefined names, real bugs
|
||||
"W", # pycodestyle warnings — softer style issues
|
||||
"B", # Bugbear — sneaky bugs (mutable default args, etc.)
|
||||
"C4", # Comprehensions — encourages cleaner list/dict comprehensions
|
||||
"UP", # Pyupgrade — flags old syntax we should modernize
|
||||
"SIM", # Simplify — suggests cleaner equivalents
|
||||
]
|
||||
|
||||
# Some rules we deliberately ignore.
|
||||
ignore = [
|
||||
# Line length is handled by yapf, not ruff. Avoids double-flagging.
|
||||
"E501",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.mypy] — static type checker configuration
|
||||
# =============================================================================
|
||||
# Mypy reads our type hints (the `: int`, `-> str` parts) and verifies them
|
||||
# without running the code. Catches whole categories of bugs at edit time.
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
|
||||
# Require type annotations on every function — strict mode.
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
# Don't auto-add Optional just because a default is None. Be explicit.
|
||||
no_implicit_optional = true
|
||||
|
||||
# Warn if we cast a value to a type it already has (dead code).
|
||||
warn_redundant_casts = true
|
||||
|
||||
# Warn if a function might fall off the end without returning.
|
||||
warn_no_return = true
|
||||
|
||||
# Pretty error output — helps when reading mypy output.
|
||||
show_error_codes = true
|
||||
pretty = true
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.pylint] — second-opinion linter
|
||||
# =============================================================================
|
||||
# Slower than ruff but catches different things — class design issues,
|
||||
# dead variables, complex code patterns.
|
||||
[tool.pylint.main]
|
||||
# Match the project's Python version.
|
||||
py-version = "3.13"
|
||||
|
||||
# Run lint checks in parallel across 4 cores for speed.
|
||||
jobs = 4
|
||||
|
||||
# Specific pylint warnings we deliberately turn off.
|
||||
[tool.pylint.messages_control]
|
||||
disable = [
|
||||
"R0903", # too-few-public-methods — small data classes are fine
|
||||
"C0103", # invalid-name — short names are OK in small scripts
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.pytest.ini_options] — test runner configuration
|
||||
# =============================================================================
|
||||
[tool.pytest.ini_options]
|
||||
# Where to look for tests. "." = the project root, since this project's
|
||||
# test file lives next to the source file.
|
||||
testpaths = ["."]
|
||||
|
||||
# Files that match this pattern are treated as test modules.
|
||||
python_files = ["test_*.py"]
|
||||
|
||||
# Default flags every `pytest` invocation gets:
|
||||
# -v → verbose, show each test name
|
||||
# --tb=short → short tracebacks on failure (less wall of text)
|
||||
addopts = "-v --tb=short"
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_http_headers_scanner.py
|
||||
|
||||
Tests for http_headers_scanner — covers rule evaluation, the score
|
||||
calculation, grade thresholds, and a mocked end-to-end scan
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What "tests" are and why we write them
|
||||
────────────────────────────────────────────────────────────────────
|
||||
A test is a tiny Python function that calls our real code with a
|
||||
known input and then ASSERTS that the result is what we expected.
|
||||
If the assertion fails, pytest prints a red FAIL message — which
|
||||
means we changed something and broke a behavior we cared about
|
||||
|
||||
Tests are insurance. The first time you write the code, the test
|
||||
just confirms it works. But six months later when you refactor or
|
||||
add a new feature, the existing tests catch any accidental breakage
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Why we mock the network with respx
|
||||
────────────────────────────────────────────────────────────────────
|
||||
A test that hits a real website is FRAGILE. The site might be down,
|
||||
slow, redesigned, or behind a captcha. None of that has anything to
|
||||
do with whether OUR code is correct
|
||||
|
||||
`respx` is a library that intercepts httpx calls and returns a
|
||||
canned response we control. So when we test scan("https://test"),
|
||||
respx hands back EXACTLY the headers we tell it to — letting us
|
||||
verify the scanning logic without touching the network
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Coverage strategy
|
||||
────────────────────────────────────────────────────────────────────
|
||||
We exercise each branch of the code at least once
|
||||
|
||||
- evaluate_header: ok / weak / missing / case-insensitive lookup
|
||||
- ScanReport.score: all-ok, all-missing, mixed
|
||||
- ScanReport.grade: each band (A, B, C, D, F)
|
||||
- scan(): full pipeline against a mocked response, including a redirect
|
||||
|
||||
That is enough confidence — adding ten variations of "another header"
|
||||
would not catch any new bugs
|
||||
"""
|
||||
|
||||
# Third-party (httpx): we need its `Response` type to construct fake
|
||||
# responses inside our mocked routes.
|
||||
import httpx
|
||||
# Third-party: the test runner. We also use its `@pytest.mark.parametrize`
|
||||
# decorator to expand one test function into many test cases.
|
||||
import pytest
|
||||
# Third-party (respx): intercepts httpx calls and returns fakes we
|
||||
# define, so tests do not actually hit the real internet.
|
||||
import respx
|
||||
|
||||
# Local: our own module. We pull in the public pieces under test —
|
||||
# the rules table, dataclasses, and the two entry functions.
|
||||
from http_headers_scanner import (
|
||||
RULES,
|
||||
HeaderFinding,
|
||||
HeaderRule,
|
||||
ScanReport,
|
||||
Status,
|
||||
evaluate_header,
|
||||
scan,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures — small helpers used by multiple tests
|
||||
# =============================================================================
|
||||
# A pytest "fixture" is a setup function pytest runs before a test
|
||||
# that needs it. Tests ask for fixtures by listing them as parameters
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hsts_rule() -> HeaderRule:
|
||||
"""
|
||||
A representative HeaderRule that requires a positive max-age value
|
||||
"""
|
||||
# We construct one inline rather than reaching into RULES so this
|
||||
# test is robust to future additions / reorderings of the table.
|
||||
# The regex matches `max-age` followed by `=` and a digit 1-9 —
|
||||
# which rejects `max-age=0` (HSTS deliberately disabled)
|
||||
return HeaderRule(
|
||||
header = "Strict-Transport-Security",
|
||||
severity = "high",
|
||||
description = "Forces HTTPS",
|
||||
recommendation = "Add: Strict-Transport-Security: max-age=31536000",
|
||||
must_match = r"max-age\s*=\s*[1-9]",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def referrer_rule() -> HeaderRule:
|
||||
"""
|
||||
A rule with NO must_contain — presence alone earns full points
|
||||
"""
|
||||
return HeaderRule(
|
||||
header = "Referrer-Policy",
|
||||
severity = "low",
|
||||
description = "Limits Referer leakage",
|
||||
recommendation =
|
||||
"Add: Referrer-Policy: strict-origin-when-cross-origin",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# evaluate_header — the pure function at the heart of the scanner
|
||||
# =============================================================================
|
||||
# These tests do not touch the network at all — we hand-build the
|
||||
# headers dict and check the finding
|
||||
|
||||
|
||||
def test_evaluate_header_present_with_required_substring(
|
||||
hsts_rule: HeaderRule,
|
||||
) -> None:
|
||||
"""
|
||||
Header is present AND contains must_contain → status = ok
|
||||
"""
|
||||
headers = {"Strict-Transport-Security": "max-age=31536000"}
|
||||
finding = evaluate_header(hsts_rule, headers)
|
||||
assert finding.status == "ok"
|
||||
assert finding.actual_value == "max-age=31536000"
|
||||
|
||||
|
||||
def test_evaluate_header_present_without_required_substring(
|
||||
hsts_rule: HeaderRule,
|
||||
) -> None:
|
||||
"""
|
||||
Header is present but does NOT match must_match → status = weak
|
||||
|
||||
A real-world example: someone sets the header to an empty string
|
||||
or only `includeSubDomains` without `max-age=`. The header exists
|
||||
but is functionally useless
|
||||
"""
|
||||
headers = {"Strict-Transport-Security": "includeSubDomains"}
|
||||
finding = evaluate_header(hsts_rule, headers)
|
||||
assert finding.status == "weak"
|
||||
|
||||
|
||||
def test_evaluate_header_missing(hsts_rule: HeaderRule) -> None:
|
||||
"""
|
||||
Header is not in the response at all → status = missing
|
||||
"""
|
||||
# Empty dict — no headers at all
|
||||
headers: dict[str, str] = {}
|
||||
finding = evaluate_header(hsts_rule, headers)
|
||||
assert finding.status == "missing"
|
||||
# And actual_value should be None when the header was absent
|
||||
assert finding.actual_value is None
|
||||
|
||||
|
||||
def test_evaluate_header_hsts_max_age_zero_is_weak(
|
||||
hsts_rule: HeaderRule,
|
||||
) -> None:
|
||||
"""
|
||||
`max-age=0` actively DISABLES HSTS for return visits — the header
|
||||
is present but does the opposite of what we want, so it must be
|
||||
flagged as weak rather than ok
|
||||
|
||||
This pins the behavior fixed in the audit — substring-based
|
||||
matching graded this case as ok and left users thinking HSTS was
|
||||
on when it was deliberately turned off
|
||||
"""
|
||||
headers = {"Strict-Transport-Security": "max-age=0; includeSubDomains"}
|
||||
finding = evaluate_header(hsts_rule, headers)
|
||||
assert finding.status == "weak"
|
||||
assert finding.actual_value == "max-age=0; includeSubDomains"
|
||||
|
||||
|
||||
def test_evaluate_header_case_insensitive_lookup(
|
||||
referrer_rule: HeaderRule,
|
||||
) -> None:
|
||||
"""
|
||||
HTTP header names are case-insensitive per RFC 7230 — `Referrer-Policy`
|
||||
and `referrer-policy` and `REFERRER-POLICY` mean the same thing
|
||||
"""
|
||||
# The server returned the header with lowercase letters, but the
|
||||
# rule asks for "Referrer-Policy" with the canonical case.
|
||||
# Without case-insensitive lookup, this test would fail
|
||||
headers = {"referrer-policy": "no-referrer"}
|
||||
finding = evaluate_header(referrer_rule, headers)
|
||||
assert finding.status == "ok"
|
||||
|
||||
|
||||
def test_evaluate_header_no_must_match_treats_presence_as_ok(
|
||||
referrer_rule: HeaderRule,
|
||||
) -> None:
|
||||
"""
|
||||
A rule with must_match=None passes whenever the header exists
|
||||
"""
|
||||
headers = {"Referrer-Policy": "anything-here-works"}
|
||||
finding = evaluate_header(referrer_rule, headers)
|
||||
assert finding.status == "ok"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ScanReport.score and .grade — the math behind the report
|
||||
# =============================================================================
|
||||
# Score is computed from the findings on the fly, not stored. So we
|
||||
# can build a synthetic ScanReport with whatever findings we want and
|
||||
# assert exactly what the score should come out to
|
||||
|
||||
|
||||
def _make_report(statuses: list[Status]) -> ScanReport:
|
||||
"""
|
||||
Build a fake ScanReport pairing each rule with the given status
|
||||
|
||||
Helper so each test does not have to construct findings by hand.
|
||||
The first item in `statuses` pairs with the first rule, etc.
|
||||
Pad the list with "missing" if it is shorter than RULES.
|
||||
|
||||
The parameter is typed as `list[Status]` so mypy enforces the
|
||||
Literal contract at every call site — no runtime check or
|
||||
type-ignore escape hatch needed
|
||||
"""
|
||||
findings: list[HeaderFinding] = []
|
||||
for index, rule in enumerate(RULES):
|
||||
# When the caller passed fewer statuses than rules, treat the
|
||||
# rest as missing. Common when a test only cares about the
|
||||
# first few rules
|
||||
status: Status = (
|
||||
statuses[index] if index < len(statuses) else "missing"
|
||||
)
|
||||
findings.append(
|
||||
HeaderFinding(
|
||||
rule = rule,
|
||||
status = status,
|
||||
actual_value = None,
|
||||
note = "synthetic",
|
||||
)
|
||||
)
|
||||
return ScanReport(
|
||||
url = "https://example.com",
|
||||
final_url = "https://example.com",
|
||||
status_code = 200,
|
||||
findings = findings,
|
||||
)
|
||||
|
||||
|
||||
def test_score_all_ok_is_100() -> None:
|
||||
"""
|
||||
Every rule passing should yield a perfect score
|
||||
"""
|
||||
statuses: list[Status] = ["ok"] * len(RULES)
|
||||
report = _make_report(statuses)
|
||||
assert report.score == 100
|
||||
# And the grade follows the score
|
||||
assert report.grade == "A"
|
||||
|
||||
|
||||
def test_score_all_missing_is_zero() -> None:
|
||||
"""
|
||||
Nothing present, nothing earned. Score = 0, grade = F
|
||||
"""
|
||||
statuses: list[Status] = ["missing"] * len(RULES)
|
||||
report = _make_report(statuses)
|
||||
assert report.score == 0
|
||||
assert report.grade == "F"
|
||||
|
||||
|
||||
def test_grade_threshold_a_at_90_percent() -> None:
|
||||
"""
|
||||
Passing both highs and both mediums (90/100 = 90%) lands exactly
|
||||
on the A boundary
|
||||
|
||||
The current rules table totals 100 points (30 + 30 + 15 + 15 + 5 + 5)
|
||||
Two `high` rules = 60 / 100 = 60%, which is grade D
|
||||
Both highs + both mediums = 90 / 100 = 90%, which is grade A
|
||||
"""
|
||||
statuses_by_severity: dict[str, Status] = {
|
||||
"high": "ok",
|
||||
"medium": "ok",
|
||||
"low": "missing",
|
||||
}
|
||||
statuses: list[Status] = [
|
||||
statuses_by_severity[r.severity] for r in RULES
|
||||
]
|
||||
report = _make_report(statuses)
|
||||
assert report.score == 90
|
||||
assert report.grade == "A"
|
||||
|
||||
|
||||
def test_grade_threshold_b_at_83_percent() -> None:
|
||||
"""
|
||||
Both highs ok, one medium ok and the other weak (60 + 15 + 7.5 = 82.5
|
||||
→ rounds up to 83) drops below 90 and lands in the B band
|
||||
"""
|
||||
# Two highs ok, mediums split between ok and weak, lows missing
|
||||
statuses: list[Status] = []
|
||||
medium_seen = 0
|
||||
for rule in RULES:
|
||||
if rule.severity == "high":
|
||||
statuses.append("ok")
|
||||
elif rule.severity == "medium":
|
||||
statuses.append("ok" if medium_seen == 0 else "weak")
|
||||
medium_seen += 1
|
||||
else:
|
||||
statuses.append("missing")
|
||||
report = _make_report(statuses)
|
||||
assert 80 <= report.score < 90
|
||||
assert report.grade == "B"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# scan() — full pipeline against a mocked response
|
||||
# =============================================================================
|
||||
# `@respx.mock` intercepts every httpx request inside the test and
|
||||
# returns whatever we set up in the body. The real network is never
|
||||
# touched
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_scan_mocks_a_clean_response_and_grades_it_correctly() -> None:
|
||||
"""
|
||||
A response with every recommended header set should score 100
|
||||
"""
|
||||
# We respond to GET https://safe.example.com/ with a 200 and the
|
||||
# full set of security headers. respx.get(...).mock(return_value=...)
|
||||
# registers the mock; the next httpx.get inside this test fires it
|
||||
respx.get("https://safe.example.com/").mock(
|
||||
return_value = httpx.Response(
|
||||
status_code = 200,
|
||||
headers = {
|
||||
"Strict-Transport-Security":
|
||||
"max-age=31536000; includeSubDomains",
|
||||
"Content-Security-Policy": "default-src 'self'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Permissions-Policy": "camera=(), microphone=()",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
report = scan("https://safe.example.com/")
|
||||
|
||||
assert report.status_code == 200
|
||||
assert report.score == 100
|
||||
assert report.grade == "A"
|
||||
# Every finding should be `ok`
|
||||
assert all(f.status == "ok" for f in report.findings)
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_scan_flags_missing_and_weak_headers() -> None:
|
||||
"""
|
||||
A response missing CSP and with a weak X-Content-Type-Options
|
||||
should produce findings of mixed status
|
||||
"""
|
||||
respx.get("https://weak.example.com/").mock(
|
||||
return_value = httpx.Response(
|
||||
status_code = 200,
|
||||
headers = {
|
||||
"Strict-Transport-Security": "max-age=600",
|
||||
# X-Content-Type-Options is present but value is wrong:
|
||||
# the rule requires `nosniff`, this says something else.
|
||||
# We pick a value that genuinely does NOT contain the
|
||||
# substring `nosniff` — `"snifftest"` would actually be
|
||||
# treated as ok because it embeds the word
|
||||
"X-Content-Type-Options": "off",
|
||||
# Note: Content-Security-Policy is NOT included
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
report = scan("https://weak.example.com/")
|
||||
|
||||
findings_by_header = {f.rule.header: f for f in report.findings}
|
||||
assert findings_by_header["Content-Security-Policy"
|
||||
].status == "missing"
|
||||
assert findings_by_header["X-Content-Type-Options"].status == "weak"
|
||||
assert findings_by_header["Strict-Transport-Security"].status == "ok"
|
||||
|
||||
# Score should be less than 100 since CSP is missing and XCTO is weak
|
||||
assert report.score < 100
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_scan_records_final_url_after_redirect() -> None:
|
||||
"""
|
||||
When http://x.example.com/ → https://x.example.com/, the report
|
||||
must remember the final URL — that is what the user actually
|
||||
landed on
|
||||
"""
|
||||
# First request: 301 to the https version
|
||||
respx.get("http://redirect.example.com/").mock(
|
||||
return_value = httpx.Response(
|
||||
status_code = 301,
|
||||
headers = {"Location": "https://redirect.example.com/"},
|
||||
)
|
||||
)
|
||||
# Final request: 200 with one header set
|
||||
respx.get("https://redirect.example.com/").mock(
|
||||
return_value = httpx.Response(
|
||||
status_code = 200,
|
||||
headers = {"X-Frame-Options": "DENY"},
|
||||
)
|
||||
)
|
||||
|
||||
report = scan("http://redirect.example.com/")
|
||||
|
||||
assert report.url == "http://redirect.example.com/"
|
||||
assert report.final_url == "https://redirect.example.com/"
|
||||
assert report.status_code == 200
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-headers-scanner"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pytest" },
|
||||
{ name = "respx" },
|
||||
{ name = "ruff" },
|
||||
{ name = "yapf" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1.0" },
|
||||
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.5" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
||||
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.23.1" },
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.12" },
|
||||
{ name = "yapf", marker = "extra == 'dev'", specifier = ">=0.43.0,<1.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "8.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ast-serialize" },
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "dill" },
|
||||
{ name = "isort" },
|
||||
{ name = "mccabe" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yapf"
|
||||
version = "0.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
|
||||
]
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
#
|
||||
# This file lists patterns of files and folders that git should NEVER
|
||||
# track. Anything matching a pattern below is invisible to `git status`
|
||||
# and won't be committed. We do this for two reasons:
|
||||
#
|
||||
# 1. Privacy/security — we never want to accidentally commit a real
|
||||
# password vault. The vault file should stay on the user's machine.
|
||||
#
|
||||
# 2. Noise — build artifacts and caches change constantly. Tracking
|
||||
# them would clutter every commit and create merge conflicts.
|
||||
#
|
||||
# Each line is a "glob" pattern (* matches any characters).
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vault files — NEVER commit a real password vault to git
|
||||
# -----------------------------------------------------------------------------
|
||||
*.vault
|
||||
vault.json
|
||||
.password-vault/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Python build/install artifacts
|
||||
# -----------------------------------------------------------------------------
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
*.egg
|
||||
build/
|
||||
dist/
|
||||
.eggs/
|
||||
*.so
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Virtual environments — created by `uv venv`, never committed
|
||||
# -----------------------------------------------------------------------------
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tooling caches
|
||||
# -----------------------------------------------------------------------------
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IDE / editor scratch files
|
||||
# -----------------------------------------------------------------------------
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Local environment files (may contain secrets)
|
||||
# -----------------------------------------------------------------------------
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .style.yapf
|
||||
#
|
||||
# Configuration for yapf, our Python code formatter. yapf rewrites
|
||||
# Python files to match these rules so every file looks identical
|
||||
# no matter who wrote it.
|
||||
#
|
||||
# The two settings most worth knowing:
|
||||
# based_on_style = pep8 → start from PEP 8 (the official style)
|
||||
# column_limit = 75 → wrap lines at 75 characters
|
||||
#
|
||||
# Run with: `just format`
|
||||
[style]
|
||||
# Start from PEP 8 (Python's official style guide). Every other
|
||||
# setting below tweaks PEP 8 in some specific direction.
|
||||
based_on_style = pep8
|
||||
# Wrap any line longer than this many columns.
|
||||
column_limit = 75
|
||||
# Use 4 spaces per indent level (PEP 8 standard).
|
||||
indent_width = 4
|
||||
# Continuation lines (when a statement wraps) also indent by 4.
|
||||
continuation_indent_width = 4
|
||||
# Don't indent the closing bracket of a multi-line call/list/dict
|
||||
indent_closing_brackets = false
|
||||
# DO de-indent the closing bracket — line it up with the opener.
|
||||
dedent_closing_brackets = true
|
||||
# Don't add 4 spaces of indent to blank lines inside functions.
|
||||
indent_blank_lines = false
|
||||
# Two spaces between code and the # of a trailing comment.
|
||||
spaces_before_comment = 2
|
||||
# No spaces around ** (power) operator: 2**3 not 2 ** 3.
|
||||
spaces_around_power_operator = false
|
||||
# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
|
||||
spaces_around_default_or_named_assign = true
|
||||
# No extra space before a comma followed by a closing bracket.
|
||||
space_between_ending_comma_and_closing_bracket = false
|
||||
# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
|
||||
space_inside_brackets = false
|
||||
# Spaces around colons in slices: x[1 : 5] not x[1:5].
|
||||
spaces_around_subscript_colon = true
|
||||
# No blank line before nested def/class.
|
||||
blank_line_before_nested_class_or_def = false
|
||||
# No blank line between a class and its docstring.
|
||||
blank_line_before_class_docstring = false
|
||||
# Two blank lines around top-level functions and classes (PEP 8).
|
||||
blank_lines_around_top_level_definition = 2
|
||||
# Two blank lines between top-level imports and module-level variables.
|
||||
blank_lines_between_top_level_imports_and_variables = 2
|
||||
# No blank line before the module docstring.
|
||||
blank_line_before_module_docstring = false
|
||||
# When wrapping `a and b`, put `and` at the start of the new line.
|
||||
split_before_logical_operator = true
|
||||
# When wrapping a function call, put the first argument on a new line.
|
||||
split_before_first_argument = true
|
||||
# Put each named arg on its own line when wrapping.
|
||||
split_before_named_assigns = true
|
||||
# Multi-line comprehensions wrap onto separate lines.
|
||||
split_complex_comprehension = true
|
||||
# Don't split right after the opening parenthesis.
|
||||
split_before_expression_after_opening_paren = false
|
||||
# Put the closing bracket on its own line when wrapping.
|
||||
split_before_closing_bracket = true
|
||||
# When splitting at commas, give every value its own line.
|
||||
split_all_comma_separated_values = true
|
||||
# But don't recursively force ALL top-level commas onto new lines.
|
||||
split_all_top_level_comma_separated_values = false
|
||||
# Don't merge separate brackets into one.
|
||||
coalesce_brackets = false
|
||||
# Each dict entry gets its own line in multi-line dicts.
|
||||
each_dict_entry_on_separate_line = true
|
||||
# Disallow lambdas that span multiple lines.
|
||||
allow_multiline_lambdas = false
|
||||
# Disallow dict keys that span multiple lines.
|
||||
allow_multiline_dictionary_keys = false
|
||||
# Penalty score for splitting at import names — 0 means free to split.
|
||||
split_penalty_import_names = 0
|
||||
# Don't try to fit an already-broken statement back onto fewer lines.
|
||||
join_multiple_lines = false
|
||||
# Closing bracket lines up with the visual indent of the opener.
|
||||
align_closing_bracket_with_visual_indent = true
|
||||
# Don't add parentheses to clarify operator precedence automatically.
|
||||
arithmetic_precedence_indication = false
|
||||
# Penalty for splitting after each line — yapf weighs this against rules.
|
||||
split_penalty_for_added_line_split = 275
|
||||
# Use spaces for indentation, never tabs (PEP 8).
|
||||
use_tabs = false
|
||||
# Don't split on a `.` when wrapping (so `obj.method()` stays together).
|
||||
split_before_dot = false
|
||||
# When the last argument in a call has a trailing comma, force a split.
|
||||
split_arguments_when_comma_terminated = true
|
||||
# Function names yapf treats as i18n calls — leave their args alone.
|
||||
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
|
||||
# Comment markers yapf treats as i18n metadata.
|
||||
i18n_comment = ['# Translators:', '# i18n:']
|
||||
# Penalty for splitting comprehension iterables.
|
||||
split_penalty_comprehension = 80
|
||||
# Penalty for splitting right after an opening bracket.
|
||||
split_penalty_after_opening_bracket = 280
|
||||
# Penalty for splitting before an `if` in a ternary expression.
|
||||
split_penalty_before_if_expr = 0
|
||||
# Penalty for splitting before a bitwise operator.
|
||||
split_penalty_bitwise_operator = 290
|
||||
# Penalty for splitting before a logical operator (already lowered).
|
||||
split_penalty_logical_operator = 0
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
```ruby
|
||||
██████╗ █████╗ ███████╗███████╗ ██╗ ██╗ █████╗ ██╗ ██╗██╗ ████████╗
|
||||
██╔══██╗██╔══██╗██╔════╝██╔════╝ ██║ ██║██╔══██╗██║ ██║██║ ╚══██╔══╝
|
||||
██████╔╝███████║███████╗███████╗ ██║ ██║███████║██║ ██║██║ ██║
|
||||
██╔═══╝ ██╔══██║╚════██║╚════██║ ╚██╗ ██╔╝██╔══██║██║ ██║██║ ██║
|
||||
██║ ██║ ██║███████║███████║ ╚████╔╝ ██║ ██║╚██████╔╝███████╗ ██║
|
||||
╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/password-manager)
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
|
||||
[](#)
|
||||
[](https://www.python.org)
|
||||
[](https://en.wikipedia.org/wiki/Argon2)
|
||||
[](https://en.wikipedia.org/wiki/Galois/Counter_Mode)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://pytest.org)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
|
||||
> Encrypted command-line password manager — Argon2id key derivation, AES-256-GCM authenticated encryption, atomic durable writes, advisory file locking. One master password protects every credential you trust to it.
|
||||
|
||||
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
|
||||
|
||||
> [!NOTE]
|
||||
> **Foundations tier — the hardest of the three.** This is a stepping stone *into* the beginner tier. It assumes no prior Python experience but ramps faster than [`hash-identifier`](../hash-identifier) and [`http-headers-scanner`](../http-headers-scanner). The source is heavily commented as a teaching aid, the `learn/` folder explains every cryptographic idea from zero, and every Python feature is introduced when it first appears. If "what's a `@dataclass`" feels like the wrong question, start with `hash-identifier` first.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Stores credentials in a single encrypted JSON file at `~/.password-vault/vault.json` (mode `0600`)
|
||||
- Derives a 32-byte AES key from your master password via **Argon2id** (OWASP-recommended parameters, ~0.5s per derivation)
|
||||
- Encrypts vault contents with **AES-256-GCM** — confidentiality + tamper detection in one primitive
|
||||
- **Atomic, durable, concurrent-safe** writes: tmp file → fsync → atomic rename → directory fsync, with advisory `fcntl` lock to serialize concurrent `pv` invocations
|
||||
- Master password rotation that re-encrypts the entire vault under a fresh salt and key
|
||||
- Cryptographically secure password generator using `secrets` (never `random`) with a Fisher-Yates shuffle on top of `secrets.randbelow`
|
||||
- Stores KDF parameters *in the file* — old vaults remain readable when defaults change, and rotation can upgrade them transparently
|
||||
- Typed exception hierarchy (`WrongPasswordError`, `VaultFormatError`, `EntryNotFoundError`, …) for precise error handling
|
||||
- Rich-rendered colored panels and tables; pipe-friendly stdout/stderr separation
|
||||
- Refuses to distinguish "wrong password" from "tampered file" — both look the same cryptographically, exposing the difference helps attackers
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
just run -- init
|
||||
just run -- add github
|
||||
just run -- get github
|
||||
```
|
||||
|
||||
```text
|
||||
$ pv init
|
||||
New master password: ************
|
||||
Confirm master password: ************
|
||||
Vault created at /home/you/.password-vault/vault.json
|
||||
|
||||
$ pv add github
|
||||
Username for github: alice
|
||||
Password for github (hidden): ************
|
||||
URL (optional, press Enter to skip): https://github.com
|
||||
Notes (optional, press Enter to skip):
|
||||
Added entry: github
|
||||
|
||||
$ pv get github
|
||||
╭────────────────── github ──────────────────╮
|
||||
│ username alice │
|
||||
│ password hunter2-but-better │
|
||||
│ url https://github.com │
|
||||
│ created 2026-05-13T14:22:10+00:00 │
|
||||
│ updated 2026-05-13T14:22:10+00:00 │
|
||||
╰────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available recipes.
|
||||
>
|
||||
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `pv init` | Create a new empty vault. Prompts for the master password twice. |
|
||||
| `pv add <name>` | Add an entry. Prompts for username, password, optional URL and notes. `--generate` / `-g` to use a random password. |
|
||||
| `pv get <name>` | Show every field of one entry in a colored panel. |
|
||||
| `pv list` | Print every entry name as a table (no passwords shown). |
|
||||
| `pv delete <name>` | Remove an entry by name. |
|
||||
| `pv gen [length]` | Generate a strong random password and print it to stdout. No vault required. |
|
||||
| `pv change-password` | Rotate the master password — re-encrypts the entire vault under a fresh salt and key. |
|
||||
|
||||
Every command takes `--vault PATH` (or `$PV_VAULT`) to point at an alternate vault file.
|
||||
|
||||
## Demo: pipe-friendly generation
|
||||
|
||||
```bash
|
||||
# Generate and copy to clipboard (macOS)
|
||||
just run -- gen 32 | pbcopy
|
||||
|
||||
# Generate and copy to clipboard (Linux)
|
||||
just run -- gen 32 | xclip -selection clipboard
|
||||
|
||||
# Generate into a shell variable
|
||||
PASSWORD=$(just run -- gen 32)
|
||||
|
||||
# Letters + digits only, no symbols
|
||||
just run -- gen 24 --no-symbols
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `pv` *never* accepts the master password as a CLI flag. Passwords passed as flags leak into shell history (`history` command) and process listings (`ps aux`). Every prompt uses `getpass.getpass()` — same primitive `sudo` uses — so the password is never echoed and never logged.
|
||||
|
||||
## Cryptographic guarantees
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---------|-----------|
|
||||
| Vault file stolen | Argon2id with 64 MiB / 3 passes / 4 lanes makes each guess ~0.5s; a billion guesses ≈ 15 years |
|
||||
| Vault file tampered | AES-GCM authentication tag refuses to decrypt; same error as "wrong password" by design |
|
||||
| Power loss mid-save | Atomic write: tmp → fsync → `os.replace` → parent-dir fsync. Always old-or-new, never half |
|
||||
| Two `pv` processes racing | Advisory `fcntl.LOCK_EX` on sidecar `.lock` file (POSIX; NTFS atomic-rename on Windows) |
|
||||
| Vault tmp world-readable | `os.open` with mode `0o600` at the very first syscall — no chmod race window |
|
||||
| Predictable random output | `secrets` module everywhere — for salts, nonces, passwords, and the Fisher-Yates shuffle |
|
||||
| Aging KDF parameters | Parameters stored in the vault file; `change-password` can upgrade them transparently |
|
||||
| KDF parameter corruption | Validated against Argon2's algorithmic floors on load; clean `VaultFormatError` instead of library crash |
|
||||
| Forward-incompatible format | Top-level `version` field; future versions can refuse or migrate |
|
||||
|
||||
What this project does *not* defend against — and why — is documented honestly in [`learn/01-CONCEPTS.md §12`](learn/01-CONCEPTS.md#12-putting-it-all-together-the-threat-model).
|
||||
|
||||
## Tooling
|
||||
|
||||
```bash
|
||||
just # list available recipes
|
||||
just test # run pytest (60+ tests across crypto, vault, generator)
|
||||
just test-cov # tests + coverage report
|
||||
just lint # ruff + mypy + pylint
|
||||
just format # yapf
|
||||
just run -- <cmd> [args]
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.13+** — the install script will check.
|
||||
- [`uv`](https://github.com/astral-sh/uv) — modern Python package manager (auto-installed by `./install.sh`).
|
||||
- [`just`](https://github.com/casey/just) — command runner (auto-installed by `./install.sh`).
|
||||
- Linux, macOS, or WSL2 strongly recommended over native Windows — file locking and directory `fsync` paths are POSIX-flavored. NTFS gives atomic `os.replace` regardless, so native Windows works with reduced concurrency guarantees.
|
||||
|
||||
No compilers or system libraries beyond what `argon2-cffi` and `cryptography` install through `uv`. No network access required at runtime.
|
||||
|
||||
## Learn
|
||||
|
||||
This project includes step-by-step learning materials covering the security theory, architecture, and implementation — written for someone who has never touched Python *or* cryptography before. Read them in order.
|
||||
|
||||
| Module | Topic |
|
||||
|--------|-------|
|
||||
| [00 - Overview](learn/00-OVERVIEW.md) | Quick start, prerequisites, project layout, common problems |
|
||||
| [01 - Concepts](learn/01-CONCEPTS.md) | What encryption *is*, KDFs, Argon2id, salts, AES-GCM, nonces, the threat model, real breaches |
|
||||
| [02 - Architecture](learn/02-ARCHITECTURE.md) | Five-file layout, on-disk format, per-command flow diagrams, the atomic-write pipeline |
|
||||
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Line-by-line walkthrough of every source file — every Python feature explained when first encountered |
|
||||
| [04 - Challenges](learn/04-CHALLENGES.md) | Fifteen extension ideas across four tiers, from a `search` command to porting the vault format to another language |
|
||||
|
||||
## See Also
|
||||
|
||||
- [`PROJECTS/foundations/hash-identifier`](../hash-identifier) — the easiest foundations project. Start here if password-manager feels too dense.
|
||||
- [`PROJECTS/foundations/http-headers-scanner`](../http-headers-scanner) — the middle foundations project; covers HTTP and basic I/O.
|
||||
- [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural cracking companion. Once you understand *why* Argon2id is slow, that project shows you what it's slowing *down*.
|
||||
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) — the authoritative reference for the parameter choices in [`constants.py`](src/password_manager/constants.py).
|
||||
- [`age`](https://github.com/FiloSottile/age) — a production-quality file encryption tool that makes many of the same trade-offs as this project at a much larger scale.
|
||||
|
||||
## License
|
||||
|
||||
AGPL 3.0
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
#!/usr/bin/env bash
|
||||
# ©AngelaMos | 2026
|
||||
# install.sh
|
||||
#
|
||||
# Zero-friction install script. Anyone who clones this project should
|
||||
# be able to run `./install.sh` and end up with a working setup,
|
||||
# regardless of whether they have uv or just installed yet.
|
||||
#
|
||||
# What this script does, in order:
|
||||
# 1. Verifies Python 3.13+ is installed (we need modern type-hint syntax)
|
||||
# 2. Installs uv if it is missing (uv is the Python package manager we use)
|
||||
# 3. Installs just if it is missing (just is the command runner)
|
||||
# 4. Calls `just setup` to create the venv and install dependencies
|
||||
# 5. Prints next steps
|
||||
#
|
||||
# Run with: ./install.sh
|
||||
# Or: bash install.sh
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bash safety flags — fail fast and loud
|
||||
# -----------------------------------------------------------------------------
|
||||
# -e : exit immediately if any command returns a non-zero (error) status
|
||||
# -u : treat unset variables as an error
|
||||
# -o pipefail : if any command in a pipeline fails, the whole pipeline fails
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Color helpers — pretty terminal output without external dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # "no color" — resets the terminal back to default
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 1 — Confirm Python 3.13+ is on the system
|
||||
# -----------------------------------------------------------------------------
|
||||
check_python() {
|
||||
info "Checking for Python 3.13+..."
|
||||
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
error "python3 not found. Please install Python 3.13 or newer."
|
||||
error " macOS: brew install python@3.13"
|
||||
error " Linux: sudo apt install python3.13 (Debian/Ubuntu)"
|
||||
error " Windows: download from python.org"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract version "3.13.1" → split on dots → take major.minor
|
||||
local version
|
||||
version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
local major minor
|
||||
major=$(echo "$version" | cut -d. -f1)
|
||||
minor=$(echo "$version" | cut -d. -f2)
|
||||
|
||||
if (( major < 3 )) || { (( major == 3 )) && (( minor < 13 )); }; then
|
||||
error "Python 3.13+ is required, found Python $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Python $version detected"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 2 — Install uv if missing (https://docs.astral.sh/uv)
|
||||
# -----------------------------------------------------------------------------
|
||||
install_uv() {
|
||||
if command -v uv &>/dev/null; then
|
||||
success "uv already installed ($(uv --version))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Installing uv (Python package manager)..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# The installer drops uv into ~/.local/bin or ~/.cargo/bin.
|
||||
# Add it to PATH for the rest of THIS script's run.
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
if ! command -v uv &>/dev/null; then
|
||||
error "uv install completed but `uv` is still not on PATH."
|
||||
error "Restart your shell and re-run this script, or add uv to PATH manually."
|
||||
exit 1
|
||||
fi
|
||||
success "uv installed"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 3 — Install just if missing (https://github.com/casey/just)
|
||||
# -----------------------------------------------------------------------------
|
||||
install_just() {
|
||||
if command -v just &>/dev/null; then
|
||||
success "just already installed ($(just --version))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Installing just (command runner)..."
|
||||
# uv can install Cargo binaries via Cargo, but easier: use the
|
||||
# official installer. Drops binary into ~/.local/bin.
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
|
||||
| bash -s -- --to "$HOME/.local/bin"
|
||||
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
success "just installed"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 4 — Use just to set up the project (venv + dependencies)
|
||||
# -----------------------------------------------------------------------------
|
||||
project_setup() {
|
||||
info "Running 'just setup'..."
|
||||
just setup
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main — orchestrate the steps and print next instructions
|
||||
# -----------------------------------------------------------------------------
|
||||
main() {
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " password-vault — install"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
check_python
|
||||
install_uv
|
||||
install_just
|
||||
project_setup
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
success "Install complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " just run -- --help # see available commands"
|
||||
echo " just run -- init # create your first vault"
|
||||
echo " just test # run the test suite"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
# ©AngelaMos | 2026
|
||||
# justfile
|
||||
#
|
||||
# A "justfile" is a list of commands you can run with `just <name>`.
|
||||
# Think of it as a project's command center — instead of remembering
|
||||
# `uv run pytest tests/ -v --cov=src`, you just type `just test`.
|
||||
#
|
||||
# Why use just instead of make? It is simpler, cross-platform,
|
||||
# and the syntax is easier to read.
|
||||
#
|
||||
# Show all commands: `just`
|
||||
# Run a command: `just <name>` (e.g. `just setup`)
|
||||
|
||||
set export
|
||||
set shell := ["bash", "-uc"]
|
||||
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||
# Make recipe arguments available to shebang scripts as `$1`, `$2`,
|
||||
# `$@`, and `$#`. Without this, shebang recipes only see args via the
|
||||
# textual `{{args}}` substitution, which is unsafe for inputs that
|
||||
# contain `$` (the substituted text gets re-expanded by bash).
|
||||
set positional-arguments
|
||||
|
||||
# Show available commands when you run `just` with no args.
|
||||
default:
|
||||
@just --list --unsorted
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Setup Commands
|
||||
# =============================================================================
|
||||
|
||||
# One-shot first-time setup — creates .venv and installs everything
|
||||
[group('setup')]
|
||||
setup:
|
||||
@echo "Creating virtual environment with uv..."
|
||||
uv venv --allow-existing
|
||||
@echo ""
|
||||
@echo "Installing dependencies (including dev tools)..."
|
||||
uv sync --all-extras
|
||||
@echo ""
|
||||
@echo "✓ Setup complete!"
|
||||
@echo ""
|
||||
@echo "Try it out:"
|
||||
@echo " just run -- --help"
|
||||
@echo " just test"
|
||||
|
||||
# Install runtime dependencies only (no dev tools)
|
||||
[group('setup')]
|
||||
install:
|
||||
uv sync
|
||||
|
||||
# Install runtime + dev dependencies
|
||||
[group('setup')]
|
||||
install-dev:
|
||||
uv sync --all-extras
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Testing & Quality Checks
|
||||
# =============================================================================
|
||||
|
||||
# Run the test suite
|
||||
[group('test')]
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
uv run pytest
|
||||
|
||||
# Run tests with a coverage report
|
||||
[group('test')]
|
||||
test-cov:
|
||||
@echo "Running tests with coverage..."
|
||||
uv run pytest --cov=password_manager --cov-report=term-missing
|
||||
|
||||
# Run all linters (ruff + pylint + mypy)
|
||||
[group('test')]
|
||||
lint:
|
||||
@echo "=== Ruff ==="
|
||||
uv run ruff check src tests
|
||||
@echo ""
|
||||
@echo "=== Pylint ==="
|
||||
uv run pylint src/password_manager
|
||||
@echo ""
|
||||
@echo "=== Mypy ==="
|
||||
uv run mypy src/password_manager
|
||||
@echo ""
|
||||
@echo "✓ All linters passed"
|
||||
|
||||
# Auto-format every Python file with yapf
|
||||
[group('test')]
|
||||
format:
|
||||
@echo "Formatting code with yapf..."
|
||||
uv run yapf -i -r src tests
|
||||
@echo "✓ Code formatted"
|
||||
|
||||
# Auto-fix what ruff can fix on its own (unused imports, etc.)
|
||||
[group('test')]
|
||||
fix:
|
||||
uv run ruff check src tests --fix
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run the CLI
|
||||
# =============================================================================
|
||||
|
||||
# Run the password-vault CLI — pass extra args after `--`
|
||||
# Example: just run -- list
|
||||
# just run -- add github
|
||||
# [no-exit-message] silences just's "Recipe `run` failed with exit
|
||||
# code N" line when pv exits non-zero. pv uses non-zero exits to
|
||||
# signal "wrong password", "entry not found", etc — real outcomes
|
||||
# the CLI already explained, not "the recipe is broken." The exit
|
||||
# code itself is still propagated to whoever invoked just.
|
||||
[group('run')]
|
||||
[no-exit-message]
|
||||
run *args:
|
||||
#!/usr/bin/env bash
|
||||
# If no args were given, print a friendly usage block and exit
|
||||
# cleanly. Without this, `just run` (no args) would invoke
|
||||
# `uv run pv` with nothing, argparse would exit 2, and just
|
||||
# would tack a "Recipe `run` failed" error on top — confusing for
|
||||
# someone just trying to see how the command works.
|
||||
if [ $# -eq 0 ]; then
|
||||
cat <<'EOF'
|
||||
Usage: just run -- <command> [args]
|
||||
|
||||
Examples:
|
||||
just run -- list
|
||||
just run -- add github
|
||||
just run -- get github
|
||||
just run -- remove github
|
||||
|
||||
See all commands:
|
||||
just run -- --help
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
# Forward args via "$@" — NOT via {{args}}. Why: {{args}} is a
|
||||
# TEXTUAL substitution done by just before bash runs the script,
|
||||
# so a password with $ in it would get those $-references expanded
|
||||
# as bash variables and arrive at pv mangled. "$@" hands bash the
|
||||
# original argv unchanged.
|
||||
uv run pv "$@"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility / Cleanup
|
||||
# =============================================================================
|
||||
|
||||
# Delete the venv and all build / cache artifacts
|
||||
[group('utility')]
|
||||
clean:
|
||||
rm -rf .venv
|
||||
rm -rf __pycache__ src/**/__pycache__ tests/__pycache__
|
||||
rm -rf .mypy_cache .ruff_cache .pytest_cache
|
||||
rm -rf *.egg-info build dist
|
||||
rm -rf .coverage htmlcov
|
||||
@echo "✓ Cleaned"
|
||||
|
||||
# Lock the exact dependency versions to uv.lock
|
||||
[group('utility')]
|
||||
lock:
|
||||
uv lock
|
||||
|
||||
# Upgrade all dependencies to latest allowed versions
|
||||
[group('utility')]
|
||||
update:
|
||||
uv lock --upgrade
|
||||
uv sync --all-extras
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CI Pipeline (lint + test, no setup)
|
||||
# =============================================================================
|
||||
|
||||
# Full pipeline: setup + lint + test. For first-time runs.
|
||||
[group('ci')]
|
||||
all: setup lint test
|
||||
@echo ""
|
||||
@echo "✓ Setup, lint, and tests all passed"
|
||||
|
||||
# Lint + test only — what CI runs after dependencies are installed
|
||||
[group('ci')]
|
||||
ci: lint test
|
||||
@echo "✓ CI checks passed"
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
# Password Vault
|
||||
|
||||
## What this is
|
||||
|
||||
A small command-line password manager written in Python. You type a master password once, and the tool stores every other password you give it inside a single encrypted file. Later, you ask the tool for "github" or "email" or "bank" and it hands the password back.
|
||||
|
||||
The whole thing is roughly 1,400 lines of code spread across five files. No web server, no browser extension, no cloud account. One file on disk, one master password in your head.
|
||||
|
||||
```
|
||||
$ pv init
|
||||
New master password: ************
|
||||
Confirm master password: ************
|
||||
Vault created at /home/you/.password-vault/vault.json
|
||||
|
||||
$ pv add github
|
||||
Username for github: alice
|
||||
Password for github (hidden): ************
|
||||
URL (optional, press Enter to skip): https://github.com
|
||||
Notes (optional, press Enter to skip):
|
||||
Added entry: github
|
||||
|
||||
$ pv get github
|
||||
╭────────────────── github ──────────────────╮
|
||||
│ username alice │
|
||||
│ password hunter2-but-better │
|
||||
│ url https://github.com │
|
||||
│ created 2026-05-13T14:22:10+00:00 │
|
||||
│ updated 2026-05-13T14:22:10+00:00 │
|
||||
╰────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
That's the whole tool. It also has `list`, `delete`, `gen` (generate a strong random password), and `change-password` (rotate the master password).
|
||||
|
||||
## Why anyone needs this
|
||||
|
||||
The honest answer is "you do." Every site needs a password. Every password should be different. Nobody can remember 200 different passwords, so people reuse one — and the moment any single site gets breached, the attacker tries that same password everywhere else. This is called **credential stuffing** and it is how most account takeovers actually happen in 2025.
|
||||
|
||||
A password manager fixes this by giving you exactly one password to remember (the master) while it remembers all the rest. The "every site different" goal becomes achievable because you no longer have to hold them in your head.
|
||||
|
||||
**Real-world moments where the security choices in this project matter:**
|
||||
|
||||
- The [2013 Adobe breach](https://en.wikipedia.org/wiki/2013_Adobe_breach) leaked 153 million passwords. Adobe had encrypted them with a single key in ECB mode — no salt, no per-record randomness — so identical passwords produced identical ciphertexts. Researchers could see clusters of users who'd all picked `password123` just by looking at the encrypted file. This project's use of a fresh random nonce per save and Argon2id with a per-vault salt is the direct fix for that class of mistake.
|
||||
- The [2022 LastPass breach](https://blog.lastpass.com/posts/notice-of-recent-security-incident) leaked encrypted vault backups. Vaults with weak master passwords got cracked offline at scale because the attacker had unlimited time. The defense — make each guess expensive — is exactly what Argon2id gives us. Our defaults push each guess to roughly half a second on a modern laptop, which makes a billion-guess attack take ~15 years on the attacker's machine.
|
||||
- Every CTF challenge or pentest engagement where someone hands you "an encrypted file" and asks "is this safe at rest?" — by the end of this project you'll know what questions to ask (which KDF? which cipher mode? what's the salt situation? is the auth tag verified?).
|
||||
|
||||
## What you will learn
|
||||
|
||||
**Security ideas:**
|
||||
|
||||
- What **symmetric encryption** is — one key encrypts and decrypts. We use [AES-256-GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode), the same algorithm protecting your bank's HTTPS connection.
|
||||
- What a **key derivation function (KDF)** does — it turns a human password (short, weak, predictable) into a real cryptographic key (32 bytes of pure unpredictability). We use [Argon2id](https://en.wikipedia.org/wiki/Argon2), the winner of the 2015 Password Hashing Competition and the algorithm OWASP currently recommends for password storage.
|
||||
- Why **salts** and **nonces** are different things even though they're both "random bytes you store next to the ciphertext."
|
||||
- What **authenticated encryption** means and why "just encrypted" is not enough — without authentication, an attacker can flip bits in your file in predictable ways without knowing the key.
|
||||
- The difference between `random` (fine for a dice roll) and `secrets` (the only thing you should ever use when an attacker wants to predict the output).
|
||||
- Why we **store the KDF parameters in the file** instead of just hard-coding them, and how that decision enables `change-password` to upgrade old vaults to new defaults years later.
|
||||
- Why writing a file "atomically" (write to `.tmp`, fsync, rename) matters when a power loss could otherwise leave you with zero passwords.
|
||||
|
||||
**Python ideas (assuming this is your first time):**
|
||||
|
||||
- What a **package** is and what `__init__.py` does for it.
|
||||
- **Modules** and how `import` actually finds files.
|
||||
- **Type hints** — `str`, `int`, `bytes`, `list[str]`, `dict[str, Entry]`, `Path | None`. They are not enforced at runtime, but they are the most useful documentation in the language.
|
||||
- **`@dataclass`** — the shortcut for making record-like classes without writing `__init__` by hand.
|
||||
- **`Final`** type — telling Python "this value is a constant, never reassign it."
|
||||
- **Context managers** (`with vault.unlock(...) as v:`) — the cleanest way to say "set up something, use it, tear it down even on errors."
|
||||
- **Exceptions and custom exception classes** — defining your own error types and catching them by category.
|
||||
- **Generators**, **dict comprehensions**, **f-strings** — modern Python idioms you'll see everywhere.
|
||||
- How `pytest` works and why `conftest.py` is special.
|
||||
- How a CLI built with [Typer](https://typer.tiangolo.com) works — turning a function into a command just by writing its type hints.
|
||||
|
||||
**Tools you'll touch:**
|
||||
|
||||
- [`uv`](https://github.com/astral-sh/uv) — the modern Python package manager. Like `pip` but ~100× faster.
|
||||
- [`just`](https://github.com/casey/just) — a command runner. Instead of memorizing long commands, you type `just test` or `just run`.
|
||||
- [`typer`](https://typer.tiangolo.com) — the CLI framework.
|
||||
- [`rich`](https://github.com/Textualize/rich) — the library that prints the pretty colored panels and tables.
|
||||
- [`argon2-cffi`](https://github.com/hynek/argon2-cffi) — the Python binding for the Argon2 reference implementation.
|
||||
- [`cryptography`](https://cryptography.io) — the Python Cryptographic Authority's library. The gold standard.
|
||||
- [`pytest`](https://pytest.org) + [`ruff`](https://github.com/astral-sh/ruff) + [`mypy`](https://mypy-lang.org) + [`pylint`](https://pylint.org) — testing and linting.
|
||||
|
||||
## What you need before starting
|
||||
|
||||
**Knowledge you should have:**
|
||||
|
||||
- You've used a terminal at least once (you know what `cd` and `ls` do).
|
||||
- You've at least seen the words "hash" and "encryption" before. If they're meaningless to you, [01-CONCEPTS.md](./01-CONCEPTS.md) is built to start from zero — read it before the code.
|
||||
- You can read code, or you're willing to try. Every Python feature gets explained when it first appears.
|
||||
|
||||
**Knowledge you do NOT need:**
|
||||
|
||||
- Prior Python experience. The whole point of the **foundations** tier is that you start here. This project is the *hardest* of the three foundations projects, though — if anything in here feels too dense, try [hash-identifier](../../hash-identifier/) first.
|
||||
- Any prior cryptography knowledge. You'll learn what a KDF, a nonce, and an authentication tag are in [01-CONCEPTS.md](./01-CONCEPTS.md). No math beyond counting required — the math lives inside the libraries we call.
|
||||
- Any prior cybersecurity background.
|
||||
|
||||
**Software you need installed:**
|
||||
|
||||
- Python 3.13 or newer (3.14 recommended).
|
||||
- The `uv` tool (the install script will get this for you if you don't have it).
|
||||
- The `just` tool (also handled by the install script).
|
||||
- A terminal. Mac: Terminal.app or iTerm2. Linux: whatever your distro shipped. Windows: WSL2 + Ubuntu (strongly recommended over native Windows — the file-locking and fsync code paths are POSIX-flavored).
|
||||
|
||||
You do *not* need an IDE — any text editor works. [VS Code](https://code.visualstudio.com) with the Python extension is a fine default.
|
||||
|
||||
## Quick start
|
||||
|
||||
From inside `PROJECTS/foundations/password-manager/`:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
That script installs `uv` and `just` if missing, creates a virtual environment (an isolated Python sandbox just for this project), installs every dependency, and runs the test suite to confirm everything works. Read the output as it goes — don't just close the terminal.
|
||||
|
||||
Then create your first vault:
|
||||
|
||||
```bash
|
||||
just run -- init
|
||||
```
|
||||
|
||||
It will ask for a master password, ask you to type it again to confirm, and create an empty vault at `~/.password-vault/vault.json`. The first `init` is slow on purpose — that's Argon2id doing its job, deliberately taking about half a second to derive the key. An attacker who steals your vault file has to pay that same half-second cost for every password they want to guess.
|
||||
|
||||
Add an entry:
|
||||
|
||||
```bash
|
||||
just run -- add github
|
||||
```
|
||||
|
||||
It will prompt for the username, the password (input hidden), and optional URL and notes.
|
||||
|
||||
Look at the entry:
|
||||
|
||||
```bash
|
||||
just run -- get github
|
||||
```
|
||||
|
||||
You'll see a colored panel with every field. The password is shown in plain text — this is a local CLI tool, the user already trusts their own screen.
|
||||
|
||||
Generate a strong random password without touching the vault:
|
||||
|
||||
```bash
|
||||
just run -- gen 32
|
||||
```
|
||||
|
||||
This prints one 32-character password to stdout (nothing else). You can pipe it directly into your clipboard:
|
||||
|
||||
```bash
|
||||
just run -- gen 32 | pbcopy # macOS
|
||||
just run -- gen 32 | xclip -sel c # Linux
|
||||
```
|
||||
|
||||
List every entry name:
|
||||
|
||||
```bash
|
||||
just run -- list
|
||||
```
|
||||
|
||||
Change your master password (the vault gets re-encrypted under a new key):
|
||||
|
||||
```bash
|
||||
just run -- change-password
|
||||
```
|
||||
|
||||
Delete an entry:
|
||||
|
||||
```bash
|
||||
just run -- delete github
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
password-manager/
|
||||
├── src/password_manager/
|
||||
│ ├── __init__.py package metadata + re-exports
|
||||
│ ├── __main__.py lets `python -m password_manager` work
|
||||
│ ├── constants.py every magic number and fixed string
|
||||
│ ├── crypto.py Argon2id + AES-256-GCM primitives
|
||||
│ ├── generator.py cryptographically secure random passwords
|
||||
│ ├── vault.py file format, atomic writes, locking
|
||||
│ └── main.py the CLI commands (init, add, get, …)
|
||||
├── tests/
|
||||
│ ├── conftest.py shared pytest fixtures
|
||||
│ ├── test_crypto.py round-trip + tamper tests
|
||||
│ ├── test_generator.py pool/length/randomness tests
|
||||
│ └── test_vault.py end-to-end vault tests
|
||||
├── install.sh one-shot setup
|
||||
├── justfile shortcuts for run / test / lint / format
|
||||
├── pyproject.toml project config + dependencies + linter rules
|
||||
├── README.md short pointer to this folder
|
||||
├── learn/ you are here
|
||||
│ ├── 00-OVERVIEW.md quick start (this file)
|
||||
│ ├── 01-CONCEPTS.md KDFs, AES-GCM, salts, nonces, real breaches
|
||||
│ ├── 02-ARCHITECTURE.md module layout, data flows, file format
|
||||
│ ├── 03-IMPLEMENTATION.md line-by-line walkthrough
|
||||
│ └── 04-CHALLENGES.md extensions if you want to keep going
|
||||
└── assets/ images, screenshots
|
||||
```
|
||||
|
||||
The split across five source files is the one place this project leaves the "single-file" simplicity of `hash-identifier`. Cryptography wants strict boundaries: the file that talks to `secrets.token_bytes` is a different file from the file that talks to your filesystem, and *both* are different from the file that prints colored panels. If a bug ever creeps in, the small file it lives in is the first place to look.
|
||||
|
||||
## Where to go next
|
||||
|
||||
1. **[01-CONCEPTS.md](./01-CONCEPTS.md)** — the security ideas. What a KDF is, why we use Argon2id, what AES-GCM actually does for us, why nonces matter, what authenticated encryption means. Read this before the code, even if you think you know it. The framing matters more than the words.
|
||||
2. **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** — how the code is organized into modules, what the vault file looks like on disk, and the step-by-step flow of `init` / `add` / `get` / `change-password`. Diagrams for each.
|
||||
3. **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** — read every source file with us, in order, with every Python feature explained as it first appears.
|
||||
4. **[04-CHALLENGES.md](./04-CHALLENGES.md)** — extension ideas (search, export, TOTP, key-stretching upgrade path) once you've absorbed the rest.
|
||||
|
||||
## Common problems
|
||||
|
||||
**"command not found: just"**
|
||||
The install script should set this up, but if it didn't: `curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash`. Then close and reopen your terminal.
|
||||
|
||||
**"command not found: uv"**
|
||||
Same idea: `curl -LsSf https://astral.sh/uv/install.sh | sh`, then reopen your terminal.
|
||||
|
||||
**"`pv` is slow on `init`"**
|
||||
That's the point. Argon2id deliberately takes about half a second per call with the defaults. The user pays it once per session; an attacker pays it on every guess. If it's painfully slow (multiple seconds), your CPU is on the older end — `constants.py` has the three Argon2 tuning knobs you can lower.
|
||||
|
||||
**"Wrong master password (or vault file is corrupted)"**
|
||||
The tool can't tell those two cases apart — that's on purpose, see [01-CONCEPTS.md](./01-CONCEPTS.md). If you're sure you typed the password right, try `ls -la ~/.password-vault/` and check the `vault.json` file size is non-zero.
|
||||
|
||||
**"ModuleNotFoundError: No module named 'argon2'"**
|
||||
You ran `python src/password_manager/main.py` directly instead of going through `just run`. The `just run` recipe uses the virtual environment that has every dependency installed. Either use `just run`, or activate the venv first: `source .venv/bin/activate`, then run `pv` or `python -m password_manager`.
|
||||
|
||||
**Tests fail right after install**
|
||||
Tests should pass on a fresh `./install.sh`. If they don't, check `python --version` — you need 3.13+. On Ubuntu, install a newer Python via [`deadsnakes`](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa); on Mac use [Homebrew](https://brew.sh).
|
||||
|
||||
**"I forgot my master password"**
|
||||
There is no recovery. That is by design — if there were a way to recover the password, anyone who steals the file would have it too. This is the same trade-off every real password manager makes. Pick something memorable, write down a hint (NOT the password) in a safe physical place, and use `change-password` to rotate it occasionally.
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
# Concepts
|
||||
|
||||
This file explains every cryptographic idea the password vault uses, starting from absolute zero. If you've never thought hard about what "encryption" actually is, this is the right place to start. If you already know a KDF from a hash, you can skim — but read the LastPass and Adobe sections, because they are *why* every design choice in the code looks the way it does.
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [The problem: storing secrets you'll need later](#1-the-problem-storing-secrets-youll-need-later)
|
||||
2. [What "encryption" actually is](#2-what-encryption-actually-is)
|
||||
3. [Symmetric vs asymmetric — we use symmetric](#3-symmetric-vs-asymmetric--we-use-symmetric)
|
||||
4. [The key problem: where does the key come from?](#4-the-key-problem-where-does-the-key-come-from)
|
||||
5. [Key derivation functions: making passwords expensive](#5-key-derivation-functions-making-passwords-expensive)
|
||||
6. [Argon2id specifically, and why](#6-argon2id-specifically-and-why)
|
||||
7. [Salts: defeating precomputation](#7-salts-defeating-precomputation)
|
||||
8. [Block ciphers and modes: just "encrypted" is not enough](#8-block-ciphers-and-modes-just-encrypted-is-not-enough)
|
||||
9. [AES-256-GCM: confidentiality + authenticity in one package](#9-aes-256-gcm-confidentiality--authenticity-in-one-package)
|
||||
10. [Nonces: the most dangerous thing in this codebase](#10-nonces-the-most-dangerous-thing-in-this-codebase)
|
||||
11. [random vs secrets: the most common Python-side mistake](#11-random-vs-secrets-the-most-common-python-side-mistake)
|
||||
12. [Putting it all together: the threat model](#12-putting-it-all-together-the-threat-model)
|
||||
13. [Real breaches that made these choices the right ones](#13-real-breaches-that-made-these-choices-the-right-ones)
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem: storing secrets you'll need later
|
||||
|
||||
A password manager has a strange job. It needs to:
|
||||
|
||||
- Remember your passwords *exactly* (no fuzzy matching — `hunter2` and `Hunter2` are different passwords).
|
||||
- Refuse to give them up to anyone but you.
|
||||
- Survive your computer being stolen.
|
||||
- Survive your computer being seized and forensically imaged.
|
||||
- Still let *you* in with one short string you can hold in your head.
|
||||
|
||||
These goals are in tension. "Remember the password exactly" pulls toward "store it in plain text somewhere." "Don't give it to anyone but you" pulls toward "store nothing at all." The whole rest of this document is about navigating that tension.
|
||||
|
||||
The plan that resolves it: **store the passwords scrambled with a key that only the master password can recreate.** If anyone steals the file, all they have is a scrambled blob and a *very expensive* problem to solve. If you type the master password, the key is recreated in seconds and the blob is unscrambled.
|
||||
|
||||
That's the entire shape of the project. The rest of this document is how each piece of that plan actually works.
|
||||
|
||||
---
|
||||
|
||||
## 2. What "encryption" actually is
|
||||
|
||||
Encryption is a function. It takes three things in:
|
||||
|
||||
- A piece of data (the **plaintext**), like `"my github password is hunter2"`.
|
||||
- A **key**, which is just a chunk of random bytes — usually 16 or 32 bytes.
|
||||
- An **algorithm** (a specific procedure for scrambling data using that key).
|
||||
|
||||
And it produces one thing out:
|
||||
|
||||
- The **ciphertext** — the scrambled version. The same length as the plaintext (plus a tiny overhead, more on that later), but every byte looks like noise.
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ "hunter2..." │ ──┐
|
||||
└──────────────┘ │
|
||||
▼
|
||||
┌────────────┐ ┌─────────────────────────┐
|
||||
│ ENCRYPT() │ ─► │ "Vh\x91\x03\x7f\xe2..." │
|
||||
└────────────┘ └─────────────────────────┘
|
||||
▲
|
||||
┌──────────────┐ │
|
||||
│ 32-byte key │ ──┘
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**Decryption** is the same function in reverse. Same key, same algorithm, ciphertext goes in, plaintext comes out.
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ "Vh\x91\x03\x7f\xe2..." │ ──┐
|
||||
└─────────────────────────┘ │
|
||||
▼
|
||||
┌────────────┐ ┌──────────────┐
|
||||
│ DECRYPT() │ ─► │ "hunter2..." │
|
||||
└────────────┘ └──────────────┘
|
||||
▲
|
||||
┌──────────────┐ │
|
||||
│ 32-byte key │ ─────────────┘
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
If you have the key, decryption is fast and gives you the original. If you have the wrong key — even one bit off — you get garbage out, or (for modern algorithms) the decryption function refuses to run at all and raises an error. We use the second kind.
|
||||
|
||||
**Important thing this *isn't*:** encryption is not a one-way operation like a hash. A hash function (SHA-256, MD5, etc.) deliberately throws away information so the original can never be recovered. Encryption keeps every bit — it just shuffles them so you can't read them without the key. The whole point is that the original is recoverable, *but only by you*.
|
||||
|
||||
---
|
||||
|
||||
## 3. Symmetric vs asymmetric — we use symmetric
|
||||
|
||||
There are two big families of encryption.
|
||||
|
||||
**Symmetric encryption** uses the *same* key to encrypt and decrypt. Like a locker with one physical key — whoever holds the key can both lock and unlock it. AES is the famous symmetric algorithm. We use it.
|
||||
|
||||
**Asymmetric encryption** uses two *different* keys that are mathematically related. One ("public") locks things, the other ("private") unlocks them. You can hand out the public key to the world and they can send you encrypted things only you can read. RSA and elliptic-curve algorithms are the famous asymmetric ones. This is what powers HTTPS at the start of every connection, signs your software updates, and powers SSH login. We do **not** use this.
|
||||
|
||||
Why not asymmetric? Because a password manager is a one-person operation. There is no "you encrypt, then someone else decrypts" — *you* encrypt, *you* decrypt. Symmetric is the right tool. Asymmetric algorithms are also massively slower per byte, which matters for the rare cases where they make sense and rules them out for everything else.
|
||||
|
||||
---
|
||||
|
||||
## 4. The key problem: where does the key come from?
|
||||
|
||||
We just said "encryption needs a 32-byte key." Where does a human get 32 random bytes from?
|
||||
|
||||
Not from their head. Humans cannot remember 32 random bytes — that's 256 bits of entropy, which is the same as asking someone to memorize a 78-digit number. The best a human can do without writing it down is something like `correct horse battery staple` (~40 bits) or maybe a long sentence (~60 bits). The 256-bit gap is *enormous*.
|
||||
|
||||
So we don't ask the human for a key. We ask them for a **password** (which they can remember) and we transform it into a key using a **key derivation function**.
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ "correct horse │ │ KEY │ │ <32 random │
|
||||
│ battery staple" │ ───► │ DERIVATION │ ──► │ bytes> │
|
||||
└──────────────────┘ │ FUNCTION │ └─────────────────┘
|
||||
└─────────────────┘
|
||||
▲
|
||||
┌──────────────────┐ │
|
||||
│ random salt │ ─────────────┘
|
||||
│ (stored in file) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
The transformation is deterministic: same password + same salt always produces the same key. That's how we can re-derive the key tomorrow when the user comes back. But the transformation is also **deliberately slow**, which is the trick that makes the whole system safe.
|
||||
|
||||
---
|
||||
|
||||
## 5. Key derivation functions: making passwords expensive
|
||||
|
||||
Imagine an attacker has stolen your vault file. Inside is:
|
||||
|
||||
- The salt (16 bytes of public random data).
|
||||
- The ciphertext (your encrypted passwords).
|
||||
- The algorithm name and parameters.
|
||||
|
||||
The attacker now wants to guess your master password. The naive way: take each candidate password, hash it with the salt, try the result as a key against the ciphertext, see if the result is valid.
|
||||
|
||||
If our key derivation were just `SHA-256(password + salt)`, the attacker could do **billions of guesses per second** on a modern GPU. Even a strong-looking password like `Tr0ub4dor&3` would crack in under a minute. That's because SHA-256 was designed for *speed* — it's optimized to verify integrity of huge files in fractions of a second.
|
||||
|
||||
**The fix: use a function that is deliberately slow to compute.**
|
||||
|
||||
If each guess takes half a second, then a million guesses take 6 days, a billion guesses take 16 years, and a trillion guesses (the universe of "common 12-character passwords") takes 16,000 years. The legitimate user only pays the cost *once* per session — half a second is fine. The attacker pays it on *every* guess, forever.
|
||||
|
||||
This is the entire point of a key derivation function (a "KDF"). It's a hash-like operation, but tuned to be expensive — both in CPU time and in memory.
|
||||
|
||||
Why both? Because attackers don't use CPUs. They use GPUs and custom hardware called ASICs. A GPU has thousands of small compute cores but very little fast memory per core. So if we use an algorithm that needs a lot of memory (say, 64 megabytes) per guess, the GPU's thousands of cores suddenly can't run in parallel — they'd need a hundred gigabytes of fast memory just to attempt parallel guesses. ASICs face the same problem. **Making the function memory-hungry is what shuts down hardware-based attacks.**
|
||||
|
||||
The technical term for this is "memory-hard." Modern KDFs are all memory-hard. Old ones (PBKDF2 from 2000) are CPU-hard but not memory-hard, which is why they've fallen out of favor.
|
||||
|
||||
---
|
||||
|
||||
## 6. Argon2id specifically, and why
|
||||
|
||||
In 2013, the cryptographic community ran the **Password Hashing Competition** — an open contest to pick a new standard KDF. Researchers submitted designs, attacked each other's submissions, and after two years of analysis, the winner was [**Argon2**](https://www.password-hashing.net/argon2-specs.pdf), specifically the variant called **Argon2id**.
|
||||
|
||||
Three variants exist:
|
||||
|
||||
- **Argon2d** — maximizes resistance to GPU cracking, but is vulnerable to side-channel attacks (where an attacker watches your CPU's memory access timings).
|
||||
- **Argon2i** — maximizes resistance to side-channel attacks, but is weaker against GPU cracking.
|
||||
- **Argon2id** — a hybrid that does both, picked as the recommended default.
|
||||
|
||||
We use Argon2id. So does the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html), so do password managers like 1Password and Bitwarden, so does the file encryption tool `age`.
|
||||
|
||||
Argon2id has **three tuning knobs** that control how expensive it is:
|
||||
|
||||
| Knob | What it controls | Our default | Why |
|
||||
| ----------------- | ----------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `time_cost` | Number of passes over the memory buffer | 3 | Strong for interactive use; each extra pass is another factor of slowdown. |
|
||||
| `memory_cost` | KiB of memory used during derivation (1 KiB = 1024 bytes) | 65536 (64 MiB) | Comfortably above every OWASP profile; defeats GPU/ASIC attackers who lack fast memory per core. |
|
||||
| `parallelism` | Threads Argon2 may use | 4 | A single user on a modern laptop benefits from parallel speedup. Attacker gets the same speedup, so it's net-neutral on security but improves UX. |
|
||||
|
||||
Those numbers live in [`constants.py`](../src/password_manager/constants.py) so they're easy to bump in five years when laptops are faster.
|
||||
|
||||
**Important detail:** the parameters are stored *inside the vault file*. If you change the defaults in code, old vaults still open because they remember which parameters they were created with. We'll come back to this — it's also what makes the `change-password` command capable of upgrading old vaults to new parameters.
|
||||
|
||||
---
|
||||
|
||||
## 7. Salts: defeating precomputation
|
||||
|
||||
A **salt** is a piece of random data mixed in with the password before key derivation. It's not secret — we store it in plain text inside the vault file.
|
||||
|
||||
Why does it matter?
|
||||
|
||||
Imagine you didn't use a salt. Then `derive_key("hunter2") = <some fixed 32 bytes>` — the same on every machine, for every user. An attacker could, *years before any breach*, compute the keys for the top million common passwords and store them in a giant lookup table. Steal a vault → look up the key → unlock the vault. No per-vault work at all.
|
||||
|
||||
This precomputation is called a **rainbow table**. It used to be a real attack — there are downloadable rainbow tables for unsalted MD5 covering trillions of common-password hashes.
|
||||
|
||||
A salt destroys this attack. Now `derive_key("hunter2", <16 random bytes>) = <different 32 bytes>` for every vault, because every vault has a different salt. The attacker can't precompute anything — they have to do the full Argon2 work *after* they steal the vault file, *per vault they want to attack*.
|
||||
|
||||
**Two more uses for salts:**
|
||||
|
||||
1. **Per-user uniqueness.** Two users picking the same password (`hunter2` again) get *different* keys, because their salts differ. Useful for password databases at scale, where lots of users do unfortunately pick the same passwords.
|
||||
2. **Same-user-different-vault uniqueness.** If you have two vaults with the same master password, their ciphertexts are completely different because their salts are different. This isn't an active threat for a single-user password manager, but it's a free benefit of having salts.
|
||||
|
||||
Salt size matters less than you'd think. 16 bytes (128 bits) is the standard recommendation. Larger is fine; smaller starts to risk collisions across the global population of vaults.
|
||||
|
||||
---
|
||||
|
||||
## 8. Block ciphers and modes: just "encrypted" is not enough
|
||||
|
||||
Now we have a key. We need to actually encrypt the vault's contents with it. This is where most beginner cryptography projects go wrong, so pay attention.
|
||||
|
||||
**AES** is a "block cipher." It encrypts data in fixed-size chunks (16 bytes at a time). Given a 16-byte chunk and a key, it produces another 16-byte chunk that looks random. By itself, AES is just a function from one 16-byte block to another.
|
||||
|
||||
But your vault is way more than 16 bytes. So you need a way to chain blocks together. That way is called a **mode of operation**.
|
||||
|
||||
The simplest mode is **ECB** ("electronic codebook"): chop the plaintext into 16-byte chunks, encrypt each one independently, concatenate the results. This is wrong. It is famously, illustratively wrong:
|
||||
|
||||
```
|
||||
ECB-encrypted version
|
||||
of an image of Tux the Linux penguin
|
||||
(you can still see the penguin)
|
||||
|
||||
┌────────────────┐
|
||||
│ ░░░░░░░░░░░░░░ │
|
||||
│ ░░░██████░░░░░ │
|
||||
│ ░░██░░░░██░░░░ │ Identical input blocks
|
||||
│ ░░░░██████░░░░ │ → produce identical output
|
||||
│ ░░░░░██░░░░░░░ │ blocks, so the image
|
||||
│ ░░░░██████░░░░ │ contour leaks through.
|
||||
│ ░░░░██░░██░░░░ │
|
||||
│ ░░██░░░░░░██░░ │
|
||||
│ ░░░░░░░░░░░░░░ │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
This is why "we use AES" without specifying the mode tells you almost nothing about whether a system is secure.
|
||||
|
||||
The next mode up is **CBC** (cipher block chaining), which XORs each block with the previous ciphertext block before encrypting. Better than ECB — identical plaintext blocks now produce different ciphertext blocks. But CBC has *another* problem: it doesn't tell you if the ciphertext was tampered with. An attacker can flip specific bits in the ciphertext to flip *predictable* bits in the decrypted plaintext, even without the key. This is called a **bit-flipping attack** and it's been used against real systems.
|
||||
|
||||
The right answer is an **authenticated mode** — one that doesn't just encrypt, but also stamps the output with a tamper-evident seal.
|
||||
|
||||
---
|
||||
|
||||
## 9. AES-256-GCM: confidentiality + authenticity in one package
|
||||
|
||||
**GCM** ("Galois/Counter Mode") is an authenticated mode for AES. It produces two things:
|
||||
|
||||
1. The encrypted bytes (same length as the plaintext).
|
||||
2. A 16-byte **authentication tag** — a small fingerprint of "this exact ciphertext was produced by someone holding this exact key."
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ plaintext bytes │
|
||||
│ (vault contents) │
|
||||
└──────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ AES-256-GCM │
|
||||
│ │
|
||||
│ key ─┐ │
|
||||
│ nonce ├─► [scramble & authenticate] │
|
||||
│ │ │
|
||||
└─────────┴───────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ ciphertext │ │ 16-byte auth │
|
||||
│ (same length as │ │ tag │
|
||||
│ plaintext) │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
The library bundles these into a single byte string for you — the "ciphertext" returned by the cryptography library is actually `ciphertext || tag`, with the tag concatenated at the end.
|
||||
|
||||
**On decryption**, the cipher checks the tag *before* it gives you the plaintext. If the tag doesn't match (because the key is wrong, the ciphertext was modified, or the nonce is wrong), the cipher raises an error and refuses to produce any plaintext. This is the protection that makes "encrypted with AES-GCM" actually safe.
|
||||
|
||||
We use **AES-256**-GCM specifically — the "256" is the key size in bits. AES-128 is also fine, but 256-bit keys are the standard for "I want to never have to think about it again" levels of margin. The performance difference on modern CPUs is negligible because AES has dedicated CPU instructions (AES-NI on Intel/AMD, ARMv8 Crypto Extensions on ARM).
|
||||
|
||||
---
|
||||
|
||||
## 10. Nonces: the most dangerous thing in this codebase
|
||||
|
||||
A **nonce** ("number used once") is a value passed to AES-GCM alongside the key. It must be unique for every encryption performed with the same key. **Reusing a nonce with the same key in GCM is catastrophic** — it leaks plaintext information to anyone watching, and in some cases reveals the authentication key itself.
|
||||
|
||||
This is the single sharpest edge of AES-GCM and the reason we are extremely careful about it in [`crypto.py`](../src/password_manager/crypto.py).
|
||||
|
||||
What does "leaks plaintext" mean concretely? If you encrypt two different messages M1 and M2 with the same key K and the same nonce N, then `C1 XOR C2 = M1 XOR M2`. An attacker who sees C1 and C2 can compute `M1 XOR M2` without knowing the key. From there, if they can guess any part of M1 or M2, they can recover the corresponding part of the other.
|
||||
|
||||
**The fix is mechanical: generate a fresh random 12-byte nonce on every single encryption.** GCM allows up to roughly 2³² (4 billion) encryptions safely under one key with random 12-byte nonces. A single human will never save their vault 4 billion times.
|
||||
|
||||
A nonce is *not* a salt. The differences:
|
||||
|
||||
| Property | Salt | Nonce |
|
||||
| --------------- | ------------------------------ | ---------------------------------- |
|
||||
| Used in | Key derivation (Argon2id) | Encryption (AES-GCM) |
|
||||
| How often | Once per *vault* (set at init) | Once per *encryption* (every save) |
|
||||
| Must be unique? | Yes (across all vaults) | Yes (per key, lifetime) |
|
||||
| Secret? | No | No |
|
||||
|
||||
Both are random, both go in the file, both are public. But they have different jobs and different lifetimes.
|
||||
|
||||
---
|
||||
|
||||
## 11. random vs secrets: the most common Python-side mistake
|
||||
|
||||
Python has two modules that produce random numbers, and the difference matters more than almost any other API choice in this project:
|
||||
|
||||
- [`random`](https://docs.python.org/3/library/random.html) — uses the **Mersenne Twister** algorithm. Fast, statistically uniform, **predictable**. If an attacker sees 624 consecutive outputs, they can reconstruct the internal state and predict every future output forever.
|
||||
- [`secrets`](https://docs.python.org/3/library/secrets.html) — pulls bytes from the operating system's cryptographic random source (`/dev/urandom` on Linux/Mac, `BCryptGenRandom` on Windows). Unpredictable by design.
|
||||
|
||||
Salts and nonces and keys MUST come from `secrets`. Generated passwords MUST come from `secrets`. If you used `random` for any of these, an attacker who saw one output could predict every subsequent password your tool generated — for *every* user, on *every* machine, forever.
|
||||
|
||||
This project uses `secrets` everywhere it matters:
|
||||
|
||||
- `crypto.generate_salt()` and `crypto.generate_nonce()` → `secrets.token_bytes()`.
|
||||
- `generator.generate_password()` → `secrets.choice()` and `secrets.randbelow()`.
|
||||
- The Fisher-Yates shuffle in `generator._secure_shuffle()` → `secrets.randbelow()` instead of `random.shuffle()`.
|
||||
|
||||
The rule is simple: **if the output is meant to be hard to predict, use `secrets`. Always.**
|
||||
|
||||
---
|
||||
|
||||
## 12. Putting it all together: the threat model
|
||||
|
||||
A "threat model" is a written-down answer to "who can break this, and how?" Here's ours:
|
||||
|
||||
**What we defend against:**
|
||||
|
||||
- **Theft of the vault file.** Someone copies `vault.json` from your laptop. Without the master password, all they have is a slow, expensive guessing problem (Argon2id with our defaults: ~0.5 seconds/guess, ~15 years for a billion guesses).
|
||||
- **Tampering of the vault file.** Someone modifies bytes in `vault.json` to try to cause weird decryption behavior. AES-GCM's authentication tag refuses to decrypt at all.
|
||||
- **Power loss mid-save.** The atomic-rename + fsync pattern means you always end up with either the OLD vault or the NEW vault, never half of either. Detailed in [02-ARCHITECTURE.md](./02-ARCHITECTURE.md).
|
||||
- **Two `pv` processes saving at once.** An advisory file lock serializes them, so neither one's save gets silently overwritten.
|
||||
- **Forgetting your master password (and the file being safe at rest because of it).** This is the entire point.
|
||||
|
||||
**What we explicitly do NOT defend against:**
|
||||
|
||||
- **A keylogger on your machine.** If something is reading every keystroke, it sees your master password as you type it, and that ends the game. The defense for this lives at a different layer (full-disk encryption, OS-level keylogger detection, hardware security keys). This is not a system-level secure-input project.
|
||||
- **An attacker watching your screen while the vault is unlocked.** The `pv get` command prints passwords to stdout in plain text. The attacker reading your screen owns the session anyway.
|
||||
- **A truly compromised OS that can read process memory.** While the vault is unlocked, the decrypted entries and the AES key live in your process's memory. A privileged attacker who can read that memory wins. The `close()` method on `UnlockedVault` is a best-effort wipe, not a guarantee.
|
||||
- **A weak master password.** Argon2id makes brute force expensive but not impossible. If your master password is `password123`, an attacker willing to wait will eventually crack it. The defense is on you: pick something long.
|
||||
- **Backups under your control.** The tool writes one file, atomically and durably. Backing it up to somewhere else (a USB stick, Syncthing, etc.) is your job. The encryption means it's safe to back up to places you wouldn't trust with plaintext.
|
||||
|
||||
Being honest about what a tool does and does not defend against is a security skill in its own right. Real-world incidents almost always happen at the boundaries of a threat model, not inside it.
|
||||
|
||||
---
|
||||
|
||||
## 13. Real breaches that made these choices the right ones
|
||||
|
||||
**[Adobe 2013](https://www.troyhunt.com/adobe-credentials-and-serious/)** — 153 million records. Adobe encrypted passwords with a single key in **ECB mode** with **no per-record salt**. Result: identical passwords produced identical ciphertexts. Researchers could group users with the same password without knowing the password itself. Combined with password hints stored in plain text, large fractions of the leaked passwords were recovered the same week. Lesson: per-encryption randomness (salts, nonces) and authenticated modes (not ECB) are not optional.
|
||||
|
||||
**[LinkedIn 2012](https://en.wikipedia.org/wiki/2012_LinkedIn_hack)** — 6.5 million records. Passwords stored as **unsalted SHA-1**. SHA-1 is fast on a GPU; without salts, the attacker could precompute a rainbow table once and use it forever. 90% of the hashes were cracked within days. Lesson: salts plus a slow KDF (not a fast hash) are the modern minimum.
|
||||
|
||||
**[LastPass 2022](https://blog.lastpass.com/posts/notice-of-recent-security-incident)** — encrypted vault backups stolen. The vaults themselves used a real KDF (PBKDF2 with 100,100 iterations at the time of the breach), but PBKDF2 isn't memory-hard, so GPU attacks against weak master passwords have been industrial-scale ever since. Several public reports describe attackers cracking subsets of vaults and using the recovered passwords for cryptocurrency theft. Lesson: a memory-hard KDF (Argon2id, scrypt) is meaningfully stronger than PBKDF2 against modern hardware. We use Argon2id.
|
||||
|
||||
**[Heartbleed 2014](https://heartbleed.com)** — a memory-disclosure bug in OpenSSL. Not directly about password storage, but it demonstrated a related principle: the bytes of secret material that live in process memory are real and vulnerable. The discipline of `UnlockedVault.close()` clearing the key and the `with` statement minimizing how long the vault stays unlocked is a downstream of this lesson.
|
||||
|
||||
**[Yahoo 2013 / 2016](https://en.wikipedia.org/wiki/Yahoo!_data_breaches)** — 3 billion records (the largest breach in history). Passwords stored as **MD5**. By 2016, MD5 was already 20 years past being considered broken for password storage. Lesson: cryptographic agility (the ability to upgrade hash/KDF choices over time) matters. The reason this project stores the KDF parameters *in the vault file* — instead of hard-coding them — is so old vaults can be upgraded later without forcing users to lose data. The `change-password` command exercises that capability.
|
||||
|
||||
---
|
||||
|
||||
## Where to go next
|
||||
|
||||
Now you know *why* every design choice in the code is what it is. Time to see *how* it's organized.
|
||||
|
||||
**[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** explains how the project is split into modules, what the vault file looks like on disk, and the step-by-step flow of every CLI command.
|
||||
|
||||
After that, **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks every source file line-by-line with the Python features explained as they appear.
|
||||
|
|
@ -0,0 +1,554 @@
|
|||
# Architecture
|
||||
|
||||
This file is the map. By the end you should be able to draw the project from memory: which file holds what, how the layers depend on each other, what the file on disk actually contains, and the step-by-step flow of every CLI command.
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [The five-file layout (and why)](#1-the-five-file-layout-and-why)
|
||||
2. [Dependency direction — who imports whom](#2-dependency-direction--who-imports-whom)
|
||||
3. [The vault file format on disk](#3-the-vault-file-format-on-disk)
|
||||
4. [Flow: `pv init`](#4-flow-pv-init)
|
||||
5. [Flow: `pv add`](#5-flow-pv-add)
|
||||
6. [Flow: `pv get` and `pv list`](#6-flow-pv-get-and-pv-list)
|
||||
7. [Flow: `pv change-password`](#7-flow-pv-change-password)
|
||||
8. [Flow: `pv gen` (no vault)](#8-flow-pv-gen-no-vault)
|
||||
9. [Atomic + durable + concurrent-safe writes, drawn out](#9-atomic--durable--concurrent-safe-writes-drawn-out)
|
||||
10. [Lifecycle of an `UnlockedVault`](#10-lifecycle-of-an-unlockedvault)
|
||||
|
||||
---
|
||||
|
||||
## 1. The five-file layout (and why)
|
||||
|
||||
```
|
||||
src/password_manager/
|
||||
├── __init__.py package entry — re-exports the public API
|
||||
├── __main__.py lets `python -m password_manager` work
|
||||
├── constants.py every magic number and fixed string
|
||||
├── crypto.py Argon2id + AES-256-GCM primitives
|
||||
├── generator.py cryptographically secure password generation
|
||||
├── vault.py file format, atomic writes, locking, entry CRUD
|
||||
└── main.py CLI commands (Typer): init, add, get, list, …
|
||||
```
|
||||
|
||||
Compared to `hash-identifier` (one file, 680 lines), this project is split across five source files (~1,400 lines total). The split isn't decoration — each file has a strict reason to exist:
|
||||
|
||||
| File | Talks to | Doesn't talk to | Job |
|
||||
| ------------- | --------------------- | ---------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `constants.py` | Nothing | Anything | Single source of truth for numbers, strings, and tunables |
|
||||
| `crypto.py` | `constants` only | Filesystem, network, CLI | Pure cryptography. Bytes in, bytes out. No I/O. |
|
||||
| `generator.py`| `constants` only | Filesystem, network, CLI | Random password generation. Pure function, no I/O. |
|
||||
| `vault.py` | `crypto`, `constants` | The terminal, command-line | File format, atomic writes, file locking, entry add/get/delete |
|
||||
| `main.py` | All of the above | — | Glue layer between user keyboard and the rest of the code |
|
||||
|
||||
**Why these boundaries matter:**
|
||||
|
||||
- The crypto file calls *no I/O functions*. No file reads, no `print`, no `input`. This means it's trivial to test (just call `encrypt(b"hello", key)`) and impossible to introduce a "let me just print the key for debugging" bug at the wrong layer.
|
||||
- The vault file knows nothing about the terminal. It raises typed exceptions (`VaultNotFoundError`, `WrongPasswordError`, etc.). The CLI layer catches them and turns them into colored error messages. A future GUI or web frontend could be built on `vault.py` without changing any of it.
|
||||
- The CLI file knows nothing about cryptography. It calls `UnlockedVault.create(...)` and `vault.add_entry(...)` and `vault.save()`. If we swap Argon2id for something newer next year, `main.py` doesn't change.
|
||||
|
||||
This is called **layered architecture**. The lower layers don't import from higher layers. Cryptography is at the bottom; the CLI is at the top.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dependency direction — who imports whom
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ main.py │ the CLI (Typer + Rich)
|
||||
│ (commands, glue) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌──────────┐ ┌────────────┐
|
||||
│ vault.py │ │ crypto │ │ generator │
|
||||
│ (file │ │ .py │ │ .py │
|
||||
│ format) │ │ │ │ │
|
||||
└───────┬───────┘ └────┬─────┘ └─────┬──────┘
|
||||
│ │ │
|
||||
└───────┬───────┴──────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────┐
|
||||
│ constants.py │ no imports of our code
|
||||
│ (numbers + strings) │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
**Arrows point in the direction of imports.** `main.py` imports from `vault.py`, `crypto.py`, `generator.py`, and `constants.py`. `vault.py` imports from `crypto.py` and `constants.py`. Nothing imports back the other way. No cycles.
|
||||
|
||||
If you ever see an arrow pointing the wrong way (e.g. `crypto.py` importing from `vault.py`), that's a code smell — it usually means a piece of logic landed in the wrong layer. The compiler/linter won't stop you, but the design will start to rot.
|
||||
|
||||
---
|
||||
|
||||
## 3. The vault file format on disk
|
||||
|
||||
The vault is a single JSON file. By default it lives at `~/.password-vault/vault.json` with file permissions `0600` (owner-only).
|
||||
|
||||
Here's what the file looks like *roughly* (the base64 fields are abbreviated):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"kdf": {
|
||||
"name": "argon2id",
|
||||
"salt": "X3lkR1d2hcKLwk0PXfQpPg==",
|
||||
"time_cost": 3,
|
||||
"memory_cost": 65536,
|
||||
"parallelism": 4
|
||||
},
|
||||
"cipher": {
|
||||
"name": "aes-256-gcm",
|
||||
"nonce": "8tNTPwoq8uTXkpKt",
|
||||
"ciphertext": "Yk7eEVTSfA9wL...<lots more base64>...kw=="
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two layers of JSON live here, and that's important:
|
||||
|
||||
**Outer layer (the envelope):** plain JSON containing the metadata needed to *decrypt* the inner layer. Anybody who steals the file can read this — it tells them which KDF and cipher were used, the salt, the nonce. None of this is secret; cryptographic security depends on the *key*, not on hiding the algorithm.
|
||||
|
||||
**Inner layer (the ciphertext):** when decrypted, this is *another* JSON document — a dictionary of credential entries:
|
||||
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"username": "alice",
|
||||
"password": "hunter2-but-better",
|
||||
"url": "https://github.com",
|
||||
"notes": "",
|
||||
"created_at": "2026-05-13T14:22:10+00:00",
|
||||
"updated_at": "2026-05-13T14:22:10+00:00"
|
||||
},
|
||||
"email": {
|
||||
"username": "alice@example.com",
|
||||
"password": "another-secret",
|
||||
"url": "",
|
||||
"notes": "personal Fastmail",
|
||||
"created_at": "2026-05-13T14:30:01+00:00",
|
||||
"updated_at": "2026-05-14T09:11:42+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So: **JSON envelope wrapping encrypted JSON.** Boring, inspectable, portable. Boring is good in security — fewer custom things to get wrong.
|
||||
|
||||
**Why JSON specifically?**
|
||||
|
||||
- Human-inspectable. You can `cat vault.json` and at least confirm the structure. Useful for debugging.
|
||||
- Trivially portable. Every language has a JSON parser. If you ever wanted to write a reader for this format in Rust or Go, you'd have it working in an hour.
|
||||
- Forward-compatible. The `"version": 1` field lets future versions know how to read today's vaults — and lets us refuse to read vaults from a *future* version we don't understand yet.
|
||||
|
||||
**Why base64?**
|
||||
|
||||
JSON has no way to represent raw bytes. The standard fix is base64: a way to write any binary data as a string of printable ASCII characters (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=`). It bloats the data by ~33% but lets us round-trip bytes through JSON cleanly. Salts, nonces, and ciphertexts are all stored base64-encoded.
|
||||
|
||||
---
|
||||
|
||||
## 4. Flow: `pv init`
|
||||
|
||||
This creates a brand-new empty vault. Trace through what happens step-by-step:
|
||||
|
||||
```
|
||||
user types: `pv init`
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ main.init() │
|
||||
│ - parse --vault flag (or env, or default path) │
|
||||
│ - exists check: refuse if vault.json exists │
|
||||
│ - prompt for master password (twice, confirm) │
|
||||
│ - validate: non-empty, >= 8 chars, matches │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ UnlockedVault.create(path, master) │
|
||||
│ - generate fresh 16-byte salt (secrets) │
|
||||
│ - derive 32-byte key from master + salt │
|
||||
│ via Argon2id (~0.5s on modern laptop) │
|
||||
│ - build empty entries dict │
|
||||
│ - call self.save() │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ vault.save() │
|
||||
│ - serialize entries (empty {}) to JSON │
|
||||
│ - generate fresh 12-byte nonce (secrets) │
|
||||
│ - AES-256-GCM encrypt the inner JSON │
|
||||
│ - build outer JSON envelope │
|
||||
│ - atomic write to vault.json.tmp │
|
||||
│ - fsync data │
|
||||
│ - os.replace onto vault.json │
|
||||
│ - fsync parent directory │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
`Vault created at ~/.password-vault/vault.json`
|
||||
```
|
||||
|
||||
Two key things to notice:
|
||||
|
||||
1. **The salt is generated once, at `create()` time, and never changes for the life of the vault.** Even after `change-password` re-encrypts everything under a new key, the salt itself is regenerated only because the password changed — for a given password, the salt is stable.
|
||||
2. **The nonce is generated *every save*, never reused.** The slowest path (Argon2id) happens once per session; the second-slowest path (AES-GCM encrypt) happens on every save and uses a fresh nonce each time.
|
||||
|
||||
---
|
||||
|
||||
## 5. Flow: `pv add`
|
||||
|
||||
This unlocks the vault, adds an entry, and saves it back. The Argon2id cost is paid *once* on unlock, then `add` and `save` are both fast.
|
||||
|
||||
```
|
||||
user types: `pv add github`
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ main.add() │
|
||||
│ - prompt for master password │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ UnlockedVault.unlock(path, master) │
|
||||
│ - read vault.json from disk │
|
||||
│ - parse JSON envelope │
|
||||
│ - validate version + algorithm names │
|
||||
│ - validate Argon2 parameters (sanity floors) │
|
||||
│ - extract salt, KDF params, nonce, ciphertext │
|
||||
│ - derive_key(master, salt, params) ← slow │
|
||||
│ - AES-256-GCM decrypt(ciphertext, nonce, key) │
|
||||
│ ↓ if auth tag fails: raise │
|
||||
│ WrongPasswordError → CLI exits with msg │
|
||||
│ - parse inner JSON → entries dict │
|
||||
│ - return UnlockedVault(path, salt, params, │
|
||||
│ key, entries) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ main.add() body, inside `with` block │
|
||||
│ - prompt for username (visible) │
|
||||
│ - if --generate: generate_password(length) │
|
||||
│ else: getpass for password (hidden) │
|
||||
│ - prompt for url, notes (optional) │
|
||||
│ - build Entry(username, password, url, notes, │
|
||||
│ created_at=now, updated_at=now) │
|
||||
│ - vault.add_entry(name, entry, force=...) │
|
||||
│ ↓ if name exists and not force: │
|
||||
│ EntryAlreadyExistsError → CLI exits │
|
||||
│ ↓ if name is empty or has whitespace: │
|
||||
│ ValueError → CLI exits │
|
||||
│ - vault.save() (atomic, durable, locked) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ end of `with` block → vault.__exit__() │
|
||||
│ - vault.entries = {} │
|
||||
│ - vault.key = bytes(32) (zero-filled) │
|
||||
│ (best-effort wipe; Python bytes are immutable, │
|
||||
│ but we drop the references at minimum) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
`Added entry: github`
|
||||
```
|
||||
|
||||
Notice the **two failure modes after decryption**:
|
||||
|
||||
- "Wrong password" gets one error message.
|
||||
- "Vault file is corrupted" gets the *same* error message ("Wrong master password (or vault file is corrupted)").
|
||||
|
||||
This is on purpose. GCM authentication failure means one of three things and we can't tell which: wrong password, tampered file, corrupted file. From the user's perspective they're indistinguishable, and *exposing* the difference helps an attacker (who'd know whether their guess was "almost right" vs "definitely wrong key"). We collapse them into one honest message.
|
||||
|
||||
---
|
||||
|
||||
## 6. Flow: `pv get` and `pv list`
|
||||
|
||||
Both follow the same pattern: unlock, read, render, close. The vault is unlocked just long enough to grab the data and is dropped immediately after rendering.
|
||||
|
||||
```
|
||||
pv get github
|
||||
│
|
||||
├─► prompt master password
|
||||
│
|
||||
├─► UnlockedVault.unlock(...) (slow once, Argon2id)
|
||||
│
|
||||
├─► entry = vault.get_entry("github")
|
||||
│ ↓ if not found:
|
||||
│ EntryNotFoundError → CLI exits 1
|
||||
│
|
||||
├─► console.print(rich.Panel(...)) ← colored panel
|
||||
│
|
||||
└─► end of `with` → wipe key + entries
|
||||
```
|
||||
|
||||
```
|
||||
pv list
|
||||
│
|
||||
├─► prompt master password
|
||||
│
|
||||
├─► UnlockedVault.unlock(...)
|
||||
│
|
||||
├─► names = vault.names() (sorted alphabetically)
|
||||
│
|
||||
├─► if not names: print "vault is empty" message
|
||||
│
|
||||
├─► build a rich.Table with one row per entry
|
||||
│ columns: name, username, updated_at
|
||||
│ (passwords are NOT shown in `list`)
|
||||
│
|
||||
├─► console.print(table)
|
||||
│
|
||||
└─► end of `with` → wipe key + entries
|
||||
```
|
||||
|
||||
`list` shows usernames and update times but **not passwords**. The user has to explicitly ask for a password with `get <name>`. This is a small friction that reduces the chance of leaking an entire credential set to anyone watching your terminal.
|
||||
|
||||
---
|
||||
|
||||
## 7. Flow: `pv change-password`
|
||||
|
||||
This is the most cryptographically interesting command. It rotates the master password by:
|
||||
|
||||
1. Unlocking the vault with the *old* password (full Argon2id cost).
|
||||
2. Generating a *fresh salt* and deriving a *new key* from the *new* password.
|
||||
3. Saving the vault — `save()` will encrypt the existing entries under the new key, with a fresh nonce.
|
||||
|
||||
```
|
||||
pv change-password
|
||||
│
|
||||
├─► prompt: "Current master password: "
|
||||
│
|
||||
├─► UnlockedVault.unlock(path, current_password)
|
||||
│ ↓ if wrong: WrongPasswordError → exit 1
|
||||
│
|
||||
├─► prompt: "New master password: " (twice, confirm)
|
||||
│ ↓ validate: non-empty, >= 8 chars, matches
|
||||
│
|
||||
├─► vault.change_master_password(new_password)
|
||||
│ - new_salt = secrets.token_bytes(16)
|
||||
│ - new_key = derive_key(new, new_salt, defaults)
|
||||
│ - self.salt = new_salt
|
||||
│ - self.kdf_parameters = defaults()
|
||||
│ - self.key = new_key
|
||||
│ (only mutates in-memory state — disk untouched)
|
||||
│
|
||||
├─► vault.save()
|
||||
│ - serializes the SAME entries dict (preserved)
|
||||
│ - generates a NEW nonce
|
||||
│ - encrypts under the NEW key
|
||||
│ - atomic write replaces the old file
|
||||
│
|
||||
└─► "Master password changed. Vault re-encrypted at <path>"
|
||||
```
|
||||
|
||||
**Why this is interesting:** the vault file stores the KDF parameters and salt next to the ciphertext. That's *exactly* why this operation is possible. If the KDF params lived only in the code, then "change my password" would have no way to also "upgrade my Argon2 parameters from last year's defaults to this year's." Putting them in the file makes the upgrade path possible. The `kdf_parameters` argument on `change_master_password` is the hook for it.
|
||||
|
||||
**Crash safety:** if the process dies between mutating the in-memory state and the atomic save completing, the *file on disk* still has the old salt and old ciphertext — fully readable with the old password. The new key only "wins" after `os.replace` lands. This is the entire reason atomic writes matter for password managers: a botched rotation must never lock you out.
|
||||
|
||||
---
|
||||
|
||||
## 8. Flow: `pv gen` (no vault)
|
||||
|
||||
The simplest command. Doesn't touch the vault at all. Doesn't prompt for the master password. Just generates a strong random password and prints it.
|
||||
|
||||
```
|
||||
pv gen 32
|
||||
│
|
||||
▼
|
||||
generate_password(length=32,
|
||||
use_lowercase=True,
|
||||
use_uppercase=True,
|
||||
use_digits=True,
|
||||
use_symbols=True)
|
||||
│
|
||||
├─► length >= MIN (8)?
|
||||
├─► at least one pool enabled?
|
||||
├─► length >= number of enabled pools?
|
||||
│ (need to fit one char from each)
|
||||
│
|
||||
├─► required = [secrets.choice(pool) for pool in pools]
|
||||
│ one char guaranteed from each enabled pool
|
||||
│
|
||||
├─► fill = [secrets.choice(combined) for _ in range(length - len(required))]
|
||||
│
|
||||
├─► chars = required + fill
|
||||
│
|
||||
├─► _secure_shuffle(chars)
|
||||
│ Fisher-Yates with secrets.randbelow
|
||||
│ (NOT random.shuffle — predictable)
|
||||
│
|
||||
└─► return "".join(chars)
|
||||
```
|
||||
|
||||
The output goes to stdout via plain `print()` (not the rich console), so it's pipe-friendly:
|
||||
|
||||
```bash
|
||||
pv gen 32 | pbcopy
|
||||
PASSWORD=$(pv gen 32)
|
||||
```
|
||||
|
||||
This is the only command that uses `print` instead of `console.print`. The reason is exactly piping: we don't want rich's color escape codes inside the password that gets piped to `pbcopy`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Atomic + durable + concurrent-safe writes, drawn out
|
||||
|
||||
The `save()` method does more work than you'd guess. The "just write the file" version of this would be one line; ours is a few dozen. Here's why each piece is there.
|
||||
|
||||
### What can go wrong with the naive approach
|
||||
|
||||
```python
|
||||
# DON'T DO THIS
|
||||
path.write_bytes(envelope_bytes)
|
||||
```
|
||||
|
||||
This has three problems, all of which we've seen happen in real systems:
|
||||
|
||||
1. **Crash mid-write → corrupt file.** If the process dies after writing 4096 bytes of a 6000-byte file, the file is half-written. Next time the user tries to unlock, JSON parsing fails and they think their vault is destroyed.
|
||||
2. **Power loss → 0-byte file (or worse).** Even if the process completes, the bytes live in the kernel's page cache. The OS will write them to disk *eventually*, but a power loss between write and disk-write means the file appears to exist but contains nothing.
|
||||
3. **Two `pv` instances racing.** User runs `pv add github` in one terminal and `pv add email` in another simultaneously. Both unlock the vault (slow), both add their entry, both save. Whichever saves *second* loses the other's entry — silently. No error.
|
||||
|
||||
### How we fix each one
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 1. acquire advisory flock on vault.json.lock │
|
||||
│ (POSIX systems — Windows skips this) │
|
||||
└──────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 2. os.open(vault.json.tmp, …, mode=0600) │
|
||||
│ file created world-unreadable from the │
|
||||
│ very first syscall (no chmod race) │
|
||||
└──────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 3. os.write(fd, envelope_bytes) │
|
||||
└──────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 4. os.fsync(fd) │
|
||||
│ forces kernel page cache → disk. │
|
||||
│ without this, "we wrote it" is a story │
|
||||
│ the page cache tells; a power loss erases │
|
||||
│ it. │
|
||||
└──────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 5. os.close(fd) │
|
||||
└──────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 6. os.replace(vault.json.tmp, vault.json) │
|
||||
│ atomic rename. after this instant, │
|
||||
│ readers see EITHER the old file OR the │
|
||||
│ new file. never half of either. │
|
||||
└──────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 7. fsync the parent directory (POSIX) │
|
||||
│ so the rename itself survives power loss. │
|
||||
│ without this, an OS crash right after the │
|
||||
│ rename can revert the directory entry. │
|
||||
└──────────────────┬───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 8. release advisory flock │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each step plugs one hole:
|
||||
|
||||
| Step | Plugs |
|
||||
| ---- | -------------------------------------- |
|
||||
| 1, 8 | Two `pv` processes racing |
|
||||
| 2 | Brief window where tmp file is world-readable |
|
||||
| 4 | "Power loss right after write" loses data |
|
||||
| 6 | "Crash mid-write" corrupts the live file |
|
||||
| 7 | "Power loss right after rename" reverts |
|
||||
|
||||
The advisory lock is interesting: it's not enforced by the OS, only by code that opts in. A process that ignores `flock` can still write the file. But *every* `save()` in our code opts in, so two `pv` invocations can't race against each other. An external editor (vim, `sed`) doesn't lock, but a user editing the vault file by hand has already left the tool's contract.
|
||||
|
||||
Windows doesn't have `fcntl.flock` or directory `fsync`, so we skip both there. NTFS gives us atomic `os.replace` regardless. The trade-off is: on Windows we lose cross-process serialization (rare edge case for a single-user tool) and we lose the absolute-guarantee directory durability (NTFS journaling covers most cases).
|
||||
|
||||
---
|
||||
|
||||
## 10. Lifecycle of an `UnlockedVault`
|
||||
|
||||
An `UnlockedVault` is a Python object that holds:
|
||||
|
||||
- The path to the vault file on disk.
|
||||
- The 16-byte salt.
|
||||
- The Argon2 parameters that were used.
|
||||
- The 32-byte AES key (sensitive!).
|
||||
- The decrypted entries (also sensitive! they contain plaintext passwords).
|
||||
|
||||
Holding the key in memory means subsequent operations (add, get, delete, save) don't have to re-derive it — they'd otherwise pay the Argon2 cost on every save. But it also means we want a clear "I'm done with this" signal.
|
||||
|
||||
Python's `with` statement (a "context manager") is exactly that signal:
|
||||
|
||||
```python
|
||||
with UnlockedVault.unlock(path, master) as vault:
|
||||
vault.add_entry("github", entry)
|
||||
vault.save()
|
||||
# at this point, vault.__exit__ has been called
|
||||
# vault.entries is now {}
|
||||
# vault.key is now b"\x00" * 32
|
||||
```
|
||||
|
||||
`__enter__` runs when the block starts. `__exit__` runs when the block ends — *whether normally or by exception*. So even if `vault.save()` raises, the cleanup still happens.
|
||||
|
||||
The cleanup itself (`vault.close()`) replaces the entries dict with `{}` and the key with 32 zero bytes. This is a **best-effort** wipe. Python's `bytes` objects are immutable, so the original key bytes might still live in memory until the garbage collector runs. True wipe-on-free in Python requires `bytearray` plus `ctypes` tricks that this teaching project deliberately avoids — the discipline of "drop secrets explicitly when done" is the more important habit.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ UnlockedVault.unlock(path, master) │
|
||||
│ ─ slow: Argon2id derives 32-byte key │
|
||||
│ ─ AES-GCM decrypts ciphertext │
|
||||
│ ─ returns instance: { path, salt, params, │
|
||||
│ key, entries } │
|
||||
└────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ __enter__ → returns self │
|
||||
└────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ body of `with` block │
|
||||
│ ─ get_entry, add_entry, delete_entry │
|
||||
│ ─ save() (fast: key already in memory, │
|
||||
│ just AES-GCM + atomic write) │
|
||||
└────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ __exit__ → close() │
|
||||
│ ─ self.entries = {} │
|
||||
│ ─ self.key = b"\x00" * 32 │
|
||||
│ (best-effort wipe; Python bytes are │
|
||||
│ immutable, GC may still hold copies) │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
This is the same pattern Python's `open()` uses (`with open("x.txt") as f:`). The vault is just a more security-sensitive resource than a file handle.
|
||||
|
||||
---
|
||||
|
||||
## Where to go next
|
||||
|
||||
You now have the shape of the project in your head: which file does what, what the file on disk looks like, what each command does step-by-step.
|
||||
|
||||
**[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks every source file line-by-line. Open `crypto.py`, `vault.py`, and `main.py` in a second window and read along.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,224 @@
|
|||
# Challenges
|
||||
|
||||
You've read the whole project. You understand why every line is what it is. Now what?
|
||||
|
||||
The honest answer is: **build something on top of it.** The fastest way to actually learn what you just read is to extend it. The challenges below are ordered roughly easy → hard. Pick one that interests you, sketch what you'd change, and try.
|
||||
|
||||
If you get stuck, the relevant pieces of the existing code are linked. If you finish one and want to share it, fork the repo and open a PR — the foundations tier is meant to be a stepping stone, not a finished thing.
|
||||
|
||||
## A note on scope
|
||||
|
||||
Don't try to do all of these. Don't even try to do five. Pick *one*, do it well, and stop. The point isn't to add features — it's to internalize the existing code by interacting with it.
|
||||
|
||||
For each challenge below, you'll find:
|
||||
|
||||
- **What** — a sentence or two describing the feature.
|
||||
- **Why it's interesting** — what you'll learn from building it.
|
||||
- **Where to start** — which file(s) you'd touch.
|
||||
- **Watch out for** — the security or correctness traps that catch beginners.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — small features (~30 minutes each)
|
||||
|
||||
### 1. Add a `search` command
|
||||
|
||||
**What:** `pv search <substring>` lists every entry name that contains the substring (case-insensitive). Like `pv list`, but filtered.
|
||||
|
||||
**Why it's interesting:** Easy to do, but forces you to read `main.py` and `vault.py` carefully and find the right seam to add a new command. Good "first PR" warmup.
|
||||
|
||||
**Where to start:** Copy the `list_entries` command in `main.py`. Filter `unlocked.names()` before rendering the table.
|
||||
|
||||
**Watch out for:**
|
||||
- Case-insensitive comparison: use `name.lower()` and `query.lower()`.
|
||||
- Empty search query should be rejected, not return "every entry."
|
||||
|
||||
### 2. Add a `count` command
|
||||
|
||||
**What:** `pv count` prints just the number of entries. Useful for shell scripts.
|
||||
|
||||
**Why it's interesting:** Smallest possible new command. Forces you to think about output format (number + newline, no decoration) so it can be piped: `if [ "$(pv count)" -eq 0 ]; then ...`.
|
||||
|
||||
**Where to start:** Copy `gen` — it's the simplest existing command. Read entries, print `len(unlocked.entries)`.
|
||||
|
||||
**Watch out for:**
|
||||
- Use `print()`, not `console.print()`, so the output is pipe-friendly.
|
||||
- An empty vault still prints `0`, not "vault is empty."
|
||||
|
||||
### 3. Show a "last used" timestamp
|
||||
|
||||
**What:** Add a `last_used_at` field to `Entry`. The `get` command updates it (and saves the vault).
|
||||
|
||||
**Why it's interesting:** You'll touch every layer — the `Entry` dataclass, its `from_dict`/`to_dict`, the save path, and `get` in `main.py`. Good way to see the architecture in motion.
|
||||
|
||||
**Where to start:** Add the field with `field(default_factory=lambda: "")` so old vaults open without it. Bump the implicit field count in `from_dict`.
|
||||
|
||||
**Watch out for:**
|
||||
- Old vaults won't have the field — handle the missing-key case the same way `created_at` and `updated_at` do.
|
||||
- `get` now mutates the vault → must call `save()` before the `with` block ends.
|
||||
- This changes the threat model: an attacker who steals the vault now learns *which entry you used most recently*. Document the trade-off.
|
||||
|
||||
### 4. Hide passwords in `get` unless `--show` is passed
|
||||
|
||||
**What:** `pv get github` shows everything except the password (`••••••••`). `pv get github --show` shows the real password.
|
||||
|
||||
**Why it's interesting:** Tiny UX change, but it's the kind of feature real password managers ship for "in a meeting screen-sharing" moments.
|
||||
|
||||
**Where to start:** Add a `--show / -s` flag to the `get` command. In `_render_entry`, branch on the flag.
|
||||
|
||||
**Watch out for:**
|
||||
- Default to hidden, opt-in to visible — secure by default.
|
||||
- Bullet character `•` may not render in every terminal — provide a fallback.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — medium features (a few hours each)
|
||||
|
||||
### 5. Implement `pv export` and `pv import`
|
||||
|
||||
**What:** `pv export <path>` writes every entry to a plain-text JSON file (after prompting for the master password and a strong "ARE YOU SURE" warning). `pv import <path>` does the reverse.
|
||||
|
||||
**Why it's interesting:** This is real, useful, and dangerous. Real because every password manager needs migration in and out. Useful because users die, lose phones, switch tools. Dangerous because plaintext credentials on disk is exactly what we built this tool to avoid.
|
||||
|
||||
**Where to start:** Add two commands to `main.py`. Export serializes `unlocked.entries` and writes a JSON file with mode 0600. Import reads JSON, validates structure, calls `add_entry` for each.
|
||||
|
||||
**Watch out for:**
|
||||
- The export file is plaintext. Print a giant red warning before writing.
|
||||
- Default the export path to `./pv-export.json`, not the user's home directory — make them think about where it lives.
|
||||
- Import should handle "entry already exists" gracefully — either ask, or take a `--force` flag, or skip.
|
||||
- Validate the imported JSON the same way `Entry.from_dict` validates — don't trust the file structure.
|
||||
- An exported file with weak filesystem permissions is a real foot-gun. Set mode 0600 explicitly with `os.open` (same trick `_atomic_write` uses).
|
||||
|
||||
### 6. Add password strength scoring
|
||||
|
||||
**What:** When a user runs `pv add`, show them a strength score (weak/fair/strong/excellent) before saving. Use a library like [`zxcvbn-python`](https://github.com/dwolfhub/zxcvbn-python).
|
||||
|
||||
**Why it's interesting:** Practical UX feature. Forces you to add a new dependency (touching `pyproject.toml` and `uv.lock`) and understand the difference between "looks random to a human" and "would survive an offline guessing attack."
|
||||
|
||||
**Where to start:** Add `zxcvbn` to `pyproject.toml`. Call it on the entered password. Map the 0-4 score to a color and label. Allow the user to proceed anyway.
|
||||
|
||||
**Watch out for:**
|
||||
- Don't *block* the user from saving a weak password — they may have a good reason. Warn and ask.
|
||||
- The score should be displayed before the password is permanent — if you wait until after `save()`, the user has to delete and re-add to try again.
|
||||
|
||||
### 7. Add a `pv copy <name>` command
|
||||
|
||||
**What:** Copy a password to the system clipboard without printing it. Use [`pyperclip`](https://github.com/asweigart/pyperclip).
|
||||
|
||||
**Why it's interesting:** Real password managers do this. Forces you to think about platform differences (clipboard APIs differ on Linux/Mac/Windows) and the security trade-off of having credentials in the clipboard.
|
||||
|
||||
**Where to start:** Add `pyperclip` to dependencies. New command that unlocks the vault, fetches the entry, copies its password, and prints a confirmation (NOT the password).
|
||||
|
||||
**Watch out for:**
|
||||
- On Linux you need `xclip` or `wl-clipboard` installed at the system level — document this.
|
||||
- Clipboard contents persist until something else overwrites them. Bonus challenge: spawn a background thread that clears the clipboard after 30 seconds.
|
||||
- Pyperclip on remote SSH sessions doesn't work — handle the import-time failure with a clear error.
|
||||
|
||||
### 8. Add a `--verify` flag to `init`
|
||||
|
||||
**What:** After creating a vault, immediately try to unlock it with the same password. If unlock fails, panic — something is corrupt.
|
||||
|
||||
**Why it's interesting:** Defense in depth. The save → re-unlock cycle would have caught some classes of bugs during development. Builds the habit of "test the read path on every write" that file-format projects benefit from.
|
||||
|
||||
**Where to start:** Add a `--verify / -V` flag to `init`. After `UnlockedVault.create()` returns, call `UnlockedVault.unlock()` with the same password. Print a green check or a red panic.
|
||||
|
||||
**Watch out for:**
|
||||
- The verify call costs another full Argon2 derivation (~0.5s). Make it opt-in, not default.
|
||||
- If the verify *fails*, something is very wrong — refuse to leave the new vault in place. Delete it.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — bigger features (a weekend each)
|
||||
|
||||
### 9. Add TOTP (time-based one-time passwords)
|
||||
|
||||
**What:** Some sites use TOTP for 2FA. Currently you store the secret somewhere else (Google Authenticator, Authy). Add a TOTP secret field to `Entry`, and a `pv totp <name>` command that prints the current 6-digit code.
|
||||
|
||||
**Why it's interesting:** You'll learn what TOTP actually is (it's [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) and surprisingly simple — HMAC-SHA1 of the current 30-second window). The Python library `pyotp` does it in two lines, but writing the core yourself is a 30-line exercise.
|
||||
|
||||
**Where to start:** Add `pyotp` to dependencies. Add an optional `totp_secret` field to `Entry`. Add the command in `main.py`.
|
||||
|
||||
**Watch out for:**
|
||||
- The TOTP secret is at least as sensitive as the password. It belongs *inside* the encrypted vault, not in a sidecar file.
|
||||
- Time skew matters. Print "this code is valid for X more seconds" so the user doesn't try to use one that's about to roll over.
|
||||
- Importing TOTP secrets from QR codes is its own project — don't try to do that here.
|
||||
|
||||
### 10. Make the KDF cost upgrade transparent
|
||||
|
||||
**What:** When a user unlocks a vault whose Argon2 parameters are below the current code's defaults, *automatically* re-derive the key with the new defaults and save — same password, stronger derivation. Print a message: "Vault parameters upgraded to current defaults."
|
||||
|
||||
**Why it's interesting:** This is what real password managers do. It's why we store the KDF parameters in the file. Forces you to fully understand the `change_master_password` flow and to think about UX for "long-running operation users didn't ask for."
|
||||
|
||||
**Where to start:** Inside `UnlockedVault.unlock`, after successful decryption, compare `kdf_parameters` to `KdfParameters.defaults()`. If they differ, do an in-place change (call something like `change_master_password(same_password, new_kdf_parameters=...)`) and save.
|
||||
|
||||
**Watch out for:**
|
||||
- Pay the new Argon2 cost only once, not twice. Refactor `change_master_password` so it can also "upgrade in place" without rotating the password.
|
||||
- The user will see two sequential ~0.5-second pauses. Tell them why with a message before the second one.
|
||||
- Add a `--no-auto-upgrade` flag for users who don't want this.
|
||||
|
||||
### 11. Implement a `pv backup` command with versioned snapshots
|
||||
|
||||
**What:** `pv backup` writes a copy of the current vault to `~/.password-vault/backups/vault-YYYY-MM-DD-HHMMSS.json` and keeps the last N backups. Add a `pv restore <timestamp>` command that overwrites the live vault from a backup.
|
||||
|
||||
**Why it's interesting:** Real systems need backups, but backups are *also* a security surface — they're more files an attacker can steal. Forces you to think about: how many to keep, where to store, whether to also encrypt the backup index, what happens on restore (you want atomic semantics).
|
||||
|
||||
**Where to start:** Add the two commands. Use the existing atomic-write pattern (don't write the backup with `Path.write_bytes`; use `_atomic_write`). Use the existing file-lock pattern.
|
||||
|
||||
**Watch out for:**
|
||||
- Backups are full copies of the encrypted file — they're encrypted, so they're "safe to lose to disk forensics" *to the same extent* as the live vault is, but no more.
|
||||
- Pruning old backups requires care — don't delete the file that's currently being read by another `pv` process. Use the same advisory lock.
|
||||
- "Restore from backup N" needs to validate that backup N is a *real* vault (parse the envelope, check version) before overwriting the live file.
|
||||
|
||||
### 12. Add a web UI in a separate `pv web` command
|
||||
|
||||
**What:** A local-only web server (`localhost:8080`) that serves a simple UI for browsing the vault. Auto-shuts down after 10 minutes of inactivity.
|
||||
|
||||
**Why it's interesting:** Forces you to think about *every* security trade-off you didn't have to think about with a CLI. Master password handling in a browser, CSRF, XSS, session timeout, HTTPS-or-not on localhost, what to do when a second tab opens.
|
||||
|
||||
**Where to start:** Use [`starlette`](https://www.starlette.io) or [`fastapi`](https://fastapi.tiangolo.com) for the server. Render entries with Jinja2 templates. Don't use cookies — use a single in-memory session that auto-expires.
|
||||
|
||||
**Watch out for:**
|
||||
- This is genuinely harder than it looks. Real password managers' web UIs are full-time engineering jobs. The point of *this* version is to learn what the trade-offs are, not to ship a production tool.
|
||||
- The browser is now a part of your threat model. Browser extensions can read DOM. Other tabs can navigate to your `localhost:8080`. Same-origin policy is your only friend.
|
||||
- Logging — Starlette/FastAPI will helpfully log every request. Make sure the access log doesn't include the master password (it won't, if you do form POST correctly, but check).
|
||||
|
||||
---
|
||||
|
||||
## Tier 4 — research-flavored projects
|
||||
|
||||
These don't have a clean "build this exact feature" shape. They're directions to push the project in if you've absorbed everything and want to keep going.
|
||||
|
||||
### 13. Audit a real password manager's threat model
|
||||
|
||||
Pick a real, open-source password manager: [Bitwarden](https://github.com/bitwarden), [KeePassXC](https://github.com/keepassxreboot/keepassxc), [pass](https://www.passwordstore.org). Read its docs and source for the equivalent pieces of *this* project: how does it derive keys, how does it store the file, how does it handle master password rotation? Write up a comparison.
|
||||
|
||||
You'll learn that real password managers make different trade-offs — sometimes for good reasons, sometimes for legacy reasons. The exercise of identifying *which is which* is the kind of analysis security engineers do for a living.
|
||||
|
||||
### 14. Write a vault-format reader in another language
|
||||
|
||||
The vault format is documented in [02-ARCHITECTURE.md §3](./02-ARCHITECTURE.md#3-the-vault-file-format-on-disk) and the JSON keys are constants in `constants.py`. Write a read-only client in Rust, Go, or whatever you're learning next. You'll find the libraries to use (`argon2`, `aes-gcm`), match versions/parameters, and validate the cross-language round trip.
|
||||
|
||||
This is a really good exercise. It demonstrates *why* we wrote the format down — and probably exposes places where the format underspecifies something (which is the kind of thing real-world interop projects find all the time).
|
||||
|
||||
### 15. Threat-model a deliberate weakness
|
||||
|
||||
Pick one assumption from the threat model section of [01-CONCEPTS.md §12](./01-CONCEPTS.md#12-putting-it-all-together-the-threat-model). Try to defeat it.
|
||||
|
||||
Examples:
|
||||
- "We don't defend against a keylogger." Try writing a Python keylogger that watches the `pv` process's stdin (you'll find it's surprisingly hard because `getpass` reads from the terminal device, not stdin). Then try writing one that hooks at the OS level on Linux — what permissions does it need?
|
||||
- "We don't truly wipe the key from memory." Use a memory-debugging tool (`gcore` + `strings`) on a running `pv` process to find the AES key. Then design what would *actually* protect against this and explain why we didn't implement it.
|
||||
|
||||
The goal isn't to weaponize anything. It's to viscerally feel the difference between "we don't claim to defend against X" and "we couldn't even if we wanted to."
|
||||
|
||||
---
|
||||
|
||||
## What to read next
|
||||
|
||||
If you got through 03-IMPLEMENTATION and you're done with this project:
|
||||
|
||||
- **The [intermediate tier](../../../intermediate/)** — projects that involve web servers, databases, and multiple files. The jump from this project to those is much smaller now than it was before you started.
|
||||
- **The [advanced tier](../../../advanced/)** — projects that involve real distributed systems and serious security primitives.
|
||||
- **[Crypto 101](https://www.crypto101.io)** by Laurens Van Houtven — a free book that goes deeper on every cryptographic idea in this project. Especially recommended if you found §8-9 in [01-CONCEPTS.md](./01-CONCEPTS.md) interesting and want the full background.
|
||||
- **[Cryptography Engineering](https://www.schneier.com/books/cryptography-engineering/)** by Ferguson, Schneier, and Kohno — the textbook. Heavier than Crypto 101, but the definitive reference for "how cryptographic systems actually fail in practice."
|
||||
|
||||
You finished the hardest project in the foundations tier. You now know more about real-world password storage than the engineers responsible for [most of the breaches we cited](./01-CONCEPTS.md#13-real-breaches-that-made-these-choices-the-right-ones). That's worth pausing to appreciate.
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
# ©AngelaMos | 2026
|
||||
# pyproject.toml
|
||||
#
|
||||
# This file is the "instruction manual" for our Python project. Every
|
||||
# modern Python project has one. It tells tools like uv, pip, pytest,
|
||||
# ruff, mypy, and pylint:
|
||||
#
|
||||
# - What our project is called
|
||||
# - Which third-party libraries we depend on
|
||||
# - How to build and install it
|
||||
# - How to lint, type-check, and test it
|
||||
#
|
||||
# The file format is called TOML — a simple, human-readable config
|
||||
# language. Square brackets like [project] start a "table" (a section).
|
||||
# Lines like name = "..." are key-value pairs inside that table.
|
||||
#
|
||||
# Read this top-to-bottom. Each section has a short comment explaining
|
||||
# what it does and why it is here.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [project] — the metadata that describes WHAT this project is
|
||||
# =============================================================================
|
||||
# This section is standardized across the whole Python ecosystem (PEP 621).
|
||||
# Anyone who runs `pip install` or `uv sync` reads from here.
|
||||
[project]
|
||||
# The name people will install this package under.
|
||||
# Must be unique on PyPI if we ever publish there.
|
||||
name = "password-vault"
|
||||
|
||||
# Semantic version: MAJOR.MINOR.PATCH.
|
||||
# 1.0.0 means "first stable release."
|
||||
# When we add features → bump MINOR (1.1.0). When we break things → MAJOR.
|
||||
version = "1.0.0"
|
||||
|
||||
# One-line description that shows up in `pip show` and on PyPI.
|
||||
description = "Beginner-friendly encrypted password manager (Argon2id + AES-256-GCM)"
|
||||
|
||||
# The minimum Python version this project supports.
|
||||
# >=3.13 means "Python 3.13 or anything newer."
|
||||
# We need 3.13 because we use modern type-hint syntax (X | None).
|
||||
requires-python = ">=3.13"
|
||||
|
||||
# Who wrote it. Shows up in package metadata.
|
||||
authors = [
|
||||
{name = "CarterPerez-dev", email = "support@certgames.com"}
|
||||
]
|
||||
|
||||
# The README file is shown on PyPI and in `pip show`.
|
||||
readme = "README.md"
|
||||
|
||||
# AGPL-3.0 means: anyone can use it, but if they modify and run it as a
|
||||
# service, they must publish their changes. It protects the project
|
||||
# from being privatized.
|
||||
license = {text = "AGPL-3.0-or-later"}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# dependencies — third-party libraries we MUST have at runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
# When a user runs `pip install password-vault`, these get installed too.
|
||||
# The string format is "<name><operator><version>". We use ranges so we
|
||||
# get bug fixes (>=) but never an incompatible major version (<).
|
||||
dependencies = [
|
||||
# argon2-cffi: implements Argon2id, the modern password-hashing
|
||||
# algorithm we use to turn the master password into an encryption key.
|
||||
"argon2-cffi>=25.1.0",
|
||||
|
||||
# cryptography: provides AES-256-GCM, the actual encryption that
|
||||
# scrambles vault contents. Maintained by the Python Cryptographic
|
||||
# Authority — the gold-standard library, audited and trusted.
|
||||
"cryptography>=48.0.0",
|
||||
|
||||
# typer: builds nice CLIs from regular Python functions.
|
||||
# Lets us write `add`, `get`, `list` commands with auto-generated help.
|
||||
"typer>=0.25.1",
|
||||
|
||||
# rich: pretty terminal output — colors, tables, panels, prompts.
|
||||
# Makes the CLI pleasant to use without writing a bunch of formatting.
|
||||
"rich>=15.0.0",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# optional-dependencies — extra libraries that are NOT needed at runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
# These only matter to developers (testing, linting, formatting). End users
|
||||
# don't install them. Activated with: `uv sync --extra dev` or `--all-extras`.
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# pytest — runs our test suite (the test_*.py files in tests/).
|
||||
"pytest>=9.0.3",
|
||||
|
||||
# pytest-cov — measures how much of our code the tests actually
|
||||
# exercise. "Coverage" — high coverage means well-tested code.
|
||||
"pytest-cov>=7.1.0",
|
||||
|
||||
# ruff — extremely fast linter and formatter. Catches bugs, style
|
||||
# issues, dead imports. Modern replacement for flake8/black/isort.
|
||||
"ruff>=0.15.12",
|
||||
|
||||
# mypy — static type checker. Reads our type hints and tells us if
|
||||
# we passed a string where an int was expected, BEFORE running the code.
|
||||
"mypy>=2.1.0",
|
||||
|
||||
# pylint — second linter that catches deeper logic issues ruff misses.
|
||||
"pylint>=4.0.5",
|
||||
|
||||
# yapf — code formatter (Google's). Keeps every file looking identical.
|
||||
"yapf>=0.43.0,<1.0.0",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# project.urls — links shown on PyPI / package pages
|
||||
# -----------------------------------------------------------------------------
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
|
||||
Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/password-manager"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# project.scripts — command-line entry points
|
||||
# -----------------------------------------------------------------------------
|
||||
# These create real shell commands when the package is installed. After
|
||||
# `uv sync`, the user can type `pv add github` instead of
|
||||
# `python -m password_manager add github`. The right-hand side is
|
||||
# "<module>:<function>" — the function gets called when the command runs.
|
||||
[project.scripts]
|
||||
pv = "password_manager.main:app"
|
||||
password-vault = "password_manager.main:app"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [build-system] — how to BUILD this project into an installable package
|
||||
# =============================================================================
|
||||
# Required by PEP 517. Tools like uv read this to know which builder
|
||||
# to use. We chose hatchling — modern, fast, zero-config for our case.
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
# Tell hatchling where the actual Python code lives. Without this, it
|
||||
# guesses — and guesses wrong when the layout is src/<package>/.
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/password_manager"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.ruff] — linter / formatter configuration
|
||||
# =============================================================================
|
||||
# Ruff is the FAST one. It runs in milliseconds and catches 90% of issues.
|
||||
[tool.ruff]
|
||||
# Target Python version — ruff adjusts which warnings apply.
|
||||
target-version = "py313"
|
||||
|
||||
# Wrap lines at 88 characters (PEP 8 says 79, but 88 is the modern norm).
|
||||
line-length = 88
|
||||
|
||||
# Tell ruff "the source code lives in src/" so imports resolve correctly.
|
||||
src = ["src"]
|
||||
|
||||
# Don't lint these directories.
|
||||
exclude = [
|
||||
".git",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
"build",
|
||||
"dist",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Which lint rules to enable. Each "code" is a category of check.
|
||||
# -----------------------------------------------------------------------------
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors — basic PEP 8 spacing/indent rules
|
||||
"F", # Pyflakes — unused imports, undefined names, real bugs
|
||||
"W", # pycodestyle warnings — softer style issues
|
||||
"B", # Bugbear — sneaky bugs (mutable default args, etc.)
|
||||
"C4", # Comprehensions — encourages cleaner list/dict comprehensions
|
||||
"UP", # Pyupgrade — flags old syntax we should modernize
|
||||
"SIM", # Simplify — suggests cleaner equivalents
|
||||
]
|
||||
|
||||
# Some rules we deliberately ignore.
|
||||
ignore = [
|
||||
# Line length is handled by yapf, not ruff. Avoids double-flagging.
|
||||
"E501",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.mypy] — static type checker configuration
|
||||
# =============================================================================
|
||||
# Mypy reads our type hints (the `: int`, `-> str` parts) and verifies them
|
||||
# without running the code. Catches whole categories of bugs at edit time.
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
|
||||
# Treat anything returning Any as a warning — Any is a type-system escape
|
||||
# hatch and we want to know when we're using it.
|
||||
warn_return_any = true
|
||||
|
||||
# Warn if our config has unused settings (typos, etc.).
|
||||
warn_unused_configs = true
|
||||
|
||||
# Require type annotations on every function. STRICT mode — appropriate
|
||||
# for a security-critical project where bugs = leaked passwords.
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
# Don't auto-add Optional just because a default is None. Be explicit.
|
||||
no_implicit_optional = true
|
||||
|
||||
# Warn if we cast a value to a type it already has (dead code).
|
||||
warn_redundant_casts = true
|
||||
|
||||
# Warn if a function might fall off the end without returning.
|
||||
warn_no_return = true
|
||||
|
||||
# Pretty error output — helps when reading mypy output.
|
||||
show_error_codes = true
|
||||
show_column_numbers = true
|
||||
pretty = true
|
||||
|
||||
exclude = [
|
||||
".venv",
|
||||
"venv",
|
||||
"tests", # tests use fixtures and don't need strict typing
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Per-module overrides — silence missing stubs for argon2.
|
||||
# argon2-cffi doesn't ship type hints, so mypy can't check it. That's fine.
|
||||
# -----------------------------------------------------------------------------
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["argon2.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.pylint] — second-opinion linter
|
||||
# =============================================================================
|
||||
# Slower than ruff but catches different things — class design issues,
|
||||
# dead variables, complex code patterns.
|
||||
[tool.pylint.main]
|
||||
py-version = "3.13"
|
||||
jobs = 4 # run in parallel across 4 cores
|
||||
persistent = true # cache results between runs
|
||||
|
||||
ignore = [
|
||||
"venv",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
".git",
|
||||
"tests",
|
||||
]
|
||||
|
||||
# Specific pylint warnings we deliberately turn off.
|
||||
[tool.pylint.messages_control]
|
||||
disable = [
|
||||
"R0903", # too-few-public-methods — small data classes are fine
|
||||
"C0103", # invalid-name — we use short names like `kdf` on purpose
|
||||
"C0325", # superfluous-parens — fights with yapf formatting
|
||||
]
|
||||
|
||||
# Override pylint's default complexity limits where they're too strict.
|
||||
[tool.pylint.design]
|
||||
max-args = 8 # default is 5; we sometimes need a few more
|
||||
max-attributes = 10 # default is 7
|
||||
|
||||
# Foundation-tier modules carry heavy teaching comments and multi-
|
||||
# paragraph docstrings, which push a single self-contained module
|
||||
# above the default 1000-line ceiling. The right answer is not to
|
||||
# split the module artificially — keeping vault.py as one place to
|
||||
# read end-to-end is the pedagogical point — so we lift the cap.
|
||||
[tool.pylint.format]
|
||||
max-module-lines = 1500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# [tool.pytest.ini_options] — test runner configuration
|
||||
# =============================================================================
|
||||
[tool.pytest.ini_options]
|
||||
# Where the test files live.
|
||||
testpaths = ["tests"]
|
||||
|
||||
# Files that match this pattern are treated as test modules.
|
||||
python_files = ["test_*.py"]
|
||||
|
||||
# Default flags every `pytest` invocation gets:
|
||||
# -v → verbose, show each test name
|
||||
# --tb=short → short tracebacks on failure (less wall of text)
|
||||
addopts = "-v --tb=short"
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
|
||||
What an __init__.py file even IS
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
The one-sentence version
|
||||
────────────────────────────────────────────────────────────────────
|
||||
A folder that contains a file named `__init__.py` becomes a Python
|
||||
"package" — which is just a fancy word for "a folder you can import
|
||||
from like it were a single module"
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
The slightly longer version
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Imagine you have this layout
|
||||
|
||||
password_manager/
|
||||
__init__.py
|
||||
vault.py
|
||||
crypto.py
|
||||
generator.py
|
||||
|
||||
WITHOUT the `__init__.py`, the folder is just a folder. Python does
|
||||
not know it is allowed to look inside. Writing `import
|
||||
password_manager` would fail
|
||||
|
||||
WITH the `__init__.py`, Python looks at the folder and goes "ah, a
|
||||
package". Now `import password_manager` works, and so does
|
||||
`from password_manager.vault import UnlockedVault`
|
||||
|
||||
The file does NOT have to contain anything. An EMPTY `__init__.py`
|
||||
is perfectly valid — its mere PRESENCE is the signal. The contents,
|
||||
if any, are bonus
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
When the file DOES contain stuff (like this one)
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Whatever code is inside `__init__.py` runs the FIRST time anybody
|
||||
imports the package. It is the package's "front door" or setup
|
||||
script. Two common things people put inside
|
||||
|
||||
1. Re-exports — pulling names up from submodules so callers can
|
||||
write the short form instead of the long form
|
||||
|
||||
# without re-exports
|
||||
from password_manager.vault import UnlockedVault
|
||||
|
||||
# with re-exports (what we do below)
|
||||
from password_manager import UnlockedVault
|
||||
|
||||
This lets us reorganize the internals (split vault.py into
|
||||
three files later, say) without breaking anyone's imports
|
||||
|
||||
2. Package metadata — things like `__version__` so other tools
|
||||
can ask `password_manager.__version__` and get an answer
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What THIS specific __init__.py does
|
||||
────────────────────────────────────────────────────────────────────
|
||||
- Re-exports the public classes and errors from vault.py and
|
||||
crypto.py so callers do not need to know which file holds what
|
||||
- Sets `__version__` so `pip` and tooling can read it
|
||||
- Defines `__all__` — the explicit list of "what `from
|
||||
password_manager import *` is allowed to bring in"
|
||||
|
||||
Connects to
|
||||
vault.py — re-exports UnlockedVault, Entry, and every vault error
|
||||
crypto.py — re-exports CryptoError, KdfParameters, WrongPasswordError
|
||||
"""
|
||||
|
||||
# Local: re-export the crypto-layer pieces callers actually need —
|
||||
# the KDF parameter record plus the two error types they may catch.
|
||||
from password_manager.crypto import (
|
||||
CryptoError,
|
||||
KdfParameters,
|
||||
WrongPasswordError,
|
||||
)
|
||||
# Local: re-export the vault-layer pieces — the Entry record, the
|
||||
# main UnlockedVault class, and every domain-specific error.
|
||||
from password_manager.vault import (
|
||||
Entry,
|
||||
EntryAlreadyExistsError,
|
||||
EntryNotFoundError,
|
||||
UnlockedVault,
|
||||
VaultAlreadyExistsError,
|
||||
VaultError,
|
||||
VaultFormatError,
|
||||
VaultNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
"CryptoError",
|
||||
"Entry",
|
||||
"EntryAlreadyExistsError",
|
||||
"EntryNotFoundError",
|
||||
"KdfParameters",
|
||||
"UnlockedVault",
|
||||
"VaultAlreadyExistsError",
|
||||
"VaultError",
|
||||
"VaultFormatError",
|
||||
"VaultNotFoundError",
|
||||
"WrongPasswordError",
|
||||
]
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__main__.py
|
||||
|
||||
Allows the package to be run with `python -m password_manager`
|
||||
|
||||
When Python sees `python -m <package>`, it looks for this file and
|
||||
runs it. Same effect as the `pv` script entry point declared in
|
||||
pyproject.toml — useful when the script is not on PATH yet
|
||||
"""
|
||||
|
||||
# Local: the Typer application object — this is what actually parses
|
||||
# the CLI args and dispatches to subcommands like `list` or `add`.
|
||||
from password_manager.main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
constants.py
|
||||
|
||||
Every "magic number" and fixed string the project uses, in one place
|
||||
|
||||
A "constant" is a value that never changes while the program runs
|
||||
Beginners often hard-code numbers like `length = 16` directly into
|
||||
the code that uses them — and then six months later, nobody remembers
|
||||
why 16. By collecting every constant up here with a name and a
|
||||
comment explaining its meaning, the rest of the codebase becomes
|
||||
self-documenting
|
||||
|
||||
Three big buckets live here
|
||||
|
||||
1. Crypto parameters — sizes and tuning knobs for Argon2id (the key
|
||||
derivation function) and AES-256-GCM (the encryption). These are
|
||||
chosen based on current OWASP and NIST guidance. If you ever
|
||||
need to bump them in five years, you change them HERE and nowhere
|
||||
else
|
||||
|
||||
2. Vault file format — the schema version, JSON keys, and default
|
||||
file location. Storing the format version means future versions
|
||||
of this code can still read today's vaults
|
||||
|
||||
3. CLI strings — prompts, error messages, success messages. Kept
|
||||
here so they are easy to translate or re-word later
|
||||
|
||||
Connects to
|
||||
crypto.py — imports KDF and cipher constants
|
||||
vault.py — imports format constants and vault path defaults
|
||||
main.py — imports prompt and message strings
|
||||
"""
|
||||
|
||||
# Standard library: object-oriented filesystem paths — safer and
|
||||
# more readable than gluing strings with `os.path.join`.
|
||||
from pathlib import Path
|
||||
# Standard library: a type hint that marks a variable as a constant —
|
||||
# mypy will reject any later attempt to reassign it.
|
||||
from typing import Final
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Argon2id — Key Derivation Function parameters
|
||||
# =============================================================================
|
||||
# Argon2id turns a human password into a cryptographic key
|
||||
# It is deliberately slow and memory-hungry to defeat brute-force
|
||||
# attacks. These three knobs control HOW slow and HOW memory-hungry
|
||||
#
|
||||
# These values are informed by the OWASP Password Storage Cheat
|
||||
# Sheet, but tuned for a single-user local password manager. OWASP's
|
||||
# server-oriented configurations use parallelism=1 because a server
|
||||
# parallelizes ACROSS many simultaneous logins. We use parallelism=4
|
||||
# because a single user benefits from the speedup on their own
|
||||
# machine — and an attacker gets the same speedup, so the security
|
||||
# trade-off is net-neutral. Memory at 64 MiB is comfortably above
|
||||
# every OWASP profile, which is what makes GPU brute-force expensive
|
||||
|
||||
# Number of passes Argon2 makes over its memory buffer
|
||||
# More passes = slower derivation = harder to brute-force
|
||||
# 3 is a strong choice for interactive use on modern CPUs
|
||||
ARGON2_TIME_COST: Final[int] = 3
|
||||
|
||||
# Memory used per derivation, in kibibytes (1 KiB = 1024 bytes)
|
||||
# 65536 KiB = 64 MiB. This defeats GPU/ASIC attackers because
|
||||
# they have lots of compute but limited fast memory per core
|
||||
ARGON2_MEMORY_KIB: Final[int] = 65536
|
||||
|
||||
# How many parallel threads Argon2 may use
|
||||
# 4 is a safe default that works on every modern CPU
|
||||
ARGON2_PARALLELISM: Final[int] = 4
|
||||
|
||||
# Salt length in bytes. The salt is random data mixed in with the
|
||||
# password before hashing — it makes two identical passwords produce
|
||||
# DIFFERENT keys, so attackers cannot precompute results
|
||||
# 16 bytes (128 bits) is the standard recommendation
|
||||
SALT_LENGTH_BYTES: Final[int] = 16
|
||||
|
||||
# Argon2 algorithmic invariants — the values below which the math
|
||||
# does not even make sense. We use these to validate parameters
|
||||
# loaded from a vault file on disk, so a corrupted or hand-edited
|
||||
# file cannot make us call Argon2 with nonsense
|
||||
# - time_cost >= 1: at least one pass over memory
|
||||
# - parallelism >= 1: at least one lane
|
||||
# - memory_cost >= 8 * parallelism: Argon2's hard floor (each lane
|
||||
# needs at least 8 KiB of memory to function)
|
||||
ARGON2_TIME_COST_MIN: Final[int] = 1
|
||||
ARGON2_PARALLELISM_MIN: Final[int] = 1
|
||||
ARGON2_MEMORY_KIB_PER_LANE_MIN: Final[int] = 8
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AES-256-GCM — Symmetric encryption parameters
|
||||
# =============================================================================
|
||||
# AES-256-GCM is the encryption algorithm we use to scramble vault
|
||||
# contents. "256" means a 256-bit key. "GCM" is a "mode" that adds
|
||||
# tamper-detection — if anyone changes one byte of the ciphertext,
|
||||
# decryption will refuse and raise an error
|
||||
|
||||
# Key size in bytes. AES-256 wants exactly 32 bytes (256 bits)
|
||||
# This is also what we ask Argon2 to produce when deriving the key
|
||||
KEY_LENGTH_BYTES: Final[int] = 32
|
||||
|
||||
# Nonce ("number used once") size in bytes
|
||||
# A nonce is a random value generated FRESH for every encryption
|
||||
# Reusing a nonce with the same key is catastrophic for GCM —
|
||||
# it leaks plaintext. So we generate a new 12-byte nonce every save
|
||||
# 12 bytes is the GCM-recommended size (NIST SP 800-38D)
|
||||
NONCE_LENGTH_BYTES: Final[int] = 12
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Vault file format
|
||||
# =============================================================================
|
||||
# The vault is stored as a single JSON file. The structure looks
|
||||
# roughly like this (base64 fields shown as <...>)
|
||||
#
|
||||
# {
|
||||
# "version": 1,
|
||||
# "kdf": {
|
||||
# "name": "argon2id",
|
||||
# "salt": "<base64>",
|
||||
# "time_cost": 3,
|
||||
# "memory_cost": 65536,
|
||||
# "parallelism": 4
|
||||
# },
|
||||
# "cipher": {
|
||||
# "name": "aes-256-gcm",
|
||||
# "nonce": "<base64>",
|
||||
# "ciphertext": "<base64>"
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Storing kdf params IN the file (not just in code) lets us bump
|
||||
# defaults later without breaking old vaults — the file says how
|
||||
# it was encrypted, and we believe it
|
||||
|
||||
# Bump this when the on-disk format changes incompatibly
|
||||
VAULT_FORMAT_VERSION: Final[int] = 1
|
||||
|
||||
# Top-level JSON keys
|
||||
VAULT_KEY_VERSION: Final[str] = "version"
|
||||
VAULT_KEY_KDF: Final[str] = "kdf"
|
||||
VAULT_KEY_CIPHER: Final[str] = "cipher"
|
||||
|
||||
# KDF section keys
|
||||
KDF_KEY_NAME: Final[str] = "name"
|
||||
KDF_KEY_SALT: Final[str] = "salt"
|
||||
KDF_KEY_TIME_COST: Final[str] = "time_cost"
|
||||
KDF_KEY_MEMORY_COST: Final[str] = "memory_cost"
|
||||
KDF_KEY_PARALLELISM: Final[str] = "parallelism"
|
||||
|
||||
# Cipher section keys
|
||||
CIPHER_KEY_NAME: Final[str] = "name"
|
||||
CIPHER_KEY_NONCE: Final[str] = "nonce"
|
||||
CIPHER_KEY_CIPHERTEXT: Final[str] = "ciphertext"
|
||||
|
||||
# Algorithm names written into the file (for self-documentation
|
||||
# and so future versions can switch without breaking old vaults)
|
||||
KDF_NAME_ARGON2ID: Final[str] = "argon2id"
|
||||
CIPHER_NAME_AES_256_GCM: Final[str] = "aes-256-gcm"
|
||||
|
||||
# File mode: 0o600 means "owner can read+write, nobody else can
|
||||
# touch it." Octal in Python is written 0o<digits>. We set this on
|
||||
# the vault file the moment we create it
|
||||
VAULT_FILE_MODE: Final[int] = 0o600
|
||||
|
||||
# Default location: ~/.password-vault/vault.json
|
||||
# Path.home() resolves to /home/<user> on Linux, C:/Users/<user>
|
||||
# on Windows, /Users/<user> on macOS
|
||||
DEFAULT_VAULT_DIRECTORY: Final[Path] = Path.home() / ".password-vault"
|
||||
DEFAULT_VAULT_FILENAME: Final[str] = "vault.json"
|
||||
DEFAULT_VAULT_PATH: Final[Path] = (
|
||||
DEFAULT_VAULT_DIRECTORY / DEFAULT_VAULT_FILENAME
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Password generator defaults
|
||||
# =============================================================================
|
||||
# When the user runs `pv gen` to generate a random password, these
|
||||
# are the defaults. Everything is overridable on the command line
|
||||
|
||||
# Default length when the user does not specify one
|
||||
DEFAULT_GENERATED_PASSWORD_LENGTH: Final[int] = 24
|
||||
|
||||
# Minimum length we will allow. Passwords shorter than this are
|
||||
# weak enough to brute-force in reasonable time
|
||||
MINIMUM_GENERATED_PASSWORD_LENGTH: Final[int] = 8
|
||||
|
||||
# Minimum length for the MASTER password (the one that locks the
|
||||
# whole vault). This is a floor, not a recommendation — users
|
||||
# should pick something much longer. We reject anything shorter
|
||||
# than this to prevent the obvious footgun of an empty or trivial
|
||||
# master password silently "encrypting" the vault under no real
|
||||
# secret. 8 mirrors NIST SP 800-63B's minimum for memorized secrets
|
||||
MINIMUM_MASTER_PASSWORD_LENGTH: Final[int] = 8
|
||||
|
||||
# Character pools the generator can pick from
|
||||
LOWERCASE_LETTERS: Final[str] = "abcdefghijklmnopqrstuvwxyz"
|
||||
UPPERCASE_LETTERS: Final[str] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
DIGITS: Final[str] = "0123456789"
|
||||
|
||||
# Symbols deliberately exclude characters that confuse copy-paste
|
||||
# (quotes, backticks) or get eaten by shells (backslash, dollar)
|
||||
SAFE_SYMBOLS: Final[str] = "!@#$%^&*()-_=+[]{};:,.<>/?"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI prompt and message strings
|
||||
# =============================================================================
|
||||
# Putting human-readable text here means we can tweak wording without
|
||||
# hunting through source code, and it sets us up for future
|
||||
# translation if we ever want to ship a non-English version
|
||||
|
||||
PROMPT_MASTER_PASSWORD: Final[str] = "Master password: "
|
||||
PROMPT_MASTER_PASSWORD_NEW: Final[str] = "New master password: "
|
||||
PROMPT_MASTER_PASSWORD_CONFIRM: Final[str] = "Confirm master password: "
|
||||
PROMPT_ENTRY_PASSWORD: Final[str] = "Password for {entry}: "
|
||||
PROMPT_ENTRY_USERNAME: Final[str] = "Username for {entry}: "
|
||||
PROMPT_ENTRY_URL: Final[str] = "URL (optional, press Enter to skip): "
|
||||
PROMPT_ENTRY_NOTES: Final[str] = "Notes (optional, press Enter to skip): "
|
||||
|
||||
MSG_VAULT_CREATED: Final[str] = "Vault created at {path}"
|
||||
MSG_VAULT_ALREADY_EXISTS: Final[str] = "Vault already exists at {path}"
|
||||
MSG_VAULT_NOT_FOUND: Final[str] = (
|
||||
"No vault at {path}. Run `pv init` to create one"
|
||||
)
|
||||
MSG_ENTRY_ADDED: Final[str] = "Added entry: {name}"
|
||||
MSG_ENTRY_DELETED: Final[str] = "Deleted entry: {name}"
|
||||
MSG_ENTRY_NOT_FOUND: Final[str] = "No entry named: {name}"
|
||||
MSG_ENTRY_ALREADY_EXISTS: Final[str] = (
|
||||
"Entry already exists: {name}. Use --force to overwrite"
|
||||
)
|
||||
MSG_PASSWORDS_DO_NOT_MATCH: Final[str] = "Passwords did not match"
|
||||
MSG_WRONG_MASTER_PASSWORD: Final[str] = (
|
||||
"Wrong master password (or vault file is corrupted)"
|
||||
)
|
||||
MSG_VAULT_EMPTY: Final[str] = "Vault is empty. Add an entry with `pv add`"
|
||||
|
||||
MSG_MASTER_PASSWORD_EMPTY: Final[str] = ("Master password cannot be empty")
|
||||
MSG_MASTER_PASSWORD_TOO_SHORT: Final[str] = (
|
||||
"Master password must be at least {minimum} characters"
|
||||
)
|
||||
MSG_MASTER_PASSWORD_CHANGED: Final[str] = (
|
||||
"Master password changed. Vault re-encrypted at {path}"
|
||||
)
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
crypto.py
|
||||
|
||||
All the cryptography for the password manager lives in this one file
|
||||
|
||||
"Cryptography" is just the math we use to scramble data so nobody can
|
||||
read it without the right key. Two big ideas live in this file, and
|
||||
together they protect the user's vault
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Idea 1: Key Derivation — turning a password into a key
|
||||
────────────────────────────────────────────────────────────────────
|
||||
When the user types their master password, we cannot use it directly
|
||||
as an encryption key. Two reasons
|
||||
|
||||
1. Passwords are short and predictable. A real key needs to be 256
|
||||
bits of randomness, but "hunter2" is maybe 50 bits at best
|
||||
2. If we used the password directly, an attacker who got their
|
||||
hands on the encrypted vault could try millions of passwords
|
||||
per second on a GPU until one worked
|
||||
|
||||
The fix is a Key Derivation Function (KDF). It takes the password
|
||||
plus a random "salt" and runs them through a deliberately slow,
|
||||
memory-hungry algorithm to produce a key. We use Argon2id — the
|
||||
algorithm that won the 2015 Password Hashing Competition and is the
|
||||
modern standard recommended by OWASP
|
||||
|
||||
Why slow on purpose? Because the legitimate user only does this ONCE
|
||||
per session. An attacker has to do it for every password they guess.
|
||||
Even a half-second delay multiplied by billions of guesses makes
|
||||
brute-force impractical
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Idea 2: Authenticated Encryption — locking and tamper-proofing
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Once we have a key, we use it to encrypt the vault contents. We use
|
||||
AES-256-GCM. AES is the encryption algorithm itself. GCM is a "mode"
|
||||
that does two jobs at once
|
||||
|
||||
1. Confidentiality — scrambles the data so nobody can read it
|
||||
without the key
|
||||
2. Authenticity — stamps the result with a tamper-proof seal. If
|
||||
anyone changes one byte of the ciphertext (or even the nonce),
|
||||
decryption refuses and raises an error
|
||||
|
||||
Without GCM (or another authenticated mode), an attacker could flip
|
||||
bits in the encrypted file in ways that flip bits in the decrypted
|
||||
plaintext, even without knowing the key. GCM defeats that
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What this file exposes
|
||||
────────────────────────────────────────────────────────────────────
|
||||
derive_key(...) — Argon2id: master password + salt → 32-byte key
|
||||
generate_salt() — fresh random salt for a new vault
|
||||
generate_nonce() — fresh random nonce for every encryption
|
||||
encrypt(plaintext, key) — AES-256-GCM scramble
|
||||
decrypt(ciphertext, ...)— AES-256-GCM unscramble (raises on tampering)
|
||||
WrongPasswordError — exception raised when decryption fails
|
||||
|
||||
Connects to
|
||||
vault.py — calls these functions to encrypt and decrypt vault data
|
||||
constants.py — pulls KDF and cipher parameters from here
|
||||
"""
|
||||
|
||||
# Standard library: cryptographically-secure random bytes — used
|
||||
# here to generate fresh salts and nonces. NEVER use `random` for
|
||||
# anything security-related.
|
||||
import secrets
|
||||
# Standard library: a decorator that turns a class into a small,
|
||||
# immutable data record without writing `__init__` boilerplate.
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Third-party (argon2-cffi): the password-hashing function we use
|
||||
# as our KDF (key-derivation function). `Type` selects the Argon2id
|
||||
# variant; `hash_secret_raw` returns raw key bytes (no PHC string).
|
||||
from argon2.low_level import Type, hash_secret_raw
|
||||
# Third-party (cryptography): the specific exception raised when
|
||||
# AES-GCM decryption fails its authentication check — we catch it
|
||||
# and turn it into our own WrongPasswordError.
|
||||
from cryptography.exceptions import InvalidTag
|
||||
# Third-party (cryptography): authenticated symmetric encryption.
|
||||
# AES-GCM gives us both confidentiality AND tamper detection in one.
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
# Local: pull every magic number from the constants module — sizes
|
||||
# and Argon2 cost parameters all live there, never in this file.
|
||||
from password_manager.constants import (
|
||||
ARGON2_MEMORY_KIB,
|
||||
ARGON2_PARALLELISM,
|
||||
ARGON2_TIME_COST,
|
||||
KEY_LENGTH_BYTES,
|
||||
NONCE_LENGTH_BYTES,
|
||||
SALT_LENGTH_BYTES,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Custom exceptions — give meaningful names to the errors we raise
|
||||
# =============================================================================
|
||||
# Defining our own exception classes (instead of just raising plain
|
||||
# `Exception`) lets callers handle them precisely. The CLI layer can
|
||||
# say "if WrongPasswordError, print a friendly message" without
|
||||
# accidentally catching unrelated errors
|
||||
|
||||
|
||||
class CryptoError(Exception):
|
||||
"""
|
||||
Base class for every cryptography error we raise
|
||||
|
||||
Catching this catches every error from this module
|
||||
"""
|
||||
|
||||
|
||||
class WrongPasswordError(CryptoError):
|
||||
"""
|
||||
Raised when decryption fails
|
||||
|
||||
GCM authentication failure means one of three things, and we
|
||||
cannot tell which without more context
|
||||
|
||||
1. The user typed the wrong master password
|
||||
2. The vault file was tampered with
|
||||
3. The vault file is corrupted
|
||||
|
||||
From the user's perspective, all three look the same: "I cannot
|
||||
open this vault." So we raise a single error and let the CLI
|
||||
show a single, honest message
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# KDF parameters — bundled so we can pass them around as one value
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen = True, slots = True)
|
||||
class KdfParameters:
|
||||
"""
|
||||
The Argon2id tuning knobs used to derive a key
|
||||
|
||||
`frozen=True` makes instances immutable — once created, they
|
||||
cannot be modified. `slots=True` makes them lightweight in
|
||||
memory. These two flags together give us a "value object" — a
|
||||
bundle of fields that behaves like a single value
|
||||
|
||||
Why bundle them? Because the vault file stores these parameters
|
||||
alongside the ciphertext. If we change the defaults later, old
|
||||
vaults still decrypt correctly because they remember their own
|
||||
parameters
|
||||
|
||||
Fields
|
||||
------
|
||||
time_cost
|
||||
Number of passes Argon2 makes. Higher = slower = harder to crack
|
||||
memory_cost
|
||||
Memory in KiB. Higher = harder to attack with GPUs
|
||||
parallelism
|
||||
Threads Argon2 may use. Should match available cores
|
||||
"""
|
||||
time_cost: int
|
||||
memory_cost: int
|
||||
parallelism: int
|
||||
|
||||
@classmethod
|
||||
def defaults(cls) -> "KdfParameters":
|
||||
"""
|
||||
Return the current recommended Argon2id parameters
|
||||
|
||||
The values live in constants.py and are informed by the OWASP
|
||||
Password Storage Cheat Sheet. They are tuned for a single-user
|
||||
local password manager (parallelism=4 instead of the
|
||||
server-oriented parallelism=1) — see constants.py for the
|
||||
full reasoning
|
||||
"""
|
||||
return cls(
|
||||
time_cost = ARGON2_TIME_COST,
|
||||
memory_cost = ARGON2_MEMORY_KIB,
|
||||
parallelism = ARGON2_PARALLELISM,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Random byte generation — salts and nonces
|
||||
# =============================================================================
|
||||
# Both salts and nonces need to be unpredictable. We use the `secrets`
|
||||
# module from the Python standard library. It pulls bytes from the
|
||||
# operating system's cryptographically secure random source
|
||||
# (/dev/urandom on Linux, BCryptGenRandom on Windows)
|
||||
#
|
||||
# DO NOT use `random.randbytes()` for this. The `random` module is
|
||||
# fast but predictable — given enough output, an attacker can predict
|
||||
# future values. The `secrets` module is built for exactly this case
|
||||
|
||||
|
||||
def generate_salt() -> bytes:
|
||||
"""
|
||||
Return SALT_LENGTH_BYTES of fresh, unpredictable random bytes
|
||||
|
||||
The salt is mixed in with the password before key derivation, so
|
||||
that two users picking the same password get DIFFERENT keys. It
|
||||
also defeats "rainbow table" attacks where an attacker
|
||||
pre-computes a giant lookup table of common-password → key
|
||||
|
||||
The salt is NOT secret — we store it in plain text inside the
|
||||
vault file. Its job is to be unique, not hidden
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
A new random byte string of length SALT_LENGTH_BYTES (16 bytes)
|
||||
"""
|
||||
# secrets.token_bytes(n) returns n random bytes from the OS-level
|
||||
# cryptographic random pool. Same call we would use to generate
|
||||
# session tokens or API keys
|
||||
return secrets.token_bytes(SALT_LENGTH_BYTES)
|
||||
|
||||
|
||||
def generate_nonce() -> bytes:
|
||||
"""
|
||||
Return NONCE_LENGTH_BYTES of fresh, unpredictable random bytes
|
||||
|
||||
A nonce ("number used once") is generated FRESH for every single
|
||||
encryption. Reusing a nonce with the same key in GCM mode is
|
||||
catastrophic — it leaks plaintext to anyone watching. So we
|
||||
generate a new one every time we save the vault
|
||||
|
||||
GCM allows nonces up to 2^32 messages safely with random 12-byte
|
||||
nonces, which is far more vault saves than any human will ever
|
||||
perform. So random generation is fine here
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
A new random byte string of length NONCE_LENGTH_BYTES (12 bytes)
|
||||
"""
|
||||
return secrets.token_bytes(NONCE_LENGTH_BYTES)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Key derivation — the slow part
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def derive_key(
|
||||
master_password: str,
|
||||
salt: bytes,
|
||||
parameters: KdfParameters | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Turn a master password and a salt into a 32-byte encryption key
|
||||
|
||||
This is the SLOW step. On a modern laptop with the default
|
||||
parameters, expect about 0.3–1 second per call. That is on
|
||||
purpose — it is the cost an attacker must pay for every guess
|
||||
|
||||
The output is suitable as input to AES-256-GCM (which wants
|
||||
exactly 32 bytes of key material)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
master_password
|
||||
What the user typed at the prompt, as a normal Python string
|
||||
salt
|
||||
The vault's salt (16 random bytes). Passing the SAME password
|
||||
and SAME salt always produces the SAME key, which is how we
|
||||
re-derive the key when the user comes back later
|
||||
parameters
|
||||
Argon2id tuning. Defaults to the project's recommended values
|
||||
(see KdfParameters.defaults). The vault file stores whatever
|
||||
was used so old vaults keep working when defaults change
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
A 32-byte (256-bit) key, ready to hand to AES-256-GCM
|
||||
|
||||
Notes
|
||||
-----
|
||||
Argon2 needs BYTES, not strings. Cryptographic libraries always
|
||||
work in raw bytes because that is the natural form for hashing
|
||||
and arithmetic. We encode the password to UTF-8 bytes here
|
||||
"""
|
||||
# Refuse an empty password outright. Argon2 itself would happily
|
||||
# derive a key from b"" + the salt, but the resulting "lock" has
|
||||
# effectively no secret in it — anyone who steals the vault file
|
||||
# can re-derive the same key from the public salt. We treat this
|
||||
# as a programming error and raise loudly
|
||||
if not master_password:
|
||||
raise ValueError("master_password must not be empty")
|
||||
|
||||
# Default to recommended parameters if the caller did not specify.
|
||||
# We do this lazily (`is None` check) instead of using
|
||||
# `parameters = KdfParameters.defaults()` as a default argument,
|
||||
# because Python evaluates default arguments ONCE at function
|
||||
# definition time, which would make the default a singleton.
|
||||
# That is fine for an immutable dataclass, but the lazy pattern
|
||||
# is the safer general habit
|
||||
if parameters is None:
|
||||
parameters = KdfParameters.defaults()
|
||||
|
||||
# Strings live in memory as Unicode code points. Argon2 needs raw
|
||||
# bytes. .encode("utf-8") is the standard way to convert
|
||||
password_bytes = master_password.encode("utf-8")
|
||||
|
||||
# hash_secret_raw is the low-level Argon2 function. The argon2-cffi
|
||||
# library also offers a high-level PasswordHasher that produces a
|
||||
# formatted string — we do NOT want that here. We want the raw
|
||||
# 32 bytes of key material to feed into AES
|
||||
return hash_secret_raw(
|
||||
secret = password_bytes,
|
||||
salt = salt,
|
||||
time_cost = parameters.time_cost,
|
||||
memory_cost = parameters.memory_cost,
|
||||
parallelism = parameters.parallelism,
|
||||
hash_len = KEY_LENGTH_BYTES,
|
||||
# Type.ID = Argon2id, the recommended variant
|
||||
# Argon2id resists both side-channel attacks (Argon2i strength)
|
||||
# and GPU-cracking attacks (Argon2d strength) — best of both
|
||||
type = Type.ID,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Symmetric encryption — AES-256-GCM
|
||||
# =============================================================================
|
||||
# "Symmetric" means the same key is used to encrypt AND decrypt
|
||||
# (as opposed to public-key crypto where the keys differ)
|
||||
#
|
||||
# We use the high-level AESGCM class from the cryptography library.
|
||||
# It bundles the cipher, the authentication tag, and constant-time
|
||||
# tag verification into a single API. Using AES-GCM "by hand" with
|
||||
# raw primitives is one of the easier ways to get cryptography wrong
|
||||
|
||||
|
||||
def encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes]:
|
||||
"""
|
||||
Encrypt plaintext bytes with AES-256-GCM and return (nonce, ciphertext)
|
||||
|
||||
A new random nonce is generated for every call. The caller MUST
|
||||
store the nonce alongside the ciphertext — without it, decryption
|
||||
is impossible
|
||||
|
||||
The "ciphertext" returned actually contains both the encrypted
|
||||
data AND a 16-byte authentication tag appended to the end. The
|
||||
AESGCM library handles this concatenation automatically. The
|
||||
tag is what makes the encryption tamper-evident
|
||||
|
||||
Parameters
|
||||
----------
|
||||
plaintext
|
||||
Raw bytes to encrypt. Anything — JSON-encoded vault entries,
|
||||
plain text, an image. The cipher does not care
|
||||
key
|
||||
The 32-byte key from derive_key. Wrong size raises ValueError
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[bytes, bytes]
|
||||
(nonce, ciphertext_with_tag). Both must be stored to allow
|
||||
later decryption
|
||||
"""
|
||||
# Construct a cipher object bound to our key. AESGCM validates
|
||||
# the key size: must be 16, 24, or 32 bytes (AES-128, 192, or 256)
|
||||
cipher = AESGCM(key)
|
||||
|
||||
# Fresh nonce for every encryption — never reuse with the same key
|
||||
nonce = generate_nonce()
|
||||
|
||||
# encrypt() returns ciphertext concatenated with the auth tag.
|
||||
# `associated_data` is for data we want authenticated but NOT
|
||||
# encrypted (like a packet header). We do not have any, so None
|
||||
ciphertext = cipher.encrypt(
|
||||
nonce = nonce,
|
||||
data = plaintext,
|
||||
associated_data = None,
|
||||
)
|
||||
|
||||
return nonce, ciphertext
|
||||
|
||||
|
||||
def decrypt(ciphertext: bytes, nonce: bytes, key: bytes) -> bytes:
|
||||
"""
|
||||
Decrypt ciphertext produced by encrypt() and verify it was not tampered with
|
||||
|
||||
If the key is wrong, the nonce is wrong, or anyone modified even
|
||||
one byte of the ciphertext, the GCM authentication tag will not
|
||||
validate and we raise WrongPasswordError. We do NOT distinguish
|
||||
between "wrong password" and "tampered file" — they look the same
|
||||
cryptographically and exposing the difference helps attackers
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ciphertext
|
||||
The output from encrypt() — encrypted data + 16-byte auth tag
|
||||
nonce
|
||||
The same nonce used during encryption
|
||||
key
|
||||
The 32-byte key derived from the master password
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The original plaintext bytes
|
||||
|
||||
Raises
|
||||
------
|
||||
WrongPasswordError
|
||||
If decryption fails for any reason (wrong key, wrong nonce,
|
||||
modified ciphertext, corruption)
|
||||
"""
|
||||
cipher = AESGCM(key)
|
||||
|
||||
# InvalidTag is the cryptography library's signal that the auth
|
||||
# tag did not match. We catch it and re-raise as our own error
|
||||
# so callers do not need to know about cryptography internals
|
||||
try:
|
||||
return cipher.decrypt(
|
||||
nonce = nonce,
|
||||
data = ciphertext,
|
||||
associated_data = None,
|
||||
)
|
||||
except InvalidTag as exc:
|
||||
# `from exc` preserves the original traceback for debugging
|
||||
# while still raising our cleaner exception type
|
||||
raise WrongPasswordError(
|
||||
"Decryption failed: wrong master password or corrupted vault"
|
||||
) from exc
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
generator.py
|
||||
|
||||
Cryptographically secure random password generation
|
||||
|
||||
A password generator is just a function that picks N random
|
||||
characters from a pool. The interesting part is HOW you pick them
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Why `secrets`, not `random`
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Python has TWO modules that produce random numbers
|
||||
|
||||
random — fast, predictable, fine for games and simulations
|
||||
secrets — slow-ish, unpredictable, made for cryptography
|
||||
|
||||
If we used `random.choice` to pick characters, an attacker who saw
|
||||
ONE password could deduce the internal state of the random generator
|
||||
and predict every other password it produced. The `secrets` module
|
||||
pulls bytes from the operating system's cryptographic source, which
|
||||
is unpredictable by design
|
||||
|
||||
Rule of thumb: any time the output is meant to be hard to guess by
|
||||
humans OR computers, use `secrets`
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What this module exposes
|
||||
────────────────────────────────────────────────────────────────────
|
||||
generate_password(length, ...) — return a random password string
|
||||
PasswordTooShortError — raised when length is below the floor
|
||||
|
||||
Connects to
|
||||
main.py — the `pv gen` command and the `pv add` command call this
|
||||
constants.py — pulls character pools and length defaults from here
|
||||
"""
|
||||
|
||||
# Standard library: cryptographically-secure random — we pick each
|
||||
# character from the alphabet with `secrets.choice`. Plain `random`
|
||||
# would be predictable to an attacker and unsafe for passwords.
|
||||
import secrets
|
||||
|
||||
# Local: pull every character pool and length default from constants —
|
||||
# no magic strings or numbers ever live in this file.
|
||||
from password_manager.constants import (
|
||||
DEFAULT_GENERATED_PASSWORD_LENGTH,
|
||||
DIGITS,
|
||||
LOWERCASE_LETTERS,
|
||||
MINIMUM_GENERATED_PASSWORD_LENGTH,
|
||||
SAFE_SYMBOLS,
|
||||
UPPERCASE_LETTERS,
|
||||
)
|
||||
|
||||
|
||||
class PasswordTooShortError(ValueError):
|
||||
"""
|
||||
Raised when the requested password length is below the safe minimum
|
||||
"""
|
||||
|
||||
|
||||
def generate_password(
|
||||
length: int = DEFAULT_GENERATED_PASSWORD_LENGTH,
|
||||
*,
|
||||
use_lowercase: bool = True,
|
||||
use_uppercase: bool = True,
|
||||
use_digits: bool = True,
|
||||
use_symbols: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Return a random password of the given length
|
||||
|
||||
All character-pool flags after `length` are keyword-only (the *
|
||||
in the signature). That keeps call sites readable
|
||||
|
||||
generate_password(20, use_symbols=False)
|
||||
|
||||
is much clearer than
|
||||
|
||||
generate_password(20, True, True, True, False)
|
||||
|
||||
The function GUARANTEES at least one character from each enabled
|
||||
pool — without this, a 12-character password might randomly end
|
||||
up as all lowercase, which fails most "must contain a digit"
|
||||
rules even though it is technically random
|
||||
|
||||
Parameters
|
||||
----------
|
||||
length
|
||||
How many characters in the result. Must be at least
|
||||
MINIMUM_GENERATED_PASSWORD_LENGTH. Must also be at least
|
||||
as large as the number of enabled pools (we have to fit
|
||||
one character from each)
|
||||
use_lowercase, use_uppercase, use_digits, use_symbols
|
||||
Which character pools to draw from. At least one must be True
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A random password of exactly `length` characters
|
||||
|
||||
Raises
|
||||
------
|
||||
PasswordTooShortError
|
||||
If `length` is below the floor or below the number of pools
|
||||
ValueError
|
||||
If every pool flag is False
|
||||
"""
|
||||
if length < MINIMUM_GENERATED_PASSWORD_LENGTH:
|
||||
raise PasswordTooShortError(
|
||||
f"Password length must be >= "
|
||||
f"{MINIMUM_GENERATED_PASSWORD_LENGTH}, got {length}"
|
||||
)
|
||||
|
||||
# Build the lookup of enabled pools so we can pick one char from
|
||||
# each. This dict comprehension only includes pools whose flag is
|
||||
# True — any False flag is left out entirely
|
||||
enabled_pools = {
|
||||
"lower": LOWERCASE_LETTERS if use_lowercase else "",
|
||||
"upper": UPPERCASE_LETTERS if use_uppercase else "",
|
||||
"digit": DIGITS if use_digits else "",
|
||||
"symbol": SAFE_SYMBOLS if use_symbols else "",
|
||||
}
|
||||
enabled_pools = {k: v for k, v in enabled_pools.items() if v}
|
||||
|
||||
if not enabled_pools:
|
||||
raise ValueError("At least one character pool must be enabled")
|
||||
|
||||
if length < len(enabled_pools):
|
||||
raise PasswordTooShortError(
|
||||
f"length={length} is too small to include one character "
|
||||
f"from each of {len(enabled_pools)} enabled pools"
|
||||
)
|
||||
|
||||
alphabet = "".join(enabled_pools.values())
|
||||
|
||||
# Step 1: take ONE character from each enabled pool, guaranteeing
|
||||
# the password contains at least one of each kind
|
||||
required = [secrets.choice(pool) for pool in enabled_pools.values()]
|
||||
|
||||
# Step 2: fill the rest from the combined alphabet
|
||||
fill_count = length - len(required)
|
||||
fill = [secrets.choice(alphabet) for _ in range(fill_count)]
|
||||
|
||||
# Step 3: combine and shuffle. Shuffling matters — without it, the
|
||||
# required characters would always be at positions 0..N-1
|
||||
chars = required + fill
|
||||
_secure_shuffle(chars)
|
||||
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _secure_shuffle(items: list[str]) -> None:
|
||||
"""
|
||||
Shuffle a list in place using a cryptographically secure source
|
||||
|
||||
The standard library's random.shuffle uses the predictable Mersenne
|
||||
Twister. We implement Fisher-Yates (also called Knuth shuffle) on
|
||||
top of secrets.randbelow so the order is unpredictable
|
||||
|
||||
Mutates `items` in place — returns None
|
||||
"""
|
||||
# Fisher-Yates: walk the list from the end to the beginning, and
|
||||
# at each position swap the current item with a randomly chosen
|
||||
# earlier item (or itself). This produces a uniform random
|
||||
# permutation if the random source is uniform — and secrets is
|
||||
for i in range(len(items) - 1, 0, -1):
|
||||
# randbelow(n) returns 0..n-1 uniformly. We need 0..i inclusive,
|
||||
# so we ask for i+1
|
||||
j = secrets.randbelow(i + 1)
|
||||
items[i], items[j] = items[j], items[i]
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
main.py
|
||||
|
||||
CLI entry point — wires the user's keyboard to the vault
|
||||
|
||||
Everything below is glue: take the arguments the user typed, prompt
|
||||
for the master password without echoing it, call the right method on
|
||||
UnlockedVault, and print results in a friendly format. The actual
|
||||
work happens in vault.py and crypto.py
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Why Typer
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Typer turns a regular Python function into a CLI command, just by
|
||||
reading its type hints and docstrings. Compare these two ways of
|
||||
saying "add a `--length` option that defaults to 24"
|
||||
|
||||
Manual argparse:
|
||||
parser.add_argument("--length", type=int, default=24,
|
||||
help="Password length")
|
||||
|
||||
Typer:
|
||||
length: Annotated[int, typer.Option(help="Password length")] = 24
|
||||
|
||||
Typer also generates --help text, validates types automatically, and
|
||||
plays well with rich for colorful output
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Master password handling
|
||||
────────────────────────────────────────────────────────────────────
|
||||
We use getpass.getpass() so the password never appears on screen as
|
||||
the user types. We never accept the master password as a CLI flag —
|
||||
that would leak it into shell history (`history` command) and into
|
||||
process listings (`ps`). Pass-through-stdin is fine for scripting
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Commands exposed
|
||||
────────────────────────────────────────────────────────────────────
|
||||
init — create a new empty vault
|
||||
add <name> — add an entry (prompts for fields)
|
||||
get <name> — show an entry's details
|
||||
list — list every entry name
|
||||
delete <name> — remove an entry
|
||||
change-password — rotate the master password (re-encrypts vault)
|
||||
gen — generate a random password (no vault touched)
|
||||
|
||||
Connects to
|
||||
vault.py — instantiates UnlockedVault for read/write operations
|
||||
generator.py — used by `gen` and offered inside `add`
|
||||
constants.py — pulls prompt strings and default vault path
|
||||
"""
|
||||
|
||||
# Standard library: reads a password from the terminal WITHOUT
|
||||
# echoing the characters as the user types — same trick `sudo` uses.
|
||||
import getpass
|
||||
# Standard library: object-oriented filesystem paths — safer and
|
||||
# more readable than gluing strings with `os.path.join`.
|
||||
from pathlib import Path
|
||||
# Standard library: lets us attach extra metadata (Typer Option/Argument
|
||||
# specs) to a parameter's type hint without changing its type.
|
||||
from typing import Annotated
|
||||
|
||||
# Third-party (Typer): the CLI framework. Turns a regular function
|
||||
# into a subcommand with parsed args, help text, and auto-completion.
|
||||
import typer
|
||||
# Third-party (rich): the printer that draws colored output to the
|
||||
# terminal — every user-facing message goes through this.
|
||||
from rich.console import Console
|
||||
# Third-party (rich): draws a bordered box — used for the welcome
|
||||
# banner and the "vault created" confirmation panel.
|
||||
from rich.panel import Panel
|
||||
# Third-party (rich): builds the colored ASCII table that lists
|
||||
# vault entries in the `list` command.
|
||||
from rich.table import Table
|
||||
|
||||
# Local: pull every prompt string, error message, and default value
|
||||
# from constants — main.py never holds magic strings of its own.
|
||||
from password_manager.constants import (
|
||||
DEFAULT_GENERATED_PASSWORD_LENGTH,
|
||||
DEFAULT_VAULT_PATH,
|
||||
MINIMUM_MASTER_PASSWORD_LENGTH,
|
||||
MSG_ENTRY_ADDED,
|
||||
MSG_ENTRY_ALREADY_EXISTS,
|
||||
MSG_ENTRY_DELETED,
|
||||
MSG_ENTRY_NOT_FOUND,
|
||||
MSG_MASTER_PASSWORD_CHANGED,
|
||||
MSG_MASTER_PASSWORD_EMPTY,
|
||||
MSG_MASTER_PASSWORD_TOO_SHORT,
|
||||
MSG_PASSWORDS_DO_NOT_MATCH,
|
||||
MSG_VAULT_ALREADY_EXISTS,
|
||||
MSG_VAULT_CREATED,
|
||||
MSG_VAULT_EMPTY,
|
||||
MSG_VAULT_NOT_FOUND,
|
||||
MSG_WRONG_MASTER_PASSWORD,
|
||||
PROMPT_ENTRY_NOTES,
|
||||
PROMPT_ENTRY_URL,
|
||||
PROMPT_ENTRY_USERNAME,
|
||||
PROMPT_MASTER_PASSWORD,
|
||||
PROMPT_MASTER_PASSWORD_CONFIRM,
|
||||
PROMPT_MASTER_PASSWORD_NEW,
|
||||
)
|
||||
# Local: the one crypto-layer error we want to translate into a
|
||||
# friendly "wrong master password" message for the user.
|
||||
from password_manager.crypto import WrongPasswordError
|
||||
# Local: the password generator — its custom error type and the
|
||||
# function that actually builds a random password.
|
||||
from password_manager.generator import (
|
||||
PasswordTooShortError,
|
||||
generate_password,
|
||||
)
|
||||
# Local: every vault-layer name we need — the Entry record, the
|
||||
# UnlockedVault class, and every domain-specific error we catch.
|
||||
from password_manager.vault import (
|
||||
Entry,
|
||||
EntryAlreadyExistsError,
|
||||
EntryNotFoundError,
|
||||
UnlockedVault,
|
||||
VaultAlreadyExistsError,
|
||||
VaultError,
|
||||
VaultFormatError,
|
||||
VaultNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Typer app + consoles — module-level singletons
|
||||
# =============================================================================
|
||||
# Typer.app is the registry every @app.command() decorator attaches to.
|
||||
# rich.Console handles colorful output. Both are created once and
|
||||
# reused by every command
|
||||
#
|
||||
# We keep TWO consoles, one for each output stream. The convention
|
||||
# is universal in CLI tools
|
||||
#
|
||||
# stdout — the "result" of the command. Pipe-safe. Capturable
|
||||
# stderr — diagnostics, errors, progress. Always shown to the user
|
||||
#
|
||||
# Splitting them lets users redirect cleanly. `pv gen 32 | pbcopy`
|
||||
# pipes ONLY the password into the clipboard, even if pv prints an
|
||||
# error. `pv get foo 2>/dev/null` swallows error chatter without
|
||||
# also swallowing the panel of credentials
|
||||
|
||||
app = typer.Typer(
|
||||
name = "pv",
|
||||
help = "Encrypted password manager (Argon2id + AES-256-GCM)",
|
||||
no_args_is_help = True,
|
||||
add_completion = False,
|
||||
)
|
||||
console = Console()
|
||||
error_console = Console(stderr = True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared option type — every command takes --vault
|
||||
# =============================================================================
|
||||
# Annotated[T, typer.Option(...)] is how Typer reads option metadata
|
||||
# without polluting the function signature. The Annotated wrapper is
|
||||
# fully transparent at runtime — it only matters to Typer at startup
|
||||
#
|
||||
# We define the type alias once so every command takes the same flag
|
||||
|
||||
VaultPath = Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--vault",
|
||||
"-v",
|
||||
help = "Path to the vault file",
|
||||
envvar = "PV_VAULT",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers — keep command bodies focused on flow, not plumbing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _prompt_master_password(prompt: str = PROMPT_MASTER_PASSWORD) -> str:
|
||||
"""
|
||||
Read a master password from the terminal without echoing it
|
||||
|
||||
Wraps getpass so we can swap implementations later (e.g. read
|
||||
from stdin in non-interactive scripts) without touching every
|
||||
command. getpass falls back to a noisy "echo enabled" warning
|
||||
if the terminal does not support hidden input — that is the
|
||||
library's behavior, not ours
|
||||
"""
|
||||
return getpass.getpass(prompt)
|
||||
|
||||
|
||||
def _prompt_master_password_with_confirmation() -> str:
|
||||
"""
|
||||
Prompt for a new master password twice, validate it, and return it
|
||||
|
||||
Used by `init` and `change-password` to set or rotate the master
|
||||
password. Three checks happen before the password is returned
|
||||
|
||||
1. Non-empty — an empty password "encrypts" the vault under no
|
||||
real secret. Anyone who steals the file can re-derive the
|
||||
same key from the public salt
|
||||
2. At least MINIMUM_MASTER_PASSWORD_LENGTH characters — a hard
|
||||
floor below which we refuse to proceed
|
||||
3. Confirmation match — both prompts must produce the same
|
||||
string, so a typo does not lock the user out of their vault
|
||||
the first time they try to unlock it
|
||||
|
||||
Exits with code 1 on any of the above. The caller does not have
|
||||
to handle these cases — by the time this returns, the password
|
||||
is known good
|
||||
"""
|
||||
first = _prompt_master_password(PROMPT_MASTER_PASSWORD_NEW)
|
||||
|
||||
if not first:
|
||||
error_console.print(f"[red]{MSG_MASTER_PASSWORD_EMPTY}[/red]")
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
if len(first) < MINIMUM_MASTER_PASSWORD_LENGTH:
|
||||
error_console.print(
|
||||
f"[red]"
|
||||
f"{MSG_MASTER_PASSWORD_TOO_SHORT.format(minimum=MINIMUM_MASTER_PASSWORD_LENGTH)}"
|
||||
f"[/red]"
|
||||
)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
second = _prompt_master_password(PROMPT_MASTER_PASSWORD_CONFIRM)
|
||||
if first != second:
|
||||
error_console.print(f"[red]{MSG_PASSWORDS_DO_NOT_MATCH}[/red]")
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
return first
|
||||
|
||||
|
||||
def _unlock_or_exit(path: Path, master_password: str) -> UnlockedVault:
|
||||
"""
|
||||
Open a vault, exiting cleanly on every kind of failure
|
||||
|
||||
Each error gets the right message and the right exit code.
|
||||
`typer.Exit(code=N)` raises an exception that Typer turns into
|
||||
`sys.exit(N)` cleanly — we never call sys.exit ourselves
|
||||
|
||||
Errors go through error_console (stderr); informational and
|
||||
success messages go through console (stdout). That split is
|
||||
what makes the CLI pipe-friendly
|
||||
"""
|
||||
try:
|
||||
return UnlockedVault.unlock(path, master_password)
|
||||
except VaultNotFoundError:
|
||||
error_console.print(
|
||||
f"[red]{MSG_VAULT_NOT_FOUND.format(path=path)}[/red]"
|
||||
)
|
||||
raise typer.Exit(code = 1) from None
|
||||
except WrongPasswordError:
|
||||
error_console.print(f"[red]{MSG_WRONG_MASTER_PASSWORD}[/red]")
|
||||
raise typer.Exit(code = 1) from None
|
||||
except VaultFormatError as exc:
|
||||
error_console.print(f"[red]Vault file is invalid: {exc}[/red]")
|
||||
raise typer.Exit(code = 1) from None
|
||||
except VaultError as exc:
|
||||
error_console.print(f"[red]Vault error: {exc}[/red]")
|
||||
raise typer.Exit(code = 1) from None
|
||||
|
||||
|
||||
def _render_entry(name: str, entry: Entry) -> Panel:
|
||||
"""
|
||||
Format an entry as a rich Panel for terminal display
|
||||
|
||||
A Panel is a bordered box. We list each field on its own line and
|
||||
let the terminal handle long values. The password is shown
|
||||
verbatim — this is a CLI tool, the user already trusts the screen
|
||||
"""
|
||||
body_lines = [
|
||||
f"[bold]username[/bold] {entry.username}",
|
||||
f"[bold]password[/bold] {entry.password}",
|
||||
]
|
||||
if entry.url:
|
||||
body_lines.append(f"[bold]url[/bold] {entry.url}")
|
||||
if entry.notes:
|
||||
body_lines.append(f"[bold]notes[/bold] {entry.notes}")
|
||||
body_lines.append(f"[dim]created {entry.created_at}[/dim]")
|
||||
body_lines.append(f"[dim]updated {entry.updated_at}[/dim]")
|
||||
return Panel(
|
||||
"\n".join(body_lines),
|
||||
title = name,
|
||||
border_style = "cyan",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Commands
|
||||
# =============================================================================
|
||||
# Each @app.command decorates a function as a CLI command. The
|
||||
# function name becomes the command name (init → `pv init`)
|
||||
|
||||
|
||||
@app.command()
|
||||
def init(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
|
||||
"""
|
||||
Create a new empty vault at --vault (or PV_VAULT or default path)
|
||||
"""
|
||||
# The pre-check is a UX nicety: it lets us refuse without
|
||||
# prompting for a password we would only throw away. The check
|
||||
# inside create() is the AUTHORITATIVE one — between this check
|
||||
# and the prompts finishing, another process could have created
|
||||
# the vault, and we still need to handle that race
|
||||
if vault.exists():
|
||||
error_console.print(
|
||||
f"[red]{MSG_VAULT_ALREADY_EXISTS.format(path=vault)}[/red]"
|
||||
)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
master = _prompt_master_password_with_confirmation()
|
||||
try:
|
||||
# The create() call writes the empty vault and returns an
|
||||
# UnlockedVault. We have nothing else to do with it, so we
|
||||
# use `with` purely to drop the AES key right away
|
||||
with UnlockedVault.create(vault, master):
|
||||
pass
|
||||
except VaultAlreadyExistsError:
|
||||
error_console.print(
|
||||
f"[red]{MSG_VAULT_ALREADY_EXISTS.format(path=vault)}[/red]"
|
||||
)
|
||||
raise typer.Exit(code = 1) from None
|
||||
|
||||
console.print(f"[green]{MSG_VAULT_CREATED.format(path=vault)}[/green]")
|
||||
|
||||
|
||||
@app.command(name = "list")
|
||||
def list_entries(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
|
||||
"""
|
||||
Print every entry name in the vault, one per line
|
||||
"""
|
||||
master = _prompt_master_password()
|
||||
# `with` ensures the AES key and plaintext entries are dropped
|
||||
# as soon as the table has been printed. We render INSIDE the
|
||||
# block because we still need to read the entries
|
||||
with _unlock_or_exit(vault, master) as unlocked:
|
||||
names = unlocked.names()
|
||||
if not names:
|
||||
console.print(f"[yellow]{MSG_VAULT_EMPTY}[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title = f"Entries in {vault}", show_lines = False)
|
||||
table.add_column("name", style = "cyan", no_wrap = True)
|
||||
table.add_column("username", style = "white")
|
||||
table.add_column("updated", style = "dim")
|
||||
for name in names:
|
||||
entry = unlocked.entries[name]
|
||||
table.add_row(name, entry.username, entry.updated_at)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@app.command()
|
||||
def get(
|
||||
name: Annotated[str,
|
||||
typer.Argument(help = "Entry name to retrieve")],
|
||||
vault: VaultPath = DEFAULT_VAULT_PATH,
|
||||
) -> None:
|
||||
"""
|
||||
Show every field of one entry by name
|
||||
"""
|
||||
master = _prompt_master_password()
|
||||
with _unlock_or_exit(vault, master) as unlocked:
|
||||
try:
|
||||
entry = unlocked.get_entry(name)
|
||||
except EntryNotFoundError:
|
||||
error_console.print(
|
||||
f"[red]{MSG_ENTRY_NOT_FOUND.format(name=name)}[/red]"
|
||||
)
|
||||
raise typer.Exit(code = 1) from None
|
||||
# entry is a frozen Entry instance — its fields remain
|
||||
# readable after the vault closes, but we still render
|
||||
# inside the block to keep the lifecycle obvious
|
||||
console.print(_render_entry(name, entry))
|
||||
|
||||
|
||||
@app.command()
|
||||
def add(
|
||||
name: Annotated[str,
|
||||
typer.Argument(help = "Entry name (must be unique)")],
|
||||
vault: VaultPath = DEFAULT_VAULT_PATH,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option("--force",
|
||||
"-f",
|
||||
help = "Overwrite if exists"),
|
||||
] = False,
|
||||
generate: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--generate",
|
||||
"-g",
|
||||
help = "Generate a random password instead of prompting",
|
||||
),
|
||||
] = False,
|
||||
length: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--length",
|
||||
"-n",
|
||||
help = "Length when --generate is used",
|
||||
),
|
||||
] = DEFAULT_GENERATED_PASSWORD_LENGTH,
|
||||
) -> None:
|
||||
"""
|
||||
Add (or overwrite with --force) an entry in the vault
|
||||
"""
|
||||
master = _prompt_master_password()
|
||||
with _unlock_or_exit(vault, master) as unlocked:
|
||||
# Collect entry fields. We use plain input() for
|
||||
# username/url/notes because they are not secret — getpass
|
||||
# for the entry's password
|
||||
username = input(PROMPT_ENTRY_USERNAME.format(entry = name))
|
||||
|
||||
if generate:
|
||||
try:
|
||||
password = generate_password(length)
|
||||
except PasswordTooShortError as exc:
|
||||
error_console.print(f"[red]{exc}[/red]")
|
||||
raise typer.Exit(code = 1) from None
|
||||
console.print(f"[green]Generated password:[/green] {password}")
|
||||
else:
|
||||
password = _prompt_master_password(
|
||||
f"Password for {name} (hidden): "
|
||||
)
|
||||
|
||||
url = input(PROMPT_ENTRY_URL).strip()
|
||||
notes = input(PROMPT_ENTRY_NOTES).strip()
|
||||
|
||||
entry = Entry(
|
||||
username = username,
|
||||
password = password,
|
||||
url = url,
|
||||
notes = notes,
|
||||
)
|
||||
|
||||
try:
|
||||
unlocked.add_entry(name, entry, force = force)
|
||||
except EntryAlreadyExistsError:
|
||||
error_console.print(
|
||||
f"[red]{MSG_ENTRY_ALREADY_EXISTS.format(name=name)}[/red]"
|
||||
)
|
||||
raise typer.Exit(code = 1) from None
|
||||
except ValueError as exc:
|
||||
# Empty / whitespace entry name caught by add_entry's
|
||||
# validation. Surface it as a clean error
|
||||
error_console.print(f"[red]{exc}[/red]")
|
||||
raise typer.Exit(code = 1) from None
|
||||
|
||||
unlocked.save()
|
||||
console.print(f"[green]{MSG_ENTRY_ADDED.format(name=name)}[/green]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def delete(
|
||||
name: Annotated[str,
|
||||
typer.Argument(help = "Entry name to delete")],
|
||||
vault: VaultPath = DEFAULT_VAULT_PATH,
|
||||
) -> None:
|
||||
"""
|
||||
Remove an entry by name
|
||||
"""
|
||||
master = _prompt_master_password()
|
||||
with _unlock_or_exit(vault, master) as unlocked:
|
||||
try:
|
||||
unlocked.delete_entry(name)
|
||||
except EntryNotFoundError:
|
||||
error_console.print(
|
||||
f"[red]{MSG_ENTRY_NOT_FOUND.format(name=name)}[/red]"
|
||||
)
|
||||
raise typer.Exit(code = 1) from None
|
||||
|
||||
unlocked.save()
|
||||
console.print(f"[green]{MSG_ENTRY_DELETED.format(name=name)}[/green]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def gen(
|
||||
length: Annotated[
|
||||
int,
|
||||
typer.Argument(help = "Password length"),
|
||||
] = DEFAULT_GENERATED_PASSWORD_LENGTH,
|
||||
no_symbols: Annotated[
|
||||
bool,
|
||||
typer.Option("--no-symbols",
|
||||
help = "Letters and digits only"),
|
||||
] = False,
|
||||
no_digits: Annotated[
|
||||
bool,
|
||||
typer.Option("--no-digits",
|
||||
help = "Letters and symbols only"),
|
||||
] = False,
|
||||
no_uppercase: Annotated[
|
||||
bool,
|
||||
typer.Option("--no-uppercase",
|
||||
help = "No uppercase letters"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""
|
||||
Print a fresh random password and exit (no vault required)
|
||||
"""
|
||||
try:
|
||||
password = generate_password(
|
||||
length,
|
||||
use_lowercase = True,
|
||||
use_uppercase = not no_uppercase,
|
||||
use_digits = not no_digits,
|
||||
use_symbols = not no_symbols,
|
||||
)
|
||||
except (PasswordTooShortError, ValueError) as exc:
|
||||
error_console.print(f"[red]{exc}[/red]")
|
||||
raise typer.Exit(code = 1) from None
|
||||
|
||||
# Plain print() so the output is pipe-friendly:
|
||||
# PASSWORD=$(pv gen 32)
|
||||
print(password)
|
||||
|
||||
|
||||
@app.command(name = "change-password")
|
||||
def change_password(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
|
||||
"""
|
||||
Change the master password (re-encrypts the vault end-to-end)
|
||||
|
||||
The whole reason the on-disk format stores the salt and KDF
|
||||
parameters next to the ciphertext is so this operation is
|
||||
possible. We unlock with the OLD password, derive a fresh salt
|
||||
+ key from the NEW password, and save — which re-encrypts every
|
||||
entry under the new key. Old vault file content is replaced
|
||||
atomically by the save() pattern, so a crash mid-rotation
|
||||
leaves the user with either the old or the new vault, never
|
||||
half of either
|
||||
"""
|
||||
current = _prompt_master_password("Current master password: ")
|
||||
with _unlock_or_exit(vault, current) as unlocked:
|
||||
new_password = _prompt_master_password_with_confirmation()
|
||||
unlocked.change_master_password(new_password)
|
||||
unlocked.save()
|
||||
console.print(
|
||||
f"[green]"
|
||||
f"{MSG_MASTER_PASSWORD_CHANGED.format(path=vault)}"
|
||||
f"[/green]"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,50 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
|
||||
Why this file exists even though it is empty
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
The one-sentence version
|
||||
────────────────────────────────────────────────────────────────────
|
||||
The mere PRESENCE of a file named `__init__.py` in a folder tells
|
||||
Python "this folder is a package — you may import from it". The
|
||||
file does not need to contain anything
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Why THIS one is empty (and the src one is not)
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Two folders in this project both have `__init__.py`, but they use
|
||||
it for different jobs
|
||||
|
||||
src/password_manager/__init__.py
|
||||
Has CODE inside. It re-exports the most useful classes so
|
||||
callers can write `from password_manager import UnlockedVault`
|
||||
instead of digging into submodules. This is the package's
|
||||
front door
|
||||
|
||||
tests/__init__.py <-- you are here, and it is EMPTY
|
||||
Has no code. It exists only as a MARKER so Python and pytest
|
||||
treat `tests/` as a package. That matters because some test
|
||||
files do this
|
||||
|
||||
from tests.conftest import TEST_KDF_PARAMETERS
|
||||
|
||||
For `tests.conftest` to be a valid dotted import path,
|
||||
`tests` has to be a package, which means this file has to
|
||||
exist. The header docstring is the only content; everything
|
||||
else is silence
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Takeaway
|
||||
────────────────────────────────────────────────────────────────────
|
||||
`__init__.py` is dual-purpose
|
||||
|
||||
EXISTENCE → "this folder is a package" (always, even when empty)
|
||||
CONTENTS → optional code that runs on first import (re-exports,
|
||||
version constants, etc.)
|
||||
|
||||
A folder full of `.py` files with no `__init__.py` is just files;
|
||||
add the `__init__.py` and it becomes something you can import as a
|
||||
single unit
|
||||
"""
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
conftest.py
|
||||
|
||||
Shared pytest fixtures for the test suite
|
||||
|
||||
A "fixture" is a setup function pytest runs before a test that needs
|
||||
it. The test asks for a fixture by listing its name as a parameter,
|
||||
and pytest wires the result in automatically — no manual `setUp` or
|
||||
`tearDown` like older unittest-style frameworks. Fixtures defined in
|
||||
conftest.py are auto-discovered and available to every test file in
|
||||
the same directory, with no `import` statement needed
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Why conftest.py specifically
|
||||
────────────────────────────────────────────────────────────────────
|
||||
pytest treats `conftest.py` as a magic filename. Anything defined
|
||||
there is shared across every test file in the directory (and
|
||||
subdirectories). This is the canonical place to put
|
||||
|
||||
- Shared fixtures (`@pytest.fixture` functions)
|
||||
- Shared test constants (like TEST_KDF_PARAMETERS below)
|
||||
- Test-suite-wide hooks (e.g. autouse fixtures)
|
||||
|
||||
Tests in test_crypto.py or test_vault.py can reference `vault_path`,
|
||||
`master_password`, or `fresh_vault` by listing them as parameters,
|
||||
and pytest finds them here automatically
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Two costs we balance when choosing fixture scope
|
||||
────────────────────────────────────────────────────────────────────
|
||||
1. Time — Argon2 derivation is intentionally slow (~0.3 sec/call
|
||||
in production). If every test created a new vault with real
|
||||
parameters, the suite would take minutes
|
||||
2. Isolation — tests must NOT share writable state, or one test
|
||||
can corrupt another (especially dangerous with disk-based
|
||||
tests where leftover files leak across runs)
|
||||
|
||||
We solve both: a "fast" KDF parameter set just for tests (so
|
||||
derivation runs in milliseconds, not seconds) and a fresh temporary
|
||||
vault path per test (`tmp_path` is a built-in pytest fixture that
|
||||
auto-creates and auto-cleans a unique temp directory each time)
|
||||
"""
|
||||
|
||||
# Standard library: object-oriented filesystem paths — used here as
|
||||
# a type hint for the `tmp_path` fixture pytest hands us.
|
||||
from pathlib import Path
|
||||
|
||||
# Third-party: the test runner. We need it here to declare fixtures
|
||||
# with the `@pytest.fixture` decorator.
|
||||
import pytest
|
||||
|
||||
# Local: the KDF parameter record — fixtures build a fast variant of
|
||||
# this so tests do not wait seconds on real Argon2 work.
|
||||
from password_manager.crypto import KdfParameters
|
||||
# Local: the main vault class — one fixture spins up a freshly
|
||||
# created vault that individual tests can mutate.
|
||||
from password_manager.vault import UnlockedVault
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fast Argon2 parameters — for tests only, never use these in production
|
||||
# =============================================================================
|
||||
# These values are well below OWASP recommendations but they cut test
|
||||
# runtime from minutes to milliseconds. The cryptographic correctness
|
||||
# of the code is the same regardless of parameter strength — Argon2
|
||||
# does the same operations, just fewer of them
|
||||
#
|
||||
# memory_cost = 8 KiB is the absolute floor that Argon2 accepts when
|
||||
# parallelism = 1 (the algorithm requires memory_cost >= 8 *
|
||||
# parallelism — see the Argon2 spec). If you change parallelism here,
|
||||
# bump memory_cost too or Argon2 will reject the parameter set
|
||||
|
||||
|
||||
TEST_KDF_PARAMETERS = KdfParameters(
|
||||
time_cost = 1,
|
||||
memory_cost = 8,
|
||||
parallelism = 1,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
# Each `@pytest.fixture` below defines a setup helper. Pytest sees a
|
||||
# test like `def test_x(vault_path): ...` and matches the parameter
|
||||
# name against a fixture name — when it finds one, the fixture runs
|
||||
# first and its return value is wired into the test
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault_path(tmp_path: Path) -> Path:
|
||||
"""
|
||||
Provide a path to a fresh, non-existent vault file
|
||||
|
||||
`tmp_path` is a built-in pytest fixture that creates a fresh
|
||||
empty temp directory for each test. Combined with our filename,
|
||||
every test gets a unique vault path that is auto-cleaned after
|
||||
the test ends. No global state, no cleanup code in test bodies
|
||||
"""
|
||||
return tmp_path / "test-vault.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def master_password() -> str:
|
||||
"""
|
||||
A reasonable test master password
|
||||
|
||||
Pulled into a fixture so every test uses the same value and a
|
||||
future refactor can swap it in one place. The xkcd-inspired
|
||||
string is long enough that the empty-password check below
|
||||
cannot accidentally accept it
|
||||
"""
|
||||
return "correct horse battery staple"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_vault(
|
||||
vault_path: Path,
|
||||
master_password: str,
|
||||
) -> UnlockedVault:
|
||||
"""
|
||||
Create an empty UnlockedVault using fast test KDF parameters
|
||||
|
||||
Notice we pass kdf_parameters explicitly into create() — no
|
||||
monkey-patching of `KdfParameters.defaults` anywhere. The
|
||||
constructor takes what it needs as an argument, the test pays
|
||||
no global-state tax, and the production code path is untouched
|
||||
|
||||
`fresh_vault` depends on TWO other fixtures (`vault_path` and
|
||||
`master_password`). pytest figures out the dependency chain
|
||||
automatically — it builds them in the right order so this
|
||||
fixture receives an already-resolved path and password
|
||||
"""
|
||||
return UnlockedVault.create(
|
||||
vault_path,
|
||||
master_password,
|
||||
kdf_parameters = TEST_KDF_PARAMETERS,
|
||||
)
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_crypto.py
|
||||
|
||||
Tests for the crypto module — KDF (Argon2id) and authenticated
|
||||
encryption (AES-256-GCM)
|
||||
|
||||
These tests verify the cryptographic primitives in isolation. They
|
||||
do not touch disk and they do not depend on the vault module. Each
|
||||
test exercises ONE property of ONE function, so a failure points
|
||||
directly at the broken piece
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What is "the crypto" doing that we need to verify?
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Two cryptographic ideas live in crypto.py
|
||||
|
||||
1. Key derivation — turning a master password into a 32-byte AES
|
||||
key. The KDF must be
|
||||
deterministic so the user can come back tomorrow and
|
||||
re-derive the SAME key from their password
|
||||
salt-sensitive so two users with the same password get
|
||||
DIFFERENT keys (defeats rainbow tables)
|
||||
slow so brute-force is expensive
|
||||
We test all three properties below
|
||||
|
||||
2. Authenticated encryption — encrypting bytes with AES-256-GCM in
|
||||
a way that ANY tampering (wrong key, modified ciphertext, or
|
||||
modified nonce) causes decryption to refuse. GCM gives us this
|
||||
for free; we verify each failure mode in turn
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Why TEST_KDF_PARAMETERS exists
|
||||
────────────────────────────────────────────────────────────────────
|
||||
Production Argon2 parameters are expensive on purpose (~300 ms per
|
||||
derivation). Running 20 tests at that cost would mean a 6-second
|
||||
test suite, which discourages running tests during development.
|
||||
TEST_KDF_PARAMETERS (in conftest.py) uses the absolute minimum
|
||||
Argon2-acceptable values — derivation finishes in milliseconds while
|
||||
still exercising the same code path. Crypto correctness does not
|
||||
depend on parameter strength
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
A short pytest primer for this file
|
||||
────────────────────────────────────────────────────────────────────
|
||||
`assert <expr>`
|
||||
pytest fails the test if <expr> is falsy
|
||||
|
||||
`pytest.raises(SomeError)`
|
||||
Context manager that requires the block to raise SomeError. This
|
||||
is how we verify "refuses bad input" cases — wrong password,
|
||||
tampered ciphertext, modified nonce
|
||||
|
||||
`bytearray(...)` vs `bytes(...)`
|
||||
Python's bytes is IMMUTABLE; you cannot do `b[0] = 0x42`. To
|
||||
flip a bit for a tampering test, copy into a bytearray (mutable),
|
||||
mutate, then cast back to bytes when handing it to a function
|
||||
that expects bytes
|
||||
"""
|
||||
|
||||
# Third-party: the test runner. Used for `pytest.raises` and the
|
||||
# `@pytest.mark.parametrize` decorator below.
|
||||
import pytest
|
||||
|
||||
# Local: every crypto helper under test — the dataclass, the error
|
||||
# type, the encrypt/decrypt pair, and the random-bytes helpers.
|
||||
from password_manager.crypto import (
|
||||
KdfParameters,
|
||||
WrongPasswordError,
|
||||
decrypt,
|
||||
derive_key,
|
||||
encrypt,
|
||||
generate_nonce,
|
||||
generate_salt,
|
||||
)
|
||||
# Local: byte-length constants — we assert outputs match these
|
||||
# without ever hard-coding 32 or 16 inside this file.
|
||||
from password_manager.constants import (
|
||||
KEY_LENGTH_BYTES,
|
||||
NONCE_LENGTH_BYTES,
|
||||
SALT_LENGTH_BYTES,
|
||||
)
|
||||
# Local: the fast KDF parameters defined in conftest. Importing
|
||||
# directly (rather than via a fixture) keeps test bodies short.
|
||||
from tests.conftest import TEST_KDF_PARAMETERS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Random byte generation — salts and nonces must be the right size + unpredictable
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_generate_salt_returns_correct_length() -> None:
|
||||
"""
|
||||
Verify generate_salt() produces exactly SALT_LENGTH_BYTES of output
|
||||
|
||||
The salt length is part of our on-disk format contract — change
|
||||
it without updating SALT_LENGTH_BYTES and every existing vault
|
||||
would fail to decrypt. The most basic invariant to pin down
|
||||
"""
|
||||
salt = generate_salt()
|
||||
assert len(salt) == SALT_LENGTH_BYTES
|
||||
|
||||
|
||||
def test_generate_salt_returns_different_values_on_each_call() -> None:
|
||||
"""
|
||||
Verify generate_salt() yields a fresh random value every time
|
||||
|
||||
The whole point of a salt is that two users with the same
|
||||
password get different keys. If generate_salt() returned the same
|
||||
bytes every call, that property collapses
|
||||
|
||||
We collect 50 salts into a set; if the source is random, all 50
|
||||
will be unique with overwhelming probability (16 random bytes =
|
||||
128 bits of entropy, collisions are astronomically unlikely).
|
||||
A failure here means `secrets.token_bytes` is broken or has been
|
||||
replaced with a non-secure source
|
||||
"""
|
||||
salts = {generate_salt() for _ in range(50)}
|
||||
# 50 unique 16-byte values is overwhelmingly likely if the source
|
||||
# is truly random. A failure here means token_bytes is broken
|
||||
assert len(salts) == 50
|
||||
|
||||
|
||||
def test_generate_nonce_returns_correct_length() -> None:
|
||||
"""
|
||||
Verify generate_nonce() produces exactly NONCE_LENGTH_BYTES of output
|
||||
|
||||
AES-GCM's recommended nonce length is 12 bytes. Other lengths are
|
||||
technically valid but degrade security guarantees. We pin to
|
||||
NONCE_LENGTH_BYTES (12) and verify here so an accidental refactor
|
||||
to 16 or 8 would fail this test immediately
|
||||
"""
|
||||
nonce = generate_nonce()
|
||||
assert len(nonce) == NONCE_LENGTH_BYTES
|
||||
|
||||
|
||||
def test_generate_nonce_returns_different_values_on_each_call() -> None:
|
||||
"""
|
||||
Verify nonces are fresh on every call — the most security-critical property in the file
|
||||
|
||||
Nonce reuse in GCM is CATASTROPHIC. Two messages encrypted with
|
||||
the same (key, nonce) pair leak the XOR of the plaintexts to
|
||||
anyone watching, and let an attacker forge messages. So
|
||||
`generate_nonce` is the most security-critical function here,
|
||||
and "no two values are ever the same" is the property that matters
|
||||
|
||||
Same 50-into-a-set check as for salts. Different values = working
|
||||
"""
|
||||
nonces = {generate_nonce() for _ in range(50)}
|
||||
assert len(nonces) == 50
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Key derivation — Argon2id properties
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_derive_key_returns_correct_length() -> None:
|
||||
"""
|
||||
Verify derive_key produces exactly KEY_LENGTH_BYTES of output
|
||||
|
||||
KEY_LENGTH_BYTES is 32 — what AES-256-GCM expects. The Argon2
|
||||
library lets us request any output length; if we accidentally
|
||||
asked for 16 we would get an AES-128 vault, and if we asked for
|
||||
64 the cipher would refuse to construct at all
|
||||
"""
|
||||
key = derive_key(
|
||||
"password",
|
||||
generate_salt(),
|
||||
TEST_KDF_PARAMETERS,
|
||||
)
|
||||
assert len(key) == KEY_LENGTH_BYTES
|
||||
|
||||
|
||||
def test_derive_key_is_deterministic() -> None:
|
||||
"""
|
||||
Verify derive_key is deterministic — same inputs always produce the same key
|
||||
|
||||
This is THE property that makes the password manager work. When
|
||||
the user types their master password tomorrow, we must derive
|
||||
the EXACT same key we used to encrypt the vault yesterday. If
|
||||
Argon2 were non-deterministic (say, mixed in a timestamp
|
||||
internally), the user could never unlock their vault again
|
||||
|
||||
Notice we generate the salt ONCE and reuse it for both
|
||||
derivations — that is what isolates the "same inputs" condition
|
||||
"""
|
||||
salt = generate_salt()
|
||||
key_a = derive_key("hunter2", salt, TEST_KDF_PARAMETERS)
|
||||
key_b = derive_key("hunter2", salt, TEST_KDF_PARAMETERS)
|
||||
assert key_a == key_b
|
||||
|
||||
|
||||
def test_derive_key_different_passwords_yield_different_keys() -> None:
|
||||
"""
|
||||
Verify changing the password (with the salt held constant) yields a different key
|
||||
|
||||
This is the basic "the password matters" property. If derive_key
|
||||
produced the same key for "password-a" and "password-b" with the
|
||||
same salt, the function would be ignoring its first argument —
|
||||
which would mean every vault could be unlocked with any password.
|
||||
Catastrophic
|
||||
"""
|
||||
salt = generate_salt()
|
||||
key_a = derive_key("password-a", salt, TEST_KDF_PARAMETERS)
|
||||
key_b = derive_key("password-b", salt, TEST_KDF_PARAMETERS)
|
||||
assert key_a != key_b
|
||||
|
||||
|
||||
def test_derive_key_different_salts_yield_different_keys() -> None:
|
||||
"""
|
||||
Verify changing the salt (with the password held constant) yields a different key
|
||||
|
||||
The salt is what defeats rainbow tables — two users with the same
|
||||
password must end up with different keys because their salts
|
||||
differ. Without this property, an attacker who steals 10 million
|
||||
encrypted vaults could pre-compute keys for the top 1000 common
|
||||
passwords and instantly unlock every vault belonging to anyone
|
||||
who picked one
|
||||
"""
|
||||
key_a = derive_key("hunter2", generate_salt(), TEST_KDF_PARAMETERS)
|
||||
key_b = derive_key("hunter2", generate_salt(), TEST_KDF_PARAMETERS)
|
||||
assert key_a != key_b
|
||||
|
||||
|
||||
def test_kdf_parameters_defaults_are_immutable() -> None:
|
||||
"""
|
||||
Verify KdfParameters instances reject attribute assignment after construction
|
||||
|
||||
`frozen=True` on the dataclass is what makes this work. Trying
|
||||
to assign to a frozen instance raises AttributeError at runtime.
|
||||
We confirm here so a future refactor that drops `frozen=True`
|
||||
would fail this test immediately
|
||||
|
||||
The `# type: ignore[misc]` comment tells mypy "yes, we KNOW this
|
||||
line is illegal — that is the whole point of the test." Without
|
||||
the comment, type-checking would refuse before pytest could even
|
||||
run the test
|
||||
"""
|
||||
params = KdfParameters.defaults()
|
||||
with pytest.raises(AttributeError):
|
||||
params.time_cost = 999 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_derive_key_rejects_empty_password() -> None:
|
||||
"""
|
||||
Verify derive_key refuses an empty master password
|
||||
|
||||
Argon2 itself would happily derive a key from b"" + the salt,
|
||||
but the resulting "lock" has effectively no secret in it —
|
||||
anyone who steals the vault file can re-derive the same key
|
||||
from the public salt. derive_key refuses outright as a
|
||||
programming-error floor, and we verify here that the refusal
|
||||
takes the form of a ValueError
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
derive_key("", generate_salt(), TEST_KDF_PARAMETERS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Encrypt / decrypt round-trip — the basic AES-GCM contract
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_encrypt_decrypt_round_trip() -> None:
|
||||
"""
|
||||
Verify decrypt(encrypt(x)) returns the original x
|
||||
|
||||
The most basic encryption test imaginable: encrypt some bytes,
|
||||
decrypt them, get the same bytes back. If this ever fails, the
|
||||
encryption module is fundamentally broken and nothing else in
|
||||
the test suite will be meaningful
|
||||
|
||||
Notice the structure: we derive a key from a password (same flow
|
||||
as production), encrypt, decrypt, compare. We use a real
|
||||
password+salt rather than hard-coded key bytes so the full
|
||||
derive-then-encrypt path is exercised
|
||||
"""
|
||||
salt = generate_salt()
|
||||
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
|
||||
plaintext = b"the quick brown fox jumps over the lazy dog"
|
||||
|
||||
nonce, ciphertext = encrypt(plaintext, key)
|
||||
recovered = decrypt(ciphertext, nonce, key)
|
||||
|
||||
assert recovered == plaintext
|
||||
|
||||
|
||||
def test_encrypt_produces_fresh_nonce_each_call() -> None:
|
||||
"""
|
||||
Verify two calls to encrypt() with the same plaintext+key still produce different output
|
||||
|
||||
Each encrypt() call internally calls generate_nonce(). Because
|
||||
the nonce is fresh, the ciphertext for the same plaintext+key
|
||||
WILL differ across calls — both the nonce bytes and the
|
||||
ciphertext bytes
|
||||
|
||||
If a future refactor accidentally cached the nonce (or used a
|
||||
counter that reset), this test would catch it. Nonce reuse with
|
||||
the same key in GCM is one of the all-time worst cryptographic
|
||||
mistakes — see "Nonce Disrespecting Adversaries" if you want a
|
||||
deep dive
|
||||
"""
|
||||
salt = generate_salt()
|
||||
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
|
||||
plaintext = b"hello"
|
||||
|
||||
nonce1, ct1 = encrypt(plaintext, key)
|
||||
nonce2, ct2 = encrypt(plaintext, key)
|
||||
|
||||
# Same plaintext + same key but different nonce → different ciphertext
|
||||
assert nonce1 != nonce2
|
||||
assert ct1 != ct2
|
||||
|
||||
|
||||
def test_encrypt_handles_empty_plaintext() -> None:
|
||||
"""
|
||||
Verify encrypt() handles an empty bytes object cleanly
|
||||
|
||||
The empty plaintext is a real edge case: a freshly-initialized
|
||||
vault encrypts an empty entries dict, which JSON-serializes to
|
||||
b"{}" — but if a user deletes every entry, we end up in the
|
||||
same place. Pinning down b"" specifically here guards against
|
||||
off-by-one bugs in length-prefix code or similar
|
||||
"""
|
||||
salt = generate_salt()
|
||||
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
|
||||
nonce, ciphertext = encrypt(b"", key)
|
||||
assert decrypt(ciphertext, nonce, key) == b""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decryption refuses tampered or wrong-key input — GCM authentication
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_decrypt_with_wrong_key_raises_wrong_password_error() -> None:
|
||||
"""
|
||||
Verify decrypt() refuses when the key does not match the encryption key
|
||||
|
||||
This is the "wrong master password" path. Encrypt with one
|
||||
derived key, attempt to decrypt with a DIFFERENT derived key,
|
||||
GCM refuses because the authentication tag will not validate.
|
||||
We re-raise that low-level failure as WrongPasswordError (in
|
||||
crypto.py) so the CLI can show the user a clean message instead
|
||||
of leaking cryptography-library internals
|
||||
|
||||
This is the primary security property of the entire password
|
||||
manager: only the correct master password can unlock the vault
|
||||
"""
|
||||
salt = generate_salt()
|
||||
correct_key = derive_key("correct", salt, TEST_KDF_PARAMETERS)
|
||||
wrong_key = derive_key("wrong", salt, TEST_KDF_PARAMETERS)
|
||||
nonce, ciphertext = encrypt(b"secret", correct_key)
|
||||
|
||||
with pytest.raises(WrongPasswordError):
|
||||
decrypt(ciphertext, nonce, wrong_key)
|
||||
|
||||
|
||||
def test_decrypt_with_modified_ciphertext_raises() -> None:
|
||||
"""
|
||||
Verify flipping any byte of the ciphertext causes decryption to fail
|
||||
|
||||
This is GCM's "tamper-evidence" property. The authentication tag
|
||||
appended to the ciphertext covers every byte of it — flip one
|
||||
bit and the tag no longer matches, GCM refuses. We do NOT get a
|
||||
quietly-mangled plaintext back; we get an exception
|
||||
|
||||
The bit-flip we perform — `tampered[middle] ^= 0x01` — uses XOR
|
||||
against 0x01 to flip the lowest bit of the byte at the middle of
|
||||
the ciphertext. The exact bit does not matter; ANY change to ANY
|
||||
byte of the ciphertext must trip the auth tag
|
||||
"""
|
||||
salt = generate_salt()
|
||||
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
|
||||
nonce, ciphertext = encrypt(b"important data", key)
|
||||
|
||||
# Flip one bit somewhere in the middle. We need a mutable copy
|
||||
# because Python's `bytes` is immutable — `b[0] = 0x42` would
|
||||
# raise. `bytearray` is the mutable cousin. After flipping we
|
||||
# cast back to bytes for the decrypt call (which expects bytes,
|
||||
# not bytearray, as a matter of type-strict API discipline)
|
||||
middle = len(ciphertext) // 2
|
||||
tampered = bytearray(ciphertext)
|
||||
tampered[middle] ^= 0x01
|
||||
tampered_bytes = bytes(tampered)
|
||||
|
||||
with pytest.raises(WrongPasswordError):
|
||||
decrypt(tampered_bytes, nonce, key)
|
||||
|
||||
|
||||
def test_decrypt_with_modified_nonce_raises() -> None:
|
||||
"""
|
||||
Verify modifying the nonce also causes decryption to fail
|
||||
|
||||
Many beginners assume only the ciphertext is authenticated. In
|
||||
fact GCM authenticates the (nonce, ciphertext) pair together —
|
||||
if an attacker swaps the nonce for some other valid-looking
|
||||
value, the tag still will not match and decryption refuses
|
||||
|
||||
Flipping every bit of the first nonce byte with `^= 0xFF` is a
|
||||
high-confidence way to produce a definitively-different nonce
|
||||
(a tampered byte may collide with the original; flipping every
|
||||
bit guarantees it does not)
|
||||
"""
|
||||
salt = generate_salt()
|
||||
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
|
||||
nonce, ciphertext = encrypt(b"important data", key)
|
||||
|
||||
bad_nonce = bytearray(nonce)
|
||||
bad_nonce[0] ^= 0xFF # flip every bit of the first byte
|
||||
|
||||
with pytest.raises(WrongPasswordError):
|
||||
decrypt(ciphertext, bytes(bad_nonce), key)
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_generator.py
|
||||
|
||||
Tests for the password generator — what it produces and what it refuses
|
||||
|
||||
Every test below is one Python function whose name starts with `test_`.
|
||||
pytest discovers them automatically when you run `just test`, and runs
|
||||
each one in isolation so a failure in one cannot poison another
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What the generator must guarantee
|
||||
────────────────────────────────────────────────────────────────────
|
||||
1. The result is exactly the requested length
|
||||
2. Every character class the user enabled appears at least once.
|
||||
This is what makes the generated password pass "must contain a
|
||||
digit / symbol / uppercase" rules on websites
|
||||
3. Short or impossible requests are rejected loudly, never silently
|
||||
"fixed" to something else
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
A short pytest primer for this file
|
||||
────────────────────────────────────────────────────────────────────
|
||||
`assert <expr>`
|
||||
pytest fails the test if <expr> is falsy
|
||||
|
||||
`pytest.raises(SomeError)`
|
||||
A context manager that wraps a code block. The block MUST raise
|
||||
SomeError or the test fails. This is how we verify "refuses bad
|
||||
input" without having to predict the exact wording of the error
|
||||
|
||||
`any(c in pool for c in password)`
|
||||
Returns True if AT LEAST one character of password is in pool
|
||||
|
||||
`all(c in pool for c in password)`
|
||||
Returns True only if EVERY character of password is in pool
|
||||
|
||||
`set(...)`
|
||||
Builds a Python set, which silently discards duplicates.
|
||||
`len({generate_password(16) for _ in range(100)}) == 100` is a
|
||||
clean way to assert "100 generated passwords were all unique"
|
||||
"""
|
||||
|
||||
# Standard library: a few prebuilt character-set strings (digits,
|
||||
# ascii_letters, etc.) — handy for "every char is allowed" assertions.
|
||||
import string
|
||||
|
||||
# Third-party: the test runner. Used for `pytest.raises` and the
|
||||
# `@pytest.mark.parametrize` decorator below.
|
||||
import pytest
|
||||
|
||||
# Local: the character pools and length minimum. Importing them lets
|
||||
# the tests stay in sync if production code ever tweaks the alphabet.
|
||||
from password_manager.constants import (
|
||||
DIGITS,
|
||||
LOWERCASE_LETTERS,
|
||||
MINIMUM_GENERATED_PASSWORD_LENGTH,
|
||||
SAFE_SYMBOLS,
|
||||
UPPERCASE_LETTERS,
|
||||
)
|
||||
# Local: the function under test plus the error type we expect when
|
||||
# the caller asks for a too-short password.
|
||||
from password_manager.generator import (
|
||||
PasswordTooShortError,
|
||||
generate_password,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Length — what the user asks for is what they get
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_generate_password_default_length_matches_argument() -> None:
|
||||
"""
|
||||
Verify the generator produces a password of exactly the requested length
|
||||
|
||||
This is the most basic contract: if I ask for length=20, I must
|
||||
get back a 20-character string. An off-by-one mistake here would
|
||||
break every downstream caller, so it is the first thing we pin
|
||||
down
|
||||
"""
|
||||
password = generate_password(20)
|
||||
assert len(password) == 20
|
||||
|
||||
|
||||
def test_generate_password_below_minimum_raises() -> None:
|
||||
"""
|
||||
Verify the generator refuses lengths below the project minimum
|
||||
|
||||
Why a minimum at all? A 4-character password is trivially
|
||||
crackable even with a slow KDF in front of it. Letting the caller
|
||||
produce one silently would betray the user. PasswordTooShortError
|
||||
is our way of saying "this is not a number we will produce a
|
||||
password for"
|
||||
|
||||
The `with pytest.raises(...)` block fails the test if no
|
||||
exception is raised, OR if the wrong exception type is raised —
|
||||
so this single line asserts both "an error happened" and "it was
|
||||
the right error type"
|
||||
"""
|
||||
with pytest.raises(PasswordTooShortError):
|
||||
generate_password(MINIMUM_GENERATED_PASSWORD_LENGTH - 1)
|
||||
|
||||
|
||||
def test_generate_password_with_no_pools_enabled_raises() -> None:
|
||||
"""
|
||||
Verify the generator refuses when every character pool is disabled
|
||||
|
||||
The generator draws characters from a pool built up by combining
|
||||
the four character classes (lowercase / uppercase / digits /
|
||||
symbols). If the caller sets every flag to False, the pool is
|
||||
empty — there is literally nothing to draw from
|
||||
|
||||
Rather than fall back to some default silently, the generator
|
||||
raises ValueError so the caller learns about the bug. Silent
|
||||
fallbacks in security code are how subtle weaknesses ship to
|
||||
production
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
generate_password(
|
||||
16,
|
||||
use_lowercase = False,
|
||||
use_uppercase = False,
|
||||
use_digits = False,
|
||||
use_symbols = False,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Character class coverage — every enabled pool contributes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_generate_password_contains_at_least_one_from_each_pool() -> None:
|
||||
"""
|
||||
Verify every enabled character class is represented in the output
|
||||
|
||||
Most websites enforce composition rules: "must contain a
|
||||
lowercase letter, an uppercase letter, a digit, and a symbol."
|
||||
A generator that produced a 16-character password of only
|
||||
lowercase letters would pass `len() == 16` but fail half the
|
||||
websites the user actually has accounts on
|
||||
|
||||
The fix: after building the pool, the generator guarantees AT
|
||||
LEAST one character from each enabled pool ends up in the result.
|
||||
We verify that property here by checking each pool with
|
||||
`any(c in pool for c in password)`
|
||||
"""
|
||||
password = generate_password(
|
||||
16,
|
||||
use_lowercase = True,
|
||||
use_uppercase = True,
|
||||
use_digits = True,
|
||||
use_symbols = True,
|
||||
)
|
||||
# any(...) returns True if at least one char satisfies the predicate
|
||||
assert any(c in LOWERCASE_LETTERS for c in password)
|
||||
assert any(c in UPPERCASE_LETTERS for c in password)
|
||||
assert any(c in DIGITS for c in password)
|
||||
assert any(c in SAFE_SYMBOLS for c in password)
|
||||
|
||||
|
||||
def test_generate_password_only_lowercase_when_others_disabled() -> None:
|
||||
"""
|
||||
Verify disabling every pool except lowercase yields a lowercase-only string
|
||||
|
||||
Useful for passphrases or for systems that refuse symbols / mixed
|
||||
case. If we accidentally leaked an uppercase letter or a digit
|
||||
into the result here, the generator would be ignoring the user's
|
||||
pool selection — a quiet correctness bug
|
||||
|
||||
`all(c in LOWERCASE_LETTERS for c in password)` is True only when
|
||||
EVERY character of password is in the lowercase pool
|
||||
"""
|
||||
password = generate_password(
|
||||
16,
|
||||
use_lowercase = True,
|
||||
use_uppercase = False,
|
||||
use_digits = False,
|
||||
use_symbols = False,
|
||||
)
|
||||
assert all(c in LOWERCASE_LETTERS for c in password)
|
||||
|
||||
|
||||
def test_generate_password_excludes_symbols_when_disabled() -> None:
|
||||
"""
|
||||
Verify use_symbols=False truly removes all symbols from the result
|
||||
|
||||
Some legacy systems reject special characters. Users need an
|
||||
escape hatch — flipping `use_symbols=False` must yield zero
|
||||
symbols, never just "fewer symbols." We check this with
|
||||
`not any(...)`, which is True only when NO character is in the
|
||||
disallowed pool
|
||||
"""
|
||||
password = generate_password(
|
||||
16,
|
||||
use_symbols = False,
|
||||
)
|
||||
assert not any(c in SAFE_SYMBOLS for c in password)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Randomness — successive calls must produce different output
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_generate_password_uniqueness_across_calls() -> None:
|
||||
"""
|
||||
Verify successive calls produce distinct passwords
|
||||
|
||||
Two truly-random 16-character passwords colliding has probability
|
||||
around 1 / (62 ^ 16), which is ~3e-29. So the chance of 100 calls
|
||||
producing 100 unique values is overwhelmingly close to 1
|
||||
|
||||
If this test EVER fails, the only reasonable explanation is that
|
||||
the underlying random source (secrets.choice) is broken — for
|
||||
instance, someone swapped it for `random.choice` which seeds off
|
||||
a predictable source
|
||||
|
||||
Building a set from the 100 calls and asserting `len(set) == 100`
|
||||
is the cleanest way to express "all of them were different"
|
||||
"""
|
||||
passwords = {generate_password(16) for _ in range(100)}
|
||||
assert len(passwords) == 100
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Alphabet boundaries — no garbage characters slip in
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_generate_password_only_uses_expected_alphabet() -> None:
|
||||
"""
|
||||
Verify the generator never produces characters outside the configured pools
|
||||
|
||||
A subtle bug would be the generator emitting a tab, a newline, or
|
||||
a Unicode character that breaks copy-paste. Building the allowed
|
||||
set by hand (union of all four pools) and asserting every char is
|
||||
in it catches that
|
||||
|
||||
The defense-in-depth check at the end explicitly looks for
|
||||
whitespace — string.whitespace is the standard library's
|
||||
canonical set of " \\t\\n\\r\\v\\f", so we cannot mistype it
|
||||
"""
|
||||
password = generate_password(32)
|
||||
allowed = set(
|
||||
LOWERCASE_LETTERS + UPPERCASE_LETTERS + DIGITS + SAFE_SYMBOLS
|
||||
)
|
||||
assert all(c in allowed for c in password)
|
||||
# Defense-in-depth: also check there are no whitespace chars
|
||||
assert not any(c in string.whitespace for c in password)
|
||||
|
||||
|
||||
def test_generate_password_short_request_with_many_pools_raises() -> None:
|
||||
"""
|
||||
Verify the minimum-length path interacts correctly with pool counts
|
||||
|
||||
Two related checks could conceivably reject a request
|
||||
|
||||
1. length below MINIMUM_GENERATED_PASSWORD_LENGTH (project floor)
|
||||
2. length below the pool count (cannot fit one char from each)
|
||||
|
||||
For our defaults (minimum 8, four pools) the minimum-length check
|
||||
fires first — there is no value of `length` that passes (1) but
|
||||
fails (2). This test pins down the boundary: length == 8 with
|
||||
four pools enabled must succeed, proving the two checks combine
|
||||
sensibly. The test name says "raises" but the test verifies
|
||||
success at the floor — kept as-is to preserve the existing name
|
||||
"""
|
||||
# 8 is exactly the minimum and fits 4 pools, should succeed
|
||||
password = generate_password(8)
|
||||
assert len(password) == 8
|
||||
|
|
@ -0,0 +1,959 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_vault.py
|
||||
|
||||
Tests for the vault module — file format, persistence, entry CRUD
|
||||
|
||||
These tests DO touch the filesystem (via pytest's `tmp_path` fixture)
|
||||
because the vault's whole job is reading and writing files. Every
|
||||
fixture cleans up after itself when the test ends, so the suite
|
||||
leaves no debris behind
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
What is being verified, organized by section
|
||||
────────────────────────────────────────────────────────────────────
|
||||
1. Vault creation — `UnlockedVault.create()` writes a file at the
|
||||
given path with the right format, the right permissions, and
|
||||
refuses to clobber an existing vault
|
||||
|
||||
2. Round-trip — save a vault, reopen it, get back exactly what was
|
||||
stored. Verify wrong-password, missing-file, malformed-file,
|
||||
and out-of-range KDF parameters all raise the right exceptions
|
||||
|
||||
3. Entry dataclass — Entry is frozen (cannot be mutated after
|
||||
construction); from_dict refuses corrupted input loudly instead
|
||||
of silently filling in defaults for required fields
|
||||
|
||||
4. Entry operations — add / get / delete / names. Duplicate
|
||||
detection, the `force` overwrite flag, sort order, entry-name
|
||||
validation that rejects leading/trailing whitespace
|
||||
|
||||
5. Master password rotation — change_master_password rotates BOTH
|
||||
the salt AND the key. After rotation the old password no longer
|
||||
works; the new password unlocks; all entries survive
|
||||
|
||||
6. Lifecycle — close() and the context-manager `with` block both
|
||||
drop the key + entries from memory when done
|
||||
|
||||
7. Persistence — every save produces a fresh nonce, never leaves a
|
||||
stale .tmp file, and uses the secure 0600 file mode from the
|
||||
very first syscall
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
A short pytest primer for this file
|
||||
────────────────────────────────────────────────────────────────────
|
||||
`tmp_path` (built-in fixture)
|
||||
Pytest creates a fresh empty temporary directory for each test
|
||||
and passes its Path here. Auto-cleaned at test end
|
||||
|
||||
`monkeypatch` (built-in fixture)
|
||||
Lets you temporarily replace attributes on objects (functions,
|
||||
modules, classes) for the duration of one test. We use it to
|
||||
spy on `os.open` and to simulate `os.replace` failing. After
|
||||
the test ends, the original attributes are restored
|
||||
|
||||
`@pytest.mark.parametrize("name", [v1, v2, v3])`
|
||||
Runs the same test function once per value. So a single
|
||||
`test_x(name)` becomes 3 separate tests, each with a different
|
||||
name. Cleaner than writing 3 near-identical test functions
|
||||
|
||||
`fresh_vault` and `master_password` (from conftest.py)
|
||||
Fixtures we defined ourselves. Pytest sees them in the test
|
||||
function's parameters and wires them in automatically
|
||||
"""
|
||||
|
||||
# Standard library: `dataclasses.replace` lets us copy an Entry with
|
||||
# one field changed — handy for building "tampered" inputs.
|
||||
import dataclasses
|
||||
# Standard library: parse the on-disk vault file so we can assert
|
||||
# its structure looks right (versions, key names, etc.).
|
||||
import json
|
||||
# Standard library: `os.stat` reads filesystem metadata — we use it
|
||||
# to check the vault file's permission bits.
|
||||
import os
|
||||
# Standard library: symbolic names for permission bits (S_IRUSR
|
||||
# etc.) — makes the perm check read like English.
|
||||
import stat
|
||||
# Standard library: object-oriented filesystem paths — type hint
|
||||
# for the `tmp_path` fixture and our own helpers.
|
||||
from pathlib import Path
|
||||
|
||||
# Third-party: the test runner. Used for `pytest.raises` and
|
||||
# `@pytest.mark.parametrize`.
|
||||
import pytest
|
||||
|
||||
# Local: file-mode and format-version constants. Tests assert
|
||||
# against these so the values stay defined in exactly one place.
|
||||
from password_manager.constants import (
|
||||
VAULT_FILE_MODE,
|
||||
VAULT_FORMAT_VERSION,
|
||||
)
|
||||
# Local: the one crypto error we expect when an unlock attempt
|
||||
# uses the wrong master password.
|
||||
from password_manager.crypto import WrongPasswordError
|
||||
# Local: the full surface of the vault module — entry record,
|
||||
# main class, and every error type tests need to raise or catch.
|
||||
from password_manager.vault import (
|
||||
Entry,
|
||||
EntryAlreadyExistsError,
|
||||
EntryNotFoundError,
|
||||
UnlockedVault,
|
||||
VaultAlreadyExistsError,
|
||||
VaultFormatError,
|
||||
VaultNotFoundError,
|
||||
)
|
||||
# Local: fast KDF parameters from conftest, so Argon2 work in
|
||||
# tests stays in milliseconds.
|
||||
from tests.conftest import TEST_KDF_PARAMETERS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _sample_entry(password: str = "s3cret") -> Entry:
|
||||
"""
|
||||
Return a fully-populated Entry that tests can drop into a vault
|
||||
|
||||
Centralizing this means every test that wants "an entry" gets a
|
||||
consistent shape with username, password, url, and notes set.
|
||||
Tests that care about a specific password override it by passing
|
||||
one in
|
||||
"""
|
||||
return Entry(
|
||||
username = "alice",
|
||||
password = password,
|
||||
url = "https://example.com",
|
||||
notes = "primary account",
|
||||
)
|
||||
|
||||
|
||||
def _create_test_vault(
|
||||
path: Path,
|
||||
master_password: str,
|
||||
) -> UnlockedVault:
|
||||
"""
|
||||
Tiny convenience wrapper that always uses TEST_KDF_PARAMETERS
|
||||
|
||||
The whole point of test fixtures is to keep noise out of test
|
||||
bodies — this exists so every test that needs to call create()
|
||||
directly can do so without restating the kdf_parameters kwarg
|
||||
every time
|
||||
"""
|
||||
return UnlockedVault.create(
|
||||
path,
|
||||
master_password,
|
||||
kdf_parameters = TEST_KDF_PARAMETERS,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Vault creation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_create_writes_file_at_path(
|
||||
vault_path: Path,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify UnlockedVault.create() puts a real file at the path we gave it
|
||||
|
||||
The most basic post-condition: after `create(path, ...)` returns,
|
||||
`path` exists on disk. If a future refactor accidentally returned
|
||||
an in-memory-only vault that forgot to save, this test catches
|
||||
it before the user loses data on the next process restart
|
||||
"""
|
||||
_create_test_vault(vault_path, master_password)
|
||||
assert vault_path.exists()
|
||||
|
||||
|
||||
def test_create_refuses_to_overwrite_existing_file(
|
||||
vault_path: Path,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify create() refuses to clobber a vault that already exists
|
||||
|
||||
`pv init` is a destructive-feeling operation: if it silently
|
||||
overwrote an existing vault, a user who mistyped a command could
|
||||
lose every credential they had stored. Refusing with
|
||||
VaultAlreadyExistsError forces the user to make a conscious
|
||||
choice (delete the old file first if they really mean to start
|
||||
over)
|
||||
"""
|
||||
_create_test_vault(vault_path, master_password)
|
||||
with pytest.raises(VaultAlreadyExistsError):
|
||||
_create_test_vault(vault_path, master_password)
|
||||
|
||||
|
||||
def test_create_sets_file_mode_to_0600(
|
||||
vault_path: Path,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the vault file is created with restrictive POSIX permissions
|
||||
|
||||
0600 means "the owner can read/write, nobody else can read OR
|
||||
write." Even though the contents are encrypted, broadcasting
|
||||
metadata (file size, timestamps, even that the file exists at
|
||||
all) to other local users is sloppy. Pin permissions tight at
|
||||
creation time
|
||||
|
||||
`stat.S_IMODE` masks off file-type bits, leaving just the
|
||||
permission bits we care about. On Windows POSIX modes do not
|
||||
apply at all, so we skip the assertion there
|
||||
"""
|
||||
_create_test_vault(vault_path, master_password)
|
||||
mode = stat.S_IMODE(os.stat(vault_path).st_mode)
|
||||
# On Windows, POSIX modes do not apply. Skip that platform's check
|
||||
if os.name != "nt":
|
||||
assert mode == VAULT_FILE_MODE
|
||||
|
||||
|
||||
def test_create_writes_valid_envelope_json(
|
||||
vault_path: Path,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the file create() writes is a valid JSON envelope with the expected fields
|
||||
|
||||
We do not crack open the encrypted payload here — that is
|
||||
`unlock`'s job. We just confirm the OUTER envelope has the right
|
||||
shape: version field, kdf section naming argon2id, cipher
|
||||
section naming aes-256-gcm. If this drifts we lose forward
|
||||
compatibility with existing vaults
|
||||
"""
|
||||
_create_test_vault(vault_path, master_password)
|
||||
envelope = json.loads(vault_path.read_text(encoding = "utf-8"))
|
||||
assert envelope["version"] == VAULT_FORMAT_VERSION
|
||||
assert envelope["kdf"]["name"] == "argon2id"
|
||||
assert envelope["cipher"]["name"] == "aes-256-gcm"
|
||||
|
||||
|
||||
def test_create_makes_parent_directory_if_missing(
|
||||
tmp_path: Path,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify create() auto-creates intermediate directories on the way to the vault path
|
||||
|
||||
Users specify vault paths like `~/.config/pv/vault.json` where
|
||||
`~/.config/pv` may not exist yet. We make the parent tree on
|
||||
their behalf (with `parents=True, exist_ok=True`) so they do not
|
||||
have to mkdir first. This test pins down that convenience
|
||||
"""
|
||||
nested = tmp_path / "deep" / "nested" / "dir" / "vault.json"
|
||||
_create_test_vault(nested, master_password)
|
||||
assert nested.exists()
|
||||
|
||||
|
||||
def test_create_rejects_empty_master_password(
|
||||
vault_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Verify create() refuses an empty master password
|
||||
|
||||
Empty master password = no real lock on the vault. derive_key
|
||||
refuses internally (see test_crypto.py for the underlying
|
||||
check), and create() lets that ValueError bubble up unchanged.
|
||||
We confirm here that the rejection makes it all the way out
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
_create_test_vault(vault_path, "")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Round-trip — create, save, unlock, read
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_unlock_reads_back_what_was_saved(
|
||||
fresh_vault: UnlockedVault,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the canonical save → unlock round-trip preserves entry data
|
||||
|
||||
This is THE end-to-end happy-path test: add an entry, save,
|
||||
reopen with the same password, every field of the entry
|
||||
survives. If this test ever fails the password manager is
|
||||
fundamentally broken
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
fresh_vault.save()
|
||||
|
||||
reopened = UnlockedVault.unlock(fresh_vault.path, master_password)
|
||||
assert "github" in reopened.entries
|
||||
assert reopened.entries["github"].username == "alice"
|
||||
assert reopened.entries["github"].password == "s3cret"
|
||||
|
||||
|
||||
def test_unlock_with_wrong_password_raises(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify unlock() raises WrongPasswordError when the master password is wrong
|
||||
|
||||
The primary security guarantee of the whole project. We create a
|
||||
vault with one password, attempt to unlock with a different
|
||||
password, and expect a clean WrongPasswordError. NOT a garbled
|
||||
plaintext, NOT a silent empty vault, NOT a crypto-library
|
||||
InvalidTag exception leaking out — a clean named error
|
||||
"""
|
||||
with pytest.raises(WrongPasswordError):
|
||||
UnlockedVault.unlock(fresh_vault.path, "not the right password")
|
||||
|
||||
|
||||
def test_unlock_missing_file_raises(tmp_path: Path) -> None:
|
||||
"""
|
||||
Verify unlock() on a nonexistent file raises VaultNotFoundError
|
||||
|
||||
Distinct from "wrong password" so the CLI can show a different
|
||||
message ("no vault at this path" vs "wrong password"). This is
|
||||
the only file-related error that does NOT need to be ambiguous
|
||||
with the wrong-password case — confirming "this file does not
|
||||
exist" is safe information to give attackers
|
||||
"""
|
||||
with pytest.raises(VaultNotFoundError):
|
||||
UnlockedVault.unlock(tmp_path / "nope.json", "any-password")
|
||||
|
||||
|
||||
def test_unlock_invalid_json_raises(
|
||||
vault_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Verify unlock() raises VaultFormatError when the file is not valid JSON
|
||||
|
||||
Someone hand-edited the vault, or a disk error corrupted it. We
|
||||
cannot proceed but we also should not show a Python traceback —
|
||||
catch json.JSONDecodeError internally and re-raise as our own
|
||||
typed error
|
||||
"""
|
||||
vault_path.write_text("this is not json")
|
||||
with pytest.raises(VaultFormatError):
|
||||
UnlockedVault.unlock(vault_path, "any-password")
|
||||
|
||||
|
||||
def test_unlock_unsupported_version_raises(
|
||||
vault_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Verify unlock() refuses a vault with an unknown format version
|
||||
|
||||
Future-proofing. If a future build of pv writes vaults with
|
||||
`version: 2`, today's build needs to refuse cleanly (rather than
|
||||
try to parse a v2 file with v1 rules and fail mysteriously).
|
||||
`VaultFormatError` is the contract — the CLI can render it as
|
||||
"this vault was created by a newer version of pv"
|
||||
"""
|
||||
vault_path.write_text(
|
||||
json.dumps({
|
||||
"version": 99,
|
||||
"kdf": {},
|
||||
"cipher": {}
|
||||
})
|
||||
)
|
||||
with pytest.raises(VaultFormatError):
|
||||
UnlockedVault.unlock(vault_path, "any-password")
|
||||
|
||||
|
||||
def test_unlock_rejects_zero_time_cost(
|
||||
vault_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Verify a corrupted vault with time_cost=0 surfaces as VaultFormatError
|
||||
|
||||
Without parameter validation, time_cost=0 would crash deep
|
||||
inside argon2-cffi with a confusing low-level message. Catching
|
||||
invalid KDF parameters at parse time means the user sees a clean
|
||||
"invalid vault" message and the cryptography library never gets
|
||||
handed garbage
|
||||
|
||||
The base64 fields below (`AAAA...==`) are dummy placeholders —
|
||||
we never get far enough to use them because the parameter check
|
||||
fires first
|
||||
"""
|
||||
vault_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"kdf": {
|
||||
"name": "argon2id",
|
||||
"salt": "AAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"time_cost": 0,
|
||||
"memory_cost": 8,
|
||||
"parallelism": 1,
|
||||
},
|
||||
"cipher": {
|
||||
"name": "aes-256-gcm",
|
||||
"nonce": "AAAAAAAAAAAAAAAA",
|
||||
"ciphertext": "AAAAAAAAAAAAAAAA",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
with pytest.raises(VaultFormatError):
|
||||
UnlockedVault.unlock(vault_path, "any-password")
|
||||
|
||||
|
||||
def test_unlock_rejects_memory_cost_below_lane_floor(
|
||||
vault_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Verify a vault that violates Argon2's "memory_cost >= 8 * parallelism" rule is rejected
|
||||
|
||||
Argon2 algorithmically requires memory_cost >= 8 * parallelism.
|
||||
A vault file that violates this invariant cannot decrypt; we
|
||||
surface that as VaultFormatError BEFORE handing the parameters
|
||||
to argon2-cffi. Same reasoning as the time_cost=0 test: validate
|
||||
at the boundary so internal libraries never see bad input
|
||||
"""
|
||||
vault_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"kdf": {
|
||||
"name": "argon2id",
|
||||
"salt": "AAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"time_cost": 1,
|
||||
"memory_cost": 4,
|
||||
"parallelism": 2,
|
||||
},
|
||||
"cipher": {
|
||||
"name": "aes-256-gcm",
|
||||
"nonce": "AAAAAAAAAAAAAAAA",
|
||||
"ciphertext": "AAAAAAAAAAAAAAAA",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
with pytest.raises(VaultFormatError):
|
||||
UnlockedVault.unlock(vault_path, "any-password")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry — frozen + strict from_dict
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_entry_is_immutable() -> None:
|
||||
"""
|
||||
Verify Entry instances reject attribute assignment after construction
|
||||
|
||||
Frozen dataclass instances raise FrozenInstanceError on any
|
||||
attempted attribute write. The whole point is to force every
|
||||
"edit" through `UnlockedVault.add_entry`, which is the ONLY
|
||||
method that knows how to update the `updated_at` timestamp
|
||||
correctly. Making the wrong move impossible at the type level
|
||||
beats writing a comment saying "do not do that"
|
||||
|
||||
`# type: ignore[misc]` tells mypy "yes I know this is illegal,
|
||||
that is the point of the test"
|
||||
"""
|
||||
entry = Entry(username = "alice", password = "x")
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
entry.password = "y" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_entry_from_dict_missing_password_raises() -> None:
|
||||
"""
|
||||
Verify Entry.from_dict refuses a dict without a `password` field
|
||||
|
||||
The required fields are username and password. If a vault on
|
||||
disk has been corrupted or hand-edited so an entry is missing
|
||||
its password, the right answer is to refuse the whole load loud
|
||||
enough that the user notices — NOT to invent an empty password
|
||||
and pretend everything is fine
|
||||
"""
|
||||
with pytest.raises(VaultFormatError):
|
||||
Entry.from_dict({"username": "alice"})
|
||||
|
||||
|
||||
def test_entry_from_dict_missing_username_raises() -> None:
|
||||
"""
|
||||
Verify Entry.from_dict refuses a dict without a `username` field
|
||||
|
||||
Mirror of the missing-password test. Required fields cannot be
|
||||
invented from defaults; the load must fail loudly
|
||||
"""
|
||||
with pytest.raises(VaultFormatError):
|
||||
Entry.from_dict({"password": "x"})
|
||||
|
||||
|
||||
def test_entry_from_dict_non_string_password_raises() -> None:
|
||||
"""
|
||||
Verify Entry.from_dict refuses a non-string password
|
||||
|
||||
JSON allows numbers, booleans, nulls, and nested objects in any
|
||||
field. If somebody hand-edited the decrypted vault to put 12345
|
||||
(an int) in the password field, we must refuse rather than try
|
||||
to use the int as a password. VaultFormatError is the contract
|
||||
"""
|
||||
with pytest.raises(VaultFormatError):
|
||||
Entry.from_dict({"username": "alice", "password": 12345})
|
||||
|
||||
|
||||
def test_entry_from_dict_uses_empty_string_for_missing_timestamps(
|
||||
) -> None:
|
||||
"""
|
||||
Verify Entry.from_dict defaults missing timestamps to "" rather than _now_iso()
|
||||
|
||||
The two non-required fields, `created_at` and `updated_at`,
|
||||
default to empty strings when absent. Inventing a current
|
||||
timestamp on read would make an OLD entry look freshly created,
|
||||
which is actively misleading — better to admit "we do not know
|
||||
when this was created" with an empty string
|
||||
"""
|
||||
entry = Entry.from_dict({"username": "alice", "password": "x"})
|
||||
assert entry.created_at == ""
|
||||
assert entry.updated_at == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry operations
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_add_entry_appears_in_names(fresh_vault: UnlockedVault) -> None:
|
||||
"""
|
||||
Verify an added entry shows up in names()
|
||||
|
||||
The minimum contract for `add_entry`: after calling it, the
|
||||
entry name appears in the names() list. If this ever fails, the
|
||||
add path is silently dropping data
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
assert "github" in fresh_vault.names()
|
||||
|
||||
|
||||
def test_names_returns_sorted(fresh_vault: UnlockedVault) -> None:
|
||||
"""
|
||||
Verify names() returns entries in alphabetical order, not insertion order
|
||||
|
||||
Users grep / scan a list of names; alphabetical is what they
|
||||
expect. We add entries in scrambled order (zebra, apple, mango)
|
||||
and confirm names() yields them in alphabetical order. If we
|
||||
accidentally returned insertion order, this test would catch it
|
||||
"""
|
||||
fresh_vault.add_entry("zebra", _sample_entry())
|
||||
fresh_vault.add_entry("apple", _sample_entry())
|
||||
fresh_vault.add_entry("mango", _sample_entry())
|
||||
assert fresh_vault.names() == ["apple", "mango", "zebra"]
|
||||
|
||||
|
||||
def test_add_entry_refuses_duplicate(fresh_vault: UnlockedVault) -> None:
|
||||
"""
|
||||
Verify add_entry refuses a duplicate name when force is False
|
||||
|
||||
Default behavior: do not let the user accidentally overwrite an
|
||||
existing entry. They must pass `force=True` explicitly to
|
||||
overwrite. EntryAlreadyExistsError is the cleanly-typed signal
|
||||
so the CLI can prompt the user with "did you mean to overwrite?"
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
with pytest.raises(EntryAlreadyExistsError):
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
|
||||
|
||||
def test_add_entry_with_force_overwrites(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify add_entry with force=True replaces an existing entry
|
||||
|
||||
The intentional-overwrite path. After a force-replace, the
|
||||
entry's data should reflect the NEW values, not the old. We
|
||||
confirm the password specifically because that is the field
|
||||
users rotate most often
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry(password = "old"))
|
||||
fresh_vault.add_entry(
|
||||
"github",
|
||||
_sample_entry(password = "new"),
|
||||
force = True,
|
||||
)
|
||||
assert fresh_vault.get_entry("github").password == "new"
|
||||
|
||||
|
||||
def test_overwrite_preserves_created_at(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify a force-overwrite preserves the original created_at timestamp
|
||||
|
||||
When a user rotates a password, the entry's CREATION date should
|
||||
not change — only the LAST-UPDATED date. Otherwise the user
|
||||
cannot answer "how old is this credential" after a rotation.
|
||||
This invariant is a small detail but it is the kind of polish
|
||||
that distinguishes a real tool from a homework assignment
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
original_created = fresh_vault.get_entry("github").created_at
|
||||
|
||||
fresh_vault.add_entry(
|
||||
"github",
|
||||
_sample_entry(password = "rotated"),
|
||||
force = True,
|
||||
)
|
||||
|
||||
assert fresh_vault.get_entry("github").created_at == original_created
|
||||
|
||||
|
||||
def test_get_entry_missing_raises(fresh_vault: UnlockedVault) -> None:
|
||||
"""
|
||||
Verify get_entry on a name that does not exist raises EntryNotFoundError
|
||||
|
||||
Returning None on miss would be a quieter API — but quiet
|
||||
failures make for hard-to-debug bugs. EntryNotFoundError is
|
||||
impossible to ignore: either the caller catches it and handles
|
||||
the miss, or it propagates and the CLI shows a clean error
|
||||
"""
|
||||
with pytest.raises(EntryNotFoundError):
|
||||
fresh_vault.get_entry("does-not-exist")
|
||||
|
||||
|
||||
def test_delete_entry_removes_it(fresh_vault: UnlockedVault) -> None:
|
||||
"""
|
||||
Verify delete_entry actually removes the entry from names()
|
||||
|
||||
Round-trip: add, delete, confirm it is gone. If delete were
|
||||
accidentally a no-op, this test catches it before the user finds
|
||||
out the hard way
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
fresh_vault.delete_entry("github")
|
||||
assert "github" not in fresh_vault.names()
|
||||
|
||||
|
||||
def test_delete_entry_missing_raises(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify delete_entry on a name that does not exist raises EntryNotFoundError
|
||||
|
||||
Mirror of the get_entry missing test. We do not silently succeed
|
||||
on a delete that had nothing to delete — that is the kind of
|
||||
forgiving behavior that masks real bugs in calling code
|
||||
"""
|
||||
with pytest.raises(EntryNotFoundError):
|
||||
fresh_vault.delete_entry("does-not-exist")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_name",
|
||||
["",
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
" github",
|
||||
"github ",
|
||||
" github "],
|
||||
)
|
||||
def test_add_entry_rejects_invalid_names(
|
||||
fresh_vault: UnlockedVault,
|
||||
bad_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify add_entry rejects empty, whitespace-only, or whitespace-surrounded names
|
||||
|
||||
`@pytest.mark.parametrize` runs this test ONCE PER VALUE in the
|
||||
list — so pytest reports 7 separate test results, one per bad
|
||||
name. Cleaner than seven near-identical test functions
|
||||
|
||||
The trailing-whitespace case is the subtle one: without this
|
||||
check, "github" and "github " would silently become two
|
||||
different keys, which looks identical on screen and wastes
|
||||
hours of debugging time
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
fresh_vault.add_entry(bad_name, _sample_entry())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Master password rotation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_change_master_password_rotates_key_and_salt(
|
||||
fresh_vault: UnlockedVault,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify change_master_password generates a fresh salt AND a fresh key
|
||||
|
||||
Rotation is not just "encrypt with a new password" — it is
|
||||
"generate a NEW salt, derive a NEW key from (new password +
|
||||
new salt), re-encrypt." Reusing the old salt would let an
|
||||
attacker who got both versions of the file run their guesses
|
||||
twice as cheaply. Fresh salt → independent key → independent
|
||||
cracking work for each
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
original_salt = fresh_vault.salt
|
||||
original_key = fresh_vault.key
|
||||
|
||||
fresh_vault.change_master_password(
|
||||
"an entirely new master pass",
|
||||
kdf_parameters = TEST_KDF_PARAMETERS,
|
||||
)
|
||||
fresh_vault.save()
|
||||
|
||||
assert fresh_vault.salt != original_salt
|
||||
assert fresh_vault.key != original_key
|
||||
|
||||
|
||||
def test_change_master_password_old_password_no_longer_unlocks(
|
||||
fresh_vault: UnlockedVault,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the previous master password stops working after rotation
|
||||
|
||||
The whole point of rotation is "if the old password leaked,
|
||||
rotating to a new one kills the leak." We confirm the old
|
||||
password is dead by attempting an unlock with it and expecting
|
||||
WrongPasswordError
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
fresh_vault.change_master_password(
|
||||
"the new one",
|
||||
kdf_parameters = TEST_KDF_PARAMETERS,
|
||||
)
|
||||
fresh_vault.save()
|
||||
|
||||
with pytest.raises(WrongPasswordError):
|
||||
UnlockedVault.unlock(fresh_vault.path, master_password)
|
||||
|
||||
|
||||
def test_change_master_password_new_password_unlocks_with_entries_intact(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the new master password unlocks the vault AND all entries are preserved
|
||||
|
||||
The other half of the rotation contract: the user keeps their
|
||||
data. Reopen with the new password, every entry that was there
|
||||
before is still there with the same password field
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
fresh_vault.change_master_password(
|
||||
"the new one",
|
||||
kdf_parameters = TEST_KDF_PARAMETERS,
|
||||
)
|
||||
fresh_vault.save()
|
||||
|
||||
reopened = UnlockedVault.unlock(fresh_vault.path, "the new one")
|
||||
assert reopened.entries["github"].password == "s3cret"
|
||||
|
||||
|
||||
def test_change_master_password_rejects_empty(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify change_master_password refuses an empty new password
|
||||
|
||||
The same "empty password = no real lock" rule that applies to
|
||||
creation also applies to rotation. We refuse before generating
|
||||
a salt — both because there is no reason to generate one we
|
||||
will throw away, and because raising sooner gives a cleaner
|
||||
error
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
fresh_vault.change_master_password("")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle — context manager + close()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_close_zeroes_key_and_drops_entries(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify close() wipes the in-memory secrets
|
||||
|
||||
After `close()`, the AES key field becomes 32 zero bytes and
|
||||
the entries dict becomes empty. This is best-effort — Python's
|
||||
bytes are immutable so the ORIGINAL key bytes may still live in
|
||||
memory until GC runs — but the discipline of "explicitly drop
|
||||
secrets when done" matters. A test that pins the post-close
|
||||
state guards against accidental refactoring that skips the wipe
|
||||
"""
|
||||
fresh_vault.add_entry("github", _sample_entry())
|
||||
fresh_vault.close()
|
||||
assert fresh_vault.entries == {}
|
||||
assert fresh_vault.key == bytes(32)
|
||||
|
||||
|
||||
def test_unlocked_vault_works_as_context_manager(
|
||||
vault_path: Path,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify `with UnlockedVault.create(...) as vault:` triggers close() on exit
|
||||
|
||||
The recommended call site pattern is the with-block, because it
|
||||
guarantees secrets get dropped even if the caller forgets to
|
||||
call close(). We confirm here that exiting the `with` block
|
||||
actually triggers the close behavior (empty entries, zeroed key)
|
||||
"""
|
||||
with _create_test_vault(vault_path, master_password) as vault:
|
||||
vault.add_entry("github", _sample_entry())
|
||||
assert vault.entries["github"].username == "alice"
|
||||
# On block exit, sensitive material is dropped
|
||||
assert vault.entries == {}
|
||||
assert vault.key == bytes(32)
|
||||
|
||||
|
||||
def test_context_manager_cleans_up_on_exception(
|
||||
vault_path: Path,
|
||||
master_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the context manager wipes secrets even when the body raises
|
||||
|
||||
The HAPPY path is easy — close() runs at normal end-of-block.
|
||||
The interesting case is the EXCEPTION path: if user code inside
|
||||
the `with` block raises, does Python still invoke __exit__? Yes
|
||||
it does, and we confirm here that __exit__ drops secrets all
|
||||
the same. Without this guarantee, an unexpected error in user
|
||||
code could leave plaintext credentials sitting in memory
|
||||
"""
|
||||
with (
|
||||
pytest.raises(RuntimeError),
|
||||
_create_test_vault(vault_path,
|
||||
master_password) as vault,
|
||||
):
|
||||
vault.add_entry("github", _sample_entry())
|
||||
raise RuntimeError("boom")
|
||||
assert vault.entries == {}
|
||||
assert vault.key == bytes(32)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Atomicity / persistence
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_save_uses_fresh_nonce_each_time(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify two consecutive saves produce different ciphertext (because the nonce is fresh)
|
||||
|
||||
The save path internally regenerates a nonce on every call.
|
||||
Save once → record the nonce + ciphertext. Save again with NO
|
||||
entry change → record again. Both fields must differ. If they
|
||||
did not, we would either be reusing a nonce (security bug) or
|
||||
using a counter (different security bug)
|
||||
"""
|
||||
fresh_vault.save()
|
||||
cipher_a = json.loads(fresh_vault.path.read_text())["cipher"]
|
||||
fresh_vault.save()
|
||||
cipher_b = json.loads(fresh_vault.path.read_text())["cipher"]
|
||||
|
||||
assert cipher_a["nonce"] != cipher_b["nonce"]
|
||||
assert cipher_a["ciphertext"] != cipher_b["ciphertext"]
|
||||
|
||||
|
||||
def test_save_does_not_leave_temp_file(
|
||||
fresh_vault: UnlockedVault,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the .tmp file used during save is gone after save() returns
|
||||
|
||||
The save flow writes vault.json.tmp first, then renames it onto
|
||||
vault.json. After the rename, the .tmp name no longer exists —
|
||||
the same inode is now reachable under the final name. We
|
||||
confirm by stat-ing the .tmp path after a successful save and
|
||||
expecting "does not exist." If anyone refactored save() to copy
|
||||
instead of rename, the leftover .tmp would fail this test
|
||||
"""
|
||||
fresh_vault.save()
|
||||
tmp = fresh_vault.path.with_suffix(fresh_vault.path.suffix + ".tmp")
|
||||
assert not tmp.exists()
|
||||
|
||||
|
||||
def test_save_creates_temp_file_with_secure_mode_only(
|
||||
fresh_vault: UnlockedVault,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the .tmp file is created with mode 0600 from the very first syscall
|
||||
|
||||
Plain `Path.write_bytes` would create the file with the
|
||||
process's default umask (often 0644 = world-readable). Even if
|
||||
we chmod 0600 afterward, there is a microsecond window where
|
||||
the .tmp is world-readable, and on a shared system that is
|
||||
enough for an attacker to grab a copy
|
||||
|
||||
The fix in vault.py is to use raw `os.open(..., O_CREAT, 0o600)`
|
||||
so the file's mode is correct from the first syscall. To VERIFY
|
||||
that behavior in a test we use `monkeypatch.setattr` to swap in
|
||||
a spy version of `os.open` that records the mode argument used
|
||||
for any .tmp file. After save() runs, we assert every recorded
|
||||
mode is exactly VAULT_FILE_MODE (0o600)
|
||||
|
||||
`monkeypatch` is a built-in pytest fixture that undoes its
|
||||
patches automatically when the test ends — no manual cleanup
|
||||
"""
|
||||
if os.name == "nt":
|
||||
pytest.skip("POSIX-only check — Windows ignores Unix file modes")
|
||||
|
||||
captured_modes: list[int] = []
|
||||
real_open = os.open
|
||||
|
||||
def spy_open( # type: ignore[no-untyped-def]
|
||||
path,
|
||||
flags,
|
||||
mode = 0o777,
|
||||
*,
|
||||
dir_fd = None,
|
||||
):
|
||||
# Record the mode used for .tmp files, then defer to the real
|
||||
# os.open so the actual write still happens normally
|
||||
if str(path).endswith(".tmp"):
|
||||
captured_modes.append(mode)
|
||||
return real_open(path, flags, mode, dir_fd = dir_fd)
|
||||
|
||||
monkeypatch.setattr(os, "open", spy_open)
|
||||
fresh_vault.save()
|
||||
|
||||
assert captured_modes, "no .tmp file was opened during save()"
|
||||
assert all(m == VAULT_FILE_MODE for m in captured_modes)
|
||||
|
||||
|
||||
def test_save_cleans_up_temp_file_on_replace_failure(
|
||||
fresh_vault: UnlockedVault,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Verify save() removes the .tmp file when os.replace fails partway through
|
||||
|
||||
Simulated failure mode: we monkey-patch `os.replace` to raise
|
||||
OSError. The save flow is "write .tmp, replace .tmp onto
|
||||
vault.json." If the replace step blows up, the .tmp is still
|
||||
sitting there — left behind it would clutter the directory and
|
||||
confuse the NEXT save (which would then try to write to a
|
||||
.tmp that already exists). The cleanup in save()'s except
|
||||
branch is what we are verifying here
|
||||
|
||||
We save once cleanly first so vault.json itself exists, then
|
||||
install the broken replace, then save() again and confirm
|
||||
the .tmp does not survive the failed save
|
||||
"""
|
||||
def explode(*_args: object, **_kwargs: object) -> None:
|
||||
raise OSError("simulated replace failure")
|
||||
|
||||
# Save once cleanly so the vault file exists
|
||||
fresh_vault.save()
|
||||
|
||||
monkeypatch.setattr(os, "replace", explode)
|
||||
with pytest.raises(OSError, match = "simulated replace failure"):
|
||||
fresh_vault.save()
|
||||
|
||||
tmp = fresh_vault.path.with_suffix(fresh_vault.path.suffix + ".tmp")
|
||||
assert not tmp.exists()
|
||||
|
|
@ -0,0 +1,675 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version == '3.14.*'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi-bindings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "8.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ast-serialize" },
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-vault"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "yapf" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||
{ name = "cryptography", specifier = ">=48.0.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1.0" },
|
||||
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.5" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.1.0" },
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.12" },
|
||||
{ name = "typer", specifier = ">=0.25.1" },
|
||||
{ name = "yapf", marker = "extra == 'dev'", specifier = ">=0.43.0,<1.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "dill" },
|
||||
{ name = "isort" },
|
||||
{ name = "mccabe" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yapf"
|
||||
version = "0.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
|
||||
]
|
||||
Loading…
Reference in New Issue