diff --git a/PROJECTS/beginner/jwt-auditor/.gitignore b/PROJECTS/beginner/jwt-auditor/.gitignore new file mode 100644 index 00000000..6117ef46 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/.gitignore @@ -0,0 +1,21 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +wheels/ +*.egg-info/ +*.egg +.venv/ +venv/ +*.venv +.coverage +htmlcov/ +.tox/ +.dmypy.json +dmypy.json +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ diff --git a/PROJECTS/beginner/jwt-auditor/.style.yapf b/PROJECTS/beginner/jwt-auditor/.style.yapf new file mode 100644 index 00000000..e8e673b4 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/.style.yapf @@ -0,0 +1,46 @@ +[style] +based_on_style = pep8 +column_limit = 82 +indent_width = 4 +continuation_indent_width = 4 +indent_closing_brackets = false +dedent_closing_brackets = true +indent_blank_lines = false +spaces_before_comment = 2 +spaces_around_power_operator = false +spaces_around_default_or_named_assign = true +space_between_ending_comma_and_closing_bracket = false +space_inside_brackets = false +spaces_around_subscript_colon = true +blank_line_before_nested_class_or_def = false +blank_line_before_class_docstring = false +blank_lines_around_top_level_definition = 2 +blank_lines_between_top_level_imports_and_variables = 2 +blank_line_before_module_docstring = false +split_before_logical_operator = true +split_before_first_argument = true +split_before_named_assigns = true +split_complex_comprehension = true +split_before_expression_after_opening_paren = false +split_before_closing_bracket = true +split_all_comma_separated_values = true +split_all_top_level_comma_separated_values = false +coalesce_brackets = false +each_dict_entry_on_separate_line = true +allow_multiline_lambdas = false +allow_multiline_dictionary_keys = false +split_penalty_import_names = 0 +join_multiple_lines = false +align_closing_bracket_with_visual_indent = true +arithmetic_precedence_indication = false +split_penalty_for_added_line_split = 275 +use_tabs = false +split_before_dot = false +split_arguments_when_comma_terminated = true +i18n_function_call = ['_', 'N_', 'gettext', 'ngettext'] +i18n_comment = ['# Translators:', '# i18n:'] +split_penalty_comprehension = 80 +split_penalty_after_opening_bracket = 280 +split_penalty_before_if_expr = 0 +split_penalty_bitwise_operator = 290 +split_penalty_logical_operator = 0 diff --git a/PROJECTS/beginner/jwt-auditor/DEMO.md b/PROJECTS/beginner/jwt-auditor/DEMO.md new file mode 100644 index 00000000..16b0b1ba --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/DEMO.md @@ -0,0 +1,133 @@ +# Demo + +Real output from the tool against sample tokens. Every token here is built +locally, so you can reproduce these runs yourself. + +## Decode a token + +`decode` shows what a token carries without checking the signature. + +``` +$ jwt-auditor decode eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi... + +╭───── Header ──────╮ +│ { │ +│ "alg": "HS256", │ +│ "typ": "JWT" │ +│ } │ +╰───────────────────╯ +╭─────────── Payload ────────────╮ +│ { │ +│ "sub": "1234567890", │ +│ "name": "John Doe", │ +│ "user_password": "P@ssw0rd", │ +│ "admin": true │ +│ } │ +╰────────────────────────────────╯ +╭──── Signature ────╮ +│ algorithm : HS256 │ +│ present : True │ +│ bytes : 32 │ +╰───────────────────╯ +``` + +Notice the `user_password` claim. Anyone holding this token can read it. The +payload is base64url, not encryption. + +## Audit a weak token + +This token is signed with the secret `secret`, carries a password claim, and +has no expiration. The audit finds all three. + +``` +$ jwt-auditor audit eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +╭─── JWT Audit Summary ────╮ +│ algorithm : HS256 │ +│ risk score : 10.0 / 10 │ +│ worst finding: critical │ +│ │ +│ critical : 1 │ +│ high : 1 │ +│ medium : 1 │ +│ low : 0 │ +│ info : 1 │ +╰──────────────────────────╯ + Findings +┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ Severity ┃ Issue ┃ Evidence ┃ +┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ +│ CRITICAL │ HMAC secret recovered from │ secret = 'secret' │ +│ │ wordlist │ │ +│ HIGH │ Payload may contain sensitive │ suspicious claim names = │ +│ │ data │ ['user_password'] │ +│ MEDIUM │ No expiration claim │ payload has no 'exp' │ +│ INFO │ Recommended claims are missing │ missing = ['iss', 'aud'] │ +└──────────┴─────────────────────────────────┴─────────────────────────────────┘ + +$ echo $? +1 +``` + +The exit code is 1 because a finding reached the default `--fail-level` of +`high`. That is what makes it usable as a CI gate. + +## Crack the secret directly + +``` +$ jwt-auditor crack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +Secret found: 'secret' +The token can now be forged. Rotate this key. +``` + +## The alg none downgrade + +An attacker takes a real token, changes the header to `{"alg": "none"}`, and +drops the signature. A library that honors the header accepts it. + +``` +$ jwt-auditor audit eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0... + +CRITICAL Algorithm is 'none' (unsigned token) header.alg = 'none' +``` + +## RS256 to HS256 confusion, proven + +When you have the server's public key, the tool proves the confusion attack +instead of only warning about it. The forged token below was signed with the +public key bytes used as an HMAC secret. + +``` +$ jwt-auditor audit --public-key server_pub.pem + +CRITICAL Token verifies with the public key public key PEM as stored + as an HMAC secret (verified as HS256) +``` + +## JSON output + +Add `--json` to feed another tool. + +``` +$ jwt-auditor audit --json + +{ + "algorithm": "none", + "risk_score": 10.0, + "highest_severity": "critical", + "finding_counts": { + "critical": 1, "high": 0, "medium": 1, "low": 0, "info": 1 + }, + "findings": [ + { + "id": "alg-none", + "title": "Algorithm is 'none' (unsigned token)", + "severity": "critical", + "detail": "The header declares alg 'none' ...", + "evidence": "header.alg = 'none'", + "recommendation": "Reject 'none' outright ..." + } + ] +} +``` diff --git a/PROJECTS/beginner/jwt-auditor/Justfile b/PROJECTS/beginner/jwt-auditor/Justfile new file mode 100644 index 00000000..e2ff3621 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/Justfile @@ -0,0 +1,35 @@ +default: + @just --list + +install: + uv sync --all-extras + +test: + uv run pytest tests/ -v + +cov: + uv run pytest tests/ --cov=jwt_auditor --cov-report=term-missing + +lint: + uv run ruff check src/ tests/ + +lint-fix: + uv run ruff check --fix src/ tests/ + +typecheck: + uv run mypy src/ + +check-all: lint typecheck test + +clean: + rm -rf .pytest_cache .mypy_cache .ruff_cache htmlcov .coverage + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + +# Audit a token with the built in checks +audit TOKEN: + uv run jwt-auditor audit "{{TOKEN}}" + +# Decode a token without verifying it +decode TOKEN: + uv run jwt-auditor decode "{{TOKEN}}" diff --git a/PROJECTS/beginner/jwt-auditor/LICENSE b/PROJECTS/beginner/jwt-auditor/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/PROJECTS/beginner/jwt-auditor/README.md b/PROJECTS/beginner/jwt-auditor/README.md new file mode 100644 index 00000000..5ba750ac --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/README.md @@ -0,0 +1,194 @@ +# jwt-auditor + +A command line tool that decodes JSON Web Tokens and audits them for the +mistakes that keep showing up in real systems: the `alg: none` downgrade, +weak HMAC secrets, the RS256 to HS256 confusion attack, tokens that never +expire, and secrets stuffed into the payload. + +Everything runs offline against a token string. No network calls, and no +PyJWT. The signature code is plain `hmac` from the standard library, because +seeing that "verify" is just "recompute the HMAC and compare" is the fastest +way to understand why half of these attacks work. + +``` +$ jwt-auditor audit eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.abc... + +╭─── JWT Audit Summary ────╮ +│ algorithm : HS256 │ +│ risk score : 10.0 / 10 │ +│ worst finding: critical │ +╰──────────────────────────╯ +CRITICAL HMAC secret recovered from wordlist secret = 'secret' +HIGH Payload may contain sensitive data ['user_password'] +MEDIUM No expiration claim payload has no 'exp' +``` + +## Why this is useful + +JWTs are everywhere: session tokens, API keys, OAuth access tokens, service +to service auth. They are also easy to get wrong, and the failures are quiet. +A token signed with the secret `secret` looks identical to one signed with a +256 bit random key until someone runs a wordlist against it. + +This tool checks a token the way an attacker would look at it, then tells you +what a defender should fix. Point it at a token from your app, your staging +environment, or a bug bounty target you are authorized to test, and it flags +the problems in one pass. + +## Features + +- **Decode** any JWT into its header, payload, and signature without trusting it +- **`alg: none` detection**, the classic signature stripping downgrade +- **Weak secret cracking** against a built in list or your own wordlist +- **RS256 to HS256 confusion test**, and it proves the finding when you supply + the server's public key +- **Expiration checks**: missing `exp`, already expired, or a suspiciously long + lifetime +- **Clock sanity checks** on `iat` and `nbf` +- **Sensitive data detection**, catching passwords and PII carried in claims +- **Missing claim hints** for `iss`, `aud`, and `sub` +- **JSON output** and a `--fail-level` exit code so it drops into CI as a gate + +## Educational value + +Building and reading this project teaches you: + +- How a JWT is actually structured, down to base64url without padding +- Why `alg: none` was a real vulnerability in many libraries around 2015, and + why you never let a token pick its own verification algorithm +- How the RS256 to HS256 confusion attack turns a public key into a signing key +- Why HMAC verification must use a constant time compare +- What belongs in a token and what never should, since the payload is encoded, + not encrypted + +The `learn/` folder walks through all of this, from the concepts to a line by +line tour of the code. + +## Prerequisites + +- **Python 3.12 or newer** +- **[uv](https://github.com/astral-sh/uv)** for dependency management. It is + what this repository standardizes on. +- Basic comfort with the terminal. You paste a token, you read a table. + +Helpful but not required: familiarity with base64, HMAC, and the idea of a +bearer token. + +## Installation + +```bash +# from the project directory +cd PROJECTS/beginner/jwt-auditor + +# create the environment and install the tool plus dev dependencies +uv sync --all-extras + +# confirm it runs +uv run jwt-auditor --help +``` + +## Usage + +The tool has three commands: `decode`, `audit`, and `crack`. Each one reads +the token from an argument, from `--input-file`, or from stdin, so it fits +into a pipeline. + +### Decode a token + +```bash +uv run jwt-auditor decode eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig +``` + +Add `--json` for machine readable output. Decode never checks the signature. +It only shows you what the token claims. + +### Audit a token + +```bash +# run every check with the built in wordlist +uv run jwt-auditor audit + +# use your own wordlist for the secret check +uv run jwt-auditor audit --wordlist rockyou.txt + +# prove the RS to HS confusion attack with the server public key +uv run jwt-auditor audit --public-key server_pub.pem + +# fail the process on medium or worse, for CI +uv run jwt-auditor audit --fail-level medium +``` + +Pipe a token in without it landing in your shell history: + +```bash +echo "$TOKEN" | uv run jwt-auditor audit +``` + +### Crack an HMAC secret + +```bash +uv run jwt-auditor crack --wordlist rockyou.txt +``` + +Exits 0 and prints the secret on a hit, exits 1 if nothing matched. + +## Configuration + +There are no config files or environment variables. Behavior is controlled by +flags: + +| Flag | Command | Meaning | +|------|---------|---------| +| `--input-file`, `-i` | all | read the token from a file | +| `--json` | decode, audit | emit JSON instead of a table | +| `--wordlist`, `-w` | audit, crack | secrets to try against HS tokens | +| `--public-key`, `-p` | audit | public key PEM to test alg confusion | +| `--max-lifetime` | audit | hours before a token counts as long lived (default 24) | +| `--fail-level` | audit | exit non-zero at this severity or worse (default `high`) | + +## Architecture + +The pipeline is small and one directional: + +``` +token string + │ + ▼ +decoder.py split into 3 parts, decode header and payload + │ + ▼ +checks.py run each check, collect Finding objects + │ (calls signatures.py for the secret and confusion checks) + ▼ +models.py AuditReport scores the findings + │ + ▼ +output.py render a Rich table or JSON +``` + +The checks never print and never import Rich, so they are easy to test in +isolation. See `learn/02-ARCHITECTURE.md` for the full breakdown. + +## Security considerations + +- **Only test tokens you are authorized to test.** Cracking a secret for a + system you do not own is not authorized security testing. +- **Tokens are credentials.** Prefer stdin or a file over pasting a live token + as a shell argument, where it lands in your history and process list. +- This tool does not verify RSA or ECDSA signatures. It decodes them and warns + about algorithm handling. The point is auditing configuration, not acting as + a full JOSE verifier for production traffic. + +## Running the tests + +```bash +uv run pytest tests/ -v # 59 tests +uv run pytest --cov=jwt_auditor # coverage +uv run ruff check src/ tests/ # lint +uv run mypy src/ # types +``` + +## License + +Released under the GNU Affero General Public License v3.0. See +[LICENSE](./LICENSE). diff --git a/PROJECTS/beginner/jwt-auditor/learn/00-OVERVIEW.md b/PROJECTS/beginner/jwt-auditor/learn/00-OVERVIEW.md new file mode 100644 index 00000000..d39f1e71 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/learn/00-OVERVIEW.md @@ -0,0 +1,151 @@ +# jwt-auditor + +## What This Is + +A command line tool that takes a JSON Web Token and reports the security +problems in it: unsigned `alg: none` tokens, weak HMAC secrets you can guess, +the RS256 to HS256 confusion attack, tokens with no expiration, and secrets +carried in the payload. It decodes and audits offline, with no network calls +and no third party JWT library. + +## Why This Matters + +JWTs are the default bearer credential for modern web apps. They sit in +`Authorization` headers, in cookies, and in service to service calls. When one +is built wrong, the failure is silent. A token signed with the secret `secret` +verifies exactly like one signed with a 256 bit random key, right up until an +attacker runs a wordlist against it and starts minting admin tokens. + +The mistakes this tool looks for are not theoretical. They have real CVEs and +real breaches behind them. + +**Real world scenarios where this applies:** + +- You are reviewing an API before launch and want to confirm its tokens expire, + are signed with a strong key, and do not leak PII in the claims. +- You are on a bug bounty program, you captured a JWT, and you want to know in + one command whether the secret is guessable or the header accepts `none`. +- You run a CI pipeline and want a gate that fails the build if someone commits + code that issues a token with no `exp` or a hardcoded weak secret. + +## What You'll Learn + +This project teaches you how a JWT works under the hood and why the common +attacks against it succeed. By building it yourself, you will understand: + +**Security Concepts:** + +- Signature stripping and the `alg: none` downgrade, where a token declares it + is unsigned and a naive verifier believes it. +- Algorithm confusion, where a server that trusts the token's declared + algorithm can be tricked into verifying an RSA token with HMAC, using the + public key as the secret. +- Why HMAC secrets have to be high entropy, and how offline guessing works when + the attacker holds the token. + +**Technical Skills:** + +- Decoding base64url without padding, the encoding JWT actually uses. +- Computing and verifying an HMAC signature with the standard library, and why + the comparison has to be constant time. +- Turning a set of independent checks into a scored report with a clean data + model. + +**Tools and Techniques:** + +- `hmac` and `hashlib` for signatures, used the way a JWT library uses them. +- Typer and Rich for a CLI that prints readable tables and clean JSON. + +## Prerequisites + +**Required knowledge:** + +- Basic Python: functions, dataclasses, dictionaries, exceptions. +- What base64 is, roughly. You do not need to know the alphabet by heart. +- The idea of a bearer token: whoever holds it is treated as the user. + +**Tools you'll need:** + +- Python 3.12 or newer, for the modern type syntax the code uses. +- [uv](https://github.com/astral-sh/uv), the package manager this repository + standardizes on. + +**Helpful but not required:** + +- Familiarity with HMAC and public key crypto. +- Having seen a JWT on [jwt.io](https://jwt.io) before. + +## Quick Start + +```bash +cd PROJECTS/beginner/jwt-auditor + +# install the tool and its dev dependencies +uv sync --all-extras + +# build a throwaway HS256 token signed with the weak secret "secret", +# then audit it (generated at runtime so no token is hardcoded here) +python3 - <<'PY' | uv run jwt-auditor audit - +import base64, hmac, hashlib, json +b = lambda raw: base64.urlsafe_b64encode(raw).rstrip(b"=").decode() +head = b(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) +body = b(json.dumps({"sub": "admin"}).encode()) +sig = b(hmac.new(b"secret", f"{head}.{body}".encode(), hashlib.sha256).digest()) +print(f"{head}.{body}.{sig}") +PY +``` + +Expected output: a summary panel with a risk score of 10.0 and a findings +table calling out the recovered secret. If you see that, the tool works. + +## Project Structure + +``` +jwt-auditor/ +├── src/jwt_auditor/ +│ ├── decoder.py # split and decode a token, no verification +│ ├── signatures.py # HMAC sign, verify, crack, confusion test +│ ├── checks.py # the individual checks and the audit() runner +│ ├── models.py # Severity, Finding, AuditReport, risk score +│ ├── output.py # Rich tables and JSON rendering +│ ├── wordlist.py # built in weak secrets and sensitive claim names +│ └── main.py # Typer CLI: decode, audit, crack +├── tests/ # 59 tests covering every check and command +└── learn/ # this documentation +``` + +## Next Steps + +1. **Understand the concepts** - Read [01-CONCEPTS.md](./01-CONCEPTS.md) for the + security fundamentals behind each check. +2. **Study the architecture** - Read [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) + to see how the pieces fit together. +3. **Walk through the code** - Read + [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) for a line by line tour. +4. **Extend the project** - Read [04-CHALLENGES.md](./04-CHALLENGES.md) for + ideas to build on. + +## Common Issues + +**`uv: command not found`** +``` +uv: command not found +``` +Solution: install uv with `curl -LsSf https://astral.sh/uv/install.sh | sh`, +then restart your shell. + +**Pasting a token that got line wrapped** +``` +Not a valid JWT: a JWT has 3 dot separated segments, this has 1 +``` +Solution: your terminal split the token across lines. Put it in a file and use +`--input-file token.txt`, or pipe it in with `echo "$TOKEN" | jwt-auditor audit`. + +## Related Projects + +If you found this interesting, check out: + +- **caesar-cipher** - another from scratch crypto tool in this repo, good for + seeing how a small cipher is built and attacked. +- **secrets-scanner** - uses the same HIBP style thinking about weak secrets, + applied to source code instead of tokens. diff --git a/PROJECTS/beginner/jwt-auditor/learn/01-CONCEPTS.md b/PROJECTS/beginner/jwt-auditor/learn/01-CONCEPTS.md new file mode 100644 index 00000000..5c1e81b7 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/learn/01-CONCEPTS.md @@ -0,0 +1,280 @@ +# Core Security Concepts + +This document explains the security ideas behind each check the tool runs. The +goal is that by the end you could re-derive every finding yourself, without the +tool. + +## What a JWT Actually Is + +### What It Is + +A JSON Web Token is three pieces joined by dots: + +``` +eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJhZG1pbiJ9 . PGnRccPTXeax... + header payload signature +``` + +The header and payload are JSON objects, each base64url encoded. The signature +is computed over the string `header.payload`. The whole thing is a JWS, a +signed token. Decode the first two parts and you can read everything in them. + +### Why It Matters + +The single most important fact about a JWT is that the payload is **encoded, +not encrypted**. base64url is reversible by anyone. If you put a password or a +Social Security number in a claim, every party that touches the token can read +it: the browser, proxies, logs, error trackers. + +### How It Works + +``` +header = {"alg": "HS256", "typ": "JWT"} +payload = {"sub": "admin", "exp": 1767225600} + +signing_input = base64url(header) + "." + base64url(payload) +signature = HMAC_SHA256(secret, signing_input) + +token = signing_input + "." + base64url(signature) +``` + +Verification recomputes the signature and compares. That is the whole model, +and every attack below is an attack on one step of it. + +### Common Attacks + +1. **Read the claims** - decode the payload and harvest anything sensitive. +2. **Tamper and re-sign** - if the secret is weak, change the claims and sign + again with the guessed secret. +3. **Tamper without re-signing** - trick the verifier into skipping or + weakening the signature check (the `none` and confusion attacks below). + +### Defense Strategies + +Keep the secret strong and private, pin the algorithm, set a short expiry, and +never put anything in the payload you would not print in a log. The checks in +`checks.py` map one to one onto these defenses. + +## The alg none Downgrade + +### What It Is + +The JWT header names the algorithm used to sign the token. One legal value in +early implementations was `none`, meaning "this token is unsigned". A verifier +that reads the algorithm from the token and honors `none` will accept a token +with an empty signature. + +### Why It Matters + +The attacker takes a valid token, rewrites the header to `{"alg":"none"}`, +edits the payload to say `"role":"admin"`, deletes the signature, and sends it. +A vulnerable server treats it as authentic. + +This is not hypothetical. In 2015, a wide range of JWT libraries were found to +accept `alg: none` by default, tracked as **CVE-2015-9235** for the popular +`jsonwebtoken` Node library and echoed across many others. The disclosure by +Auth0 that year is the reason "always pin the algorithm" became standard advice. + +### How It Works + +``` +Original (HS256, signed): + {"alg":"HS256"}.{"sub":"alice","role":"user"}. + +Forged (none, unsigned): + {"alg":"none"}.{"sub":"alice","role":"admin"}. + ^ empty signature segment +``` + +The tool flags this in `check_alg_none` (`src/jwt_auditor/checks.py:68`). It +compares `token.algorithm.lower()` to `"none"` and, when it matches, returns a +critical finding. + +### Defense Strategies + +Never let the token choose the algorithm. Decide server side which algorithms +are acceptable and reject everything else, including `none`: + +```python +# the safe pattern, expressed in pseudocode +ALLOWED = {"HS256"} +if token.header["alg"] not in ALLOWED: + reject() +``` + +## Weak HMAC Secrets + +### What It Is + +HS256, HS384, and HS512 sign with HMAC, which uses a shared secret. The +security of the token rests entirely on that secret being unguessable. When a +developer picks the secret by hand, they pick something like `secret`, +`changeme`, or the placeholder `your-256-bit-secret` from the jwt.io debugger. + +### Why It Matters + +Because the attacker holds the token, they can guess the secret **offline**. +There is no server to rate limit them. They try a candidate, recompute the +HMAC, and compare it to the signature already in the token. A match means they +found the key and can now forge any token they want. + +### How It Works + +``` +for candidate in wordlist: + if HMAC(candidate, signing_input) == token.signature: + print("secret is", candidate) + break +``` + +The tool does exactly this in `crack_hmac_secret` +(`src/jwt_auditor/signatures.py:76`). The built in wordlist in `wordlist.py` +holds the secrets that actually appear in the wild. + +### Common Pitfalls + +**Mistake: a short or human chosen secret** +```python +# Bad +SECRET = "myappsecret" + +# Good +SECRET = secrets.token_bytes(32) # 32 random bytes from a CSPRNG +``` + +**Mistake: timing the comparison with ==** +```python +# Bad, leaks how many leading bytes matched via timing +if computed == token.signature: + ... + +# Good, constant time +if hmac.compare_digest(computed, token.signature): + ... +``` + +That second mistake is why `verify_hmac` uses `hmac.compare_digest` +(`src/jwt_auditor/signatures.py:73`). + +## Algorithm Confusion (RS256 to HS256) + +### What It Is + +RS256 signs with a private key and verifies with a public key. The public key +is meant to be public. Algorithm confusion happens when a server verifies with +"whatever algorithm the token says", and an attacker changes the algorithm from +RS256 to HS256. + +### Why It Matters + +Now the server runs HMAC verification. The HMAC secret it uses is the only key +it has: the RSA public key. That key is not secret. The attacker downloads it, +signs a forged HS256 token with it, and the server accepts the forgery. + +This class of bug has appeared repeatedly, including in widely used libraries, +and is catalogued as **CWE-347: Improper Verification of Cryptographic +Signature**. It is subtle because RS256 by itself is fine. The bug is in the +verifier accepting the header's choice. + +### How It Works + +``` +Server has: rsa_public_key (published, not secret) + +Attacker builds: + header = {"alg":"HS256"} + payload = {"sub":"admin"} + signature = HMAC(rsa_public_key, header.payload) + +Server, trusting the header, verifies with HMAC(rsa_public_key, ...) -> match +``` + +The tool proves this when you pass `--public-key`. `key_confusion_secret` +(`src/jwt_auditor/signatures.py:95`) tries the public key bytes as an HMAC +secret across the HS algorithms and reports a match. + +### Defense Strategies + +Pin the algorithm on the verifier so an RS256 endpoint only ever runs RSA +verification. Do not derive the algorithm from the token. + +## How These Concepts Relate + +``` +alg is attacker controlled + ↓ + enables → alg none (no signature at all) + ↓ + enables → RS to HS confusion (public key becomes the HMAC secret) + +secret is weak + ↓ + enables → offline secret cracking, then arbitrary forgery +``` + +Every one of these traces back to the same root cause: trusting data inside the +token to decide how to verify the token. + +## Industry Standards and Frameworks + +### OWASP + +- **OWASP API Security Top 10, API2:2023 Broken Authentication** - weak or + misconfigured token verification is the core of this category. +- **OWASP JWT Cheat Sheet** - the source for "always use an allowlist of + algorithms" and "do not accept `none`". + +### CWE + +- **CWE-347: Improper Verification of Cryptographic Signature** - the `none` + and confusion attacks both live here. +- **CWE-321: Use of Hard coded Cryptographic Key** - the weak secret case. +- **CWE-522: Insufficiently Protected Credentials** - secrets or PII in the + payload. + +## Real World Examples + +### Case Study 1: alg none in JWT libraries (2015) + +Security researchers at Auth0 published a widely cited writeup showing that many +JWT libraries accepted `alg: none` and, separately, were vulnerable to the RS to +HS confusion. What made it dangerous was the default behavior: a developer +calling `verify(token)` with no extra arguments got the insecure path. The fix +across the ecosystem was to require the caller to state the expected algorithm. + +### Case Study 2: secrets in the payload + +A recurring finding in API assessments is a JWT payload that includes an email, +a phone number, or an internal role map. The developers assumed the token was +opaque because it looks like random text. It is not. Anyone who captured the +token from a log or a proxy read the data directly. This is why +`check_sensitive_data` (`src/jwt_auditor/checks.py:345`) exists. + +## Testing Your Understanding + +Before moving on, make sure you can answer: + +1. Why can an attacker crack an HMAC secret offline, when a login form would + rate limit them? +2. In the RS to HS confusion attack, what is the "secret" the attacker signs + with, and why do they have it? +3. A colleague says the JWT is safe to store a password in because "it is + signed". What is wrong with that reasoning? + +If any of these are fuzzy, re-read the matching section. The implementation +will make more sense once these click. + +## Further Reading + +**Essential:** + +- OWASP JSON Web Token Cheat Sheet - the practical do and do not list. +- RFC 7519 (JWT) and RFC 7515 (JWS) - the actual specifications. Short and + readable. + +**Deep dives:** + +- The Auth0 2015 writeup on critical JWT vulnerabilities - the origin of the + standard advice. +- PortSwigger Web Security Academy, JWT attacks - hands on labs for `none`, + weak secrets, and confusion. diff --git a/PROJECTS/beginner/jwt-auditor/learn/02-ARCHITECTURE.md b/PROJECTS/beginner/jwt-auditor/learn/02-ARCHITECTURE.md new file mode 100644 index 00000000..1fb6f444 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/learn/02-ARCHITECTURE.md @@ -0,0 +1,286 @@ +# System Architecture + +This document explains how the tool is put together and why it is split the way +it is. + +## High Level Architecture + +``` + ┌──────────────┐ + token ───▶│ main.py │ Typer CLI: decode, audit, crack + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ decoder.py │ split into 3 parts, decode header/payload + └──────┬───────┘ + │ DecodedToken + ▼ + ┌──────────────┐ ┌──────────────┐ + │ checks.py │─────▶│ signatures.py│ HMAC sign/verify/crack + └──────┬───────┘ └──────────────┘ + │ list[Finding] ▲ + ▼ │ COMMON_SECRETS + ┌──────────────┐ ┌──────────────┐ + │ models.py │ │ wordlist.py │ + │ AuditReport │ └──────────────┘ + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ output.py │ Rich table or JSON + └──────────────┘ +``` + +### Component Breakdown + +**decoder.py** +- Purpose: turn a token string into structured data. +- Responsibilities: split on dots, base64url decode, JSON parse, expose the + signing input. +- Interfaces: `decode(str) -> DecodedToken`. Raises `InvalidTokenError`. + +**signatures.py** +- Purpose: everything involving the HMAC primitive. +- Responsibilities: sign, verify in constant time, crack a wordlist, run the + key confusion test. +- Interfaces: pure functions that take a `DecodedToken` and bytes. + +**checks.py** +- Purpose: the security policy. Each check is one rule. +- Responsibilities: inspect a `DecodedToken`, return `Finding` objects, and + orchestrate all checks in `audit()`. +- Interfaces: `audit(token, ...) -> AuditReport`, plus each `check_*` function. + +**models.py** +- Purpose: the shared vocabulary. +- Responsibilities: define `Severity`, `Finding`, `AuditReport`, and compute the + risk score. +- Interfaces: dataclasses and one enum. No behavior beyond scoring. + +**output.py** +- Purpose: presentation. +- Responsibilities: render a report as a Rich table or as a JSON dict. +- Interfaces: `render_report`, `render_decoded`, `report_to_dict`. + +**main.py** +- Purpose: wire it together for a human. +- Responsibilities: parse arguments, read the token, call the right functions, + set the exit code. + +## Data Flow + +### Auditing a token + +Step by step of what happens on `jwt-auditor audit `: + +``` +1. main.audit_command reads the token (src/jwt_auditor/main.py) + resolves it from arg, --input-file, or stdin + +2. decoder.decode parses it (src/jwt_auditor/decoder.py:90) + returns a DecodedToken, or exits 2 on a bad token + +3. checks.audit runs every check (src/jwt_auditor/checks.py:391) + each check appends zero or more Finding objects + +4. models.AuditReport scores the findings (src/jwt_auditor/models.py) + risk_score and highest_severity are computed properties + +5. output renders the report (src/jwt_auditor/output.py) + table by default, JSON with --json + +6. main sets the exit code (src/jwt_auditor/main.py) + non-zero if a finding reaches --fail-level +``` + +## Design Patterns + +### Checks as small pure functions + +**What it is:** every check is a standalone function of shape +`check_x(token, ...) -> list[Finding]`. + +**Where we use it:** all of `checks.py`, for example `check_alg_none` +(`src/jwt_auditor/checks.py:76`) and `check_expiration` +(`src/jwt_auditor/checks.py:238`). + +**Why we chose it:** a check that returns data instead of printing is trivial to +test. `test_checks.py` calls each one directly with a crafted token and asserts +on the returned findings. There is no need to capture stdout or mock a console. + +**Trade-offs:** +- Pros: isolated, testable, easy to add a new check. +- Cons: `audit()` has to know the list of checks and call each one. That list + lives in one place (`src/jwt_auditor/checks.py:409`) so it is easy to find. + +### Separating policy from presentation + +The checks decide *what* is wrong. `output.py` decides *how* it looks. They +never mix. That is why the same `AuditReport` renders as a table for a human and +as JSON for a script with no duplicated logic. + +## Layer Separation + +``` +┌───────────────────────────────────────┐ +│ CLI layer: main.py │ +│ - argument parsing, exit codes │ +│ - does not implement any check │ +└───────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────┐ +│ Logic layer: checks.py, signatures.py │ +│ - the actual security rules │ +│ - no printing, no Typer, no Rich │ +└───────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────┐ +│ Data layer: decoder.py, models.py │ +│ - parse the token, hold the results │ +│ - no policy decisions │ +└───────────────────────────────────────┘ +``` + +### Why Layers? + +- You can import `jwt_auditor.audit` in your own script and never touch the CLI. +- A test can build a `DecodedToken` and call one check with no I/O. +- Swapping the output format touches one file. + +### What Lives Where + +**Logic layer:** +- Files: `checks.py`, `signatures.py`. +- Imports: `decoder`, `models`, `wordlist`. +- Forbidden: importing `rich` or `typer`. If a check needs to print, the design + is wrong. + +**Data layer:** +- Files: `decoder.py`, `models.py`. +- Forbidden: making security decisions. `decoder.decode` never rejects a token + for being insecure, only for being malformed. The `alg: none` token decodes + fine so a check can flag it. + +## Data Models + +### DecodedToken + +```python +@dataclass +class DecodedToken: + raw: str + header: dict[str, Any] + payload: dict[str, Any] + signature: bytes + signing_input: bytes # header_b64 + "." + payload_b64, ASCII bytes + header_b64: str + payload_b64: str + signature_b64: str +``` + +**Fields explained:** +- `signing_input`: the exact bytes any signature is computed over. Storing it + here means `signatures.py` never re-derives it and cannot get it subtly wrong. +- `signature`: the raw decoded bytes, empty for an `alg: none` token. + +### Finding and Severity + +```python +class Severity(Enum): + CRITICAL = ("critical", 10.0) + HIGH = ("high", 7.0) + MEDIUM = ("medium", 4.0) + LOW = ("low", 2.0) + INFO = ("info", 0.5) +``` + +The weight drives the risk score. The rank (declaration order) drives sorting. +Keeping both on the enum means there is one source of truth. + +## Security Architecture + +### Threat Model + +What the tool assumes about the person running it: they hold a token and want to +know if it is safe. What it protects the *user* from is shipping a bad token. + +What we are analyzing for: +1. Forgeable tokens (`none`, weak secret, confusion). +2. Tokens that leak data (sensitive claims). +3. Tokens that live too long (missing or long `exp`). + +Out of scope: +- Verifying real RSA or ECDSA signatures. That needs a crypto library and is a + different job. We audit configuration, not production traffic. +- Fetching keys or tokens over the network. Everything is offline by design. + +## Configuration + +There are no config files. All behavior comes from flags, parsed in `main.py`. +The two that change results rather than formatting are `--wordlist` (which +secrets to try) and `--public-key` (enables the proven confusion test). + +## Performance Considerations + +### Bottlenecks + +The only loop that can get slow is the secret crack in `crack_hmac_secret`. It +is linear in the wordlist size, one HMAC per candidate. With `rockyou.txt` at +about 14 million lines that is 14 million HMACs, which still runs in seconds +because HMAC-SHA256 is fast and there is no I/O per candidate. + +### Optimizations + +The crack returns on the first match rather than scanning the whole list. For a +weak secret near the top of a list, it finishes almost immediately. + +## Error Handling Strategy + +### Error Types + +1. **Malformed token** - `decoder.decode` raises `InvalidTokenError`. The CLI + catches it in `_decode_or_exit` and exits 2 with a clear message. +2. **Bad flag value** - for example an unknown `--fail-level`. Raised as + `typer.BadParameter`, which Typer renders as a usage error. + +We never catch a broad `Exception` and continue. A malformed token is a real +answer ("this is not a JWT"), not something to paper over. + +## Extensibility + +### Where to Add a Check + +1. Write `check_yourthing(token, ...) -> list[Finding]` in `checks.py`. +2. Add one line to `audit()` to call it + (`src/jwt_auditor/checks.py:409`). +3. Add a test in `tests/test_checks.py`. + +That is the whole process. Because output and scoring are generic over +`Finding`, a new check shows up in the table, the JSON, and the risk score with +no other changes. + +## Limitations + +1. **No asymmetric verification.** We cannot tell you an RS256 signature is + valid, only reason about the algorithm handling. Fixing this means adding the + `cryptography` dependency, a conscious trade-off against staying standard + library only. +2. **Heuristic sensitive data check.** It matches claim names, not values. A + secret in a claim called `data` slips past. That is the cost of not guessing + at every string. + +These are trade-offs, not bugs. `04-CHALLENGES.md` turns several of them into +exercises. + +## Key Files Reference + +- `src/jwt_auditor/decoder.py` - parsing. +- `src/jwt_auditor/signatures.py` - the HMAC primitive and attacks. +- `src/jwt_auditor/checks.py` - the security rules and `audit()`. +- `src/jwt_auditor/models.py` - types and risk scoring. + +## Next Steps + +Now that you understand the shape, read +[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) for the code itself. diff --git a/PROJECTS/beginner/jwt-auditor/learn/03-IMPLEMENTATION.md b/PROJECTS/beginner/jwt-auditor/learn/03-IMPLEMENTATION.md new file mode 100644 index 00000000..8ddb5d7c --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/learn/03-IMPLEMENTATION.md @@ -0,0 +1,345 @@ +# Implementation Guide + +This document walks through the real code, file by file, in the order the data +flows. Every snippet is copied from the project, with the file and line noted so +you can open it alongside. + +## File Structure Walkthrough + +``` +src/jwt_auditor/ +├── decoder.py # parse a token string into DecodedToken +├── signatures.py # HMAC sign, verify, crack, confusion test +├── checks.py # the checks and the audit() runner +├── models.py # Severity, Finding, AuditReport +├── output.py # Rich and JSON rendering +├── wordlist.py # built in secrets and sensitive claim names +└── main.py # the Typer CLI +``` + +## Building the Decoder + +### Step 1: base64url with the padding put back + +JWT uses base64url and strips the `=` padding to keep tokens short. The standard +decoder wants that padding, so we add it back before decoding. + +`src/jwt_auditor/decoder.py:33` + +```python +def _b64url_decode(segment: str) -> bytes: + padding = "=" * (-len(segment) % 4) + try: + return base64.urlsafe_b64decode(segment + padding) + except (binascii.Error, ValueError) as exc: + raise InvalidTokenError(f"segment is not valid base64url: {exc}") from None +``` + +**Why this code works:** +- `-len(segment) % 4` computes how many pad characters are missing. For a length + that is already a multiple of 4 it is 0, otherwise 1, 2, or 3. +- We convert every decoding error into `InvalidTokenError`, so callers deal with + one exception type instead of `binascii` internals. + +**Common mistake here:** +```python +# Wrong: no padding, base64 rejects most real segments +base64.urlsafe_b64decode(segment) + +# Why this fails: JWT segments are almost never a multiple of 4 in length, +# so the decoder raises "Invalid base64-encoded string". +``` + +### Step 2: parse into a DecodedToken + +`decode` splits the token, decodes both JSON parts, and keeps the signing input. + +`src/jwt_auditor/decoder.py:90` + +```python +def decode(token: str) -> DecodedToken: + token = token.strip() + if not token: + raise InvalidTokenError("token is empty") + + segments = token.split(".") + if len(segments) != 3: + raise InvalidTokenError( + f"a JWT has 3 dot separated segments, this has {len(segments)}" + ) + + header_b64, payload_b64, signature_b64 = segments + header = _decode_json_segment(header_b64, "header") + payload = _decode_json_segment(payload_b64, "payload") + signature = _b64url_decode(signature_b64) if signature_b64 else b"" + + return DecodedToken( + raw=token, + header=header, + payload=payload, + signature=signature, + signing_input=f"{header_b64}.{payload_b64}".encode("ascii"), + header_b64=header_b64, + payload_b64=payload_b64, + signature_b64=signature_b64, + ) +``` + +**What is happening:** +1. An empty signature segment (the `alg: none` case) is allowed. We store `b""` + rather than raising, because a check further down needs to see it. +2. `signing_input` is computed once, from the original base64 text, not by + re-encoding the parsed JSON. That matters: re-encoding could reorder keys or + change spacing and produce different bytes than what was signed. + +**Why we do it this way:** the decoder is deliberately trusting. It reports what +the token says. Deciding whether the token is dangerous is the job of `checks.py`, +not the parser. + +## Building the Signature Layer + +### Verifying in constant time + +`src/jwt_auditor/signatures.py:58` + +```python +def verify_hmac(token: DecodedToken, secret: bytes, alg: str | None = None) -> bool: + chosen = alg or token.algorithm + if chosen not in _HASH_BY_ALG: + return False + if not token.signature: + return False + expected = hmac_sign(token.signing_input, secret, chosen) + return hmac.compare_digest(expected, token.signature) +``` + +**Key parts explained:** + +`hmac.compare_digest` (`src/jwt_auditor/signatures.py:78`) is the important line. +A normal `==` on bytes short circuits at the first differing byte, so a wrong +guess that shares a longer prefix takes measurably longer. Over many requests an +attacker can use that timing to recover the signature one byte at a time. +`compare_digest` always takes the same time for equal length inputs. + +The `alg` parameter defaults to the token's declared algorithm but can be forced. +That is what lets the confusion test say "verify this as HS256 even though it +claims RS256". + +### Cracking a secret + +`src/jwt_auditor/signatures.py:81` + +```python +def crack_hmac_secret(token, candidates): + if token.algorithm not in _HASH_BY_ALG: + return None + for candidate in candidates: + if verify_hmac(token, candidate.encode("utf-8")): + return candidate + return None +``` + +This is the whole attack. For a non HMAC token there is no shared secret, so it +returns immediately instead of pointlessly hashing the wordlist. On the first +match it returns the secret. + +### The key confusion test + +`src/jwt_auditor/signatures.py:100` + +```python +def key_confusion_secret(token, public_key_pem): + variants = { + "public key PEM as stored": public_key_pem, + "public key PEM without trailing newline": public_key_pem.rstrip(b"\n"), + "public key PEM with trailing newline": public_key_pem.rstrip(b"\n") + b"\n", + } + for alg in _HASH_BY_ALG: + for label, material in variants.items(): + if verify_hmac(token, material, alg=alg): + return f"{label} (verified as {alg})" + return None +``` + +**Why the variants:** HMAC is over exact bytes. Whether the server stored the PEM +with a trailing newline changes every byte of the output. Servers differ, so we +try the common forms rather than guess one. + +## Building the Checks + +Each check is small and returns findings. Here is the `alg: none` one in full. + +`src/jwt_auditor/checks.py:76` + +```python +def check_alg_none(token: DecodedToken) -> list[Finding]: + if token.algorithm.lower() != "none": + return [] + return [ + Finding( + check_id="alg-none", + title="Algorithm is 'none' (unsigned token)", + severity=Severity.CRITICAL, + detail=( + "The header declares alg 'none', meaning the token is not " + "signed at all. A server that honors this accepts any payload " + "an attacker types, including admin claims." + ), + evidence=f"header.alg = {token.header.get('alg')!r}", + recommendation=( + "Reject 'none' outright. Verify against an explicit allowlist " + "of algorithms and never let the token pick its own." + ), + ) + ] +``` + +Note `token.algorithm.lower()`. The attack has been carried out with `none`, +`None`, and `NONE` to slip past a case sensitive string compare, so we normalize. + +### A subtle bug this design avoids: bool is an int + +`src/jwt_auditor/checks.py:66` + +```python +def _as_timestamp(payload, claim): + value = payload.get(claim) + if isinstance(value, bool): # bool is an int subclass, reject it explicitly + return None + if isinstance(value, int | float): + return float(value) + return None +``` + +In Python, `True` is an instance of `int` and equals `1`. Without the explicit +`bool` check, a token with `"exp": true` would be read as expiring at Unix time +1, which is nonsense. `test_bool_claim_is_not_treated_as_timestamp` in +`tests/test_checks.py` locks this behavior in. + +## The audit runner + +`src/jwt_auditor/checks.py:391` + +```python +def audit(token, *, now=None, wordlist=None, public_key_pem=None, + max_lifetime_hours=_DEFAULT_MAX_LIFETIME_HOURS): + current = time.time() if now is None else now + secrets = COMMON_SECRETS if wordlist is None else wordlist + + findings: list[Finding] = [] + findings += check_alg_none(token) + findings += check_unknown_algorithm(token) + findings += check_unsigned(token) + findings += check_weak_hmac_secret(token, secrets) + findings += check_key_confusion(token, public_key_pem) + findings += check_expiration(token, current, max_lifetime_hours) + findings += check_time_sanity(token, current) + findings += check_missing_claims(token) + findings += check_sensitive_data(token) + + return AuditReport(token=token, findings=findings) +``` + +`now` is injectable. In production it is `time.time()`. In tests it is a fixed +value (`FIXED_NOW` in `tests/conftest.py`) so the expiration checks are +deterministic. That single design choice is why the time based tests are not +flaky. + +## The risk score + +`src/jwt_auditor/models.py` + +```python +@property +def risk_score(self) -> float: + if not self.findings: + return 0.0 + base = max(f.severity.weight for f in self.findings) + extra = 0.4 * (len(self.findings) - 1) + return round(min(10.0, base + extra), 1) +``` + +The worst finding sets the floor. Extra findings nudge it up, because five medium +issues are worse than one. It is capped at 10. A clean token is exactly 0.0. +`tests/test_models.py` covers the empty, single, and capped cases. + +## Error Handling in the CLI + +`src/jwt_auditor/main.py` + +```python +def _decode_or_exit(raw: str) -> DecodedToken: + try: + return decode(raw) + except InvalidTokenError as exc: + err_console.print(f"[red]Not a valid JWT:[/red] {exc}") + raise typer.Exit(code=2) from None +``` + +**What NOT to do:** +```python +# Bad: swallow everything +try: + return decode(raw) +except Exception: + return None # now every caller has to wonder what None means +``` + +We catch the one exception the decoder raises and turn it into a clean exit code +2 with a message. Everything else is a real bug and should crash loudly. + +## Testing Strategy + +### Unit test for a single check + +`tests/test_checks.py` + +```python +def test_weak_secret_recovered(): + token = decode(build_hs_token({"sub": "x"}, secret="secret")) + findings = checks.check_weak_hmac_secret(token, ["secret", "other"]) + assert findings[0].severity is Severity.CRITICAL + assert "secret" in findings[0].evidence +``` + +The fixture `build_hs_token` in `tests/conftest.py` signs a token with the +project's own `hmac_sign`, so the test exercises the real signing and cracking +path with no external library. + +### End to end test through the CLI + +`tests/test_cli.py` + +```python +def test_audit_none_token_fails_and_reports_critical(): + token = build_none_token({"sub": "x"}) + result = runner.invoke(app, ["audit", token]) + assert result.exit_code == 1 + assert "CRITICAL" in result.stdout +``` + +Typer's `CliRunner` runs the command in process and captures output and the exit +code, so this checks the real argument parsing and the fail level logic together. + +### Running the tests + +```bash +uv run pytest tests/ -v +``` + +All 59 pass. If one fails with an import error, you probably skipped `uv sync`. + +## Dependencies + +- **typer** - the CLI framework. Gives us subcommands, help text, and exit codes + with almost no boilerplate. +- **rich** - tables and panels for readable output, JSON pretty printing. + +That is the entire runtime dependency list. Signatures, base64, and JSON all come +from the standard library, which keeps the security relevant code auditable in +one sitting. + +## Next Steps + +Read [04-CHALLENGES.md](./04-CHALLENGES.md) to extend the tool. Good first steps: +a new check, or real RSA verification with the `cryptography` library. diff --git a/PROJECTS/beginner/jwt-auditor/learn/04-CHALLENGES.md b/PROJECTS/beginner/jwt-auditor/learn/04-CHALLENGES.md new file mode 100644 index 00000000..7807c523 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/learn/04-CHALLENGES.md @@ -0,0 +1,232 @@ +# Extension Challenges + +You have a working auditor. Now make it yours. These are ordered from quick wins +to real projects. Each one names the files you will touch and how to know it +works. + +## Easy Challenges + +### Challenge 1: Add a check for the `kid` header injection risk + +**What to build:** a check that flags a `kid` (key ID) header containing shell or +SQL metacharacters. Some servers use `kid` to look up a key by filename or in a +database, and an unsanitized `kid` has led to path traversal and SQL injection. + +**Why it's useful:** `kid` injection is a real JWT attack class that the current +tool does not cover. + +**What you'll learn:** +- How header parameters beyond `alg` become attack surface. +- Writing a check that inspects the header rather than the payload. + +**Hints:** +- Add `check_kid_injection(token)` in `src/jwt_auditor/checks.py` and wire it into + `audit()` next to the other calls. +- Look at `token.header.get("kid")`. Flag characters like `../`, `;`, `'`, `|`. +- Follow the shape of `check_alg_none` for the Finding fields. + +**Test it works:** build a token with `header={"alg":"HS256","kid":"../../etc/passwd"}` +using the pattern in `tests/conftest.py`, and assert your check returns a finding. + +### Challenge 2: Warn on HS256 keys that are too short + +**What to build:** when a weak secret is recovered, also report if the recovered +secret is shorter than 32 bytes, since RFC 7518 requires an HMAC key at least as +long as the hash output. + +**Why it's useful:** it turns "your secret is guessable" into a concrete "and it +is only 6 bytes, well under the 32 byte minimum". + +**What you'll learn:** reading a spec requirement and encoding it as a check. + +**Hints:** extend `check_weak_hmac_secret` in `checks.py`. You already have the +secret string once it is cracked. + +**Test it works:** crack a short secret and assert the evidence mentions the +length. + +### Challenge 3: Add a `--quiet` flag to audit + +**What to build:** a flag that prints only the risk score and worst severity, no +table. Handy in scripts. + +**What you'll learn:** adding a Typer option and branching the output. + +**Hints:** add the option in `audit_command` in `main.py`, and guard the call to +`render_report`. + +**Test it works:** add a `tests/test_cli.py` case asserting the table header is +absent in quiet mode. + +## Intermediate Challenges + +### Challenge 4: Real RS256 and ES256 verification + +**What to build:** given a public key, actually verify an RSA or ECDSA signature, +not just warn about it. + +**Real world application:** this makes the tool useful for confirming a token is +genuinely valid, not only that its configuration is sound. + +**What you'll learn:** +- Using the `cryptography` library for signature verification. +- The difference between HMAC (symmetric) and RSA/ECDSA (asymmetric) verification. + +**Implementation approach:** +1. Add `cryptography` to `[project.optional-dependencies]` or the main + dependencies in `pyproject.toml`. +2. Create `verify_asymmetric(token, public_key_pem)` in `signatures.py`. +3. Add `check_asymmetric_signature` that reports whether a supplied public key + validates the token. + +**Hints:** +- `cryptography.hazmat.primitives.asymmetric` has the verify functions. +- RS256 is RSA with PKCS1v15 padding and SHA256. PS256 is RSA-PSS. +- Catch `InvalidSignature` and turn it into a finding, do not let it crash. + +**Extra credit:** if verification fails, say whether the key format was wrong +versus the signature was invalid. Those are different problems for the user. + +### Challenge 5: Detect nested and encrypted tokens (JWE) + +**What to build:** recognize a five segment token (JWE, encrypted) versus a three +segment JWS, and report clearly instead of failing with "expected 3 segments". + +**What you'll learn:** the difference between a signed token and an encrypted one, +and how the JOSE family is structured. + +**Implementation approach:** +1. In `decoder.decode`, detect a five segment token and raise a specific error, + or return a marker the CLI explains. +2. Update `main.py` to print a helpful message for JWE input. + +**Hints:** a JWE is `header.encrypted_key.iv.ciphertext.tag`. You cannot audit +the claims without the decryption key, and that is the honest thing to report. + +## Advanced Challenges + +### Challenge 6: A batch mode for scanning many tokens + +**What to build:** accept a file with one token per line and produce a summary +report: how many tokens, how many with each finding, the worst offenders. + +**Why this is hard:** you have to aggregate `AuditReport` objects and design a +summary that is useful at scale without drowning the reader. + +**What you'll learn:** +- Aggregating structured results. +- Designing output that scales from 1 to 10,000 items. + +**Architecture changes needed:** + +``` +tokens.txt ──▶ decode+audit each ──▶ list[AuditReport] ──▶ aggregate ──▶ summary +``` + +**Implementation steps:** +1. Add a `scan` command in `main.py` that reads a file line by line. +2. Reuse `checks.audit` per line. Skip and count malformed lines rather than + crashing the whole run. +3. Build an aggregate table: counts per `check_id`, top N by risk score. + +**Gotchas:** +- Do not hold every token string in memory if the file is huge. Stream it. +- A malformed line is data, not a crash. Log it and keep going. + +**Success criteria:** +- [ ] Handles a file with a mix of valid and invalid tokens. +- [ ] Prints per finding counts and the highest risk tokens. +- [ ] Exits non-zero if any token reaches the fail level. + +### Challenge 7: SARIF output for CI integration + +**What to build:** emit findings in SARIF, the format code scanning tools use, so +results show up in a CI dashboard. + +**What you'll learn:** how findings map to a standard interchange format, the same +idea the `secrets-scanner` project in this repo uses. + +**Implementation approach:** add `report_to_sarif(report)` alongside +`report_to_dict` in `output.py`, and a `--sarif` flag. + +## Expert Challenges + +### Challenge 8: A safe, sandboxed forging demo + +**What to build:** a `forge` command that, given a token you cracked the secret +for, produces a new token with edited claims, purely to demonstrate impact in an +authorized test. + +**Estimated time:** a day, mostly on the guardrails. + +**Prerequisites:** finish Challenge 4 so you understand signing versus verifying. + +**What you'll learn:** +- Turning an audit finding into a proof of concept, the way a pentest report does. +- The ethics and mechanics of building a tool that can also be misused. + +**Planning this feature:** + +Before coding, think through: +- How do you make it obvious this is for authorized testing only? +- Should it refuse to run unless the secret was actually recovered first? +- What warning does it print, and does it require a confirmation flag? + +**Success criteria:** +- [ ] Only forges when given a known secret, never guesses silently. +- [ ] Requires an explicit `--i-am-authorized` style flag. +- [ ] Prints a clear notice about legal use. + +## Real World Integration Challenges + +### Integrate with a running app + +**The goal:** pull a token from your own app's login response and audit it in one +pipeline. + +**Steps:** +1. `curl` the login endpoint, extract the token with `jq`. +2. Pipe it into `jwt-auditor audit`. +3. Add it to your CI so a regression in token config fails the build. + +**Watch out for:** never do this against a service you are not authorized to test. + +## Security Challenges + +### Challenge: harden the sensitive data check + +**What to implement:** move from matching claim *names* to also scanning claim +*values* for patterns like credit card numbers (Luhn check) and JWTs nested +inside claims. + +**Testing the security:** +- Put a card number in a claim called `data` and confirm the name based check + misses it but the value based check catches it. +- Verify you do not print the sensitive value itself in the finding. + +## Challenge Completion + +Track your progress: + +- [ ] Easy Challenge 1: kid injection check +- [ ] Easy Challenge 2: short key warning +- [ ] Easy Challenge 3: quiet flag +- [ ] Intermediate Challenge 4: asymmetric verification +- [ ] Intermediate Challenge 5: JWE detection +- [ ] Advanced Challenge 6: batch scan +- [ ] Advanced Challenge 7: SARIF output +- [ ] Expert Challenge 8: sandboxed forging demo + +Finished them all? You understand JWT security better than most people shipping +tokens to production. Build something new, or contribute a check back to this +project. + +## Getting Help + +Stuck on a challenge? + +1. **Debug systematically.** What token did you build, what finding did you + expect, what did you get? Print the `DecodedToken` and look at it. +2. **Read the existing checks.** Your new check almost certainly resembles one + that already exists. +3. **Run one test in isolation.** `uv run pytest tests/test_checks.py::your_test -v`. diff --git a/PROJECTS/beginner/jwt-auditor/pyproject.toml b/PROJECTS/beginner/jwt-auditor/pyproject.toml new file mode 100644 index 00000000..e7de2026 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/pyproject.toml @@ -0,0 +1,127 @@ +[project] +name = "jwt-auditor" +version = "0.1.0" +description = "Decode and audit JSON Web Tokens for alg confusion, weak secrets, and unsafe claims." +readme = "README.md" +requires-python = ">=3.12" +license = { text = "AGPL-3.0-or-later" } +authors = [ + { name = "Mohamed Elobeid" }, +] +keywords = [ + "jwt", + "security", + "json-web-token", + "hmac", + "cli", +] +dependencies = [ + "typer>=0.15.0,<0.20.0", + "rich>=13.9.0,<15.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0,<9.0.0", + "pytest-cov>=6.0.0,<7.0.0", + "mypy>=1.13.0,<2.0.0", + "ruff>=0.8.0,<0.9.0", + "pylint>=3.3.0,<4.0.0", +] + +[project.urls] +Homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main" +Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/jwt-auditor" + +[project.scripts] +jwt-auditor = "jwt_auditor.main:app" + +[build-system] +requires = [ + "hatchling", +] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = [ + "src/jwt_auditor", +] + +[tool.ruff] +target-version = "py312" +line-length = 88 +src = [ + "src", +] + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "B", + "C4", + "UP", + "SIM", + "PTH", + "RUF", + "S", + "N", +] +ignore = [ + "E501", + "S101", + "N818", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S101", + "S105", + "S106", + "S107", +] + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true + +[[tool.mypy.overrides]] +module = [ + "tests.*", +] +ignore_errors = true + +[tool.pylint.main] +py-version = "3.12" +jobs = 4 + +[tool.pylint.messages_control] +disable = [ + "C0103", + "C0114", + "C0115", + "C0116", + "C0301", + "R0903", + "R0911", + "R0913", + "R0917", + "W0718", +] + +[tool.pylint.format] +max-line-length = 95 + +[tool.pylint.design] +max-attributes = 10 +max-args = 8 + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] +addopts = "-ra -q" diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/__init__.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/__init__.py new file mode 100644 index 00000000..aa6a0941 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/__init__.py @@ -0,0 +1,36 @@ +""" +jwt-auditor + +A command line tool that decodes JSON Web Tokens and audits them for the +security mistakes that show up again and again: the alg none downgrade, +weak HMAC secrets, the RS256 to HS256 confusion attack, tokens that never +expire, and secrets carried in the payload. + +Everything runs offline against a token string. No network, no PyJWT. + +Public surface: + decode - parse a token into its pieces (from decoder) + audit - run the full check suite (from checks) + AuditReport, Finding, Severity - the result types (from models) + +Connects to: + decoder.py - token parsing + checks.py - the audit orchestrator + models.py - shared data types +""" + +from jwt_auditor.checks import audit +from jwt_auditor.decoder import DecodedToken, InvalidTokenError, decode +from jwt_auditor.models import AuditReport, Finding, Severity + + +__version__ = "0.1.0" +__all__ = [ + "AuditReport", + "DecodedToken", + "Finding", + "InvalidTokenError", + "Severity", + "audit", + "decode", +] diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/checks.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/checks.py new file mode 100644 index 00000000..5e9bb2f3 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/checks.py @@ -0,0 +1,425 @@ +""" +checks.py + +The individual security checks and the audit() orchestrator that runs them. + +Each check is a small function that takes a DecodedToken and returns zero or +more Findings. They do not print anything and they do not depend on Rich, so +they are trivial to unit test. audit() wires them together, passes in the +current time and any optional inputs, and collects everything into an +AuditReport. + +Key exports: + audit - run every check and return an AuditReport + the individual check_* functions, exported for focused testing + +Connects to: + decoder.py - operates on a DecodedToken + signatures.py - the secret and confusion checks call into it + wordlist.py - default secrets and sensitive claim names + models.py - builds Finding and AuditReport +""" + +import time +from collections.abc import Iterable +from typing import Any + +from jwt_auditor.decoder import DecodedToken +from jwt_auditor.models import AuditReport, Finding, Severity +from jwt_auditor.signatures import ( + crack_hmac_secret, + key_confusion_secret, + supported_hmac_algs, +) +from jwt_auditor.wordlist import COMMON_SECRETS, SENSITIVE_CLAIM_KEYS + +# Registered JOSE signing algorithms (RFC 7518). Anything outside this set is +# suspicious: either a typo, a custom scheme, or an attacker probing. +_KNOWN_ALGS: frozenset[str] = frozenset( + { + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", + "EdDSA", + "none", + } +) + +_ASYMMETRIC_PREFIXES: tuple[str, ...] = ("RS", "ES", "PS") + +# Tokens that live longer than this without a stated reason are flagged. +_DEFAULT_MAX_LIFETIME_HOURS = 24.0 +# A little slack so tokens issued a second in the future by clock skew do not +# trip the "issued in the future" check. +_CLOCK_SKEW_SECONDS = 60.0 + + +def _as_timestamp(payload: dict[str, Any], claim: str) -> float | None: + """Return a numeric claim as a float, or None if absent or not a number.""" + value = payload.get(claim) + if isinstance(value, bool): # bool is an int subclass, reject it explicitly + return None + if isinstance(value, int | float): + return float(value) + return None + + +def check_alg_none(token: DecodedToken) -> list[Finding]: + """Flag the alg none downgrade, where a token carries no signature.""" + if token.algorithm.lower() != "none": + return [] + return [ + Finding( + check_id = "alg-none", + title = "Algorithm is 'none' (unsigned token)", + severity = Severity.CRITICAL, + detail = ( + "The header declares alg 'none', meaning the token is not " + "signed at all. A server that honors this accepts any payload " + "an attacker types, including admin claims." + ), + evidence = f"header.alg = {token.header.get('alg')!r}", + recommendation = ( + "Reject 'none' outright. Verify against an explicit allowlist " + "of algorithms and never let the token pick its own." + ), + ) + ] + + +def check_unknown_algorithm(token: DecodedToken) -> list[Finding]: + """Flag an alg value that is not a registered JOSE algorithm.""" + alg = token.algorithm + if not alg: + return [ + Finding( + check_id = "alg-missing", + title = "Header has no 'alg' field", + severity = Severity.MEDIUM, + detail = + "Every JWS header must declare an algorithm. This one does not.", + evidence = f"header keys = {sorted(token.header)}", + recommendation = + "Treat a header with no alg as invalid and reject it.", + ) + ] + if alg in _KNOWN_ALGS: + return [] + return [ + Finding( + check_id = "alg-unknown", + title = f"Unrecognized algorithm {alg!r}", + severity = Severity.MEDIUM, + detail = ( + "The alg is not a registered JOSE algorithm. It may be a typo, " + "a homegrown scheme, or an attacker probing what the server " + "will accept." + ), + evidence = f"header.alg = {alg!r}", + recommendation = + "Verify against a fixed allowlist of known algorithms.", + ) + ] + + +def check_unsigned(token: DecodedToken) -> list[Finding]: + """Flag a token whose signature segment is empty but alg is not none.""" + if token.algorithm.lower() == "none": + return [] # handled by check_alg_none, do not double report + if token.signature: + return [] + return [ + Finding( + check_id = "empty-signature", + title = "Signature segment is empty", + severity = Severity.HIGH, + detail = ( + "The token declares a real algorithm but carries no signature " + "bytes. Nothing about the payload is protected." + ), + evidence = f"alg {token.algorithm!r} with 0 signature bytes", + recommendation = "Reject tokens with a missing signature.", + ) + ] + + +def check_weak_hmac_secret( + token: DecodedToken, + candidates: Iterable[str], +) -> list[Finding]: + """Try to recover the HMAC secret from a wordlist. A hit is critical.""" + if token.algorithm not in supported_hmac_algs(): + return [] + found = crack_hmac_secret(token, candidates) + if found is None: + return [] + return [ + Finding( + check_id = "weak-hmac-secret", + title = "HMAC secret recovered from wordlist", + severity = Severity.CRITICAL, + detail = ( + "The signing secret was guessed offline. Anyone with the token " + "and this secret can mint valid tokens with any claims they want." + ), + evidence = f"secret = {found!r}", + recommendation = ( + "Rotate the secret immediately. Use a long random key, at least " + "32 bytes from a CSPRNG, and store it outside the codebase." + ), + ) + ] + + +def check_key_confusion( + token: DecodedToken, + public_key_pem: bytes | None, +) -> list[Finding]: + """ + Warn about RS/ES/PS tokens and, if given a public key, prove confusion. + + Without a key we can only warn, because the attack depends on how the + server verifies. With the server's public key we can show whether the + token verifies when the public key is used as an HMAC secret. + """ + alg = token.algorithm + is_asymmetric = alg.startswith(_ASYMMETRIC_PREFIXES) + + if public_key_pem is not None: + match = key_confusion_secret(token, public_key_pem) + if match is not None: + return [ + Finding( + check_id = "key-confusion", + title = "Token verifies with the public key as an HMAC secret", + severity = Severity.CRITICAL, + detail = ( + "This is the RS256 to HS256 confusion attack. The server " + "trusts the header algorithm, so an attacker signs an " + "HS256 token using the public RSA key, which is not secret." + ), + evidence = match, + recommendation = ( + "Pin the expected algorithm on the server. Do not let the " + "token header choose between HMAC and RSA verification." + ), + ) + ] + + if is_asymmetric: + return [ + Finding( + check_id = "asymmetric-alg-review", + title = f"Asymmetric algorithm {alg} needs a pinned verifier", + severity = Severity.LOW, + detail = ( + "Asymmetric tokens are fine when the server pins the " + "algorithm. They become a problem when it accepts the " + "header's choice, which enables the HMAC confusion attack. " + "Supply the public key with --public-key to test directly." + ), + evidence = f"header.alg = {alg!r}", + recommendation = + "Confirm the verifier hardcodes the expected algorithm.", + ) + ] + return [] + + +def check_expiration( + token: DecodedToken, + now: float, + max_lifetime_hours: float, +) -> list[Finding]: + """Check exp: missing, already expired, or an unusually long lifetime.""" + findings: list[Finding] = [] + exp = _as_timestamp(token.payload, "exp") + + if exp is None: + findings.append( + Finding( + check_id = "missing-exp", + title = "No expiration claim", + severity = Severity.MEDIUM, + detail = ( + "The token has no exp, so it is valid forever. A leaked " + "token stays useful until the secret is rotated." + ), + evidence = "payload has no 'exp'", + recommendation = + "Set a short exp, minutes to hours for access tokens.", + ) + ) + return findings + + if exp < now: + findings.append( + Finding( + check_id = "expired", + title = "Token is already expired", + severity = Severity.INFO, + detail = + "The exp is in the past. A correct server already rejects it.", + evidence = f"exp {_fmt_ts(exp)} is before now {_fmt_ts(now)}", + recommendation = + "No action if your server checks exp. Confirm that it does.", + ) + ) + return findings + + iat = _as_timestamp(token.payload, "iat") + lifetime_seconds = exp - iat if iat is not None else exp - now + lifetime_hours = lifetime_seconds / 3600.0 + if lifetime_hours > max_lifetime_hours: + findings.append( + Finding( + check_id = "long-lifetime", + title = "Token lifetime is long", + severity = Severity.LOW, + detail = ( + f"This token is valid for about {lifetime_hours:.1f} hours. " + "Long lived access tokens widen the window for a stolen " + "token to be used." + ), + evidence = + f"lifetime ~= {lifetime_hours:.1f}h (threshold {max_lifetime_hours:.0f}h)", + recommendation = + "Shorten access token lifetime and use refresh tokens.", + ) + ) + return findings + + +def check_time_sanity(token: DecodedToken, now: float) -> list[Finding]: + """Flag iat or nbf values that sit in the future beyond clock skew.""" + findings: list[Finding] = [] + iat = _as_timestamp(token.payload, "iat") + if iat is not None and iat > now + _CLOCK_SKEW_SECONDS: + findings.append( + Finding( + check_id = "future-iat", + title = "Issued-at time is in the future", + severity = Severity.LOW, + detail = ( + "The iat claim is later than now. That points to a clock " + "problem or a hand edited token." + ), + evidence = f"iat {_fmt_ts(iat)} is after now {_fmt_ts(now)}", + recommendation = + "Reject tokens issued in the future beyond small skew.", + ) + ) + nbf = _as_timestamp(token.payload, "nbf") + if nbf is not None and nbf > now + _CLOCK_SKEW_SECONDS: + findings.append( + Finding( + check_id = "future-nbf", + title = "Not-before time is in the future", + severity = Severity.INFO, + detail = "The nbf claim means the token is not valid yet.", + evidence = f"nbf {_fmt_ts(nbf)} is after now {_fmt_ts(now)}", + recommendation = + "Expected for pre-issued tokens. Confirm it is intentional.", + ) + ) + return findings + + +def check_missing_claims(token: DecodedToken) -> list[Finding]: + """Note common registered claims that are absent.""" + recommended = { + "iss": "issuer, so the verifier can confirm who minted the token", + "aud": "audience, so a token for one service is rejected by another", + "sub": "subject, the identity the token is about", + } + absent = [name for name in recommended if name not in token.payload] + if not absent: + return [] + listed = ", ".join(f"{name} ({recommended[name]})" for name in absent) + return [ + Finding( + check_id = "missing-claims", + title = "Recommended claims are missing", + severity = Severity.INFO, + detail = ( + "These registered claims are not present. They are not required " + "by the spec, but leaving them out removes checks a verifier " + f"could otherwise make: {listed}." + ), + evidence = f"missing = {absent}", + recommendation = + "Add and validate iss, aud, and sub where they apply.", + ) + ] + + +def check_sensitive_data(token: DecodedToken) -> list[Finding]: + """Flag claim names that suggest secrets are riding in the payload.""" + hits = [ + key for key in token.payload + if any(marker in key.lower() for marker in SENSITIVE_CLAIM_KEYS) + ] + if not hits: + return [] + return [ + Finding( + check_id = "sensitive-claim", + title = "Payload may contain sensitive data", + severity = Severity.HIGH, + detail = ( + "A JWT payload is only base64url encoded, not encrypted. Anyone " + "holding the token reads these claims in plain text." + ), + evidence = f"suspicious claim names = {hits}", + recommendation = ( + "Never put passwords, keys, or PII in a JWT. Store them server " + "side and reference by an opaque id." + ), + ) + ] + + +def audit( + token: DecodedToken, + *, + now: float | None = None, + wordlist: Iterable[str] | None = None, + public_key_pem: bytes | None = None, + max_lifetime_hours: float = _DEFAULT_MAX_LIFETIME_HOURS, +) -> AuditReport: + """ + Run every check against a token and return the collected report. + + now defaults to the wall clock. Tests pass a fixed value so time based + checks are deterministic. wordlist defaults to the built in COMMON_SECRETS. + """ + current = time.time() if now is None else now + secrets = COMMON_SECRETS if wordlist is None else wordlist + + findings: list[Finding] = [] + findings += check_alg_none(token) + findings += check_unknown_algorithm(token) + findings += check_unsigned(token) + findings += check_weak_hmac_secret(token, secrets) + findings += check_key_confusion(token, public_key_pem) + findings += check_expiration(token, current, max_lifetime_hours) + findings += check_time_sanity(token, current) + findings += check_missing_claims(token) + findings += check_sensitive_data(token) + + return AuditReport(token = token, findings = findings) + + +def _fmt_ts(value: float) -> str: + """Format a unix timestamp as a readable UTC string for evidence text.""" + stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(value)) + return f"{stamp} UTC" diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/decoder.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/decoder.py new file mode 100644 index 00000000..5356aba1 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/decoder.py @@ -0,0 +1,123 @@ +""" +decoder.py + +Splits a JWT into its three parts and decodes the header and payload. + +A JWS-style JWT is three base64url segments joined by dots: +header.payload.signature. This module does the raw decoding only. It never +checks the signature and never trusts the "alg" field. That separation is +deliberate. A decoder that quietly validates is how people end up trusting +tokens they should not. + +Key exports: + DecodedToken - dataclass holding the parsed pieces + decode - parse a token string into a DecodedToken + InvalidTokenError - raised when the string is not a well formed JWT + +Connects to: + checks.py - runs security checks against a DecodedToken + signatures.py - uses signing_input and signature to test secrets +""" + +import base64 +import binascii +import json +from dataclasses import dataclass +from typing import Any + + +class InvalidTokenError(ValueError): + """Raised when a string cannot be parsed as a JWT.""" + + +def _b64url_decode(segment: str) -> bytes: + """ + Decode a base64url segment, adding the padding JWT strips off. + + JWT drops the trailing "=" padding to keep tokens compact (RFC 7515 + calls this base64url without padding). We add it back before handing + the bytes to the standard decoder, otherwise it rejects the input. + """ + padding = "=" * (-len(segment) % 4) + try: + return base64.urlsafe_b64decode(segment + padding) + except (binascii.Error, ValueError) as exc: + raise InvalidTokenError( + f"segment is not valid base64url: {exc}" + ) from None + + +def _decode_json_segment(segment: str, name: str) -> dict[str, Any]: + """Decode a base64url segment and parse it as a JSON object.""" + raw = _b64url_decode(segment) + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise InvalidTokenError(f"{name} is not valid JSON: {exc}") from None + if not isinstance(value, dict): + raise InvalidTokenError( + f"{name} must be a JSON object, got {type(value).__name__}" + ) + return value + + +@dataclass +class DecodedToken: + """ + The decoded pieces of a JWT. + + signing_input is the exact bytes a signature is computed over + (the header and payload segments joined by a dot, ASCII encoded). + Keeping it here means the signature code never has to re-derive it. + """ + + raw: str + header: dict[str, Any] + payload: dict[str, Any] + signature: bytes + signing_input: bytes + header_b64: str + payload_b64: str + signature_b64: str + + @property + def algorithm(self) -> str: + """Return the declared alg header, or an empty string if absent.""" + alg = self.header.get("alg", "") + return alg if isinstance(alg, str) else str(alg) + + +def decode(token: str) -> DecodedToken: + """ + Parse a JWT string into its decoded parts without verifying it. + + Raises InvalidTokenError if the string does not have three segments or + if the header/payload are not base64url encoded JSON objects. An empty + signature segment (alg none tokens end with a trailing dot) is allowed + here on purpose so the checks can flag it. + """ + token = token.strip() + if not token: + raise InvalidTokenError("token is empty") + + segments = token.split(".") + if len(segments) != 3: + raise InvalidTokenError( + f"a JWT has 3 dot separated segments, this has {len(segments)}" + ) + + header_b64, payload_b64, signature_b64 = segments + header = _decode_json_segment(header_b64, "header") + payload = _decode_json_segment(payload_b64, "payload") + signature = _b64url_decode(signature_b64) if signature_b64 else b"" + + return DecodedToken( + raw = token, + header = header, + payload = payload, + signature = signature, + signing_input = f"{header_b64}.{payload_b64}".encode("ascii"), + header_b64 = header_b64, + payload_b64 = payload_b64, + signature_b64 = signature_b64, + ) diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/main.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/main.py new file mode 100644 index 00000000..35496ac1 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/main.py @@ -0,0 +1,250 @@ +""" +main.py + +CLI entry point with decode, audit, and crack commands via Typer. + +decode shows what is inside a token. audit runs the security checks and +prints a scored report. crack tries a wordlist against an HS signed token. +Every command reads the token from an argument, a file, or stdin, so the +tool drops into a pipeline the same way jq or grep would. + +Key exports: + app - the Typer application, registered as the jwt-auditor entry point + +Connects to: + decoder.py - parses the token string + checks.py - audit command runs the check suite + signatures.py - crack command recovers the secret + wordlist.py - default secrets and wordlist loading + output.py - renders results as tables or JSON +""" + +import sys +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console + +from jwt_auditor import checks +from jwt_auditor.decoder import DecodedToken, InvalidTokenError, decode +from jwt_auditor.models import AuditReport +from jwt_auditor.output import ( + decoded_to_dict, + render_decoded, + render_report, + report_to_dict, +) +from jwt_auditor.signatures import crack_hmac_secret, supported_hmac_algs +from jwt_auditor.wordlist import COMMON_SECRETS, load_wordlist + + +app = typer.Typer( + name = "jwt-auditor", + help = "Decode and audit JSON Web Tokens for common security mistakes.", + no_args_is_help = True, +) +console = Console() +err_console = Console(stderr = True) + +# Order matters: the index is the severity rank, used to compare fail levels. +_FAIL_LEVELS = ("critical", "high", "medium", "low", "info") + + +def _read_token(token: str | None, input_file: Path | None) -> str: + """ + Resolve the token from an argument, a file, or stdin, in that order. + + Reading from stdin lets you pipe a token in without it landing in your + shell history, which matters because a token is a bearer credential. A + literal "-" as the argument means stdin, the usual command line idiom. + """ + if token is not None and token != "-": # noqa: S105 - "-" is stdin, not a secret + return token + if input_file is not None: + return input_file.read_text(encoding = "utf-8").strip() + if not sys.stdin.isatty(): + piped = sys.stdin.read().strip() + if piped: + return piped + raise typer.BadParameter( + "provide a token as an argument, with --input-file, or via stdin" + ) + + +def _decode_or_exit(raw: str) -> DecodedToken: + """Decode a token or print the error and exit with code 2.""" + try: + return decode(raw) + except InvalidTokenError as exc: + err_console.print(f"[red]Not a valid JWT:[/red] {exc}") + raise typer.Exit(code = 2) from None + + +def _reaches_fail_level(report: AuditReport, fail_level: str) -> bool: + """Return True if any finding is at or above the configured fail level.""" + highest = report.highest_severity + if highest is None: + return False + return highest.rank <= _FAIL_LEVELS.index(fail_level) + + +@app.command("decode") +def decode_command( + token: Annotated[ + str | None, + typer.Argument(help = "The JWT string (or use --input-file or stdin)"), + ] = None, + input_file: Annotated[ + Path | None, + typer.Option("--input-file", + "-i", + help = "Read the token from a file"), + ] = None, + as_json: Annotated[ + bool, + typer.Option("--json", + help = "Emit machine readable JSON"), + ] = False, +) -> None: + """ + Decode a token and print its header, payload, and signature info. + + This never verifies the signature. It only shows what the token claims. + """ + parsed = _decode_or_exit(_read_token(token, input_file)) + if as_json: + console.print_json(data = decoded_to_dict(parsed)) + else: + render_decoded(console, parsed) + + +@app.command("audit") +def audit_command( + token: Annotated[ + str | None, + typer.Argument(help = "The JWT string (or use --input-file or stdin)"), + ] = None, + input_file: Annotated[ + Path | None, + typer.Option("--input-file", + "-i", + help = "Read the token from a file"), + ] = None, + wordlist: Annotated[ + Path | None, + typer.Option( + "--wordlist", + "-w", + help = "Wordlist of secrets for the HMAC check" + ), + ] = None, + public_key: Annotated[ + Path | None, + typer.Option( + "--public-key", + "-p", + help = "Public key PEM to test alg confusion" + ), + ] = None, + max_lifetime: Annotated[ + float, + typer.Option( + "--max-lifetime", + help = "Hours before a token counts as long lived" + ), + ] = 24.0, + fail_level: Annotated[ + str, + typer. + Option("--fail-level", + help = "Exit non-zero at this severity or worse"), + ] = "high", + as_json: Annotated[ + bool, + typer.Option("--json", + help = "Emit machine readable JSON"), + ] = False, +) -> None: + """ + Run the full check suite against a token and print a scored report. + + Exits non-zero when a finding reaches --fail-level, so it drops into a + CI pipeline as a gate. + """ + if fail_level not in _FAIL_LEVELS: + raise typer.BadParameter( + f"--fail-level must be one of {', '.join(_FAIL_LEVELS)}" + ) + + parsed = _decode_or_exit(_read_token(token, input_file)) + secrets = load_wordlist(wordlist + ) if wordlist is not None else list(COMMON_SECRETS) + key_bytes = public_key.read_bytes() if public_key is not None else None + + report = checks.audit( + parsed, + wordlist = secrets, + public_key_pem = key_bytes, + max_lifetime_hours = max_lifetime, + ) + + if as_json: + console.print_json(data = report_to_dict(report)) + else: + render_report(console, report) + + if _reaches_fail_level(report, fail_level): + raise typer.Exit(code = 1) + + +@app.command("crack") +def crack_command( + token: Annotated[ + str | None, + typer.Argument(help = "The JWT string (or use --input-file or stdin)"), + ] = None, + input_file: Annotated[ + Path | None, + typer.Option("--input-file", + "-i", + help = "Read the token from a file"), + ] = None, + wordlist: Annotated[ + Path | None, + typer.Option( + "--wordlist", + "-w", + help = "Wordlist of secrets (defaults to built in)" + ), + ] = None, +) -> None: + """ + Try to recover the HMAC secret of an HS signed token from a wordlist. + + Prints the secret and exits 0 on a hit, or exits 1 if nothing matched. + """ + parsed = _decode_or_exit(_read_token(token, input_file)) + + if parsed.algorithm not in supported_hmac_algs(): + err_console.print( + f"[yellow]{parsed.algorithm or 'this token'} is not HMAC signed, " + "there is no shared secret to guess.[/yellow]" + ) + raise typer.Exit(code = 1) + + secrets = load_wordlist(wordlist + ) if wordlist is not None else list(COMMON_SECRETS) + found = crack_hmac_secret(parsed, secrets) + if found is None: + console.print( + f"[red]No secret in the list of {len(secrets)} matched.[/red]" + ) + raise typer.Exit(code = 1) + + console.print(f"[bold green]Secret found:[/bold green] {found!r}") + console.print("The token can now be forged. Rotate this key.") + + +if __name__ == "__main__": + app() diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/models.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/models.py new file mode 100644 index 00000000..a625e92b --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/models.py @@ -0,0 +1,107 @@ +""" +models.py + +The data types shared across the tool: Severity, Finding, AuditReport. + +Keeping these in one place means the checks, the output layer, and the +tests all agree on what a finding looks like. The risk score lives on the +report rather than in the output code so it is testable without going +through Rich. + +Key exports: + Severity - ordered severity levels with a numeric weight + Finding - a single issue found in a token + AuditReport - the full result of auditing one token + +Connects to: + checks.py - produces Finding objects + output.py - renders AuditReport to console or JSON +""" + +from dataclasses import dataclass, field +from enum import Enum + +from jwt_auditor.decoder import DecodedToken + + +class Severity(Enum): + """ + Severity levels ordered from worst to least. + + The weight drives the risk score. The rank drives sorting and is derived + from declaration order so the enum stays the single source of truth. + """ + + CRITICAL = ("critical", 10.0) + HIGH = ("high", 7.0) + MEDIUM = ("medium", 4.0) + LOW = ("low", 2.0) + INFO = ("info", 0.5) + + def __init__(self, label: str, weight: float) -> None: + self.label = label + self.weight = weight + + @property + def rank(self) -> int: + """Position in declaration order, 0 for the most severe.""" + return list(Severity).index(self) + + +@dataclass +class Finding: + """ + One issue discovered while auditing a token. + + check_id is a short stable slug (for example "alg-none") so JSON output + consumers can match findings without parsing the human title. + """ + + check_id: str + title: str + severity: Severity + detail: str + evidence: str = "" + recommendation: str = "" + + +@dataclass +class AuditReport: + """The complete outcome of auditing a single token.""" + + token: DecodedToken + findings: list[Finding] = field(default_factory = list) + + @property + def sorted_findings(self) -> list[Finding]: + """Findings ordered most severe first, stable within a severity.""" + return sorted(self.findings, key = lambda f: f.severity.rank) + + @property + def highest_severity(self) -> Severity | None: + """The worst severity present, or None when nothing was found.""" + if not self.findings: + return None + return min((f.severity for f in self.findings), key = lambda s: s.rank) + + @property + def risk_score(self) -> float: + """ + A 0 to 10 risk score derived from the findings. + + The worst finding sets the floor. Each additional finding adds a + little, because five medium issues are worse than one. The total is + capped at 10. A clean token scores 0.0. + """ + if not self.findings: + return 0.0 + base = max(f.severity.weight for f in self.findings) + extra = 0.4 * (len(self.findings) - 1) + return round(min(10.0, base + extra), 1) + + def counts_by_severity(self) -> dict[Severity, int]: + """Return how many findings fall under each severity level.""" + counts = {severity: 0 for severity in Severity} + for finding in self.findings: + counts[finding.severity] += 1 + return counts diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/output.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/output.py new file mode 100644 index 00000000..277038ef --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/output.py @@ -0,0 +1,173 @@ +""" +output.py + +Turns decoded tokens and audit reports into console tables or JSON. + +The rendering lives here so the checks stay pure data. Colors map to +severity the way you would expect: red for critical, yellow for medium, +dim for info. The JSON builders return plain dicts so the CLI can dump +them and so tests can assert on structure without scraping terminal text. + +Key exports: + decoded_to_dict - JSON friendly view of a DecodedToken + report_to_dict - JSON friendly view of an AuditReport + render_decoded - print a decoded token to a Rich console + render_report - print an audit report to a Rich console + +Connects to: + main.py - the CLI calls these to display results + models.py - reads AuditReport and Finding + decoder.py - reads DecodedToken +""" + +import json +from typing import Any + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from jwt_auditor.decoder import DecodedToken +from jwt_auditor.models import AuditReport, Finding, Severity + + +_SEVERITY_STYLE: dict[Severity, + str] = { + Severity.CRITICAL: "bold red", + Severity.HIGH: "red", + Severity.MEDIUM: "yellow", + Severity.LOW: "cyan", + Severity.INFO: "dim", + } + + +def decoded_to_dict(token: DecodedToken) -> dict[str, Any]: + """Build a JSON friendly representation of a decoded token.""" + return { + "header": token.header, + "payload": token.payload, + "signature": { + "algorithm": token.algorithm, + "present": bool(token.signature), + "length_bytes": len(token.signature), + "value_base64url": token.signature_b64, + }, + } + + +def report_to_dict(report: AuditReport) -> dict[str, Any]: + """Build a JSON friendly representation of an audit report.""" + counts = { + severity.label: count + for severity, count in report.counts_by_severity().items() + } + highest = report.highest_severity + return { + "algorithm": report.token.algorithm, + "risk_score": report.risk_score, + "highest_severity": highest.label if highest else "none", + "finding_counts": counts, + "findings": [_finding_to_dict(f) for f in report.sorted_findings], + "decoded": decoded_to_dict(report.token), + } + + +def _finding_to_dict(finding: Finding) -> dict[str, Any]: + """Serialize one finding to a plain dict.""" + return { + "id": finding.check_id, + "title": finding.title, + "severity": finding.severity.label, + "detail": finding.detail, + "evidence": finding.evidence, + "recommendation": finding.recommendation, + } + + +def _pretty_json(value: dict[str, Any]) -> str: + """Format a dict as indented JSON for display.""" + return json.dumps(value, indent = 2, sort_keys = False, default = str) + + +def render_decoded(console: Console, token: DecodedToken) -> None: + """Print the decoded header, payload, and signature summary.""" + console.print( + Panel( + _pretty_json(token.header), + title = "Header", + border_style = "cyan", + expand = False, + ) + ) + console.print( + Panel( + _pretty_json(token.payload), + title = "Payload", + border_style = "green", + expand = False, + ) + ) + sig_summary = ( + f"algorithm : {token.algorithm or '(none declared)'}\n" + f"present : {bool(token.signature)}\n" + f"bytes : {len(token.signature)}" + ) + console.print( + Panel( + sig_summary, + title = "Signature", + border_style = "magenta", + expand = False + ) + ) + + +def render_report(console: Console, report: AuditReport) -> None: + """Print the risk summary panel and the findings table.""" + console.print(_summary_panel(report)) + + if not report.findings: + console.print("[green]No issues found by the checks that ran.[/green]") + return + + table = Table(title = "Findings", show_lines = True, expand = False) + table.add_column("Severity", justify = "left", no_wrap = True) + table.add_column("Issue", justify = "left") + table.add_column("Evidence", justify = "left", overflow = "fold") + + for finding in report.sorted_findings: + style = _SEVERITY_STYLE[finding.severity] + severity_cell = Text(finding.severity.label.upper(), style = style) + issue_cell = Text(finding.title) + issue_cell.append(f"\n{finding.detail}", style = "dim") + if finding.recommendation: + issue_cell.append( + f"\nFix: {finding.recommendation}", + style = "italic" + ) + table.add_row(severity_cell, issue_cell, finding.evidence or "-") + + console.print(table) + + +def _summary_panel(report: AuditReport) -> Panel: + """Build the top panel with algorithm, risk score, and counts.""" + highest = report.highest_severity + score_style = _SEVERITY_STYLE.get(highest, "green") if highest else "green" + lines = [ + f"algorithm : {report.token.algorithm or '(none declared)'}", + f"risk score : {report.risk_score} / 10", + f"worst finding: {highest.label if highest else 'none'}", + "", + ] + counts = report.counts_by_severity() + for severity in Severity: + lines.append(f"{severity.label:>8} : {counts[severity]}") + body = Text("\n".join(lines), style = score_style) + return Panel( + body, + title = "JWT Audit Summary", + border_style = score_style, + expand = False + ) diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/signatures.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/signatures.py new file mode 100644 index 00000000..b24fa06d --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/signatures.py @@ -0,0 +1,130 @@ +""" +signatures.py + +HMAC signing, verification, secret cracking, and the RS/HS confusion test. + +Everything here is standard library. There is no PyJWT dependency on +purpose. Computing an HS256 signature is nine lines of hmac, and doing it +by hand is the point of the project. You learn far more about why the +alg confusion attack works when you can see that "verify" is just +"recompute the HMAC and compare". + +Key exports: + supported_hmac_algs - the HS algs this tool understands + hmac_sign - compute an HMAC signature for a signing input + verify_hmac - constant time check of a token against a candidate secret + crack_hmac_secret - try a wordlist against an HS signed token + key_confusion_secret - test whether a public key doubles as the HMAC secret + +Connects to: + decoder.py - operates on DecodedToken.signing_input and .signature + checks.py - the weak secret and confusion checks call in here +""" + +import hmac +from collections.abc import Iterable + +from jwt_auditor.decoder import DecodedToken + + +# alg name -> the hashlib digest name RFC 7518 pairs it with. hmac.new accepts +# the digest as a string, so there is no need to import hashlib here. +_HASH_BY_ALG: dict[str, + str] = { + "HS256": "sha256", + "HS384": "sha384", + "HS512": "sha512", + } + + +def supported_hmac_algs() -> frozenset[str]: + """Return the set of HMAC algorithms this module can compute.""" + return frozenset(_HASH_BY_ALG) + + +def hmac_sign(signing_input: bytes, secret: bytes, alg: str) -> bytes: + """ + Compute the raw HMAC signature bytes for a signing input. + + Raises KeyError style ValueError if alg is not an HS variant so callers + do not silently sign with the wrong primitive. + """ + digest_name = _HASH_BY_ALG.get(alg) + if digest_name is None: + raise ValueError(f"{alg} is not an HMAC algorithm") + return hmac.new(secret, signing_input, digest_name).digest() + + +def verify_hmac( + token: DecodedToken, + secret: bytes, + alg: str | None = None +) -> bool: + """ + Return True if secret produces the token's signature under alg. + + Uses hmac.compare_digest so a wrong guess takes the same time as a + right one up to the mismatch. Timing a naive == comparison is a real + way secrets leak, so we never do that here. alg defaults to the token's + declared algorithm, but callers can force one to model the confusion + attack where an attacker rewrites the header to HS256. + """ + chosen = alg or token.algorithm + if chosen not in _HASH_BY_ALG: + return False + if not token.signature: + return False + expected = hmac_sign(token.signing_input, secret, chosen) + return hmac.compare_digest(expected, token.signature) + + +def crack_hmac_secret( + token: DecodedToken, + candidates: Iterable[str], +) -> str | None: + """ + Return the first candidate secret that verifies the token, or None. + + Only meaningful for HS signed tokens. For anything else there is no + shared secret to guess, so we return None immediately rather than + burning through the wordlist. + """ + if token.algorithm not in _HASH_BY_ALG: + return None + for candidate in candidates: + if verify_hmac(token, candidate.encode("utf-8")): + return candidate + return None + + +def key_confusion_secret( + token: DecodedToken, + public_key_pem: bytes, +) -> str | None: + """ + Test whether the token verifies with a public key used as an HMAC secret. + + This is the RS256 to HS256 confusion attack. A server that accepts the + algorithm from the token header will, for an HS256 token, verify with + HMAC using whatever it thinks the key is. If that key is the RSA public + key (which is not secret), an attacker can forge tokens. + + We try the PEM as given and with trailing whitespace variants, because + servers differ on whether the stored key has a trailing newline and a + one byte difference changes the whole HMAC. + + Returns a short label describing which form matched, or None. + """ + variants: dict[str, + bytes] = { + "public key PEM as stored": public_key_pem, + "public key PEM without trailing newline": + public_key_pem.rstrip(b"\n"), + "public key PEM with trailing newline": + public_key_pem.rstrip(b"\n") + b"\n", + } + for alg in _HASH_BY_ALG: + for label, material in variants.items(): + if verify_hmac(token, material, alg = alg): + return f"{label} (verified as {alg})" + return None diff --git a/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/wordlist.py b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/wordlist.py new file mode 100644 index 00000000..800c05e2 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/src/jwt_auditor/wordlist.py @@ -0,0 +1,88 @@ +""" +wordlist.py + +The built in list of weak HMAC secrets and the sensitive claim patterns. + +The secrets here are the ones that actually show up. "your-256-bit-secret" +is the placeholder from the jwt.io debugger that people ship to production. +"secret", "changeme", and single dictionary words are what you get when a +developer picks a key by hand instead of generating one. This list is small +on purpose. It exists so the audit command finds the obvious cases with no +setup. Point at a real wordlist with --wordlist when you want depth. + +Key exports: + COMMON_SECRETS - built in weak HMAC secrets to try + SENSITIVE_CLAIM_KEYS - claim names that should never hold real values + load_wordlist - read newline separated secrets from a file + +Connects to: + checks.py - the weak secret and sensitive data checks read these + main.py - the crack command falls back to COMMON_SECRETS +""" + +from pathlib import Path + + +COMMON_SECRETS: tuple[str, + ...] = ( + "secret", + "password", + "changeme", + "admin", + "test", + "jwt", + "key", + "private", + "secretkey", + "supersecret", + "your-256-bit-secret", + "your-384-bit-secret", + "your-512-bit-secret", + "s3cr3t", + "123456", + "12345678", + "qwerty", + "letmein", + "default", + "token", + "hmac", + "signature", + "root", + "0000", + ) + + +# Claim names that suggest sensitive data is being carried in the payload. +# A JWT payload is only base64url encoded, so anyone holding the token can +# read these. Matched case insensitively against claim keys. +SENSITIVE_CLAIM_KEYS: tuple[str, + ...] = ( + "password", + "passwd", + "pwd", + "secret", + "api_key", + "apikey", + "access_key", + "private_key", + "ssn", + "social_security", + "credit_card", + "card_number", + "cvv", + "pin", + "bank_account", + "session_secret", + ) + + +def load_wordlist(path: Path) -> list[str]: + """ + Read a wordlist file into a list of candidate secrets. + + Blank lines are skipped. Everything else is kept verbatim, including + leading or trailing spaces stripped only at the line ends, because a + secret can legitimately contain internal spaces. + """ + lines = path.read_text(encoding = "utf-8", errors = "replace").splitlines() + return [line for line in (raw.strip() for raw in lines) if line] diff --git a/PROJECTS/beginner/jwt-auditor/tests/__init__.py b/PROJECTS/beginner/jwt-auditor/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/beginner/jwt-auditor/tests/conftest.py b/PROJECTS/beginner/jwt-auditor/tests/conftest.py new file mode 100644 index 00000000..fa64d224 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/tests/conftest.py @@ -0,0 +1,69 @@ +""" +Shared test helpers for building JWTs. + +Tokens are built with the project's own hmac_sign so the tests exercise the +real signing path and stay dependency free. A fixed clock is exposed so the +time based checks are deterministic. +""" + +import base64 +import json +from typing import Any + +import pytest + +from jwt_auditor.signatures import hmac_sign + +# A fixed "now" used across time based tests: 2026-01-01 00:00:00 UTC. +FIXED_NOW = 1767225600.0 +ONE_HOUR = 3600.0 +ONE_DAY = 86400.0 + + +def b64url(data: bytes) -> str: + """Encode bytes as base64url without padding, the way JWT does.""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _segment(obj: dict[str, Any]) -> str: + """Encode a JSON object as a base64url JWT segment.""" + return b64url(json.dumps(obj, separators = (",", ":")).encode("utf-8")) + + +def build_hs_token( + payload: dict[str, + Any], + secret: str = "secret", + alg: str = "HS256", + header: dict[str, + Any] | None = None, +) -> str: + """Build an HMAC signed token using the given secret.""" + head = header or {"alg": alg, "typ": "JWT"} + signing_input = f"{_segment(head)}.{_segment(payload)}".encode("ascii") + signature = hmac_sign(signing_input, secret.encode("utf-8"), alg) + return f"{_segment(head)}.{_segment(payload)}.{b64url(signature)}" + + +def build_none_token(payload: dict[str, Any]) -> str: + """Build an unsigned alg none token ending in a trailing dot.""" + head = {"alg": "none", "typ": "JWT"} + return f"{_segment(head)}.{_segment(payload)}." + + +def build_unsigned_token(payload: dict[str, Any], alg: str = "HS256") -> str: + """Build a token that declares a real alg but carries no signature.""" + head = {"alg": alg, "typ": "JWT"} + return f"{_segment(head)}.{_segment(payload)}." + + +def build_bare_alg_token(payload: dict[str, Any], alg: str) -> str: + """Build a token with an arbitrary alg and a dummy signature segment.""" + head = {"alg": alg, "typ": "JWT"} + return f"{_segment(head)}.{_segment(payload)}.{b64url(b'dummy-signature')}" + + +@pytest.fixture +def now() -> float: + """The fixed clock value for deterministic time checks.""" + return FIXED_NOW diff --git a/PROJECTS/beginner/jwt-auditor/tests/test_checks.py b/PROJECTS/beginner/jwt-auditor/tests/test_checks.py new file mode 100644 index 00000000..70607e7c --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/tests/test_checks.py @@ -0,0 +1,163 @@ +"""Tests for the individual checks and the audit orchestrator.""" + +from jwt_auditor import checks +from jwt_auditor.decoder import decode +from jwt_auditor.models import Severity +from tests.conftest import ( + FIXED_NOW, + ONE_DAY, + ONE_HOUR, + build_bare_alg_token, + build_hs_token, + build_none_token, + build_unsigned_token, +) + + +def _ids(findings: list) -> set[str]: + return {f.check_id for f in findings} + + +def test_alg_none_is_critical() -> None: + token = decode(build_none_token({"sub": "x"})) + findings = checks.check_alg_none(token) + assert len(findings) == 1 + assert findings[0].severity is Severity.CRITICAL + assert findings[0].check_id == "alg-none" + + +def test_alg_none_ignores_signed_token() -> None: + token = decode(build_hs_token({"sub": "x"})) + assert checks.check_alg_none(token) == [] + + +def test_unknown_algorithm_flagged() -> None: + token = decode(build_bare_alg_token({"sub": "x"}, alg = "HS999")) + findings = checks.check_unknown_algorithm(token) + assert findings[0].check_id == "alg-unknown" + + +def test_known_algorithm_not_flagged() -> None: + token = decode(build_hs_token({"sub": "x"})) + assert checks.check_unknown_algorithm(token) == [] + + +def test_unsigned_real_alg_is_high() -> None: + token = decode(build_unsigned_token({"sub": "x"}, alg = "HS256")) + findings = checks.check_unsigned(token) + assert findings[0].severity is Severity.HIGH + assert findings[0].check_id == "empty-signature" + + +def test_unsigned_ignores_none_alg() -> None: + token = decode(build_none_token({"sub": "x"})) + assert checks.check_unsigned(token) == [] + + +def test_weak_secret_recovered() -> None: + token = decode(build_hs_token({"sub": "x"}, secret = "secret")) + findings = checks.check_weak_hmac_secret(token, ["secret", "other"]) + assert findings[0].severity is Severity.CRITICAL + assert "secret" in findings[0].evidence + + +def test_strong_secret_not_recovered() -> None: + token = decode(build_hs_token({"sub": "x"}, secret = "k4Jd9-random-XYZ")) + assert checks.check_weak_hmac_secret(token, ["secret", "admin"]) == [] + + +def test_missing_exp_is_medium() -> None: + token = decode(build_hs_token({"sub": "x"})) + findings = checks.check_expiration(token, FIXED_NOW, 24.0) + assert findings[0].check_id == "missing-exp" + assert findings[0].severity is Severity.MEDIUM + + +def test_expired_token_is_info() -> None: + token = decode(build_hs_token({"sub": "x", "exp": FIXED_NOW - ONE_HOUR})) + findings = checks.check_expiration(token, FIXED_NOW, 24.0) + assert findings[0].check_id == "expired" + + +def test_long_lifetime_is_low() -> None: + payload = {"sub": "x", "iat": FIXED_NOW, "exp": FIXED_NOW + 5 * ONE_DAY} + token = decode(build_hs_token(payload)) + findings = checks.check_expiration(token, FIXED_NOW, 24.0) + assert findings[0].check_id == "long-lifetime" + + +def test_normal_lifetime_has_no_finding() -> None: + payload = {"sub": "x", "iat": FIXED_NOW, "exp": FIXED_NOW + ONE_HOUR} + token = decode(build_hs_token(payload)) + assert checks.check_expiration(token, FIXED_NOW, 24.0) == [] + + +def test_future_iat_flagged() -> None: + token = decode(build_hs_token({"sub": "x", "iat": FIXED_NOW + ONE_DAY})) + findings = checks.check_time_sanity(token, FIXED_NOW) + assert "future-iat" in _ids(findings) + + +def test_bool_claim_is_not_treated_as_timestamp() -> None: + # exp = True must not be read as the integer 1 + token = decode(build_hs_token({"sub": "x", "exp": True})) + findings = checks.check_expiration(token, FIXED_NOW, 24.0) + assert findings[0].check_id == "missing-exp" + + +def test_missing_claims_reported() -> None: + token = decode(build_hs_token({"foo": "bar"})) + findings = checks.check_missing_claims(token) + assert findings[0].check_id == "missing-claims" + + +def test_all_claims_present_not_reported() -> None: + token = decode(build_hs_token({"iss": "a", "aud": "b", "sub": "c"})) + assert checks.check_missing_claims(token) == [] + + +def test_sensitive_claim_flagged() -> None: + token = decode(build_hs_token({"sub": "x", "user_password": "hunter2"})) + findings = checks.check_sensitive_data(token) + assert findings[0].severity is Severity.HIGH + + +def test_audit_terrible_token_scores_high() -> None: + token = decode(build_none_token({"password": "p"})) + report = checks.audit(token, now = FIXED_NOW) + ids = _ids(report.findings) + assert "alg-none" in ids + assert "sensitive-claim" in ids + assert report.risk_score >= 9.0 + assert report.highest_severity is Severity.CRITICAL + + +def test_audit_clean_token_scores_low() -> None: + payload = { + "iss": "auth.example.com", + "aud": "api.example.com", + "sub": "user-123", + "iat": FIXED_NOW, + "exp": FIXED_NOW + ONE_HOUR, + } + token = decode( + build_hs_token(payload, + secret = "k4Jd9-random-XYZ-not-in-list") + ) + report = checks.audit(token, now = FIXED_NOW) + assert report.findings == [] + assert report.risk_score == 0.0 + assert report.highest_severity is None + + +def test_audit_uses_builtin_wordlist_by_default() -> None: + token = decode( + build_hs_token({ + "iss": "a", + "aud": "b", + "sub": "c" + }, + secret = "admin") + ) + report = checks.audit(token, now = FIXED_NOW) + assert "weak-hmac-secret" in _ids(report.findings) diff --git a/PROJECTS/beginner/jwt-auditor/tests/test_cli.py b/PROJECTS/beginner/jwt-auditor/tests/test_cli.py new file mode 100644 index 00000000..e46abd32 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/tests/test_cli.py @@ -0,0 +1,100 @@ +"""End to end tests for the CLI commands via Typer's CliRunner.""" + +import json + +from typer.testing import CliRunner + +from jwt_auditor.main import app +from tests.conftest import build_hs_token, build_none_token + + +runner = CliRunner() + + +def test_decode_prints_payload() -> None: + token = build_hs_token({"sub": "alice"}) + result = runner.invoke(app, ["decode", token]) + assert result.exit_code == 0 + assert "alice" in result.stdout + + +def test_decode_json_is_parseable() -> None: + token = build_hs_token({"sub": "alice", "role": "admin"}) + result = runner.invoke(app, ["decode", token, "--json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["payload"]["role"] == "admin" + assert data["signature"]["algorithm"] == "HS256" + + +def test_invalid_token_exits_two() -> None: + result = runner.invoke(app, ["decode", "not-a-jwt"]) + assert result.exit_code == 2 + + +def test_audit_none_token_fails_and_reports_critical() -> None: + token = build_none_token({"sub": "x"}) + result = runner.invoke(app, ["audit", token]) + assert result.exit_code == 1 + assert "CRITICAL" in result.stdout + + +def test_audit_json_contains_findings() -> None: + token = build_none_token({"user_password": "p"}) + result = runner.invoke(app, ["audit", token, "--json"]) + data = json.loads(result.stdout) + ids = {f["id"] for f in data["findings"]} + assert "alg-none" in ids + assert data["risk_score"] > 0 + + +def test_audit_clean_token_passes() -> None: + payload = {"iss": "a", "aud": "b", "sub": "c"} + token = build_hs_token(payload, secret = "k4Jd9-random-XYZ-not-in-list") + # No exp means one medium finding, below the default high fail level. + result = runner.invoke(app, ["audit", token]) + assert result.exit_code == 0 + + +def test_audit_fail_level_medium_trips_on_missing_exp() -> None: + payload = {"iss": "a", "aud": "b", "sub": "c"} + token = build_hs_token(payload, secret = "k4Jd9-random-XYZ-not-in-list") + result = runner.invoke(app, ["audit", token, "--fail-level", "medium"]) + assert result.exit_code == 1 + + +def test_crack_finds_weak_secret() -> None: + token = build_hs_token({"sub": "x"}, secret = "changeme") + result = runner.invoke(app, ["crack", token]) + assert result.exit_code == 0 + assert "changeme" in result.stdout + + +def test_crack_reports_no_match_for_strong_secret() -> None: + token = build_hs_token({"sub": "x"}, secret = "k4Jd9-random-XYZ-not-in-list") + result = runner.invoke(app, ["crack", token]) + assert result.exit_code == 1 + + +def test_crack_rejects_non_hmac_token() -> None: + # An RS256 token has no shared secret, so crack should refuse it. + from tests.conftest import build_bare_alg_token + + rs_token = build_bare_alg_token({"sub": "x"}, alg = "RS256") + result = runner.invoke(app, ["crack", rs_token]) + assert result.exit_code == 1 + + +def test_audit_reads_token_from_stdin() -> None: + token = build_none_token({"sub": "x"}) + result = runner.invoke(app, ["audit"], input = token) + assert result.exit_code == 1 + assert "CRITICAL" in result.stdout + + +def test_audit_reads_token_from_stdin_with_dash() -> None: + # A literal "-" argument means read from stdin, the usual idiom. + token = build_none_token({"sub": "x"}) + result = runner.invoke(app, ["audit", "-"], input = token) + assert result.exit_code == 1 + assert "CRITICAL" in result.stdout diff --git a/PROJECTS/beginner/jwt-auditor/tests/test_decoder.py b/PROJECTS/beginner/jwt-auditor/tests/test_decoder.py new file mode 100644 index 00000000..80415de0 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/tests/test_decoder.py @@ -0,0 +1,64 @@ +"""Tests for the raw JWT decoding layer.""" + +import pytest + +from jwt_auditor.decoder import InvalidTokenError, decode +from tests.conftest import build_hs_token, build_none_token + + +def test_decode_returns_header_and_payload() -> None: + token = build_hs_token({"sub": "alice", "role": "user"}) + parsed = decode(token) + assert parsed.header["alg"] == "HS256" + assert parsed.header["typ"] == "JWT" + assert parsed.payload["sub"] == "alice" + assert parsed.payload["role"] == "user" + + +def test_algorithm_property_reads_header() -> None: + parsed = decode(build_hs_token({"sub": "x"}, alg = "HS512")) + assert parsed.algorithm == "HS512" + + +def test_none_token_has_empty_signature() -> None: + parsed = decode(build_none_token({"sub": "x"})) + assert parsed.signature == b"" + assert parsed.signature_b64 == "" + assert parsed.algorithm == "none" + + +def test_signing_input_is_header_dot_payload() -> None: + token = build_hs_token({"sub": "x"}) + parsed = decode(token) + expected = ".".join(token.split(".")[: 2]).encode("ascii") + assert parsed.signing_input == expected + + +def test_whitespace_is_stripped() -> None: + token = build_hs_token({"sub": "x"}) + assert decode(f" {token}\n").payload["sub"] == "x" + + +def test_empty_string_is_rejected() -> None: + with pytest.raises(InvalidTokenError): + decode(" ") + + +@pytest.mark.parametrize("bad", ["only-one-part", "two.parts", "a.b.c.d"]) +def test_wrong_segment_count_is_rejected(bad: str) -> None: + with pytest.raises(InvalidTokenError): + decode(bad) + + +def test_non_base64_header_is_rejected() -> None: + with pytest.raises(InvalidTokenError): + decode("!!!.@@@.###") + + +def test_header_that_is_not_json_object_is_rejected() -> None: + # base64url of the JSON string "hello" (a string, not an object) + import base64 + + seg = base64.urlsafe_b64encode(b'"hello"').rstrip(b"=").decode() + with pytest.raises(InvalidTokenError): + decode(f"{seg}.{seg}.{seg}") diff --git a/PROJECTS/beginner/jwt-auditor/tests/test_models.py b/PROJECTS/beginner/jwt-auditor/tests/test_models.py new file mode 100644 index 00000000..cf5776a2 --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/tests/test_models.py @@ -0,0 +1,75 @@ +"""Tests for the shared data models and risk scoring.""" + +from jwt_auditor.decoder import decode +from jwt_auditor.models import AuditReport, Finding, Severity +from tests.conftest import build_hs_token + + +def _finding(sev: Severity, check_id: str = "x") -> Finding: + return Finding(check_id = check_id, title = "t", severity = sev, detail = "d") + + +def test_severity_weight_and_rank_order() -> None: + assert Severity.CRITICAL.weight > Severity.INFO.weight + assert Severity.CRITICAL.rank < Severity.HIGH.rank < Severity.INFO.rank + + +def test_empty_report_scores_zero() -> None: + token = decode(build_hs_token({"sub": "x"})) + report = AuditReport(token = token, findings = []) + assert report.risk_score == 0.0 + assert report.highest_severity is None + + +def test_single_critical_scores_ten() -> None: + token = decode(build_hs_token({"sub": "x"})) + report = AuditReport(token = token, findings = [_finding(Severity.CRITICAL)]) + assert report.risk_score == 10.0 + + +def test_score_is_capped_at_ten() -> None: + token = decode(build_hs_token({"sub": "x"})) + findings = [_finding(Severity.CRITICAL, f"c{i}") for i in range(5)] + report = AuditReport(token = token, findings = findings) + assert report.risk_score == 10.0 + + +def test_extra_findings_raise_score() -> None: + token = decode(build_hs_token({"sub": "x"})) + one = AuditReport(token = token, findings = [_finding(Severity.LOW, "a")]) + two = AuditReport( + token = token, + findings = [_finding(Severity.LOW, + "a"), + _finding(Severity.LOW, + "b")], + ) + assert two.risk_score > one.risk_score + + +def test_sorted_findings_are_worst_first() -> None: + token = decode(build_hs_token({"sub": "x"})) + report = AuditReport( + token = token, + findings = [ + _finding(Severity.LOW, + "a"), + _finding(Severity.CRITICAL, + "b") + ], + ) + assert next(f.severity for f in report.sorted_findings) is Severity.CRITICAL + + +def test_counts_by_severity() -> None: + token = decode(build_hs_token({"sub": "x"})) + report = AuditReport( + token = token, + findings = [_finding(Severity.HIGH, + "a"), + _finding(Severity.HIGH, + "b")], + ) + counts = report.counts_by_severity() + assert counts[Severity.HIGH] == 2 + assert counts[Severity.LOW] == 0 diff --git a/PROJECTS/beginner/jwt-auditor/tests/test_signatures.py b/PROJECTS/beginner/jwt-auditor/tests/test_signatures.py new file mode 100644 index 00000000..fdc4efcb --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/tests/test_signatures.py @@ -0,0 +1,78 @@ +"""Tests for HMAC signing, verification, cracking, and key confusion.""" + +import hashlib +import hmac + +from jwt_auditor.decoder import decode +from jwt_auditor.signatures import ( + crack_hmac_secret, + hmac_sign, + key_confusion_secret, + supported_hmac_algs, + verify_hmac, +) +from tests.conftest import build_bare_alg_token, build_hs_token + +# A stand in "public key" for the confusion test. Any non secret bytes work. +FAKE_PUBLIC_KEY = b"-----BEGIN PUBLIC KEY-----\nMFkwEwYHfake\n-----END PUBLIC KEY-----\n" + + +def test_supported_algs() -> None: + assert supported_hmac_algs() == {"HS256", "HS384", "HS512"} + + +def test_hmac_sign_matches_stdlib() -> None: + signing_input = b"header.payload" + expected = hmac.new(b"secret", signing_input, hashlib.sha256).digest() + assert hmac_sign(signing_input, b"secret", "HS256") == expected + + +def test_verify_hmac_accepts_correct_secret() -> None: + token = decode(build_hs_token({"sub": "x"}, secret = "hunter2")) + assert verify_hmac(token, b"hunter2") is True + + +def test_verify_hmac_rejects_wrong_secret() -> None: + token = decode(build_hs_token({"sub": "x"}, secret = "hunter2")) + assert verify_hmac(token, b"wrong") is False + + +def test_verify_hmac_rejects_non_hmac_alg() -> None: + token = decode(build_bare_alg_token({"sub": "x"}, alg = "RS256")) + assert verify_hmac(token, b"anything") is False + + +def test_crack_finds_secret_in_list() -> None: + token = decode(build_hs_token({"sub": "x"}, secret = "changeme")) + assert crack_hmac_secret(token, ["nope", "changeme", "other"]) == "changeme" + + +def test_crack_returns_none_when_absent() -> None: + token = decode( + build_hs_token({"sub": "x"}, + secret = "a-very-strong-random-key") + ) + assert crack_hmac_secret(token, ["nope", "other"]) is None + + +def test_crack_returns_none_for_non_hmac() -> None: + token = decode(build_bare_alg_token({"sub": "x"}, alg = "RS256")) + assert crack_hmac_secret(token, ["secret", "changeme"]) is None + + +def test_key_confusion_detects_public_key_as_secret() -> None: + # Attacker forges an HS256 token signed with the public key bytes. + forged = build_hs_token( + {"sub": "admin"}, + secret = FAKE_PUBLIC_KEY.decode("latin-1"), + alg = "HS256", + ) + token = decode(forged) + result = key_confusion_secret(token, FAKE_PUBLIC_KEY) + assert result is not None + assert "verified as HS256" in result + + +def test_key_confusion_returns_none_for_unrelated_key() -> None: + token = decode(build_hs_token({"sub": "x"}, secret = "unrelated-secret")) + assert key_confusion_secret(token, FAKE_PUBLIC_KEY) is None diff --git a/PROJECTS/beginner/jwt-auditor/uv.lock b/PROJECTS/beginner/jwt-auditor/uv.lock new file mode 100644 index 00000000..e3f65c0d --- /dev/null +++ b/PROJECTS/beginner/jwt-auditor/uv.lock @@ -0,0 +1,480 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "astroid" +version = "3.3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, +] + +[[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 = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, +] + +[[package]] +name = "jwt-auditor" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0,<2.0.0" }, + { name = "pylint", marker = "extra == 'dev'", specifier = ">=3.3.0,<4.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0,<7.0.0" }, + { name = "rich", specifier = ">=13.9.0,<15.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0,<0.9.0" }, + { name = "typer", specifier = ">=0.15.0,<0.20.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "librt" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, + { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, + { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, + { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, + { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, + { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, + { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, + { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, + { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, +] + +[[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/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { 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.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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 = "3.3.9" +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/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +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/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, +] + +[[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.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/00/089db7890ea3be5709e3ece6e46408d6f1e876026ec3fd081ee585fef209/ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5", size = 3473116, upload-time = "2025-01-04T12:23:00.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/28/aa07903694637c2fa394a9f4fe93cf861ad8b09f1282fa650ef07ff9fe97/ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3", size = 10628735, upload-time = "2025-01-04T12:21:53.632Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/827bb1448f1fcb0fb42e9c6edf8fb067ca8244923bf0ddf12b7bf949065c/ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1", size = 10386758, upload-time = "2025-01-04T12:22:00.349Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/fc852a81c3cd315b14676db3b8327d2bb2d7508649ad60bfdb966d60738d/ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807", size = 10007808, upload-time = "2025-01-04T12:22:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/94/e9/e0ed4af1794335fb280c4fac180f2bf40f6a3b859cae93a5a3ada27325ae/ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25", size = 10861031, upload-time = "2025-01-04T12:22:09.252Z" }, + { url = "https://files.pythonhosted.org/packages/82/68/da0db02f5ecb2ce912c2bef2aa9fcb8915c31e9bc363969cfaaddbc4c1c2/ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d", size = 10388246, upload-time = "2025-01-04T12:22:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1d/b85383db181639019b50eb277c2ee48f9f5168f4f7c287376f2b6e2a6dc2/ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75", size = 11424693, upload-time = "2025-01-04T12:22:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b7/30bc78a37648d31bfc7ba7105b108cb9091cd925f249aa533038ebc5a96f/ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315", size = 12141921, upload-time = "2025-01-04T12:22:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/60/b3/ee0a14cf6a1fbd6965b601c88d5625d250b97caf0534181e151504498f86/ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188", size = 11692419, upload-time = "2025-01-04T12:22:23.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/c597062b2931ba3e3861e80bd2b147ca12b3370afc3889af46f29209037f/ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf", size = 12981648, upload-time = "2025-01-04T12:22:26.663Z" }, + { url = "https://files.pythonhosted.org/packages/68/84/21f578c2a4144917985f1f4011171aeff94ab18dfa5303ac632da2f9af36/ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117", size = 11251801, upload-time = "2025-01-04T12:22:29.59Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/1ac02537c8edeb13e0955b5db86b5c050a1dcba54f6d49ab567decaa59c1/ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe", size = 10849857, upload-time = "2025-01-04T12:22:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/eb/00/020cb222252d833956cb3b07e0e40c9d4b984fbb2dc3923075c8f944497d/ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d", size = 10470852, upload-time = "2025-01-04T12:22:36.374Z" }, + { url = "https://files.pythonhosted.org/packages/00/56/e6d6578202a0141cd52299fe5acb38b2d873565f4670c7a5373b637cf58d/ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a", size = 10972997, upload-time = "2025-01-04T12:22:41.424Z" }, + { url = "https://files.pythonhosted.org/packages/be/31/dd0db1f4796bda30dea7592f106f3a67a8f00bcd3a50df889fbac58e2786/ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76", size = 11317760, upload-time = "2025-01-04T12:22:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/70/cfcb693dc294e034c6fed837fa2ec98b27cc97a26db5d049345364f504bf/ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764", size = 8799729, upload-time = "2025-01-04T12:22:49.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/ae6bcaa0edc83af42751bd193138bfb7598b2990939d3e40494d6c00698c/ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905", size = 9673857, upload-time = "2025-01-04T12:22:53.052Z" }, + { url = "https://files.pythonhosted.org/packages/91/f8/3765e053acd07baa055c96b2065c7fab91f911b3c076dfea71006666f5b0/ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162", size = 9149556, upload-time = "2025-01-04T12:22:57.173Z" }, +] + +[[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.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]