diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f0ec85af..7d7ab794 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -406,9 +406,9 @@ repos: - id: check-symlinks exclude: (\.pnpm-store|node_modules)/ - id: end-of-file-fixer - exclude: (\.pnpm-store|node_modules)/|^PROJECTS/advanced/hsm-emulator/vendor/ + exclude: (\.pnpm-store|node_modules)/|^PROJECTS/advanced/hsm-emulator/vendor/|^PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/ - id: trailing-whitespace - exclude: (\.pnpm-store|node_modules)/|^PROJECTS/advanced/hsm-emulator/vendor/ + exclude: (\.pnpm-store|node_modules)/|^PROJECTS/advanced/hsm-emulator/vendor/|^PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/ - id: check-illegal-windows-names exclude: (\.pnpm-store|node_modules)/ - id: check-executables-have-shebangs diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.dockerignore b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.dockerignore new file mode 100644 index 00000000..f593405c --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.dockerignore @@ -0,0 +1,12 @@ +# ©AngelaMos | 2026 +# .dockerignore + +target/ +**/node_modules/ +**/dist/ +.git/ +docs/ +*.db +*.sqlite +.env +.env.development diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.env.example b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.env.example new file mode 100644 index 00000000..42ebc963 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.env.example @@ -0,0 +1,37 @@ +# ©AngelaMos | 2026 +# .env.example +# Copy to .env (production) and .env.development (development), then fill in. +# Both .env and .env.development are gitignored. Run `just init` to set the +# project name and randomize ports, or `just ports` to re-randomize ports. + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- +APP_NAME=tlsfp + +# Frontend build-time vars (baked into the bundle by Vite) +VITE_APP_TITLE=JA3/JA4 TLS Fingerprinting +VITE_API_URL=/api + +# Vite standalone dev proxy target (only used when running pnpm dev outside +# the compose network; inside compose, nginx proxies /api to the backend) +VITE_API_TARGET=http://tlsfp:8080 + +# --------------------------------------------------------------------------- +# Host ports (randomized per-machine to avoid collisions) +# --------------------------------------------------------------------------- +NGINX_HOST_PORT=11790 +FRONTEND_HOST_PORT=39755 + +# --------------------------------------------------------------------------- +# Backend (Rust) +# --------------------------------------------------------------------------- +# Read by the tlsfp binary via EnvFilter. The dashboard backend service is +# defined under the `backend` compose profile and is not started by default +# until the serve command is implemented. +RUST_LOG=tlsfp=info + +# --------------------------------------------------------------------------- +# Cloudflare Tunnel (overlay: cloudflared.compose.yml) +# --------------------------------------------------------------------------- +CLOUDFLARE_TUNNEL_TOKEN= diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.gitignore b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.gitignore index 50b47180..ea0f5073 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.gitignore +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/.gitignore @@ -7,3 +7,11 @@ target/ *.sqlite fuzz/corpus/ fuzz/artifacts/ + +.env +.env.development +node_modules/ +**/node_modules/ +dist/ +**/dist/ +.biome_cache/ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.lock b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.lock index 4befdd6c..d9779cd7 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.lock +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.lock @@ -330,6 +330,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1421,13 +1442,13 @@ dependencies = [ "flume", "pcap", "pcap-parser", - "rusqlite", "rustix", "serde", "serde_json", "smallvec", "thiserror", "tlsfp-core", + "tlsfp-intel", "tokio", "tower", "tower-http", @@ -1454,6 +1475,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "tlsfp-intel" +version = "0.1.0" +dependencies = [ + "anyhow", + "csv", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "tlsfp-core", +] + [[package]] name = "tokio" version = "1.52.3" diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.toml b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.toml index 887f6f47..a39c2fda 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.toml +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/Cargo.toml @@ -3,7 +3,7 @@ [workspace] resolver = "3" -members = ["crates/tlsfp-core", "crates/tlsfp"] +members = ["crates/tlsfp-core", "crates/tlsfp-intel", "crates/tlsfp"] [workspace.package] version = "0.1.0" @@ -15,6 +15,7 @@ repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects" [workspace.dependencies] tlsfp-core = { path = "crates/tlsfp-core" } +tlsfp-intel = { path = "crates/tlsfp-intel" } winnow = "0.7" md-5 = "0.10" @@ -35,6 +36,7 @@ flume = "0.11" rustix = { version = "1", features = ["std", "event"] } rusqlite = { version = "0.32", features = ["bundled"] } +csv = "1" axum = "0.8" tower-http = { version = "0.6", features = ["fs", "trace", "cors"] } diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/LICENSE b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/LICENSE index a615e738..0ad25db4 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/LICENSE +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/LICENSE @@ -1,21 +1,661 @@ -MIT License + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Copyright (c) 2026 Carter Perez + 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. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Preamble -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -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/intermediate/ja3-ja4-tls-fingerprinting/NOTICE.md b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/NOTICE.md index 8947fe21..e9d44e22 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/NOTICE.md +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/NOTICE.md @@ -6,7 +6,7 @@ applies to what. ## Our own code -Everything under `crates/` is original work licensed under the MIT License (see +Everything under `crates/` is original work licensed under the AGPL 3.0 License (see `LICENSE`). No source code from any reference implementation was copied. The algorithms were implemented from their published specifications. diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/cloudflared.compose.yml b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/cloudflared.compose.yml new file mode 100644 index 00000000..076b4642 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/cloudflared.compose.yml @@ -0,0 +1,27 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# cloudflared.compose.yml +# ============================================================================= +# Cloudflare Tunnel for production remote access +# Usage: docker compose -f compose.yml -f cloudflared.compose.yml up -d +# ============================================================================= + +services: + cloudflared: + image: cloudflare/cloudflared:latest + container_name: ${APP_NAME:-tlsfp}-tunnel + command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - app + depends_on: + nginx: + condition: service_started + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + restart: unless-stopped diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/compose.yml b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/compose.yml new file mode 100644 index 00000000..819421f1 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/compose.yml @@ -0,0 +1,67 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# compose.yml +# ============================================================================= +# Production compose: nginx serves the built React app and proxies /api to the +# tlsfp backend. The backend lives under the `backend` profile and is not +# started by default until the serve command is implemented. +# docker compose --env-file .env up +# docker compose --env-file .env --profile backend up +# docker compose -f compose.yml -f cloudflared.compose.yml up +# ============================================================================= + +name: ${APP_NAME:-tlsfp} + +services: + nginx: + build: + context: . + dockerfile: infra/docker/vite.prod + args: + - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_APP_TITLE=${VITE_APP_TITLE:-JA3/JA4 TLS Fingerprinting} + container_name: ${APP_NAME:-tlsfp}-nginx + ports: + - "${NGINX_HOST_PORT:-11790}:80" + networks: + - app + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + reservations: + cpus: '0.25' + memory: 64M + restart: unless-stopped + + tlsfp: + build: + context: . + dockerfile: infra/docker/tlsfp.prod + container_name: ${APP_NAME:-tlsfp}-backend + command: ["serve", "0.0.0.0:8080"] + profiles: + - backend + environment: + - RUST_LOG=${RUST_LOG:-tlsfp=info} + volumes: + - tlsfp_data:/data + networks: + - app + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + reservations: + cpus: '0.25' + memory: 64M + restart: unless-stopped + +networks: + app: + driver: bridge + +volumes: + tlsfp_data: diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/event.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/event.rs index 70d2c9fe..88d8463f 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/event.rs +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/event.rs @@ -36,6 +36,8 @@ pub enum StreamEvent { ja4h: Ja4Family, method: String, host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_agent: Option, }, TcpSyn { ja4t: String, @@ -84,11 +86,19 @@ impl fmt::Display for FingerprintEvent { write!(f, "server_hello ja4s={} ja3s={ja3s}", ja4s.hash) } StreamEvent::Certificate { ja4x } => write!(f, "certificate ja4x={ja4x}"), - StreamEvent::HttpRequest { ja4h, method, host } => { + StreamEvent::HttpRequest { + ja4h, + method, + host, + user_agent, + } => { write!(f, "http_request ja4h={} method={method}", ja4h.hash)?; if let Some(host) = host { write!(f, " host={host}")?; } + if let Some(user_agent) = user_agent { + write!(f, " ua={user_agent}")?; + } Ok(()) } StreamEvent::TcpSyn { ja4t } => write!(f, "tcp_syn ja4t={ja4t}"), diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/tls.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/tls.rs index 9f242408..aa27d104 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/tls.rs +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-core/src/pipeline/tls.rs @@ -149,10 +149,16 @@ fn advance_http(proto: &mut StreamProtocol, stream: &[u8], sink: &mut impl FnMut .iter() .find(|(name, _)| name.eq_ignore_ascii_case("host")) .map(|(_, value)| value.clone()); + let user_agent = request + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("user-agent")) + .map(|(_, value)| value.clone()); sink(StreamEvent::HttpRequest { ja4h: ja4h(&request), method: request.method.clone(), host, + user_agent, }); *proto = StreamProtocol::Done; } else { diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/Cargo.toml b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/Cargo.toml new file mode 100644 index 00000000..d5d0a2e9 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/Cargo.toml @@ -0,0 +1,26 @@ +# ©AngelaMos | 2026 +# Cargo.toml + +[package] +name = "tlsfp-intel" +description = "Local threat intelligence store and fingerprint matching engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +tlsfp-core.workspace = true +rusqlite.workspace = true +csv.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/PROVENANCE.md b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/PROVENANCE.md new file mode 100644 index 00000000..399e6a04 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/PROVENANCE.md @@ -0,0 +1,64 @@ + + + +# Seed provenance + +These three files are the intelligence the tool ships with. They are compiled +into the binary with `include_str!`, so a freshly built `tlsfp` can seed its +database with no network access. The large external source, ja4db.com, is +fetched at install time instead of bundled, because its license is unspecified +and its records are known to contain dirty entries that the importer validates +and rejects on the way in. + +Each row is a fingerprint plus a label. None of these files is edited after +import beyond skipping comment lines and rows whose first field is not a +fingerprint, so the sha256 below is the exact bundled content. + +## sslbl-ja3.csv + +- Source: https://sslbl.abuse.ch/blacklist/ja3_fingerprints.csv +- Retrieved: 2026-06-14 +- Snapshot date in the file header: 2021-08-03 (abuse.ch froze the JA3 list) +- License: CC0 1.0 (public domain dedication), so the snapshot is committable +- Records: 97 malicious JA3 hashes with a family label and first and last seen dates +- sha256: 6974182489b6e5f5f64079454b81a7219a2d82ae950f108567fa371220eb09e9 + +The abuse.ch SSL Blacklist carries a false positive disclaimer: a JA3 is a hash +of a TLS client library, so a benign program that links the same library lands +on the same hash. That disclaimer is the reason the matching engine scores +prevalence across sources instead of treating any single feed hit as proof. + +## salesforce-osx-nix-ja3.csv + +- Source: https://raw.githubusercontent.com/salesforce/ja3/master/lists/osx-nix-ja3.csv +- Retrieved: 2026-06-14 +- License: BSD 3-Clause, recorded verbatim in the first record of the file +- Records: 157 benign JA3 hashes for common macOS and nix applications +- sha256: 2865b3f73a68e603dd3fa7fb56565dca70a269cda70f460ed5db03f5d724c4e1 + +The upstream salesforce/ja3 repository was archived on 2025-05-01. The file is +the benign half of the picture: it is what makes a collision visible. Several +hashes in this list also appear in sslbl-ja3.csv under a malware label, which +is the cleanest real demonstration that a JA3 alone cannot decide intent. The +field that names the application is quoted and can hold several comma separated +names, so the loader parses it as RFC 4180 CSV rather than splitting on commas. + +Note on licensing: the research notes that seeded this project called this list +MIT. The file header itself says BSD 3-Clause, and the file is the ground truth, +so it is attributed as BSD 3-Clause here. + +## curated-c2-intel.csv + +- Source: hand curated for this project from named primary sources +- License: same as this repository +- Records: 17 rows covering C2 frameworks, malware families, dual-use tooling, + and two benign browser baselines, each with a primary source in the reference + column +- sha256: 202c0a1edc495a5149d7f6161d21adc152b1b71efdde2f5052d4767ebb0f7b66 + +This file fills the gaps the public feeds leave: server side JA3S for Cobalt +Strike, TrickBot, and Emotet, a JA4 for RedLine Stealer, and JA4 baselines for +Chrome over TCP and QUIC that let the engine recognise a tool that copies a +browser cipher list but not its full extension order. Where a public feed labels +one of these hashes with a different family, the divergence is written into the +reference column rather than silently reconciled. diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/curated-c2-intel.csv b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/curated-c2-intel.csv new file mode 100644 index 00000000..9c2a0e34 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/curated-c2-intel.csv @@ -0,0 +1,28 @@ +# ©AngelaMos | 2026 +# curated-c2-intel.csv +# +# Hand-curated fingerprints for C2 frameworks, malware families, and benign +# baselines. Every row is traced to a named primary source in the reference +# column. Categories: c2 and malware are malicious; tool is dual-use offensive +# or privacy tooling that is noteworthy but not conclusive on its own; benign +# is a known-good baseline. Where a public feed labels the same hash with a +# different family name, that divergence is recorded in the reference rather +# than hidden, because a single JA3 can be shared by unrelated software. +fp_kind,value,label,category,reference +ja3,72a589da586844d7f0818ce684948eea,Cobalt Strike,c2,"Cobalt Strike default malleable profile over an IP; the same TLS stack is used by Emotet so this JA3 collides between the two" +ja3,a0e9f5d64349fb13191bc781f81f42e1,Cobalt Strike,c2,"Cobalt Strike default profile over a domain" +ja3,19e29534fd49dd27d09234e639c4057e,Sliver,c2,"Sliver implant; observed during Ivanti gateway exploitation in 2024 (Darktrace)" +ja3,c12f54a3f91dc7bafd92cb59fe009a35,PoshC2,c2,"PoshC2 proxy-aware command and control framework" +ja3,8916410db85077a5460817142dcbc8de,Metasploit,tool,"Metasploit Meterpreter HTTPS stager; abuse.ch SSLBL lists the identical JA3 as TrickBot, a feed label divergence" +ja3,f5e62b5a2ed9467df09fae7a8a54dda6,BazarLoader,malware,"BazarLoader and BazarBackdoor, part of the TrickBot and Conti ecosystem" +ja3,b386946a5a44d1ddcc843bc75336dfce,Dyre,malware,"Dyre and Dyreza banking trojan; the original JA3 pivot that surfaced 112 related samples; abuse.ch SSLBL lists the identical JA3 as Dridex" +ja3,e7d705a3286e19ea42f587b344ee6865,Tor,tool,"Tor client TLS; WannaCry tunnelled its command and control over Tor, but most Tor traffic is legitimate" +ja3,a85be79f7b569f1df5e6087b69deb493,Remcos,malware,"Remcos remote access trojan" +ja3,4d7a28d6f2263ed61de88ca66eb011e3,Emotet,malware,"Emotet loader; abuse.ch SSLBL lists the identical JA3 as Tofsee, a feed label divergence" +ja3,6734f37431670b3ab4292b8f60f29984,TrickBot,malware,"TrickBot banking trojan and loader" +ja3s,b742b407517bac9536a77a7b0fee28e9,Cobalt Strike,c2,"Cobalt Strike team server ServerHello" +ja3s,623de93db17d313345d7ea481e7443cf,TrickBot,c2,"TrickBot command and control server ServerHello" +ja3s,80b3a14bccc8598a1f3bbe83e71f735f,Emotet,c2,"Emotet command and control server ServerHello" +ja4,t10d070600_c50f5591e341_1a3805c3aa63,RedLine Stealer,malware,"RedLine Stealer and SnakeLogger infostealer (VirusTotal 2024)" +ja4,t13d1516h2_8daaf6152771_e5627efa2ab1,Google Chrome,benign,"Chrome over TCP; FoxIO reference fingerprint from the chrome-cloudflare capture" +ja4,q13d0310h3_55b375c5d22e_cd85d2d88918,Google Chrome,benign,"Chrome over QUIC; FoxIO reference fingerprint from the chrome-cloudflare capture" diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/salesforce-osx-nix-ja3.csv b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/salesforce-osx-nix-ja3.csv new file mode 100644 index 00000000..a5494c74 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/salesforce-osx-nix-ja3.csv @@ -0,0 +1,161 @@ +"Copyright (c) 2017 salesforce.com inc. +All rights reserved. +Licensed under the BSD 3-Clause license. +For full license text see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause", +61d50e7771aee7f2f4b89a7200b4d45e,"AcroCEF" +49a6cf42956937669a01438f26e7c609,"AIM" +561145462cfc7de1d6a97e93d3264786,"Airmail 3" +f6fd83a21f9f3c5f9ff7b5c63bbc179d,"Alation Compose" +6003b52942a2e1e1ea72d802d153ec08,"Amazon Music" +eb149984fc9c44d85ed7f12c90d818be,"Amazon Music,Dreamweaver,Spotify" +8e3f1bf87bc652a20de63bfd4952b16a,"AnypointStudio" +5507277945374659a5b4572e1b6d9b9f,"apple.geod" +f753495f2eab5155c61b760c838018f8,"apple.geod" +ba40fea2b2638908a3b3b482ac78d729,"apple.geod,parsecd,apple.photomoments" +474e73aea21d1e0910f25c3e6c178535,"apple.WebKit.Networking" +eeeb5e7485f5e10cbc39db4cfb69b264,"apple.WebKit.Networking" +d4693422c5ce1565377aca25940ad80c,"apple.WebKit.Networking,CalendarAgent,Go for Gmail" +63de2b6188d5694e79b678f585b13264,"apple.WebKit.Networking,Chatter,FieldServiceApp,socialstudio" +3e4e87dda5a3162306609b7e330441d2,"apple.WebKit.Networking,itunesstored" +7b343af1092863fdd822d6f10645abfb,"apple.WebKit.Networking,itunesstored" +a312f9162a08eeedf7feb7a13cd7e9bb,"apple.WebKit.Networking,Spotify,WhatsApp,Skype,iTunes" +c5c11e6105c56fd29cc72c3ac7a2b78b,"AT&T Connect" +fa030dbcb2e3c7141d3c2803780ee8db,"Battle.net,Dropbox" +0ef9ca1c10d3f186f5786e1ef3461a46,"bitgo,ShapeShift" +cdec81515ccc75a5aa41eb3db22226e6,"BlueJeans,CEPHtmlEngine" +83e04bc58d402f9633983cbf22724b02,"Charles,Google Play Music Desktop Player,Postman,Slack,and other desktop programs" +424008725394c634a4616b8b1f2828a5,"Charles,java,eclipse" +be9f1360cf52dc1f61ae025252f192a3,"Chromium" +def8761e4bcaaf91d99801a22ac6f6d4,"Chromium" +fc5cb0985a5f5e295163cc8ffff8a6e1,"Chromium" +e7d46c98b078477c4324031e0d3b22f5,"Cisco AnyConnect Secure Mobility Client" +ed36017db541879619c399c95e22067d,"Cisco AnyConnect Secure Mobility Client" +5ee1a653fb824db7182714897fd3b5df,"Citrix Viewer" +a9d17f74e55dd53fcf7c234f8a240919,"Covenant Eyes" +c882d9444412c00e71b643f3f54145ff,"Creative Cloud" +bc0608d33dc64506b42f7f5f87958f37,"cscan" +ccaa60f6ccc701bde536ef409be3cf63,"curl no-SNI" +fe048fe8faf797796e278f2b4f1e9c24,"curl SNI" +4fcd1770545298cc119865aeba81daba,"Deezer" +4c40bf8baa7c301c5dba8a20bc4119e2,"Dynalist,Postman,Google Chrome,Franz,GOG Galaxy" +0411bbb5ff27ad46e1874a7a8beedacb,"eclipse" +4990c9da08f44a01ecd7ddc3837caf25,"eclipse" +fa106fe5beec443af7e211ef8902e7e0,"eclipse" +d74778f454e2b047e030b291b94dd698,"eclipse,java" +187dfde7edc8ceddccd3deeccc21daeb,"eclipse,java,studio,STS" +8c5a50f1e833ed581e9cfc690814719a,"eclipse,JavaApplicationStub,idea" +1fbe5382f9d8430fe921df747c46d95f,"FieldServiceApp,socialstudio" +0a81538cf247c104edb677bdb8902ed5,"firefox" +0b6592fd91d4843c823b75e49b43838d,"firefox" +0ffee3ba8e615ad22535e7f771690a28,"firefox" +1c15aca4a38bad90f9c40678f6aface9,"firefox" +5163bc7c08f57077bc652ec370459c2f,"firefox" +a88f1426c4603f2a8cd8bb41e875cb75,"firefox" +b03910cc6de801d2fcfa0c3b9f397df4,"firefox" +bfcc1a3891601edb4f137ab7ab25b840,"firefox" +ce694315cbb81ce95e6ae4ae8cbafde6,"firefox" +f15797a734d0b4f171a86fd35c9a5e43,"firefox" +07b4162d4db57554961824a21c4a0fde,"firefox,thunderbird" +61d0d709fe7ac199ef4b2c52bc8cef75,"firefox,thunderbird" +8498fe4268764dbf926a38283e9d3d8f,"Franz,Google Chrome,Kiwi,Spotify,nwjs,Slack" +900c1fa84b4ea86537e1d148ee16eae8,"Fuze" +107144b88827da5da9ed42d8776ccdc5,"geod" +c46941d4de99445aef6b497679474cf4,"geod" +002205d0f96c37c5e660b9f041363c11,"Google Chrome" +073eede15b2a5a0302d823ecbd5ad15b,"Google Chrome" +0b61c673ee71fe9ee725bd687c455809,"Google Chrome" +6cd1b944f5885e2cfbe98a840b75eeb8,"Google Chrome" +94c485bca29d5392be53f2b8cf7f4304,"Google Chrome" +b4f4e6164f938870486578536fc1ffce,"Google Chrome" +b8f81673c0e1d29908346f3bab892b9b,"Google Chrome" +baaac9b6bf25ad098115c71c59d29e51,"Google Chrome" +bc6c386f480ee97b9d9e52d472b772d8,"Google Chrome" +da949afd9bd6df820730f8f171584a71,"Google Chrome" +f58966d34ff9488a83797b55c804724d,"Google Chrome" +fd6314b03413399e4f23d1524d206692,"Google Chrome" +0e46737668fe75092919ee047a0b5945,"Google Chrome Helper" +39fa85654105398ee7ef6a3a1c81d685,"Google Chrome Helper" +4ba7b7022f5f5e1e500bb19199d8b1a4,"Google Chrome Helper" +5498cef2cca704eb01cf2041cc1089c1,"Google Chrome,Slack" +d27fb8deca6e3b9739db3fda2b229fe3,"Google Drive File Stream" +ae340571b4fd0755c4a0821b18d8fa93,"Google Earth" +f059212ce3de94b1e8253a7522cb1b44,"Google Photos Backup" +fd10cc8cce9493a966c57249e074755f,"gramblr" +3e860202fc555b939e83e7a7ab518c38,"hola_svc" +56ac3a0bef0824c49e4b569941937088,"hola_svc" +5c1c89f930122bccc7a97d52f73bea2c,"hola_svc" +77310efe11f1943306ee317cf02150b7,"hola_svc" +8bd59c4b7f3193db80fd64318429bcec,"hola_svc" +d1f9f9b224387d2597f02095fcec96d7,"hola_svc" +ff1040ba1e3d235855ef0d7cd9237fdc,"hola_svc" +5af143afdbf58ec11ab3b3d53dd4e5e3,"IDSyncDaemon" +d06acbe8ac31e753f40600a9d6717cba,"Inbox OSX" +093081b45872912be9a1f2a8163fe041,"java" +2080bf56cb87e64303e27fcd781e7efd,"java" +225a24b45f0f1adbc2e245d4624c6e08,"java" +3afe1fb5976d0999abe833b14b7d6485,"java" +3b844830bfbb12eb5d2f8dc281d349a9,"java" +51a7ad14509fd614c7bb3a50c4982b8c,"java" +550628650380ff418de25d3d890e836e,"java" +5b270b309ad8c6478586a15dece20a88,"java" +5d7abe53ae15b4272a34f10431e06bf3,"java" +7c7a68b96d2aab15d678497a12119f4f,"java" +88afa0dea1608e28f50acbad32d7f195,"java" +8ce6933b8c12ce931ca238e9420cc5dd,"java" +a61299f9b501adcf680b9275d79d4ac6,"java" +a9fead344bf3ac09f62df3cd9b22c268,"java" +4056657a50a8a4e5cfac40ba48becfa2,"java,eclipse" +f22bdd57e3a52de86cda40da2d84e83b,"java,eclipse,Cyberduck" +028563cffc7a3a2e32090aee0294d636,"java,eclipse,STS" +5f9b53f0d39dc9d940a3b5568fe5f0bb,"java,JavaApplicationStub" +2db6873021f2a95daa7de0d93a1d1bf2,"java,studio,eclipse" +c376061f96329e1020865a1dc726927d,"JavaApplicationStub" +e516ad69a423f8e0407307aa7bfd6344,"Kindle,stack,nextcloud" +3959d0a1344896e9fb5c0564ca0a2956,"LeagueClientUx" +0fe51fa93812c2ebb50a655222a57bf2,"LINE Messaging" +2e094913d88f0ad8dc69447cb7d2ce65,"LINE Messaging" +193349d34561d1d5d1a270172eb2d97e,"LogMeIn Client" +d732ca39155f38942f90e9fc2b0f97f7,"Maxthon" +c9dbeed362a32f9a50a26f4d9b32bbd8,"Messenger,Jumpshare" +6acb250ada693067812c3335705dae79,"mono-sgen,Syncplicity,Axure RP 8,Amazon Drive" +3ee4aaac7147ff2b80ada31686db660c,"node-webkit,Kindle" +641df9d6dbe7fdb74f70c8ad93def8cc,"node.js" +9811c1bb9f0f6835d5c13a831cca4173,"node.js" +106ecbd3d14b4dc6e413494263720afe,"node.js,Postman,WhatsApp" +49de9b1c7e60bd3b8e1d4f7a49ba362e,"nwjs,Chromium" +38cbe70b308f42da7c9980c0e1c89656,"p4v,owncloud" +62448833d8230241227c03b7d441e31b,"parsecd,apple.geod,apple.photomoments,photoanalysisd,FreedomProxy" +e846898acc767ebeb2b4388e58a968d4,"postbox-bin" +a7823092705a5e91ce2b7f561b6e5b98,"Qsync Client" +c048d9f26a79e11ca7276499ef24daf3,"RescueTime,Plantronics Hub" +d219efd07cbb8fbe547e6a5335843f0f,"ruby" +c36fb08942cf19508c08d96af22d4ffc,"Safari" +844166382cc98d98595e6778c470f5d5,"Salesforce Files" +49a341a21f4fd4ac63b027ff2b1a331f,"Skype" +a5aa6e939e4770e3b8ac38ce414fd0d5,"Slack" +116ffc8889873efad60457cd55eaf543,"Spark" +8db4b0f8e9dd8f2fff38ee7c5a1e4496,"SpotlightNetHelper,Safari" +39cf5b7a13a764494de562add874f016,"Steam OSX" +2d3854d1cbcdceece83eabd85bdcc056,"Tableau" +a585c632a2b49be1256881fb0c16c864,"Tableau" +cd7c06b9459c9cfd4af2dba5696ea930,"Tableau" +df65746370dcabc9b4f370c6e14a8156,"True Key" +84071ea96fc8a60c55fc8a405e214c0f,"Used by many desktop apps,Quip,Spotify,GitHub Desktop" +40fd0a5e81ebdcf0ec82a4710a12dec1,"Used by many programs on OSX,apple.WebKit.Networking" +618ee2509ef52bf0b8216e1564eea909,"Used by many programs on OSX,apple.WebKit.Networking" +799135475da362592a4be9199d258726,"Used by many programs on OSX,apple.WebKit.Networking" +7b530a25af9016a9d12de5abc54d9e74,"Used by many programs on OSX,apple.WebKit.Networking" +7e72698146290dd68239f788a452e7d8,"Used by many programs on OSX,apple.WebKit.Networking" +a9aecaa66ad9c6cfe1c361da31768506,"Used by many programs on OSX,apple.WebKit.Networking" +c05de18b01a054f2f6900ffe96b3da7a,"Used by many programs on OSX,apple.WebKit.Networking" +c07cb55f88702033a8f52c046d23e0b2,"Used by many programs on OSX,apple.WebKit.Networking" +e4d448cdfe06dc1243c1eb026c74ac9a,"Used by many programs on OSX,apple.WebKit.Networking" +f1c5cf087b959cec31bd6285407f689a,"Used by many programs on OSX,apple.WebKit.Networking" +488b6b601cb141b062d4da7f524b4b22,"Used by many programs,Python,PHP,Git,dotnet,Adobe" +f28d34ce9e732f644de2350027d74c3f,"Used by many programs,Quip,Aura,Spotify,Chatty" +190dfb280fe3b541acc6a2e5f00690e6,"Used by many programs,Quip,Spotify,Dropbox,GitHub Desktop,etc" +20dd18bdd3209ea718989030a6f93364,"Used by many programs,Slack,Postman,Spotify,Google Chrome" +e0224fc1c33658f2d3d963bfb0a76a85,"Viber" +01319090aea981dde6fc8d6ae71ead54,"vpnkit" +84607748f3887541dd60fe974a042c71,"wineserver" +c2b4710c6888a5d47befe865c8e6fb19,"ZwiftApp" diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/sslbl-ja3.csv b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/sslbl-ja3.csv new file mode 100644 index 00000000..92f98f0e --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/seeds/sslbl-ja3.csv @@ -0,0 +1,108 @@ +################################################################ +# abuse.ch Suricata JA3 Fingerprint Blacklist (CSV) # +# For Suricata 4.1.0 or newer # +# Last updated: 2021-08-03 14:33:44 UTC # +# # +# Terms Of Use: https://sslbl.abuse.ch/blacklist/ # +# For questions please contact sslbl [at] abuse.ch # +################################################################ +# +# ja3_md5,Firstseen,Lastseen,Listingreason +b386946a5a44d1ddcc843bc75336dfce,2017-07-14 18:08:15,2019-07-27 20:42:54,Dridex +8991a387e4cc841740f25d6f5139f92d,2017-07-14 19:02:03,2019-07-28 00:34:38,Adware +cb98a24ee4b9134448ffb5714fd870ac,2017-07-14 19:48:28,2019-05-22 03:22:38,Dridex +1aa7bf8b97e540ca5edd75f7b8384bfa,2017-07-14 20:23:38,2019-07-28 01:38:22,TrickBot +3d89c0dfb1fa44911b8fa7523ef8dedb,2017-07-15 04:23:45,2021-02-01 18:23:25,Adware +bc6c386f480ee97b9d9e52d472b772d8,2017-07-15 10:57:38,2021-03-13 07:33:39,Adware +8f52d1ce303fb4a6515836aec3cc16b1,2017-07-15 19:05:11,2019-07-27 20:00:57,TrickBot +d6f04b5a910115f4b50ecec09d40a1df,2017-07-15 19:42:24,2018-10-14 08:12:51,Dridex +35c0a31c481927f022a3b530255ac080,2017-07-15 19:43:19,2021-04-10 12:54:04,Tofsee +e330bca99c8a5256ae126a55c4c725c5,2017-07-15 19:59:29,2021-01-13 00:29:37,Adware +d551fafc4f40f1dec2bb45980bfa9492,2017-07-15 19:59:29,2020-11-16 13:06:20,Adware +83e04bc58d402f9633983cbf22724b02,2017-07-16 01:32:03,2021-03-02 04:07:43,Adware +b8f81673c0e1d29908346f3bab892b9b,2017-07-16 01:32:03,2021-03-02 04:07:36,Adware +70722097d1fe1d78d8c2164640ab6df4,2017-07-16 02:39:08,2021-05-04 09:52:20,Tofsee +9c2589e1c0e9f533a022c6205f9719e1,2017-07-16 08:37:17,2021-07-25 08:33:18,Adware +849b04bdbd1d2b983f6e8a457e0632a8,2017-07-16 08:37:17,2021-07-25 08:33:18,Adware +16efcf0e00504ddfedde13bfea997952,2017-07-16 19:45:45,2020-12-23 15:10:32,Adware +4d7a28d6f2263ed61de88ca66eb011e3,2017-07-16 21:20:29,2020-12-08 18:10:55,Tofsee +550dce18de1bb143e69d6dd9413b8355,2017-07-16 22:17:20,2018-12-21 07:04:50,Adware +c50f6a8b9173676b47ba6085bd0c6cee,2017-07-16 22:38:41,2019-05-21 09:42:17,TrickBot +590a232d04d56409fab72e752a8a2634,2017-07-18 18:53:24,2020-10-11 20:48:33,Tofsee +51a7ad14509fd614c7bb3a50c4982b8c,2017-07-19 07:28:19,2019-07-14 11:58:32,JBifrost +96eba628dcb2b47607192ba74a3b55ba,2017-07-19 18:53:48,2021-07-31 01:48:32,Tofsee +df5c30e670dba99f9270ed36060cf054,2017-07-20 17:44:07,2018-04-11 15:57:59,Tofsee +098f55e27d8c4b0a590102cbdb3a5f3a,2017-07-21 09:52:01,2019-04-08 01:09:54,Adware +29085f03f8e8a03f0b399c5c7cf0b0b8,2017-07-22 14:07:36,2021-04-11 06:42:45,Adware +46efd49abcca8ea9baa932da68fdb529,2017-07-22 14:07:36,2021-04-11 05:54:57,Adware +d7150af4514b868defb854db0f62a441,2017-07-23 09:39:24,2018-07-24 01:04:58,Tofsee +03e186a7f83285e93341de478334006e,2017-07-24 18:17:14,2021-03-20 07:45:40,Tofsee +3cda52da4ade09f1f781ad2e82dcfa20,2017-07-30 18:41:36,2019-05-21 17:34:18,Quakbot +b13d01846ad7a14a70bf030a16775c78,2017-08-08 07:12:49,2021-04-10 00:03:33,Adware +1543a7c46633acf71e8401baccbd0568,2017-08-08 21:32:28,2021-04-08 19:50:46,Tofsee +1d095e68489d3c535297cd8dffb06cb9,2017-08-12 19:56:28,2020-10-28 11:06:23,Tofsee +698e36219f3979420fa2581b21dac7ec,2017-08-28 12:20:47,2020-12-31 02:06:31,Adware +93d056782d649deb51cda44ecb714bb0,2017-08-28 12:20:47,2019-04-15 23:47:27,Adware +1712287800ac91b34cadd5884ce85568,2017-08-28 16:01:59,2021-07-28 14:16:00,TorrentLocker +5e573c9c9f8ba720ef9b18e9fce2e2f7,2017-08-30 13:44:56,2021-03-13 07:33:38,Adware +f6fd83a21f9f3c5f9ff7b5c63bbc179d,2017-10-20 08:03:21,2018-11-06 06:42:12,Adware +92579701f145605e9edc0b01a901c6d5,2017-10-23 00:10:48,2021-07-25 17:07:34,Adware +a61299f9b501adcf680b9275d79d4ac6,2017-11-04 18:03:59,2020-04-21 17:08:24,Tofsee +b2b61db7b9490a60d270ccb20b462826,2017-11-14 20:12:03,2021-06-06 20:27:10,Adware +7dcce5b76c8b17472d024758970a406b,2017-11-22 12:42:46,2021-03-16 12:53:35,Tofsee +534ce2dbc413c68e908363b5df0ae5e0,2017-12-22 09:36:21,2019-07-27 15:22:33,TrickBot +fb00055a1196aeea8d1bc609885ba953,2018-01-01 22:49:25,2019-04-09 06:58:58,TrickBot +a50a861119aceb0ccc74902e8fddb618,2018-01-02 08:16:23,2018-07-05 02:33:08,Tofsee +e7643725fcff971e3051fe0e47fc2c71,2018-01-31 08:06:13,2020-03-25 16:19:48,Tofsee +7c410ce832e848a3321432c9a82e972b,2018-01-31 20:04:25,2021-08-01 06:13:14,Tofsee +da949afd9bd6df820730f8f171584a71,2018-02-03 05:19:37,2021-03-08 22:10:10,Tofsee +906004246f3ba5e755b043c057254a29,2018-03-11 08:25:38,2018-04-14 00:59:16,Tofsee +fd80fa9c6120cdeea8520510f3c644ac,2018-03-11 09:34:30,2021-08-11 12:34:00,Tofsee +b90bdbe961a648f0427db21aaa6ccb59,2018-03-11 10:37:43,2020-05-29 23:39:01,Tofsee +1fe4c7a3544eb27afec2adfb3a3dbf60,2018-03-11 19:23:08,2021-08-09 11:42:58,Tofsee +c201b92f8b483fa388be174d6689f534,2018-03-12 13:43:52,2021-01-28 06:17:06,Gozi +9f62c4f26b90d3d757bea609e82f2eaf,2018-03-13 06:23:41,2021-05-20 23:01:06,Tofsee +1be3ecebe5aa9d3654e6e703d81f6928,2018-03-13 11:50:02,2021-08-11 13:02:35,Ransomware.Troldesh +e3b2ab1f9a56f2fb4c9248f2f41631fa,2018-03-15 01:06:34,2021-07-02 21:51:49,Tofsee +dff8a0aa1c904aaea76c5bf624e88333,2018-03-18 09:41:15,2020-10-27 09:50:24,Tofsee +17fd49722f8d11f3d76dce84f8e099a7,2018-03-19 23:02:27,2021-08-01 00:40:52,Tofsee +911479ac8a0813ed1241b3686ccdade9,2018-03-19 23:24:59,2020-03-30 04:09:18,Tofsee +c5deb9465d47232dd48772f9c4d14679,2018-03-22 15:42:48,2021-03-23 00:34:25,Tofsee +f22bdd57e3a52de86cda40da2d84e83b,2018-03-27 13:40:19,2019-01-20 14:31:39,Tofsee +d18a4da84af59e1108862a39bae7c9d4,2018-04-03 00:40:51,2021-02-06 01:53:12,Tofsee +2d8794cb7b52b777bee2695e79c15760,2018-04-04 06:56:37,2021-07-26 08:07:00,Ransomware +40adfd923eb82b89d8836ba37a19bca1,2018-04-15 15:49:08,2021-04-11 04:42:47,CoinMiner +1aee0238942d453d679fc1e37a303387,2018-05-13 01:59:49,2021-07-30 12:27:07,Tofsee +2092e1fffb45d7e4a19a57f9bc5e203a,2018-05-16 21:59:36,2018-09-05 01:58:33,Adware +bffa4501966196d3d6e90cee1f88fc89,2018-06-07 15:08:04,2020-03-16 00:03:44,Tofsee +807fca46d9d0cf63adf4e5e80e414bbe,2018-06-07 16:51:03,2021-08-07 03:15:42,Tofsee +fb58831f892190644fe44e25bc830b45,2018-06-08 12:07:59,2021-07-20 01:39:05,Adware +0cc1e84568e471aa1d62ad4158ade6b5,2018-06-24 10:50:47,2021-06-21 02:35:57,Tofsee +d2935c58fe676744fecc8614ee5356c7,2018-08-14 21:48:41,2021-08-11 11:54:42,Adwind +8916410db85077a5460817142dcbc8de,2018-08-21 12:32:28,2021-08-11 15:00:50,TrickBot +c5235d3a8b9934b7fbbd204d50bc058d,2018-08-23 17:36:08,2019-10-13 05:11:09,Gootkit +57f3642b4e37e28f5cbe3020c9331b4c,2018-08-28 15:54:53,2021-08-11 13:05:18,Gozi +e62a5f4d538cbf169c2af71bec2399b4,2018-08-30 15:45:40,2021-08-11 09:48:52,TrickBot +51c64c77e60f3980eea90869b68c58a8,2018-08-30 21:04:57,2021-08-11 08:13:08,Dridex +7691297bcb20a41233fd0a0baa0a3628,2018-09-17 02:50:05,2021-08-11 12:20:33,Adware +7dd50e112cd23734a310b90f6f44a7cd,2018-09-17 17:54:58,2021-08-01 11:28:46,Quakbot +52c7396a501e4fecbdfa99c5408334ac,2018-09-18 00:29:04,2019-12-03 17:24:02,Tofsee +fc54e0d16d9764783542f0146a98b300,2018-09-24 12:33:44,2021-08-11 12:51:10,AsyncRAT +f735bbc6b69723b9df7b0e7ef27872af,2018-10-02 18:04:16,2021-08-11 07:25:14,TrickBot +49ed2ef3f1321e5f044f1e71b0e6fdd5,2018-10-02 18:04:17,2021-08-08 22:08:01,TrickBot +d76ee64fb7273733cbe455ac81c292e6,2018-11-16 13:26:39,2018-11-18 19:19:36,Tofsee +8f6c918dcb585ebbea05e2cc94530e3d,2018-11-16 13:26:41,2020-05-06 15:45:21,Tofsee +34f14a69ad7009ca5863379218af17f3,2018-11-17 05:17:22,2021-01-28 08:19:18,Tofsee +c2b4710c6888a5d47befe865c8e6fb19,2018-11-29 20:46:04,2021-08-03 23:37:22,Tofsee +decfb48a53789ebe081b88aabb58ee34,2018-12-21 09:06:16,2021-06-14 05:27:16,Adwind +08a8a4e85b25ac42e1490bc85cfdb5ce,2019-01-30 02:48:34,2020-10-27 09:50:19,Tofsee +c0220cd64849a629397a9cb68f78a0ea,2019-03-24 00:12:32,2021-07-31 00:26:06,Tofsee +7a29c223fb122ec64d10f0a159e07996,2019-06-09 22:55:29,2020-10-27 09:50:26,None +70a04365be5bbd4653698bebeb43ce68,2019-07-02 06:26:56,2020-05-30 04:19:00,Tofsee +d81d654effb94714a4086734fa0adad9,2019-07-16 23:29:02,2020-10-27 09:50:21,Tofsee +25d74b7b4b779eb1efd4b31d26d651c6,2019-08-03 20:15:33,2020-07-14 21:43:25,Tofsee +fc2299d5b2964cd242c5a2c8c531a5f0,2019-08-09 23:56:32,2021-08-11 02:20:22,Tofsee +32926ca3e59f0413d0b98725454594f5,2019-09-12 06:56:10,2021-04-08 19:50:43,Tofsee +ffefafdb86336d057eda5fdf02b3d5ce,2019-10-26 07:31:49,2020-07-25 00:14:09,Tofsee +8515076cbbca9dce33151b798f782456,2020-12-27 16:53:04,2021-08-11 15:06:36,BitRAT +# END (97) entries \ No newline at end of file diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/detect.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/detect.rs new file mode 100644 index 00000000..cf7404eb --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/detect.rs @@ -0,0 +1,1012 @@ +// ©AngelaMos | 2026 +// detect.rs + +//! The detection engine: what turns a stream of fingerprints into alerts. +//! +//! Every fingerprint the capture pipeline produces is recorded as an +//! observation, and then six rules read the history around it. The history is +//! the database itself, not an in memory accumulator, so a rule behaves the +//! same whether it is fed a packet capture all at once or a live link one frame +//! at a time, and a long running sensor keeps its memory across restarts. +//! +//! The rules, in the order they are evaluated: +//! +//! - known bad: the fingerprint matches malicious or suspicious intelligence. +//! - User-Agent mismatch: a request calls itself a browser while the same +//! client's TLS fingerprint is a script or a tool. This is the headline, the +//! lie that a fingerprint catches and a User-Agent string cannot tell. +//! - operating-system mismatch: the operating system a User-Agent claims +//! disagrees with the one the client's TCP SYN reveals. +//! - first seen: this exact fingerprint has never been recorded before. +//! - rotation: one address has presented an unusual number of distinct +//! fingerprints, the tell of a client cycling its identity to evade matching. +//! - monoculture: one fingerprint has appeared from an unusual number of +//! distinct addresses, the tell of a botnet built from one toolkit. +//! +//! The two correlation rules look only backwards, at observations already +//! recorded for the same address, so a pair of flows raises one alert when the +//! second of the pair completes the picture, not two. + +use anyhow::Result; +use rusqlite::{Connection, params}; +use serde::Serialize; +use tlsfp_core::{FingerprintEvent, StreamEvent}; + +use crate::matcher::{event_fingerprints, match_one}; +use crate::model::{Category, FpKind, MatchReport, Severity, Verdict}; +use crate::signal::{ + OsClass, ja4t_os_class, label_is_browser, label_is_non_browser, ua_family, ua_os_class, +}; + +/// How far back the correlation and diversity rules look, in seconds. +const DEFAULT_WINDOW_SECS: i64 = 3600; + +/// How many distinct fingerprints from one address, inside the window, count as +/// rotation rather than ordinary repeat visits. +const DEFAULT_ROTATION_THRESHOLD: i64 = 5; + +/// How many distinct addresses presenting one fingerprint, inside the window, +/// count as a monoculture rather than a popular client. +const DEFAULT_MONOCULTURE_THRESHOLD: i64 = 10; + +const NANOS_PER_SEC: i64 = 1_000_000_000; + +/// The thresholds and window the engine runs with. The defaults suit a quiet +/// link; a busy sensor would raise them, and the tests lower them to trip a +/// rule with a handful of events. +#[derive(Debug, Clone, Copy)] +pub struct DetectConfig { + pub window_secs: i64, + pub rotation_threshold: i64, + pub monoculture_threshold: i64, +} + +impl Default for DetectConfig { + fn default() -> Self { + Self { + window_secs: DEFAULT_WINDOW_SECS, + rotation_threshold: DEFAULT_ROTATION_THRESHOLD, + monoculture_threshold: DEFAULT_MONOCULTURE_THRESHOLD, + } + } +} + +/// Which rule raised an alert, the first half of its provenance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Rule { + KnownBad, + UaMismatch, + OsMismatch, + FirstSeen, + FpRotation, + Monoculture, +} + +impl Rule { + /// The token stored in the database and printed on the CLI. + pub const fn as_str(self) -> &'static str { + match self { + Rule::KnownBad => "known_bad", + Rule::UaMismatch => "ua_mismatch", + Rule::OsMismatch => "os_mismatch", + Rule::FirstSeen => "first_seen", + Rule::FpRotation => "fp_rotation", + Rule::Monoculture => "monoculture", + } + } + + /// Parses a stored rule token, falling back to first seen for an + /// unrecognised value so reading the feed never fails on a stray row. + pub fn from_token(token: &str) -> Self { + match token { + "known_bad" => Rule::KnownBad, + "ua_mismatch" => Rule::UaMismatch, + "os_mismatch" => Rule::OsMismatch, + "fp_rotation" => Rule::FpRotation, + "monoculture" => Rule::Monoculture, + _ => Rule::FirstSeen, + } + } +} + +/// How much a human should care, the urgency carried alongside the rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AlertSeverity { + Info, + Low, + Medium, + High, + Critical, +} + +impl AlertSeverity { + /// The token stored in the database and printed on the CLI. + pub const fn as_str(self) -> &'static str { + match self { + AlertSeverity::Info => "info", + AlertSeverity::Low => "low", + AlertSeverity::Medium => "medium", + AlertSeverity::High => "high", + AlertSeverity::Critical => "critical", + } + } + + /// Parses a stored severity token, falling back to info for anything + /// unrecognised so reading the feed never fails on a stray row. + pub fn from_token(token: &str) -> Self { + match token { + "low" => AlertSeverity::Low, + "medium" => AlertSeverity::Medium, + "high" => AlertSeverity::High, + "critical" => AlertSeverity::Critical, + _ => AlertSeverity::Info, + } + } +} + +/// One alert: a rule that fired, where, and the evidence that tripped it. The +/// `detail` field is the second half of the provenance, the human readable +/// reason this rule decided what it did. +#[derive(Debug, Clone, Serialize)] +pub struct Alert { + pub ts_nanos: i64, + pub rule: Rule, + pub severity: AlertSeverity, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fp_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fp_value: Option, + pub title: String, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub score: Option, +} + +/// The fingerprint kind and value an event is recorded under, the JA4 family +/// member that names the event. +fn primary_fingerprint(event: &StreamEvent) -> (FpKind, String) { + match event { + StreamEvent::ClientHello { ja4, .. } => (FpKind::Ja4, ja4.hash.clone()), + StreamEvent::ServerHello { ja4s, .. } => (FpKind::Ja4s, ja4s.hash.clone()), + StreamEvent::Certificate { ja4x } => (FpKind::Ja4x, ja4x.clone()), + StreamEvent::HttpRequest { ja4h, .. } => (FpKind::Ja4h, ja4h.hash.clone()), + StreamEvent::TcpSyn { ja4t } => (FpKind::Ja4t, ja4t.clone()), + StreamEvent::TcpSynAck { ja4ts } => (FpKind::Ja4ts, ja4ts.clone()), + } +} + +/// The context fields an event carries beyond its fingerprint: the server name +/// a TLS client asked for, and the host, User-Agent, and claimed operating +/// system an HTTP request declared. +struct Context { + sni: Option, + host: Option, + user_agent: Option, + os_claim: Option, +} + +fn context_of(event: &StreamEvent) -> Context { + match event { + StreamEvent::ClientHello { sni, .. } => Context { + sni: sni.clone(), + host: None, + user_agent: None, + os_claim: None, + }, + StreamEvent::HttpRequest { + host, user_agent, .. + } => { + let os_claim = user_agent + .as_deref() + .and_then(ua_os_class) + .map(|class| class.as_str().to_string()); + Context { + sni: None, + host: host.clone(), + user_agent: user_agent.clone(), + os_claim, + } + } + _ => Context { + sni: None, + host: None, + user_agent: None, + os_claim: None, + }, + } +} + +/// Records one event and returns every alert it raised, all inside the caller's +/// transaction so the observation and its alerts commit together or not at all. +pub(crate) fn run( + conn: &Connection, + event: &FingerprintEvent, + config: &DetectConfig, +) -> Result> { + let ts = i64::try_from(event.ts_nanos).unwrap_or(i64::MAX); + let ip_text = event.src.ip().to_string(); + let (kind, value) = primary_fingerprint(&event.event); + let context = context_of(&event.event); + let since = ts.saturating_sub(config.window_secs.saturating_mul(NANOS_PER_SEC)); + + let reports = lookups(conn, event)?; + let summary = Summary::from_reports(&reports); + + let mut alerts = Vec::new(); + + let fp_known = fp_seen_anywhere(conn, kind, &value)?; + let ip_fp_known = ip_fp_seen(conn, &ip_text, kind, &value)?; + + correlate_ua_http(conn, &ip_text, since, &context, &mut alerts)?; + correlate_ua_tls( + conn, + &ip_text, + since, + kind, + &value, + summary.label.as_deref(), + summary.category, + &mut alerts, + )?; + correlate_os(conn, &ip_text, since, &context, kind, &value, &mut alerts)?; + + let observation_id = insert_observation(conn, ts, &ip_text, kind, &value, &summary, &context)?; + + if let Some(alert) = known_bad(conn, ts, &ip_text, &reports, kind, &value)? { + alerts.push(alert); + } + + if !fp_known { + alerts.push(first_seen(ts, &ip_text, kind, &value, &summary)); + } + + if !ip_fp_known { + let distinct = distinct_fp_for_ip(conn, &ip_text, kind, since)?; + if distinct > config.rotation_threshold { + alerts.push(rotation(ts, &ip_text, kind, distinct, config.window_secs)); + } + let addresses = distinct_ip_for_fp(conn, kind, &value, since)?; + if addresses > config.monoculture_threshold { + alerts.push(monoculture(ts, kind, &value, addresses, config.window_secs)); + } + } + + for alert in &mut alerts { + alert.ts_nanos = ts; + } + for alert in &alerts { + persist(conn, alert, observation_id)?; + } + + Ok(alerts) +} + +/// The intelligence reports for every fingerprint the event carries, keeping +/// only the ones that matched something, so the JA3 the public feeds index on +/// is checked alongside the JA4 the event is recorded under. +fn lookups(conn: &Connection, event: &FingerprintEvent) -> Result> { + let mut reports = Vec::new(); + for (kind, value) in event_fingerprints(event) { + let report = match_one(conn, kind, &value)?; + if report.has_hits() { + reports.push(report); + } + } + Ok(reports) +} + +/// The most telling thing intelligence said about an event, distilled to one +/// verdict, label, and category for the observation row. +struct Summary { + verdict: Option, + label: Option, + category: Option, +} + +impl Summary { + /// Picks the worst hit across every report: the highest severity, breaking + /// ties on match strength, so a malware label wins over a benign collision + /// and an exact hit wins over a partial one. + fn from_reports(reports: &[MatchReport]) -> Self { + let mut best: Option<(u8, f64, Verdict, &str, Category)> = None; + for report in reports { + for hit in &report.hits { + let rank = severity_rank(hit.category.severity()); + let weight = hit.strength.weight(); + let better = match best { + Some((best_rank, best_weight, ..)) => (rank, weight) > (best_rank, best_weight), + None => true, + }; + if better { + best = Some(( + rank, + weight, + report.verdict, + hit.label.as_str(), + hit.category, + )); + } + } + } + match best { + Some((_, _, verdict, label, category)) => Self { + verdict: Some(verdict), + label: Some(label.to_string()), + category: Some(category), + }, + None => Self { + verdict: None, + label: None, + category: None, + }, + } + } +} + +fn severity_rank(severity: Severity) -> u8 { + match severity { + Severity::Malicious => 3, + Severity::Suspicious => 2, + Severity::Benign => 1, + } +} + +/// The known bad rule: if any fingerprint matched malicious or suspicious +/// intelligence, raise one alert citing the strongest report and how rare the +/// fingerprint is in what we have seen. +fn known_bad( + conn: &Connection, + ts: i64, + ip: &str, + reports: &[MatchReport], + kind: FpKind, + value: &str, +) -> Result> { + let Some(report) = worst_report(reports) else { + return Ok(None); + }; + let severity = match report.verdict { + Verdict::Malicious => AlertSeverity::High, + _ => AlertSeverity::Medium, + }; + let labels = report + .hits + .iter() + .map(|hit| format!("{} ({})", hit.label, hit.source)) + .collect::>() + .join(", "); + let (seen, total) = fp_prevalence(conn, kind, value)?; + let detail = format!( + "{} {} matched {}; threat {:.2} confidence {:.2}; {}", + report.kind.as_str(), + report.observed, + labels, + report.threat_score, + report.confidence, + prevalence_phrase(seen, total), + ); + Ok(Some(Alert { + ts_nanos: ts, + rule: Rule::KnownBad, + severity, + ip: Some(ip.to_string()), + fp_kind: Some(kind), + fp_value: Some(value.to_string()), + title: format!("fingerprint matches known {} intelligence", report.verdict), + detail, + score: Some(report.threat_score), + })) +} + +/// The most severe report worth alerting on, malicious before suspicious and +/// higher threat first, or nothing if every match was benign. +fn worst_report(reports: &[MatchReport]) -> Option<&MatchReport> { + reports + .iter() + .filter(|report| matches!(report.verdict, Verdict::Malicious | Verdict::Suspicious)) + .max_by(|a, b| { + verdict_rank(a.verdict) + .cmp(&verdict_rank(b.verdict)) + .then(a.threat_score.total_cmp(&b.threat_score)) + }) +} + +fn verdict_rank(verdict: Verdict) -> u8 { + match verdict { + Verdict::Malicious => 3, + Verdict::Suspicious => 2, + Verdict::Benign => 1, + Verdict::Unknown => 0, + } +} + +/// The User-Agent mismatch rule, evaluated from whichever side of the pair +/// completes it: an HTTP request claiming a browser checked against the +/// address's earlier TLS tool fingerprints, and a TLS tool fingerprint checked +/// against the address's earlier browser claims. +fn correlate_ua_http( + conn: &Connection, + ip: &str, + since: i64, + context: &Context, + alerts: &mut Vec, +) -> Result<()> { + let Some(user_agent) = &context.user_agent else { + return Ok(()); + }; + let Some(claimed) = ua_family(user_agent) else { + return Ok(()); + }; + if !claimed.is_browser() { + return Ok(()); + } + for (fp_kind, fp_value, label, category) in prior_tls_labels(conn, ip, since)? { + if label_is_non_browser(&label, category) && !label_is_browser(&label) { + alerts.push(ua_mismatch_alert( + ip, + claimed.as_str(), + user_agent, + &fp_kind, + &fp_value, + &label, + category, + )); + break; + } + } + Ok(()) +} + +/// The TLS side of the User-Agent mismatch rule: when the event being recorded +/// is itself a non-browser TLS fingerprint, look back for a browser claim from +/// the same address. +#[allow(clippy::too_many_arguments)] +fn correlate_ua_tls( + conn: &Connection, + ip: &str, + since: i64, + kind: FpKind, + value: &str, + label: Option<&str>, + category: Option, + alerts: &mut Vec, +) -> Result<()> { + if !matches!(kind, FpKind::Ja3 | FpKind::Ja4) { + return Ok(()); + } + let (Some(label), Some(category)) = (label, category) else { + return Ok(()); + }; + if !label_is_non_browser(label, category) || label_is_browser(label) { + return Ok(()); + } + for user_agent in prior_browser_uas(conn, ip, since)? { + if let Some(claimed) = ua_family(&user_agent) { + if claimed.is_browser() { + alerts.push(ua_mismatch_alert( + ip, + claimed.as_str(), + &user_agent, + kind.as_str(), + value, + label, + category, + )); + break; + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn ua_mismatch_alert( + ip: &str, + claimed: &str, + user_agent: &str, + fp_kind: &str, + fp_value: &str, + label: &str, + category: Category, +) -> Alert { + Alert { + ts_nanos: 0, + rule: Rule::UaMismatch, + severity: AlertSeverity::High, + ip: Some(ip.to_string()), + fp_kind: FpKind::from_token(fp_kind), + fp_value: Some(fp_value.to_string()), + title: format!("User-Agent claims {claimed} but the TLS fingerprint is not a browser"), + detail: format!( + "address {ip} sent User-Agent '{user_agent}' yet presented {fp_kind} {fp_value}, known as {label} ({}); a real {claimed} does not produce a {label} handshake", + category.as_str(), + ), + score: None, + } +} + +/// The operating-system mismatch rule, evaluated from whichever side completes +/// it: an HTTP claim checked against the address's earlier SYN, and a SYN +/// checked against the address's earlier HTTP claim. +fn correlate_os( + conn: &Connection, + ip: &str, + since: i64, + context: &Context, + kind: FpKind, + value: &str, + alerts: &mut Vec, +) -> Result<()> { + if let Some(user_agent) = &context.user_agent { + let Some(claimed) = ua_os_class(user_agent) else { + return Ok(()); + }; + for ja4t in prior_ja4t(conn, ip, since)? { + if let Some(observed) = ja4t_os_class(&ja4t) { + if observed != claimed { + alerts.push(os_mismatch_alert( + ip, + claimed.as_str(), + observed.as_str(), + &ja4t, + user_agent, + )); + break; + } + } + } + return Ok(()); + } + + if matches!(kind, FpKind::Ja4t) { + if let Some(observed) = ja4t_os_class(value) { + for (user_agent, os_claim) in prior_os_claims(conn, ip, since)? { + if let Some(claimed) = OsClass::from_token(&os_claim) { + if claimed != observed { + alerts.push(os_mismatch_alert( + ip, + claimed.as_str(), + observed.as_str(), + value, + &user_agent, + )); + break; + } + } + } + } + } + Ok(()) +} + +fn os_mismatch_alert( + ip: &str, + claimed: &str, + observed: &str, + ja4t: &str, + user_agent: &str, +) -> Alert { + Alert { + ts_nanos: 0, + rule: Rule::OsMismatch, + severity: AlertSeverity::Medium, + ip: Some(ip.to_string()), + fp_kind: Some(FpKind::Ja4t), + fp_value: Some(ja4t.to_string()), + title: format!("User-Agent claims {claimed} but the TCP stack looks like {observed}"), + detail: format!( + "address {ip} sent User-Agent '{user_agent}' claiming {claimed}, but its SYN fingerprint {ja4t} matches a {observed} TCP stack", + ), + score: None, + } +} + +fn first_seen(ts: i64, ip: &str, kind: FpKind, value: &str, summary: &Summary) -> Alert { + let known = match &summary.label { + Some(label) => format!(", known as {label}"), + None => String::new(), + }; + Alert { + ts_nanos: ts, + rule: Rule::FirstSeen, + severity: AlertSeverity::Info, + ip: Some(ip.to_string()), + fp_kind: Some(kind), + fp_value: Some(value.to_string()), + title: format!( + "first time this {} fingerprint has been seen", + kind.as_str() + ), + detail: format!( + "{} {value} recorded for the first time{known}", + kind.as_str() + ), + score: None, + } +} + +fn rotation(ts: i64, ip: &str, kind: FpKind, distinct: i64, window_secs: i64) -> Alert { + Alert { + ts_nanos: ts, + rule: Rule::FpRotation, + severity: AlertSeverity::Medium, + ip: Some(ip.to_string()), + fp_kind: Some(kind), + fp_value: None, + title: format!("address is rotating {} fingerprints", kind.as_str()), + detail: format!( + "address {ip} presented {distinct} distinct {} fingerprints within {window_secs}s, more than a single client explains", + kind.as_str(), + ), + score: None, + } +} + +fn monoculture(ts: i64, kind: FpKind, value: &str, addresses: i64, window_secs: i64) -> Alert { + Alert { + ts_nanos: ts, + rule: Rule::Monoculture, + severity: AlertSeverity::Medium, + ip: None, + fp_kind: Some(kind), + fp_value: Some(value.to_string()), + title: format!("one {} fingerprint spans many addresses", kind.as_str()), + detail: format!( + "{} {value} appeared from {addresses} distinct addresses within {window_secs}s, the shape of a single toolkit run widely", + kind.as_str(), + ), + score: None, + } +} + +fn prevalence_phrase(seen: i64, total: i64) -> String { + if total <= 0 { + return "prevalence unknown".to_string(); + } + let pct = 100.0 * f64_of(seen) / f64_of(total); + format!("prevalence {pct:.1}% ({seen}/{total} observations)") +} + +/// Converts an observation count to a float for the prevalence percentage. A +/// non negative integer is exact in an f64 up to two to the fifty third, far +/// more rows than any observation table holds, so the precision the lint warns +/// about is never actually lost here. +#[allow(clippy::cast_precision_loss)] +fn f64_of(n: i64) -> f64 { + n.max(0) as f64 +} + +fn fp_seen_anywhere(conn: &Connection, kind: FpKind, value: &str) -> Result { + let exists: i64 = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM observation WHERE fp_kind = ?1 AND fp_value = ?2)", + params![kind.as_str(), value], + |row| row.get(0), + )?; + Ok(exists == 1) +} + +fn ip_fp_seen(conn: &Connection, ip: &str, kind: FpKind, value: &str) -> Result { + let exists: i64 = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM observation WHERE ip = ?1 AND fp_kind = ?2 AND fp_value = ?3)", + params![ip, kind.as_str(), value], + |row| row.get(0), + )?; + Ok(exists == 1) +} + +fn distinct_fp_for_ip(conn: &Connection, ip: &str, kind: FpKind, since: i64) -> Result { + let count: i64 = conn.query_row( + "SELECT count(DISTINCT fp_value) FROM observation + WHERE ip = ?1 AND fp_kind = ?2 AND ts >= ?3", + params![ip, kind.as_str(), since], + |row| row.get(0), + )?; + Ok(count) +} + +fn distinct_ip_for_fp(conn: &Connection, kind: FpKind, value: &str, since: i64) -> Result { + let count: i64 = conn.query_row( + "SELECT count(DISTINCT ip) FROM observation + WHERE fp_kind = ?1 AND fp_value = ?2 AND ts >= ?3", + params![kind.as_str(), value, since], + |row| row.get(0), + )?; + Ok(count) +} + +fn fp_prevalence(conn: &Connection, kind: FpKind, value: &str) -> Result<(i64, i64)> { + let seen: i64 = conn.query_row( + "SELECT count(*) FROM observation WHERE fp_kind = ?1 AND fp_value = ?2", + params![kind.as_str(), value], + |row| row.get(0), + )?; + let total: i64 = conn.query_row("SELECT count(*) FROM observation", [], |row| row.get(0))?; + Ok((seen, total)) +} + +fn prior_tls_labels( + conn: &Connection, + ip: &str, + since: i64, +) -> Result> { + let mut statement = conn.prepare( + "SELECT fp_kind, fp_value, label, category FROM observation + WHERE ip = ?1 AND fp_kind IN ('ja3', 'ja4') AND label IS NOT NULL AND ts >= ?2 + ORDER BY ts DESC", + )?; + let rows = statement + .query_map(params![ip, since], |row| { + let kind: String = row.get(0)?; + let value: String = row.get(1)?; + let label: String = row.get(2)?; + let category: String = row.get(3)?; + Ok((kind, value, label, category)) + })? + .collect::>>()?; + Ok(rows + .into_iter() + .map(|(kind, value, label, category)| (kind, value, label, Category::from_token(&category))) + .collect()) +} + +fn prior_browser_uas(conn: &Connection, ip: &str, since: i64) -> Result> { + let mut statement = conn.prepare( + "SELECT user_agent FROM observation + WHERE ip = ?1 AND fp_kind = 'ja4h' AND user_agent IS NOT NULL AND ts >= ?2 + ORDER BY ts DESC", + )?; + let rows = statement + .query_map(params![ip, since], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok(rows) +} + +fn prior_ja4t(conn: &Connection, ip: &str, since: i64) -> Result> { + let mut statement = conn.prepare( + "SELECT fp_value FROM observation + WHERE ip = ?1 AND fp_kind = 'ja4t' AND ts >= ?2 + ORDER BY ts DESC", + )?; + let rows = statement + .query_map(params![ip, since], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok(rows) +} + +fn prior_os_claims(conn: &Connection, ip: &str, since: i64) -> Result> { + let mut statement = conn.prepare( + "SELECT user_agent, os_claim FROM observation + WHERE ip = ?1 AND fp_kind = 'ja4h' AND os_claim IS NOT NULL AND ts >= ?2 + ORDER BY ts DESC", + )?; + let rows = statement + .query_map(params![ip, since], |row| { + let user_agent: Option = row.get(0)?; + let os_claim: String = row.get(1)?; + Ok((user_agent.unwrap_or_default(), os_claim)) + })? + .collect::>>()?; + Ok(rows) +} + +#[allow(clippy::too_many_arguments)] +fn insert_observation( + conn: &Connection, + ts: i64, + ip: &str, + kind: FpKind, + value: &str, + summary: &Summary, + context: &Context, +) -> Result { + conn.execute( + "INSERT INTO observation + (ts, ip, fp_kind, fp_value, verdict, label, category, sni, host, user_agent, os_claim) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + ts, + ip, + kind.as_str(), + value, + summary.verdict.map(Verdict::as_str), + summary.label, + summary.category.map(Category::as_str), + context.sni, + context.host, + context.user_agent, + context.os_claim, + ], + )?; + Ok(conn.last_insert_rowid()) +} + +fn persist(conn: &Connection, alert: &Alert, observation_id: i64) -> Result<()> { + let observation_id = match alert.rule { + Rule::Monoculture => None, + _ => Some(observation_id), + }; + conn.execute( + "INSERT INTO alert + (ts, rule, severity, ip, fp_kind, fp_value, title, detail, score, observation_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + alert.ts_nanos, + alert.rule.as_str(), + alert.severity.as_str(), + alert.ip, + alert.fp_kind.map(FpKind::as_str), + alert.fp_value, + alert.title, + alert.detail, + alert.score, + observation_id, + ], + )?; + Ok(()) +} + +/// The most recent alerts, newest first, for the CLI feed and the dashboard. +pub(crate) fn recent(conn: &Connection, limit: i64) -> Result> { + let mut statement = conn.prepare( + "SELECT ts, rule, severity, ip, fp_kind, fp_value, title, detail, score + FROM alert ORDER BY ts DESC, id DESC LIMIT ?1", + )?; + let rows = statement + .query_map(params![limit], |row| { + let rule: String = row.get(1)?; + let severity: String = row.get(2)?; + let fp_kind: Option = row.get(4)?; + Ok(Alert { + ts_nanos: row.get(0)?, + rule: Rule::from_token(&rule), + severity: AlertSeverity::from_token(&severity), + ip: row.get(3)?, + fp_kind: fp_kind.and_then(|token| FpKind::from_token(&token)), + fp_value: row.get(5)?, + title: row.get(6)?, + detail: row.get(7)?, + score: row.get(8)?, + }) + })? + .collect::>>()?; + Ok(rows) +} + +/// A small count of alerts per rule, for the stats summary. +pub(crate) fn counts_by_rule(conn: &Connection) -> Result> { + let mut statement = + conn.prepare("SELECT rule, count(*) FROM alert GROUP BY rule ORDER BY count(*) DESC")?; + let rows = statement + .query_map([], |row| { + let rule: String = row.get(0)?; + let count: i64 = row.get(1)?; + Ok((Rule::from_token(&rule), count)) + })? + .collect::>>()?; + Ok(rows) +} + +#[cfg(test)] +mod tests { + use super::{ + AlertSeverity, Rule, Summary, prevalence_phrase, severity_rank, verdict_rank, worst_report, + }; + use crate::model::{Category, FpKind, IntelHit, MatchReport, MatchStrength, Severity, Verdict}; + + fn hit(category: Category, strength: MatchStrength, label: &str) -> IntelHit { + IntelHit { + kind: FpKind::Ja4, + value: "v".into(), + label: label.into(), + category, + source: "feed".into(), + reference: None, + strength, + } + } + + #[test] + fn summary_keeps_the_worst_hit() { + let report = MatchReport::from_hits( + FpKind::Ja4, + "x".into(), + vec![ + hit(Category::Benign, MatchStrength::Exact, "java"), + hit(Category::Malware, MatchStrength::Exact, "TrickBot"), + ], + ); + let summary = Summary::from_reports(&[report]); + assert_eq!(summary.category, Some(Category::Malware)); + assert_eq!(summary.label.as_deref(), Some("TrickBot")); + } + + #[test] + fn summary_of_nothing_is_empty() { + let summary = Summary::from_reports(&[]); + assert!(summary.verdict.is_none()); + assert!(summary.label.is_none()); + assert!(summary.category.is_none()); + } + + #[test] + fn worst_report_ignores_benign_and_prefers_malicious() { + let benign = MatchReport::from_hits( + FpKind::Ja4, + "x".into(), + vec![hit(Category::Benign, MatchStrength::Exact, "Chrome")], + ); + assert!(worst_report(std::slice::from_ref(&benign)).is_none()); + + let malicious = MatchReport::from_hits( + FpKind::Ja3, + "y".into(), + vec![hit(Category::Malware, MatchStrength::Exact, "Bad")], + ); + let suspicious = MatchReport::from_hits( + FpKind::Ja4, + "z".into(), + vec![hit(Category::Tool, MatchStrength::Exact, "Tool")], + ); + let reports = [benign, suspicious, malicious]; + let chosen = worst_report(&reports).unwrap(); + assert_eq!(chosen.verdict, Verdict::Malicious); + } + + #[test] + fn prevalence_phrase_handles_empty_and_normal() { + assert_eq!(prevalence_phrase(0, 0), "prevalence unknown"); + assert_eq!( + prevalence_phrase(1, 4), + "prevalence 25.0% (1/4 observations)" + ); + } + + #[test] + fn rank_helpers_order_correctly() { + assert!(severity_rank(Severity::Malicious) > severity_rank(Severity::Suspicious)); + assert!(severity_rank(Severity::Suspicious) > severity_rank(Severity::Benign)); + assert!(verdict_rank(Verdict::Malicious) > verdict_rank(Verdict::Benign)); + assert!(verdict_rank(Verdict::Benign) > verdict_rank(Verdict::Unknown)); + } + + #[test] + fn alert_severity_is_ordered() { + assert!(AlertSeverity::Critical > AlertSeverity::High); + assert!(AlertSeverity::High > AlertSeverity::Medium); + assert!(AlertSeverity::Medium > AlertSeverity::Low); + assert!(AlertSeverity::Low > AlertSeverity::Info); + } + + #[test] + fn rule_tokens_round_trip() { + for rule in [ + Rule::KnownBad, + Rule::UaMismatch, + Rule::OsMismatch, + Rule::FirstSeen, + Rule::FpRotation, + Rule::Monoculture, + ] { + assert_eq!(Rule::from_token(rule.as_str()), rule); + } + assert_eq!(Rule::from_token("garbage"), Rule::FirstSeen); + } + + #[test] + fn severity_tokens_round_trip() { + for severity in [ + AlertSeverity::Info, + AlertSeverity::Low, + AlertSeverity::Medium, + AlertSeverity::High, + AlertSeverity::Critical, + ] { + assert_eq!(AlertSeverity::from_token(severity.as_str()), severity); + } + assert_eq!(AlertSeverity::from_token("garbage"), AlertSeverity::Info); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/import.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/import.rs new file mode 100644 index 00000000..50203c5b --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/import.rs @@ -0,0 +1,321 @@ +// ©AngelaMos | 2026 +// import.rs + +//! Importing the ja4db.com enrichment feed. +//! +//! Unlike the three bundled feeds, ja4db is fetched at install time rather than +//! committed, because its licence is unspecified and the install script is the +//! honest place to pull it. Its records are also known to be dirty: the upstream +//! project tracks an issue where some fingerprint fields hold placeholders or +//! truncated values. So every fingerprint is validated for its kind before it +//! goes in, and the count of rejected rows is reported rather than hidden. +//! +//! The payload is read as untyped JSON rather than a fixed struct. ja4db is a +//! community database whose shape drifts, and a parser that demands an exact +//! schema would reject the whole file the day a field is renamed. Reading each +//! record as a map of optional fields keeps the importer working across those +//! changes and is itself the tolerance the dirty data calls for. +//! +//! ja4db identifies applications, so a record is treated as benign unless its +//! own classification fields name it as malicious. That makes ja4db the benign +//! baseline that the malicious feeds are weighed against, without silently +//! relabelling the handful of malware entries it does carry. + +use anyhow::{Context, Result}; +use rusqlite::Connection; +use serde_json::Value; + +use super::model::{Category, FpKind}; +use super::{NewFingerprint, get_or_create_source, insert_fingerprint, refresh_source_count}; + +const SOURCE_NAME: &str = "ja4db.com"; +const SOURCE_URL: &str = "https://ja4db.com/api/read/"; + +/// The JSON field that carries each fingerprint kind in a ja4db record. +const FIELDS: &[(&str, FpKind)] = &[ + ("ja4_fingerprint", FpKind::Ja4), + ("ja4s_fingerprint", FpKind::Ja4s), + ("ja4h_fingerprint", FpKind::Ja4h), + ("ja4x_fingerprint", FpKind::Ja4x), + ("ja4t_fingerprint", FpKind::Ja4t), + ("ja4ts_fingerprint", FpKind::Ja4ts), +]; + +/// What one import run did. +#[derive(Debug, Clone)] +pub struct ImportSummary { + pub records: usize, + pub imported: usize, + pub skipped: usize, +} + +/// Imports a ja4db `/api/read/` JSON array, validating every fingerprint. +pub fn import_ja4db(conn: &mut Connection, json: &str) -> Result { + let payload: Value = serde_json::from_str(json).context("parsing ja4db JSON")?; + let records = payload + .as_array() + .context("ja4db payload is not a JSON array")?; + + let tx = conn.transaction()?; + let source_id = get_or_create_source( + &tx, + SOURCE_NAME, + Some(SOURCE_URL), + Some("unspecified"), + "fetched", + )?; + + let mut imported = 0; + let mut skipped = 0; + for record in records { + let category = classify(record); + let label = build_label(record); + let reference = build_reference(record); + for (field, kind) in FIELDS { + let Some(raw) = record.get(field).and_then(Value::as_str) else { + continue; + }; + let value = raw.trim(); + if value.is_empty() { + continue; + } + if !valid_fingerprint(*kind, value) { + skipped += 1; + continue; + } + if insert_fingerprint( + &tx, + source_id, + &NewFingerprint { + kind: *kind, + value, + label: &label, + category, + reference: reference.as_deref(), + first_seen: None, + }, + )? { + imported += 1; + } + } + } + refresh_source_count(&tx, source_id)?; + tx.commit()?; + + Ok(ImportSummary { + records: records.len(), + imported, + skipped, + }) +} + +/// Builds a display label from the application, library, or device, with the +/// operating system in parentheses when ja4db knows it. +fn build_label(record: &Value) -> String { + let primary = ["application", "library", "device"] + .into_iter() + .filter_map(|key| record.get(key).and_then(Value::as_str)) + .map(str::trim) + .find(|name| !name.is_empty()); + let os = record + .get("os") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()); + match (primary, os) { + (Some(name), Some(os)) => format!("{name} ({os})"), + (Some(name), None) => name.to_string(), + (None, Some(os)) => os.to_string(), + (None, None) => "unknown".to_string(), + } +} + +/// Picks a human readable reference for a record, preferring the user agent +/// string and falling back to free form notes. +fn build_reference(record: &Value) -> Option { + for key in ["user_agent_string", "notes"] { + if let Some(text) = record.get(key).and_then(Value::as_str) { + let text = text.trim(); + if !text.is_empty() { + return Some(text.to_string()); + } + } + } + None +} + +/// Classifies a record as benign unless one of its classification fields names +/// it as malicious. Identity fields like the application name are deliberately +/// not scanned, so an app that merely has an alarming name is not relabelled. +fn classify(record: &Value) -> Category { + const MALICIOUS: &[&str] = &[ + "malware", + "malicious", + "trojan", + "botnet", + "ransomware", + "stealer", + "cobalt strike", + "backdoor", + "command and control", + ]; + let mut text = String::new(); + for key in ["notes", "classification", "comment", "tags", "category"] { + if let Some(value) = record.get(key).and_then(Value::as_str) { + text.push_str(&value.to_ascii_lowercase()); + text.push(' '); + } + } + if MALICIOUS.iter().any(|needle| text.contains(needle)) { + Category::Malware + } else { + Category::Benign + } +} + +/// Whether a value has the right shape for its fingerprint kind. The checks are +/// loose enough to admit real data and strict enough to reject the placeholders +/// and truncations that the dirty ja4db rows are made of. +fn valid_fingerprint(kind: FpKind, value: &str) -> bool { + match kind { + FpKind::Ja3 | FpKind::Ja3s => is_hex(value, 32), + FpKind::Ja4 => { + let parts = value.split('_').collect::>(); + parts.len() == 3 + && is_prefix(parts[0], 8, 12) + && is_hex(parts[1], 12) + && is_hex(parts[2], 12) + } + FpKind::Ja4s => { + let parts = value.split('_').collect::>(); + parts.len() == 3 + && is_prefix(parts[0], 5, 9) + && is_hex_between(parts[1], 2, 8) + && is_hex(parts[2], 12) + } + FpKind::Ja4x => { + let parts = value.split('_').collect::>(); + parts.len() == 3 && parts.iter().all(|part| is_hex(part, 12)) + } + FpKind::Ja4t | FpKind::Ja4ts => { + let parts = value.split('_').collect::>(); + (2..=4).contains(&parts.len()) + && !parts[0].is_empty() + && parts[0].bytes().all(|byte| byte.is_ascii_digit()) + } + FpKind::Ja4h => { + let parts = value.split('_').collect::>(); + parts.len() >= 2 + && parts[0].len() >= 8 + && parts[0].bytes().all(|byte| byte.is_ascii_alphanumeric()) + } + } +} + +fn is_hex(value: &str, len: usize) -> bool { + value.len() == len && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn is_hex_between(value: &str, min: usize, max: usize) -> bool { + (min..=max).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn is_prefix(value: &str, min: usize, max: usize) -> bool { + (min..=max).contains(&value.len()) + && matches!(value.as_bytes().first(), Some(b't' | b'q' | b'd')) + && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use crate::IntelStore; + use crate::model::{FpKind, Verdict}; + + const SAMPLE: &str = r#"[ + {"application":"Chrome","os":"Windows","ja4_fingerprint":"t13d1516h2_8daaf6152771_e5627efa2ab1","user_agent_string":"Mozilla/5.0"}, + {"application":"curl","library":"OpenSSL","ja4_fingerprint":"t13d1234h1_aaaaaaaaaaaa_bbbbbbbbbbbb"}, + {"application":"Loader","notes":"known malware stealer","ja4_fingerprint":"t10d070600_c50f5591e341_1a3805c3aa63"}, + {"application":"Dirty","ja4_fingerprint":"GREASE"}, + {"application":"NoFp","ja4_fingerprint":null}, + {"application":"Server","ja4s_fingerprint":"t130200_1301_234ea6891581"} + ]"#; + + #[test] + fn imports_valid_rows_and_counts_dirty_ones() { + let mut store = IntelStore::open_in_memory().unwrap(); + let summary = store.import_ja4db(SAMPLE).unwrap(); + assert_eq!(summary.records, 6); + assert_eq!(summary.imported, 4); + assert_eq!(summary.skipped, 1); + } + + #[test] + fn re_importing_adds_nothing_new() { + let mut store = IntelStore::open_in_memory().unwrap(); + store.import_ja4db(SAMPLE).unwrap(); + let second = store.import_ja4db(SAMPLE).unwrap(); + assert_eq!(second.imported, 0); + assert_eq!(second.skipped, 1); + } + + #[test] + fn classification_field_marks_malware() { + let mut store = IntelStore::open_in_memory().unwrap(); + store.import_ja4db(SAMPLE).unwrap(); + let report = store + .match_fingerprint(FpKind::Ja4, "t10d070600_c50f5591e341_1a3805c3aa63") + .unwrap(); + assert_eq!(report.verdict, Verdict::Malicious); + } + + #[test] + fn identified_application_is_benign() { + let mut store = IntelStore::open_in_memory().unwrap(); + store.import_ja4db(SAMPLE).unwrap(); + let report = store + .match_fingerprint(FpKind::Ja4, "t13d1516h2_8daaf6152771_e5627efa2ab1") + .unwrap(); + assert_eq!(report.verdict, Verdict::Benign); + assert!( + report + .hits + .iter() + .any(|hit| hit.label == "Chrome (Windows)") + ); + } + + #[test] + fn a_non_array_payload_is_an_error() { + let mut store = IntelStore::open_in_memory().unwrap(); + assert!(store.import_ja4db("{}").is_err()); + assert!(store.import_ja4db("not json").is_err()); + } + + #[test] + fn an_empty_array_imports_nothing() { + let mut store = IntelStore::open_in_memory().unwrap(); + let summary = store.import_ja4db("[]").unwrap(); + assert_eq!(summary.records, 0); + assert_eq!(summary.imported, 0); + } + + #[test] + fn validators_reject_placeholders() { + use super::valid_fingerprint; + assert!(valid_fingerprint( + FpKind::Ja4, + "t13d1516h2_8daaf6152771_e5627efa2ab1" + )); + assert!(!valid_fingerprint(FpKind::Ja4, "GREASE")); + assert!(!valid_fingerprint( + FpKind::Ja4, + "t13d1516h2_short_e5627efa2ab1" + )); + assert!(!valid_fingerprint(FpKind::Ja4, "")); + assert!(valid_fingerprint( + FpKind::Ja3, + "1aa7bf8b97e540ca5edd75f7b8384bfa" + )); + assert!(!valid_fingerprint(FpKind::Ja3, "N/A")); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/lib.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/lib.rs new file mode 100644 index 00000000..81c851c6 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/lib.rs @@ -0,0 +1,339 @@ +// ©AngelaMos | 2026 +// lib.rs + +//! The local threat intelligence store. +//! +//! This is the half of the tool that turns a fingerprint into a judgement. It +//! owns a bundled SQLite database, seeds it from three vendored feeds, can pull +//! a fourth feed at install time, and answers the one question the rest of the +//! tool cares about: is this fingerprint known, and is it known to be bad. +//! +//! The store is deliberately synchronous. The capture pipeline that feeds it is +//! a plain loop, and a lookup is a single indexed query, so wrapping it in an +//! async runtime would buy nothing here. The web server, when it arrives, is +//! the place that needs concurrent access, and that is where an async wrapper +//! belongs. + +mod detect; +mod import; +mod matcher; +mod model; +mod schema; +mod seed; +mod signal; + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use rusqlite::{Connection, params}; +use serde::Serialize; +use tlsfp_core::FingerprintEvent; + +pub use detect::{Alert, AlertSeverity, DetectConfig, Rule}; +pub use import::ImportSummary; +pub use model::{Category, FpKind, IntelHit, MatchReport, MatchStrength, Verdict}; +pub use seed::{FeedLoad, SeedSummary}; + +/// A handle to the open intelligence database. +pub struct IntelStore { + conn: Connection, +} + +impl IntelStore { + /// Opens or creates the database at `path`, creating any missing parent + /// directories and bringing the schema up to date. + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating database directory {}", parent.display()))?; + } + } + let conn = Connection::open(path) + .with_context(|| format!("opening database {}", path.display()))?; + conn.execute_batch("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;")?; + Self::migrate(conn) + } + + /// Opens a private in memory database, used by tests and by commands that + /// only need a scratch store. + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + Self::migrate(conn) + } + + fn migrate(mut conn: Connection) -> Result { + schema::apply_migrations(&mut conn).context("applying schema migrations")?; + Ok(Self { conn }) + } + + /// Loads the three vendored feeds, skipping rows already present so a second + /// call changes nothing. + pub fn seed_bundled(&mut self) -> Result { + seed::load_bundled(&mut self.conn) + } + + /// Imports a ja4db.com `/api/read/` JSON payload, validating each + /// fingerprint and reporting how many rows were kept and skipped. + pub fn import_ja4db(&mut self, json: &str) -> Result { + import::import_ja4db(&mut self.conn, json) + } + + /// Looks up one fingerprint and scores the hits into a verdict. + pub fn match_fingerprint(&self, kind: FpKind, value: &str) -> Result { + matcher::match_one(&self.conn, kind, value) + } + + /// Looks up every fingerprint carried by one capture event, returning a + /// report for each kind that found intelligence. + pub fn match_event(&self, event: &FingerprintEvent) -> Result> { + let mut reports = Vec::new(); + for (kind, value) in matcher::event_fingerprints(event) { + let report = self.match_fingerprint(kind, &value)?; + if report.has_hits() { + reports.push(report); + } + } + Ok(reports) + } + + /// Records one capture event and returns every alert its detection rules + /// raised, using the default thresholds. + pub fn detect(&mut self, event: &FingerprintEvent) -> Result> { + self.detect_with(event, &DetectConfig::default()) + } + + /// Records one capture event under explicit thresholds. The observation and + /// any alerts it raises commit together inside one transaction. + pub fn detect_with( + &mut self, + event: &FingerprintEvent, + config: &DetectConfig, + ) -> Result> { + let tx = self.conn.transaction()?; + let alerts = detect::run(&tx, event, config)?; + tx.commit()?; + Ok(alerts) + } + + /// The most recent alerts, newest first, for the CLI feed and the dashboard. + pub fn recent_alerts(&self, limit: i64) -> Result> { + detect::recent(&self.conn, limit) + } + + /// A count of recorded alerts per rule, for the stats summary. + pub fn alert_counts(&self) -> Result> { + detect::counts_by_rule(&self.conn) + } + + /// Summarises what the store holds, by feed and by category. + pub fn stats(&self) -> Result { + let sources = self + .conn + .prepare("SELECT name, kind, license, record_count FROM intel_source ORDER BY name")? + .query_map([], |row| { + Ok(SourceStat { + name: row.get(0)?, + kind: row.get(1)?, + license: row.get(2)?, + records: row.get(3)?, + }) + })? + .collect::>>()?; + + let by_category = self + .conn + .prepare( + "SELECT category, count(*) FROM intel_fingerprint GROUP BY category ORDER BY category", + )? + .query_map([], |row| { + Ok(CategoryStat { + category: row.get(0)?, + records: row.get(1)?, + }) + })? + .collect::>>()?; + + let total: i64 = + self.conn + .query_row("SELECT count(*) FROM intel_fingerprint", [], |row| { + row.get(0) + })?; + + Ok(Stats { + sources, + by_category, + total, + }) + } +} + +/// A per feed row of the stats summary. +#[derive(Debug, Clone, Serialize)] +pub struct SourceStat { + pub name: String, + pub kind: String, + pub license: Option, + pub records: i64, +} + +/// A per category row of the stats summary. +#[derive(Debug, Clone, Serialize)] +pub struct CategoryStat { + pub category: String, + pub records: i64, +} + +/// What the store currently holds. +#[derive(Debug, Clone, Serialize)] +pub struct Stats { + pub sources: Vec, + pub by_category: Vec, + pub total: i64, +} + +/// The default on disk location of the database, under the XDG data directory +/// when one is set and the home directory otherwise. +pub fn default_db_path() -> PathBuf { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local").join("share")) + }) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("tlsfp").join("intel.db") +} + +/// Splits a JA4 client fingerprint into its capability prefix and cipher hash, +/// the two parts the partial matcher indexes on. Returns `None` for a value +/// that is not the expected three underscore separated fields. +pub(crate) fn ja4_parts(value: &str) -> Option<(String, String)> { + let mut fields = value.split('_'); + let prefix = fields.next()?; + let cipher = fields.next()?; + let extensions = fields.next()?; + if fields.next().is_some() || prefix.is_empty() || cipher.is_empty() || extensions.is_empty() { + return None; + } + Some((prefix.to_string(), cipher.to_string())) +} + +/// Inserts a feed by name or updates it if already present, returning its row +/// id. The id is kept stable across re imports so the fingerprints that point +/// at it are never orphaned. +pub(crate) fn get_or_create_source( + conn: &Connection, + name: &str, + url: Option<&str>, + license: Option<&str>, + kind: &str, +) -> rusqlite::Result { + conn.execute( + "INSERT INTO intel_source (name, url, license, kind, imported_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(name) DO UPDATE SET + url = excluded.url, + license = excluded.license, + kind = excluded.kind, + imported_at = excluded.imported_at", + params![name, url, license, kind, unix_now()], + )?; + conn.query_row( + "SELECT id FROM intel_source WHERE name = ?1", + params![name], + |row| row.get(0), + ) +} + +/// One fingerprint about to be written, shared by the seed loader and the +/// ja4db importer so both compute the JA4 partial match columns identically. +pub(crate) struct NewFingerprint<'a> { + pub kind: FpKind, + pub value: &'a str, + pub label: &'a str, + pub category: Category, + pub reference: Option<&'a str>, + pub first_seen: Option<&'a str>, +} + +/// Inserts one fingerprint, lowercasing the value, filling the JA4 partial +/// match columns where they apply, and leaving any existing row untouched. +/// Returns whether a new row was written. +pub(crate) fn insert_fingerprint( + conn: &Connection, + source_id: i64, + fingerprint: &NewFingerprint, +) -> rusqlite::Result { + let value = fingerprint.value.trim().to_ascii_lowercase(); + let (part_a, part_b) = if fingerprint.kind.supports_partial() { + match ja4_parts(&value) { + Some((prefix, cipher)) => (Some(prefix), Some(cipher)), + None => (None, None), + } + } else { + (None, None) + }; + let affected = conn.execute( + "INSERT OR IGNORE INTO intel_fingerprint + (fp_kind, value, part_a, part_b, label, category, reference, first_seen, source_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + fingerprint.kind.as_str(), + value, + part_a, + part_b, + fingerprint.label, + fingerprint.category.as_str(), + fingerprint.reference, + fingerprint.first_seen, + source_id, + ], + )?; + Ok(affected == 1) +} + +/// Recomputes and stores a feed's record count after its rows are inserted. +pub(crate) fn refresh_source_count(conn: &Connection, source_id: i64) -> rusqlite::Result<()> { + conn.execute( + "UPDATE intel_source + SET record_count = (SELECT count(*) FROM intel_fingerprint WHERE source_id = ?1) + WHERE id = ?1", + params![source_id], + )?; + Ok(()) +} + +/// The current wall clock time in whole seconds since the Unix epoch, clamped to +/// zero if the clock is set before 1970. +pub(crate) fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|d| i64::try_from(d.as_secs()).ok()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::ja4_parts; + + #[test] + fn ja4_parts_splits_three_fields() { + let parts = ja4_parts("t13d1516h2_8daaf6152771_e5627efa2ab1"); + assert_eq!( + parts, + Some(("t13d1516h2".to_string(), "8daaf6152771".to_string())) + ); + } + + #[test] + fn ja4_parts_rejects_wrong_shape() { + assert_eq!(ja4_parts("nounderscores"), None); + assert_eq!(ja4_parts("only_two"), None); + assert_eq!(ja4_parts("a_b_c_d"), None); + assert_eq!(ja4_parts("a__c"), None); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/matcher.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/matcher.rs new file mode 100644 index 00000000..ceff2ad5 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/matcher.rs @@ -0,0 +1,258 @@ +// ©AngelaMos | 2026 +// matcher.rs + +//! Looking a fingerprint up against the store. +//! +//! Every kind supports an exact match. The JA4 client fingerprint also supports +//! two partial tiers, because its value is three fields joined by underscores: +//! a capability prefix, a hash of the cipher list, and a hash of the extension +//! list. A tool that copies a browser's cipher order but not its full set of +//! extensions produces a different whole fingerprint but the same cipher hash, +//! so matching on the cipher hash alone catches the impersonation that an exact +//! match misses. The three tiers query disjoint rows, so a hit is counted once +//! at its true strength with no later de duplication. + +use rusqlite::{Connection, Params, Row, params}; +use tlsfp_core::{FingerprintEvent, StreamEvent}; + +use super::ja4_parts; +use super::model::{Category, FpKind, IntelHit, MatchReport, MatchStrength}; + +const SELECT: &str = "SELECT f.fp_kind, f.value, f.label, f.category, s.name, f.reference + FROM intel_fingerprint f + JOIN intel_source s ON s.id = f.source_id"; + +/// Looks one fingerprint up and scores every hit into a verdict. +pub fn match_one(conn: &Connection, kind: FpKind, value: &str) -> anyhow::Result { + let value = value.trim().to_ascii_lowercase(); + let mut hits = collect( + conn, + &format!("{SELECT} WHERE f.fp_kind = ?1 AND f.value = ?2"), + params![kind.as_str(), value], + MatchStrength::Exact, + )?; + + if kind.supports_partial() { + if let Some((prefix, cipher)) = ja4_parts(&value) { + hits.extend(collect( + conn, + &format!( + "{SELECT} WHERE f.fp_kind = 'ja4' AND f.part_a = ?1 AND f.part_b = ?2 AND f.value != ?3" + ), + params![prefix, cipher, value], + MatchStrength::CipherAndPrefix, + )?); + hits.extend(collect( + conn, + &format!("{SELECT} WHERE f.fp_kind = 'ja4' AND f.part_b = ?1 AND f.part_a != ?2"), + params![cipher, prefix], + MatchStrength::CipherOnly, + )?); + } + } + + Ok(MatchReport::from_hits(kind, value, hits)) +} + +/// The fingerprints carried by one capture event, paired with their kind. +pub fn event_fingerprints(event: &FingerprintEvent) -> Vec<(FpKind, String)> { + match &event.event { + StreamEvent::ClientHello { ja3, ja4, .. } => { + vec![ + (FpKind::Ja3, ja3.to_string()), + (FpKind::Ja4, ja4.hash.clone()), + ] + } + StreamEvent::ServerHello { ja3s, ja4s, .. } => { + vec![ + (FpKind::Ja3s, ja3s.to_string()), + (FpKind::Ja4s, ja4s.hash.clone()), + ] + } + StreamEvent::Certificate { ja4x } => vec![(FpKind::Ja4x, ja4x.clone())], + StreamEvent::HttpRequest { ja4h, .. } => vec![(FpKind::Ja4h, ja4h.hash.clone())], + StreamEvent::TcpSyn { ja4t } => vec![(FpKind::Ja4t, ja4t.clone())], + StreamEvent::TcpSynAck { ja4ts } => vec![(FpKind::Ja4ts, ja4ts.clone())], + } +} + +fn collect( + conn: &Connection, + sql: &str, + params: impl Params, + strength: MatchStrength, +) -> rusqlite::Result> { + let mut statement = conn.prepare(sql)?; + let hits = statement + .query_map(params, |row| map_row(row, strength))? + .collect::>>()?; + Ok(hits) +} + +fn map_row(row: &Row, strength: MatchStrength) -> rusqlite::Result { + let kind: String = row.get(0)?; + let category: String = row.get(3)?; + Ok(IntelHit { + kind: FpKind::from_token(&kind).unwrap_or(FpKind::Ja3), + value: row.get(1)?, + label: row.get(2)?, + category: Category::from_token(&category), + source: row.get(4)?, + reference: row.get(5)?, + strength, + }) +} + +#[cfg(test)] +mod tests { + use super::{event_fingerprints, match_one}; + use crate::IntelStore; + use crate::model::{FpKind, MatchStrength, Verdict}; + use tlsfp_core::fingerprint::{Ja3, Ja4Family}; + use tlsfp_core::{FingerprintEvent, StreamEvent}; + + fn seeded() -> IntelStore { + let mut store = IntelStore::open_in_memory().unwrap(); + store.seed_bundled().unwrap(); + store + } + + #[test] + fn known_malicious_ja3_is_malicious() { + let store = seeded(); + let report = store + .match_fingerprint(FpKind::Ja3, "1aa7bf8b97e540ca5edd75f7b8384bfa") + .unwrap(); + assert_eq!(report.verdict, Verdict::Malicious); + assert!(report.hits.iter().any(|hit| hit.label == "TrickBot")); + } + + #[test] + fn known_benign_ja3_is_benign() { + let store = seeded(); + let report = store + .match_fingerprint(FpKind::Ja3, "c36fb08942cf19508c08d96af22d4ffc") + .unwrap(); + assert_eq!(report.verdict, Verdict::Benign); + assert!(report.hits.iter().any(|hit| hit.label == "Safari")); + } + + #[test] + fn cross_feed_collision_is_suspicious() { + let store = seeded(); + let report = store + .match_fingerprint(FpKind::Ja3, "51a7ad14509fd614c7bb3a50c4982b8c") + .unwrap(); + assert_eq!(report.verdict, Verdict::Suspicious); + assert!(report.hits.len() >= 2); + assert!((report.threat_score - 0.5).abs() < 1e-9); + } + + #[test] + fn unknown_fingerprint_returns_no_hits() { + let store = seeded(); + let report = store + .match_fingerprint(FpKind::Ja3, "00000000000000000000000000000000") + .unwrap(); + assert_eq!(report.verdict, Verdict::Unknown); + assert!(!report.has_hits()); + } + + #[test] + fn case_is_normalised_before_lookup() { + let store = seeded(); + let report = store + .match_fingerprint(FpKind::Ja3, "1AA7BF8B97E540CA5EDD75F7B8384BFA") + .unwrap(); + assert_eq!(report.verdict, Verdict::Malicious); + } + + #[test] + fn exact_ja4_lookup_hits() { + let store = seeded(); + let report = store + .match_fingerprint(FpKind::Ja4, "t10d070600_c50f5591e341_1a3805c3aa63") + .unwrap(); + assert_eq!(report.verdict, Verdict::Malicious); + assert_eq!(report.hits[0].strength, MatchStrength::Exact); + assert!(report.hits.iter().any(|hit| hit.label == "RedLine Stealer")); + } + + #[test] + fn ja4_same_ciphers_different_extensions_is_a_partial_hit() { + let store = seeded(); + let report = match_one( + &store.conn, + FpKind::Ja4, + "t13d1516h2_8daaf6152771_ffffffffffff", + ) + .unwrap(); + assert!(report.has_hits()); + assert_eq!(report.hits[0].strength, MatchStrength::CipherAndPrefix); + assert!(report.hits.iter().any(|hit| hit.label == "Google Chrome")); + } + + #[test] + fn ja4_same_cipher_list_under_a_different_profile_is_cipher_only() { + let store = seeded(); + let report = match_one( + &store.conn, + FpKind::Ja4, + "t13d0000h0_8daaf6152771_000000000000", + ) + .unwrap(); + assert!(report.has_hits()); + assert_eq!(report.hits[0].strength, MatchStrength::CipherOnly); + } + + #[test] + fn ja3_does_not_do_partial_matching() { + let store = seeded(); + let report = store + .match_fingerprint(FpKind::Ja3, "1aa7bf8b97e540ca5edd75f7b8384bf0") + .unwrap(); + assert!(!report.has_hits()); + } + + #[test] + fn event_fingerprints_pulls_both_client_fingerprints() { + let event = FingerprintEvent { + ts_nanos: 0, + src: "10.0.0.1:1000".parse().unwrap(), + dst: "10.0.0.2:443".parse().unwrap(), + event: StreamEvent::ClientHello { + ja3: Ja3::from_digest([0x1a; 16]), + ja3_raw: "raw".into(), + ja4: Ja4Family::new("t13d1516h2_8daaf6152771_e5627efa2ab1".into(), "raw".into()), + sni: None, + alpn: None, + }, + }; + let fingerprints = event_fingerprints(&event); + assert_eq!(fingerprints.len(), 2); + assert_eq!(fingerprints[0].0, FpKind::Ja3); + assert_eq!(fingerprints[1].0, FpKind::Ja4); + assert_eq!(fingerprints[1].1, "t13d1516h2_8daaf6152771_e5627efa2ab1"); + } + + #[test] + fn match_event_only_reports_kinds_with_intel() { + let store = seeded(); + let event = FingerprintEvent { + ts_nanos: 0, + src: "10.0.0.1:1000".parse().unwrap(), + dst: "10.0.0.2:443".parse().unwrap(), + event: StreamEvent::ClientHello { + ja3: Ja3::from_digest([0x00; 16]), + ja3_raw: "raw".into(), + ja4: Ja4Family::new("t13d1516h2_8daaf6152771_e5627efa2ab1".into(), "raw".into()), + sni: None, + alpn: None, + }, + }; + let reports = store.match_event(&event).unwrap(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].kind, FpKind::Ja4); + assert_eq!(reports[0].verdict, Verdict::Benign); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/model.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/model.rs new file mode 100644 index 00000000..5fae7d9f --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/model.rs @@ -0,0 +1,461 @@ +// ©AngelaMos | 2026 +// model.rs + +//! Domain types for the intelligence store. +//! +//! These describe what the store holds (a fingerprint, its kind, and how its +//! label is classified) and what a lookup returns (a set of hits and the +//! verdict they add up to). The scoring lives here too, kept apart from the SQL +//! so it can be unit tested on plain values with no database in the picture. + +use std::fmt; + +use serde::Serialize; + +/// Which fingerprint algorithm a stored value belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FpKind { + Ja3, + Ja3s, + Ja4, + Ja4s, + Ja4h, + Ja4x, + Ja4t, + Ja4ts, +} + +impl FpKind { + /// The lowercase token used for this kind in the database and on the CLI. + pub const fn as_str(self) -> &'static str { + match self { + FpKind::Ja3 => "ja3", + FpKind::Ja3s => "ja3s", + FpKind::Ja4 => "ja4", + FpKind::Ja4s => "ja4s", + FpKind::Ja4h => "ja4h", + FpKind::Ja4x => "ja4x", + FpKind::Ja4t => "ja4t", + FpKind::Ja4ts => "ja4ts", + } + } + + /// Parses a kind token, returning `None` for anything unrecognised. + pub fn from_token(token: &str) -> Option { + Some(match token { + "ja3" => FpKind::Ja3, + "ja3s" => FpKind::Ja3s, + "ja4" => FpKind::Ja4, + "ja4s" => FpKind::Ja4s, + "ja4h" => FpKind::Ja4h, + "ja4x" => FpKind::Ja4x, + "ja4t" => FpKind::Ja4t, + "ja4ts" => FpKind::Ja4ts, + _ => return None, + }) + } + + /// Whether partial, structure aware matching applies to this kind. + /// + /// Only the JA4 client fingerprint carries a cipher list hash and a + /// capability prefix that mean something on their own, so it is the only + /// kind that supports the cipher and prefix match tiers. Everything else is + /// either an opaque digest (JA3) or a single server side value where a + /// partial match would not be informative. + pub const fn supports_partial(self) -> bool { + matches!(self, FpKind::Ja4) + } +} + +impl fmt::Display for FpKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The classification carried by a stored label, as read from a seed feed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Category { + Malware, + C2, + Tool, + Benign, + Os, + Unknown, +} + +impl Category { + /// The token used for this category in the database and seed files. + pub const fn as_str(self) -> &'static str { + match self { + Category::Malware => "malware", + Category::C2 => "c2", + Category::Tool => "tool", + Category::Benign => "benign", + Category::Os => "os", + Category::Unknown => "unknown", + } + } + + /// Parses a category token, falling back to `Unknown` for anything else so + /// that a dirty feed value never aborts an import. + pub fn from_token(token: &str) -> Self { + match token.trim().to_ascii_lowercase().as_str() { + "malware" => Category::Malware, + "c2" => Category::C2, + "tool" => Category::Tool, + "benign" => Category::Benign, + "os" => Category::Os, + _ => Category::Unknown, + } + } + + /// How a hit in this category weighs on the final verdict. + /// + /// Command and control and malware are malicious. Dual use tooling, such as + /// Metasploit or a Tor client, is suspicious rather than malicious because + /// its presence is noteworthy but not proof of compromise. Benign and + /// operating system baselines are benign. An unlabelled entry is treated as + /// suspicious, since it was put in the store for some reason. + pub const fn severity(self) -> Severity { + match self { + Category::Malware | Category::C2 => Severity::Malicious, + Category::Tool | Category::Unknown => Severity::Suspicious, + Category::Benign | Category::Os => Severity::Benign, + } + } +} + +/// The coarse direction a single hit pushes the verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Malicious, + Suspicious, + Benign, +} + +/// How closely a stored fingerprint matched the observed one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchStrength { + /// Every byte of the fingerprint is identical. + Exact, + /// JA4 only: the capability prefix and the cipher hash both match but the + /// extension hash differs, so the same client stack presented a different + /// extension set, often just a different server name. + CipherAndPrefix, + /// JA4 only: the cipher hash matches but the capability prefix differs, so a + /// different protocol or version profile is carrying the same cipher list. + /// This is the tell of a tool that copies a browser cipher order. + CipherOnly, +} + +impl MatchStrength { + /// The token used for this strength on the CLI and in JSON. + pub const fn as_str(self) -> &'static str { + match self { + MatchStrength::Exact => "exact", + MatchStrength::CipherAndPrefix => "cipher_and_prefix", + MatchStrength::CipherOnly => "cipher_only", + } + } + + /// A weight in the range zero to one expressing how much trust a match of + /// this strength earns when scoring. + pub const fn weight(self) -> f64 { + match self { + MatchStrength::Exact => 1.0, + MatchStrength::CipherAndPrefix => 0.8, + MatchStrength::CipherOnly => 0.55, + } + } +} + +/// One stored fingerprint that matched the observed one, with its provenance. +#[derive(Debug, Clone, Serialize)] +pub struct IntelHit { + pub kind: FpKind, + pub value: String, + pub label: String, + pub category: Category, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reference: Option, + pub strength: MatchStrength, +} + +/// The judgement for an observed fingerprint after weighing every hit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + Malicious, + Suspicious, + Benign, + Unknown, +} + +impl Verdict { + pub const fn as_str(self) -> &'static str { + match self { + Verdict::Malicious => "malicious", + Verdict::Suspicious => "suspicious", + Verdict::Benign => "benign", + Verdict::Unknown => "unknown", + } + } +} + +impl fmt::Display for Verdict { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The full result of looking up one observed fingerprint. +#[derive(Debug, Clone, Serialize)] +pub struct MatchReport { + pub kind: FpKind, + pub observed: String, + pub verdict: Verdict, + pub threat_score: f64, + pub confidence: f64, + pub hits: Vec, +} + +impl MatchReport { + /// Whether the lookup found any intelligence at all. + pub fn has_hits(&self) -> bool { + !self.hits.is_empty() + } + + /// Scores a set of hits into a verdict. + /// + /// The threat score follows the prevalence idea from public sandboxes: a + /// fingerprint seen mostly in malicious sources scores high, one seen mostly + /// in benign sources scores low, and one claimed by both lands in the + /// middle. Each hit contributes its match strength as weight, so an exact + /// hash hit counts for more than a partial cipher hit. Suspicious, dual use + /// hits count as half a malicious vote. + /// + /// Confidence is separate from the score: it says how sure the verdict is, + /// rising with the strength of the best match and with the number of + /// corroborating hits that agree with the verdict. + pub fn from_hits(kind: FpKind, observed: String, hits: Vec) -> Self { + if hits.is_empty() { + return Self { + kind, + observed, + verdict: Verdict::Unknown, + threat_score: 0.0, + confidence: 0.0, + hits, + }; + } + + let mut malicious = 0.0; + let mut suspicious = 0.0; + let mut benign = 0.0; + let mut best = 0.0_f64; + for hit in &hits { + let weight = hit.strength.weight(); + best = best.max(weight); + match hit.category.severity() { + Severity::Malicious => malicious += weight, + Severity::Suspicious => suspicious += weight, + Severity::Benign => benign += weight, + } + } + let total = malicious + suspicious + benign; + let threat_score = (malicious + 0.5 * suspicious) / total; + + let verdict = if threat_score >= 0.8 { + Verdict::Malicious + } else if threat_score <= 0.2 { + Verdict::Benign + } else { + Verdict::Suspicious + }; + + let aligned = hits + .iter() + .filter(|hit| verdict_aligns(verdict, hit.category.severity())) + .count(); + let corroboration = 1.0 - 1.0 / (1.0 + count_to_f64(aligned)); + let confidence = best * (0.6 + 0.4 * corroboration); + + Self { + kind, + observed, + verdict, + threat_score, + confidence, + hits, + } + } +} + +/// Whether a hit of a given severity supports the chosen verdict, used to count +/// how many hits corroborate the result when scoring confidence. +fn verdict_aligns(verdict: Verdict, severity: Severity) -> bool { + match verdict { + Verdict::Malicious => severity == Severity::Malicious, + Verdict::Benign => severity == Severity::Benign, + Verdict::Suspicious => true, + Verdict::Unknown => false, + } +} + +/// Converts a small count to a float without tripping the precision loss lint. +/// Intel hit counts are tiny, so saturating at `u32::MAX` is unreachable. +fn count_to_f64(n: usize) -> f64 { + f64::from(u32::try_from(n).unwrap_or(u32::MAX)) +} + +#[cfg(test)] +mod tests { + use super::{Category, FpKind, IntelHit, MatchReport, MatchStrength, Severity, Verdict}; + + fn hit(category: Category, strength: MatchStrength) -> IntelHit { + IntelHit { + kind: FpKind::Ja3, + value: "x".into(), + label: "x".into(), + category, + source: "s".into(), + reference: None, + strength, + } + } + + #[test] + fn kind_tokens_round_trip() { + for kind in [ + FpKind::Ja3, + FpKind::Ja3s, + FpKind::Ja4, + FpKind::Ja4s, + FpKind::Ja4h, + FpKind::Ja4x, + FpKind::Ja4t, + FpKind::Ja4ts, + ] { + assert_eq!(FpKind::from_token(kind.as_str()), Some(kind)); + } + assert_eq!(FpKind::from_token("nope"), None); + } + + #[test] + fn only_ja4_supports_partial() { + assert!(FpKind::Ja4.supports_partial()); + assert!(!FpKind::Ja3.supports_partial()); + assert!(!FpKind::Ja4s.supports_partial()); + } + + #[test] + fn category_severity_mapping() { + assert_eq!(Category::Malware.severity(), Severity::Malicious); + assert_eq!(Category::C2.severity(), Severity::Malicious); + assert_eq!(Category::Tool.severity(), Severity::Suspicious); + assert_eq!(Category::Unknown.severity(), Severity::Suspicious); + assert_eq!(Category::Benign.severity(), Severity::Benign); + assert_eq!(Category::Os.severity(), Severity::Benign); + } + + #[test] + fn no_hits_is_unknown() { + let report = MatchReport::from_hits(FpKind::Ja3, "x".into(), vec![]); + assert_eq!(report.verdict, Verdict::Unknown); + assert!(report.threat_score.abs() < 1e-9); + assert!(report.confidence.abs() < 1e-9); + assert!(!report.has_hits()); + } + + #[test] + fn single_exact_malware_is_malicious() { + let report = MatchReport::from_hits( + FpKind::Ja3, + "x".into(), + vec![hit(Category::Malware, MatchStrength::Exact)], + ); + assert_eq!(report.verdict, Verdict::Malicious); + assert!((report.threat_score - 1.0).abs() < 1e-9); + assert!((report.confidence - 0.8).abs() < 1e-9); + } + + #[test] + fn single_benign_is_benign() { + let report = MatchReport::from_hits( + FpKind::Ja3, + "x".into(), + vec![hit(Category::Benign, MatchStrength::Exact)], + ); + assert_eq!(report.verdict, Verdict::Benign); + assert!((report.threat_score - 0.0).abs() < 1e-9); + assert!((report.confidence - 0.8).abs() < 1e-9); + } + + #[test] + fn malicious_and_benign_collision_is_suspicious() { + let report = MatchReport::from_hits( + FpKind::Ja3, + "x".into(), + vec![ + hit(Category::Malware, MatchStrength::Exact), + hit(Category::Benign, MatchStrength::Exact), + ], + ); + assert_eq!(report.verdict, Verdict::Suspicious); + assert!((report.threat_score - 0.5).abs() < 1e-9); + let expected = 1.0 * (0.6 + 0.4 * (1.0 - 1.0 / 3.0)); + assert!((report.confidence - expected).abs() < 1e-9); + } + + #[test] + fn dual_use_tool_alone_is_suspicious() { + let report = MatchReport::from_hits( + FpKind::Ja3, + "x".into(), + vec![hit(Category::Tool, MatchStrength::Exact)], + ); + assert_eq!(report.verdict, Verdict::Suspicious); + assert!((report.threat_score - 0.5).abs() < 1e-9); + } + + #[test] + fn partial_cipher_hit_lowers_confidence() { + let exact = MatchReport::from_hits( + FpKind::Ja4, + "x".into(), + vec![hit(Category::Malware, MatchStrength::Exact)], + ); + let partial = MatchReport::from_hits( + FpKind::Ja4, + "x".into(), + vec![hit(Category::Malware, MatchStrength::CipherOnly)], + ); + assert!(partial.confidence < exact.confidence); + assert_eq!(partial.verdict, Verdict::Malicious); + } + + #[test] + fn corroboration_raises_confidence() { + let one = MatchReport::from_hits( + FpKind::Ja3, + "x".into(), + vec![hit(Category::Malware, MatchStrength::Exact)], + ); + let three = MatchReport::from_hits( + FpKind::Ja3, + "x".into(), + vec![ + hit(Category::Malware, MatchStrength::Exact), + hit(Category::C2, MatchStrength::Exact), + hit(Category::Malware, MatchStrength::Exact), + ], + ); + assert!(three.confidence > one.confidence); + assert_eq!(three.verdict, Verdict::Malicious); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/schema.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/schema.rs new file mode 100644 index 00000000..1d699c41 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/schema.rs @@ -0,0 +1,167 @@ +// ©AngelaMos | 2026 +// schema.rs + +//! The database schema and its migration runner. +//! +//! Versions are tracked in SQLite's own `user_version` header field rather than +//! a side table, so an empty database and a fully migrated one are told apart +//! with a single pragma and no bootstrapping. Each migration is the whole SQL +//! to move from one version to the next, applied inside a transaction so a +//! half applied schema can never be left behind. Running the migrations on an +//! already current database is a no op, which is what lets every command open +//! the store and migrate without checking first. +//! +//! Migration one is the intelligence half: feeds and the fingerprints they +//! carry. Migration two is the detection half: the observations the engine +//! records as it watches traffic and the alerts it raises when a rule fires. +//! Both tables stand on their own, so a detection run needs intelligence loaded +//! only for the rules that consult it. + +use rusqlite::Connection; + +/// The ordered list of migrations. Index zero moves a fresh database to version +/// one, index one to version two, and so on. Append, never edit in place, or an +/// existing database will silently disagree with a new one. +const MIGRATIONS: &[&str] = &[ + r" +CREATE TABLE intel_source ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + url TEXT, + license TEXT, + kind TEXT NOT NULL, + imported_at INTEGER NOT NULL, + record_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE intel_fingerprint ( + id INTEGER PRIMARY KEY, + fp_kind TEXT NOT NULL, + value TEXT NOT NULL, + part_a TEXT, + part_b TEXT, + label TEXT NOT NULL, + category TEXT NOT NULL, + reference TEXT, + first_seen TEXT, + source_id INTEGER NOT NULL REFERENCES intel_source(id) ON DELETE CASCADE, + UNIQUE(fp_kind, value, source_id) +); + +CREATE INDEX idx_fp_kind_value ON intel_fingerprint(fp_kind, value); +CREATE INDEX idx_fp_kind_part_b ON intel_fingerprint(fp_kind, part_b); +CREATE INDEX idx_fp_kind_part_a ON intel_fingerprint(fp_kind, part_a); +", + r" +CREATE TABLE observation ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + ip TEXT NOT NULL, + fp_kind TEXT NOT NULL, + fp_value TEXT NOT NULL, + verdict TEXT, + label TEXT, + category TEXT, + sni TEXT, + host TEXT, + user_agent TEXT, + os_claim TEXT +); + +CREATE INDEX idx_obs_ip ON observation(ip); +CREATE INDEX idx_obs_ip_kind ON observation(ip, fp_kind); +CREATE INDEX idx_obs_fp ON observation(fp_kind, fp_value); +CREATE INDEX idx_obs_ts ON observation(ts); + +CREATE TABLE alert ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + rule TEXT NOT NULL, + severity TEXT NOT NULL, + ip TEXT, + fp_kind TEXT, + fp_value TEXT, + title TEXT NOT NULL, + detail TEXT NOT NULL, + score REAL, + observation_id INTEGER REFERENCES observation(id) ON DELETE SET NULL +); + +CREATE INDEX idx_alert_ts ON alert(ts); +CREATE INDEX idx_alert_rule ON alert(rule); +CREATE INDEX idx_alert_ip ON alert(ip); +", +]; + +/// Brings a connection's schema up to the latest version, applying only the +/// migrations it is missing. Safe to call on every open. +pub fn apply_migrations(conn: &mut Connection) -> rusqlite::Result<()> { + let mut version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + while usize::try_from(version).unwrap_or(usize::MAX) < MIGRATIONS.len() { + let index = usize::try_from(version).unwrap_or(usize::MAX); + let tx = conn.transaction()?; + tx.execute_batch(MIGRATIONS[index])?; + tx.pragma_update(None, "user_version", version + 1)?; + tx.commit()?; + version += 1; + } + Ok(()) +} + +/// The schema version a fully migrated database reports, used by the tests to +/// assert the runner reaches the head of the migration list. +#[cfg(test)] +fn latest_version() -> i64 { + i64::try_from(MIGRATIONS.len()).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::{MIGRATIONS, apply_migrations, latest_version}; + use rusqlite::Connection; + + fn user_version(conn: &Connection) -> i64 { + conn.pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap() + } + + #[test] + fn migrates_a_fresh_database_to_latest() { + let mut conn = Connection::open_in_memory().unwrap(); + assert_eq!(user_version(&conn), 0); + apply_migrations(&mut conn).unwrap(); + assert_eq!(user_version(&conn), latest_version()); + } + + #[test] + fn migrating_twice_is_a_no_op() { + let mut conn = Connection::open_in_memory().unwrap(); + apply_migrations(&mut conn).unwrap(); + apply_migrations(&mut conn).unwrap(); + assert_eq!(user_version(&conn), latest_version()); + } + + #[test] + fn expected_tables_exist() { + let mut conn = Connection::open_in_memory().unwrap(); + apply_migrations(&mut conn).unwrap(); + let count: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('intel_source','intel_fingerprint','observation','alert')", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 4); + } + + #[test] + fn migration_two_is_appended_not_edited() { + assert!( + MIGRATIONS.len() >= 2, + "detection tables live in migration two" + ); + assert!(MIGRATIONS[0].contains("intel_fingerprint")); + assert!(MIGRATIONS[1].contains("observation") && MIGRATIONS[1].contains("alert")); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/seed.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/seed.rs new file mode 100644 index 00000000..8fb4052b --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/seed.rs @@ -0,0 +1,360 @@ +// ©AngelaMos | 2026 +// seed.rs + +//! Loading the three vendored feeds into the database. +//! +//! The feeds are compiled into the binary, so seeding needs no network. Each +//! feed has its own column layout, so each gets its own small parser, but they +//! share one insert path that computes the JA4 partial match columns and skips +//! rows already present. Parsing uses a real CSV reader rather than splitting on +//! commas, because the salesforce application names are quoted and can contain +//! commas, and its licence header is a quoted field that spans several lines. +//! +//! Every malicious feed row is classified by the feed it came from: abuse.ch +//! SSLBL is a blocklist, so its rows are malware, and the salesforce list is a +//! benign application catalogue, so its rows are benign. The curated file +//! carries an explicit category per row. The point of loading both a malicious +//! and a benign feed is that some hashes appear in both, and the matcher needs +//! to see that disagreement to score it. + +use anyhow::Result; +use rusqlite::Connection; + +use super::model::{Category, FpKind}; +use super::{NewFingerprint, get_or_create_source, insert_fingerprint, refresh_source_count}; + +const SSLBL: &str = include_str!("../seeds/sslbl-ja3.csv"); +const SALESFORCE: &str = include_str!("../seeds/salesforce-osx-nix-ja3.csv"); +const CURATED: &str = include_str!("../seeds/curated-c2-intel.csv"); + +const SSLBL_NAME: &str = "abuse.ch SSLBL"; +const SSLBL_URL: &str = "https://sslbl.abuse.ch/blacklist/ja3_fingerprints.csv"; +const SALESFORCE_NAME: &str = "salesforce/ja3 osx-nix"; +const SALESFORCE_URL: &str = "https://github.com/salesforce/ja3"; +const CURATED_NAME: &str = "tlsfp curated"; + +/// How many rows one feed contributed on a seed run. +#[derive(Debug, Clone)] +pub struct FeedLoad { + pub name: String, + pub inserted: usize, + pub parsed: usize, +} + +/// The result of a full seed run across every feed. +#[derive(Debug, Clone)] +pub struct SeedSummary { + pub feeds: Vec, +} + +impl SeedSummary { + /// Rows newly inserted across all feeds, zero on a repeat seed. + pub fn inserted(&self) -> usize { + self.feeds.iter().map(|feed| feed.inserted).sum() + } + + /// Valid rows parsed across all feeds, the same on every seed. + pub fn parsed(&self) -> usize { + self.feeds.iter().map(|feed| feed.parsed).sum() + } +} + +/// Loads all three vendored feeds inside a single transaction. +pub fn load_bundled(conn: &mut Connection) -> Result { + let tx = conn.transaction()?; + let mut feeds = Vec::new(); + + let sslbl_id = + get_or_create_source(&tx, SSLBL_NAME, Some(SSLBL_URL), Some("CC0-1.0"), "bundled")?; + let (inserted, parsed) = load_sslbl(&tx, sslbl_id)?; + refresh_source_count(&tx, sslbl_id)?; + feeds.push(FeedLoad { + name: SSLBL_NAME.to_string(), + inserted, + parsed, + }); + + let sf_id = get_or_create_source( + &tx, + SALESFORCE_NAME, + Some(SALESFORCE_URL), + Some("BSD-3-Clause"), + "bundled", + )?; + let (inserted, parsed) = load_salesforce(&tx, sf_id)?; + refresh_source_count(&tx, sf_id)?; + feeds.push(FeedLoad { + name: SALESFORCE_NAME.to_string(), + inserted, + parsed, + }); + + let curated_id = get_or_create_source(&tx, CURATED_NAME, None, Some("project"), "bundled")?; + let (inserted, parsed) = load_curated(&tx, curated_id)?; + refresh_source_count(&tx, curated_id)?; + feeds.push(FeedLoad { + name: CURATED_NAME.to_string(), + inserted, + parsed, + }); + + tx.commit()?; + Ok(SeedSummary { feeds }) +} + +/// abuse.ch SSLBL: `ja3_md5, Firstseen, Lastseen, Listingreason`, every row a +/// known malicious JA3. +fn load_sslbl(conn: &Connection, source_id: i64) -> Result<(usize, usize)> { + let mut reader = csv::ReaderBuilder::new() + .has_headers(false) + .flexible(true) + .comment(Some(b'#')) + .from_reader(SSLBL.as_bytes()); + + let mut inserted = 0; + let mut parsed = 0; + for record in reader.records() { + let record = record?; + let value = record.get(0).unwrap_or_default().trim(); + if !is_hex_md5(value) { + continue; + } + let first_seen = record.get(1).map(str::trim).filter(|seen| !seen.is_empty()); + let label = record + .get(3) + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .unwrap_or("unknown"); + parsed += 1; + if insert_fingerprint( + conn, + source_id, + &NewFingerprint { + kind: FpKind::Ja3, + value, + label, + category: Category::Malware, + reference: None, + first_seen, + }, + )? { + inserted += 1; + } + } + Ok((inserted, parsed)) +} + +/// salesforce osx-nix: `ja3_md5, "application name(s)"`, every row a benign app. +/// The quoted multi line licence header is the first record and is dropped by +/// the hex check, since its first field is prose rather than a hash. +fn load_salesforce(conn: &Connection, source_id: i64) -> Result<(usize, usize)> { + let mut reader = csv::ReaderBuilder::new() + .has_headers(false) + .flexible(true) + .from_reader(SALESFORCE.as_bytes()); + + let mut inserted = 0; + let mut parsed = 0; + for record in reader.records() { + let record = record?; + let value = record.get(0).unwrap_or_default().trim(); + if !is_hex_md5(value) { + continue; + } + let label = record + .get(1) + .map(str::trim) + .filter(|app| !app.is_empty()) + .unwrap_or("unknown"); + parsed += 1; + if insert_fingerprint( + conn, + source_id, + &NewFingerprint { + kind: FpKind::Ja3, + value, + label, + category: Category::Benign, + reference: None, + first_seen: None, + }, + )? { + inserted += 1; + } + } + Ok((inserted, parsed)) +} + +/// The curated file: `fp_kind, value, label, category, reference`, each row a +/// hand classified entry from a named source. +fn load_curated(conn: &Connection, source_id: i64) -> Result<(usize, usize)> { + let mut reader = csv::ReaderBuilder::new() + .has_headers(true) + .flexible(true) + .comment(Some(b'#')) + .from_reader(CURATED.as_bytes()); + + let mut inserted = 0; + let mut parsed = 0; + for record in reader.records() { + let record = record?; + let Some(kind) = record.get(0).map(str::trim).and_then(FpKind::from_token) else { + continue; + }; + let value = record.get(1).unwrap_or_default().trim(); + if value.is_empty() { + continue; + } + let label = record + .get(2) + .map(str::trim) + .filter(|label| !label.is_empty()) + .unwrap_or("unknown"); + let category = record + .get(3) + .map_or(Category::Unknown, Category::from_token); + let reference = record.get(4).map(str::trim).filter(|note| !note.is_empty()); + parsed += 1; + if insert_fingerprint( + conn, + source_id, + &NewFingerprint { + kind, + value, + label, + category, + reference, + first_seen: None, + }, + )? { + inserted += 1; + } + } + Ok((inserted, parsed)) +} + +/// Whether a string is a lowercase or uppercase 32 character hex digest, the +/// shape of every JA3 value and the test that skips comment and header rows. +fn is_hex_md5(value: &str) -> bool { + value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::{CURATED_NAME, SALESFORCE_NAME, SSLBL_NAME, load_bundled}; + use crate::IntelStore; + use crate::model::{Category, FpKind}; + + fn category_of(store: &IntelStore, value: &str, source: &str) -> Option { + store + .conn + .query_row( + "SELECT category FROM intel_fingerprint f + JOIN intel_source s ON s.id = f.source_id + WHERE f.value = ?1 AND s.name = ?2", + rusqlite::params![value, source], + |row| row.get(0), + ) + .ok() + } + + #[test] + fn seeds_every_vendored_row() { + let mut store = IntelStore::open_in_memory().unwrap(); + let summary = store.seed_bundled().unwrap(); + assert_eq!(summary.parsed(), 97 + 157 + 17); + assert_eq!(summary.inserted(), summary.parsed()); + } + + #[test] + fn seeding_twice_inserts_nothing_new() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + super::super::schema::apply_migrations(&mut conn).unwrap(); + let first = load_bundled(&mut conn).unwrap(); + let second = load_bundled(&mut conn).unwrap(); + assert!(first.inserted() > 0); + assert_eq!(second.inserted(), 0); + assert_eq!(second.parsed(), first.parsed()); + } + + #[test] + fn sslbl_rows_are_malware_salesforce_rows_are_benign() { + let mut store = IntelStore::open_in_memory().unwrap(); + store.seed_bundled().unwrap(); + assert_eq!( + category_of(&store, "1aa7bf8b97e540ca5edd75f7b8384bfa", SSLBL_NAME).as_deref(), + Some("malware"), + ); + assert_eq!( + category_of(&store, "c36fb08942cf19508c08d96af22d4ffc", SALESFORCE_NAME).as_deref(), + Some("benign"), + ); + } + + #[test] + fn curated_carries_its_own_categories() { + let mut store = IntelStore::open_in_memory().unwrap(); + store.seed_bundled().unwrap(); + assert_eq!( + category_of(&store, "72a589da586844d7f0818ce684948eea", CURATED_NAME).as_deref(), + Some("c2"), + ); + assert_eq!( + category_of(&store, "8916410db85077a5460817142dcbc8de", CURATED_NAME).as_deref(), + Some("tool"), + ); + } + + #[test] + fn the_same_hash_can_be_both_malicious_and_benign() { + let mut store = IntelStore::open_in_memory().unwrap(); + store.seed_bundled().unwrap(); + let collision = "51a7ad14509fd614c7bb3a50c4982b8c"; + assert_eq!( + category_of(&store, collision, SSLBL_NAME).as_deref(), + Some("malware"), + ); + assert_eq!( + category_of(&store, collision, SALESFORCE_NAME).as_deref(), + Some("benign"), + ); + } + + #[test] + fn ja4_rows_get_partial_match_columns() { + let mut store = IntelStore::open_in_memory().unwrap(); + store.seed_bundled().unwrap(); + let parts: (Option, Option) = store + .conn + .query_row( + "SELECT part_a, part_b FROM intel_fingerprint WHERE fp_kind = 'ja4' AND value = ?1", + rusqlite::params!["t13d1516h2_8daaf6152771_e5627efa2ab1"], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(parts.0.as_deref(), Some("t13d1516h2")); + assert_eq!(parts.1.as_deref(), Some("8daaf6152771")); + let ja3_parts: (Option, Option) = store + .conn + .query_row( + "SELECT part_a, part_b FROM intel_fingerprint WHERE fp_kind = 'ja3' LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(ja3_parts, (None, None)); + } + + #[test] + fn category_tokens_parse() { + assert_eq!(Category::from_token("malware"), Category::Malware); + assert_eq!(Category::from_token(" C2 "), Category::C2); + assert_eq!(Category::from_token("garbage"), Category::Unknown); + } + + #[test] + fn fp_kind_tokens_parse() { + assert_eq!(FpKind::from_token("ja4"), Some(FpKind::Ja4)); + assert_eq!(FpKind::from_token("zz"), None); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/signal.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/signal.rs new file mode 100644 index 00000000..404b91f8 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/src/signal.rs @@ -0,0 +1,417 @@ +// ©AngelaMos | 2026 +// signal.rs + +//! The classifiers the detection rules read fingerprints and headers through. +//! +//! These are deliberately pure: a string in, a small verdict out, no database +//! and no state. That keeps the judgement calls, which are the part most likely +//! to be wrong, testable against named example values. +//! +//! Two families of classifier live here. The first maps a fingerprint or a +//! User-Agent to a client family, so a request that calls itself a browser can +//! be checked against what its fingerprint actually is. The second maps a JA4T +//! or a User-Agent to a coarse operating-system class, so the operating system +//! a connection claims can be checked against the one its TCP stack reveals. +//! +//! The operating-system heuristic is intentionally coarse: Windows against +//! everything Unix-like. It rests on one signature that is stable across stack +//! versions and well documented: Microsoft Windows does not send the TCP +//! timestamp option (kind 8) on a SYN, while Unix-like stacks do, and within a +//! stack that sends no timestamp, Windows orders the window-scale option before +//! the SACK-permitted option where Linux without timestamps does the reverse. +//! Anything that does not match a known signature is left unclassified rather +//! than guessed, so the mismatch rule never fires on an ambiguous stack. +//! +//! Sources: the JA4T specification and the FoxIO JA4T write-up +//! (blog.foxio.io/ja4t-tcp-fingerprinting), and the p0f v3 SYN signature +//! database it builds on for the per-operating-system option layouts. + +use crate::model::Category; + +/// A coarse operating-system class, the resolution the SYN signature can carry +/// without guessing. Finer naming from one packet is not reliable, so the +/// mismatch rule works at this granularity on purpose. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OsClass { + Windows, + Unix, +} + +impl OsClass { + /// A short token for the class, used when describing an alert. + pub const fn as_str(self) -> &'static str { + match self { + OsClass::Windows => "windows", + OsClass::Unix => "unix", + } + } + + /// Parses a class token back into a class, the inverse of `as_str`. This + /// reads the operating system an observation already resolved and stored, + /// which is not a User-Agent and must not be run back through the loose + /// User-Agent classifier. + pub fn from_token(token: &str) -> Option { + match token { + "windows" => Some(OsClass::Windows), + "unix" => Some(OsClass::Unix), + _ => None, + } + } +} + +/// The operating system a User-Agent string claims, read from the platform +/// token every mainstream browser places near the front of the string. +/// +/// Returns `None` when no platform token is recognised, so an unusual or absent +/// User-Agent never produces a false claim to compare against. +#[must_use] +pub fn ua_os_class(user_agent: &str) -> Option { + const UNIX_TOKENS: &[&str] = &[ + "android", + "linux", + "mac os x", + "macintosh", + "iphone", + "ipad", + "ipod", + " cros ", + "x11", + "freebsd", + "openbsd", + "netbsd", + ]; + let ua = user_agent.to_ascii_lowercase(); + if ua.contains("windows") { + return Some(OsClass::Windows); + } + if UNIX_TOKENS.iter().any(|token| ua.contains(token)) { + return Some(OsClass::Unix); + } + None +} + +/// The operating-system class a JA4T implies from its TCP option layout. +/// +/// The JA4T value is `window_options_mss_windowscale`; only the options field, +/// a dash separated list of TCP option kind numbers, is read here. A timestamp +/// option marks a Unix-like stack. Its absence, combined with the window-scale +/// option preceding the SACK-permitted option, marks Windows. Every other +/// layout, including a Unix stack with timestamps disabled, is left +/// unclassified. +#[must_use] +pub fn ja4t_os_class(ja4t: &str) -> Option { + let options = ja4t.split('_').nth(1)?; + if options.is_empty() || options == "0" { + return None; + } + let kinds: Vec<&str> = options.split('-').collect(); + if kinds.contains(&"8") { + return Some(OsClass::Unix); + } + let window_scale = kinds.iter().position(|kind| *kind == "3"); + let sack = kinds.iter().position(|kind| *kind == "4"); + if let (Some(window_scale), Some(sack)) = (window_scale, sack) { + if window_scale < sack { + return Some(OsClass::Windows); + } + } + None +} + +/// A client software family, coarse enough that a fingerprint label and a +/// User-Agent can be compared even when they word the same software +/// differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Family { + Chrome, + Firefox, + Safari, + Edge, + Opera, + Brave, + Curl, + Wget, + Python, + Go, + OkHttp, + Java, + Tor, +} + +impl Family { + /// A short token for the family, used when describing an alert. + pub const fn as_str(self) -> &'static str { + match self { + Family::Chrome => "chrome", + Family::Firefox => "firefox", + Family::Safari => "safari", + Family::Edge => "edge", + Family::Opera => "opera", + Family::Brave => "brave", + Family::Curl => "curl", + Family::Wget => "wget", + Family::Python => "python", + Family::Go => "go-http", + Family::OkHttp => "okhttp", + Family::Java => "java", + Family::Tor => "tor", + } + } + + /// Whether this family is a human-driven web browser rather than a script, + /// library, or command line client. The mismatch rule turns on this line: + /// a request that claims a browser but fingerprints as one of the others is + /// the impersonation worth flagging. + pub const fn is_browser(self) -> bool { + matches!( + self, + Family::Chrome + | Family::Firefox + | Family::Safari + | Family::Edge + | Family::Opera + | Family::Brave + ) + } +} + +/// The family a User-Agent string claims to be. +/// +/// Browsers are checked before the engines they embed, because a Chromium +/// derivative carries the Chrome and Safari tokens too, and a script that sets +/// a real browser string would otherwise be read as that browser. +#[must_use] +pub fn ua_family(user_agent: &str) -> Option { + let ua = user_agent.to_ascii_lowercase(); + if ua.contains("edg/") || ua.contains("edga/") || ua.contains("edgios/") { + return Some(Family::Edge); + } + if ua.contains("opr/") || ua.contains("opera") { + return Some(Family::Opera); + } + if ua.contains("brave") { + return Some(Family::Brave); + } + if ua.contains("firefox") || ua.contains("fxios") { + return Some(Family::Firefox); + } + if ua.contains("chrome") || ua.contains("chromium") || ua.contains("crios") { + return Some(Family::Chrome); + } + if ua.contains("safari") { + return Some(Family::Safari); + } + if ua.contains("curl") { + return Some(Family::Curl); + } + if ua.contains("wget") { + return Some(Family::Wget); + } + if ua.contains("python") || ua.contains("urllib") || ua.contains("aiohttp") { + return Some(Family::Python); + } + if ua.contains("go-http-client") { + return Some(Family::Go); + } + if ua.contains("okhttp") { + return Some(Family::OkHttp); + } + if ua.contains("java") { + return Some(Family::Java); + } + None +} + +/// The family an intelligence label names, by keyword. +/// +/// This reads the human label a feed attached to a fingerprint, so it +/// recognises the same software the User-Agent classifier does and nothing it +/// cannot name with confidence. +#[must_use] +pub fn label_family(label: &str) -> Option { + let label = label.to_ascii_lowercase(); + if label.contains("edge") { + return Some(Family::Edge); + } + if label.contains("opera") { + return Some(Family::Opera); + } + if label.contains("brave") { + return Some(Family::Brave); + } + if label.contains("firefox") { + return Some(Family::Firefox); + } + if label.contains("chrome") || label.contains("chromium") { + return Some(Family::Chrome); + } + if label.contains("safari") { + return Some(Family::Safari); + } + if label.contains("curl") { + return Some(Family::Curl); + } + if label.contains("wget") { + return Some(Family::Wget); + } + if label.contains("python") || label.contains("requests") || label.contains("urllib") { + return Some(Family::Python); + } + if label.contains("go-http") || label.contains("golang") { + return Some(Family::Go); + } + if label.contains("okhttp") { + return Some(Family::OkHttp); + } + if label.contains("java") { + return Some(Family::Java); + } + if label.contains("tor") { + return Some(Family::Tor); + } + None +} + +/// Whether an observed fingerprint should be read as a non-browser client. +/// +/// A label that names a script or tool says so directly. A category of tool, +/// malware, or command and control says it too, even when the label is just a +/// family name like TrickBot, because none of those are a browser. A benign or +/// unknown category with an unrecognised label is left alone, so the mismatch +/// rule needs a real reason to call something not a browser. +#[must_use] +pub fn label_is_non_browser(label: &str, category: Category) -> bool { + if label_family(label).is_some_and(|family| !family.is_browser()) { + return true; + } + matches!(category, Category::Tool | Category::Malware | Category::C2) +} + +/// Whether an observed fingerprint should be read as a browser, used to suppress +/// the mismatch rule when both sides agree the client is a browser. +#[must_use] +pub fn label_is_browser(label: &str) -> bool { + label_family(label).is_some_and(Family::is_browser) +} + +#[cfg(test)] +mod tests { + use super::{ + Family, OsClass, ja4t_os_class, label_family, label_is_browser, label_is_non_browser, + ua_family, ua_os_class, + }; + use crate::model::Category; + + #[test] + fn windows_user_agent_is_windows() { + let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"; + assert_eq!(ua_os_class(ua), Some(OsClass::Windows)); + } + + #[test] + fn unix_user_agents_are_unix() { + for ua in [ + "Mozilla/5.0 (X11; Linux x86_64) Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", + "Mozilla/5.0 (Linux; Android 14) Chrome/120.0", + ] { + assert_eq!(ua_os_class(ua), Some(OsClass::Unix), "{ua}"); + } + } + + #[test] + fn unknown_platform_is_unclassified() { + assert_eq!(ua_os_class("curl/8.4.0"), None); + assert_eq!(ua_os_class(""), None); + } + + #[test] + fn ja4t_with_timestamp_is_unix() { + assert_eq!(ja4t_os_class("29200_2-4-8-1-3_1424_7"), Some(OsClass::Unix)); + assert_eq!(ja4t_os_class("65535_2-4-8-1-3_1460_6"), Some(OsClass::Unix)); + } + + #[test] + fn ja4t_windows_layout_is_windows() { + assert_eq!( + ja4t_os_class("64240_2-1-3-1-1-4_1460_8"), + Some(OsClass::Windows) + ); + } + + #[test] + fn ja4t_ambiguous_layout_is_unclassified() { + assert_eq!(ja4t_os_class("64240_2-4-1-3_1460_7"), None); + assert_eq!(ja4t_os_class("64240_0_0_0"), None); + assert_eq!(ja4t_os_class("nonsense"), None); + } + + #[test] + fn windows_scale_byte_is_not_read_as_a_timestamp_option() { + let windows = ja4t_os_class("64240_2-1-3-1-1-4_1460_8"); + assert_eq!(windows, Some(OsClass::Windows)); + } + + #[test] + fn os_class_tokens_round_trip() { + assert_eq!( + OsClass::from_token(OsClass::Windows.as_str()), + Some(OsClass::Windows) + ); + assert_eq!( + OsClass::from_token(OsClass::Unix.as_str()), + Some(OsClass::Unix) + ); + assert_eq!(OsClass::from_token("plan9"), None); + } + + #[test] + fn browser_user_agents_classify() { + assert_eq!( + ua_family("Mozilla/5.0 (Windows NT 10.0) Gecko Firefox/121.0"), + Some(Family::Firefox) + ); + assert_eq!( + ua_family( + "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML) Chrome/120.0 Safari/537.36" + ), + Some(Family::Chrome) + ); + assert_eq!( + ua_family("Mozilla/5.0 (Macintosh) AppleWebKit/605.1 Version/17.0 Safari/605.1"), + Some(Family::Safari) + ); + } + + #[test] + fn edge_and_opera_are_not_read_as_chrome() { + assert_eq!( + ua_family("Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36 Edg/120.0"), + Some(Family::Edge) + ); + assert_eq!( + ua_family("Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36 OPR/106.0"), + Some(Family::Opera) + ); + } + + #[test] + fn tool_user_agents_classify() { + assert_eq!(ua_family("curl/8.4.0"), Some(Family::Curl)); + assert_eq!(ua_family("python-requests/2.31.0"), Some(Family::Python)); + assert_eq!(ua_family("Go-http-client/2.0"), Some(Family::Go)); + } + + #[test] + fn labels_classify_and_categories_decide_non_browser() { + assert_eq!(label_family("Google Chrome"), Some(Family::Chrome)); + assert_eq!(label_family("curl"), Some(Family::Curl)); + assert!(label_is_browser("Google Chrome")); + assert!(!label_is_browser("curl")); + assert!(label_is_non_browser("curl", Category::Benign)); + assert!(label_is_non_browser("TrickBot", Category::Malware)); + assert!(!label_is_non_browser("Google Chrome", Category::Benign)); + assert!(!label_is_non_browser("unrecognised", Category::Benign)); + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/tests/detect.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/tests/detect.rs new file mode 100644 index 00000000..5143341b --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/tests/detect.rs @@ -0,0 +1,340 @@ +// ©AngelaMos | 2026 +// detect.rs + +//! End to end tests for the detection engine. +//! +//! Each test drives a short sequence of hand built fingerprint events through a +//! store and checks that the right rule fires, and just as importantly that the +//! quiet cases stay quiet. The events are synthetic on purpose: the engine +//! consumes events, not packets, so building them directly is the honest seam +//! to test the rules at. + +use tlsfp_core::fingerprint::{Ja3, Ja4Family}; +use tlsfp_core::{FingerprintEvent, StreamEvent}; +use tlsfp_intel::{AlertSeverity, DetectConfig, IntelStore, Rule}; + +const CURL_JA4: &str = "t13d1234h1_aaaaaaaaaaaa_bbbbbbbbbbbb"; +const MALWARE_JA4: &str = "t10d070600_c50f5591e341_1a3805c3aa63"; +const CHROME_JA4: &str = "t13d1516h2_8daaf6152771_e5627efa2ab1"; +const BROWSER_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"; +const CURL_UA: &str = "curl/8.4.0"; +const UNIX_JA4T: &str = "29200_2-4-8-1-3_1424_7"; +const WINDOWS_JA4T: &str = "64240_2-1-3-1-1-4_1460_8"; + +const FEED: &str = r#"[ + {"application":"Chrome","os":"Windows","ja4_fingerprint":"t13d1516h2_8daaf6152771_e5627efa2ab1"}, + {"application":"curl","library":"OpenSSL","ja4_fingerprint":"t13d1234h1_aaaaaaaaaaaa_bbbbbbbbbbbb"}, + {"application":"Loader","notes":"known malware stealer","ja4_fingerprint":"t10d070600_c50f5591e341_1a3805c3aa63"} +]"#; + +fn store() -> IntelStore { + let mut store = IntelStore::open_in_memory().unwrap(); + store.import_ja4db(FEED).unwrap(); + store +} + +fn cfg() -> DetectConfig { + DetectConfig { + window_secs: 86_400, + rotation_threshold: 2, + monoculture_threshold: 2, + } +} + +fn client_hello(ip: &str, ts: u64, ja4: &str) -> FingerprintEvent { + FingerprintEvent { + ts_nanos: ts, + src: format!("{ip}:1000").parse().unwrap(), + dst: "10.0.0.250:443".parse().unwrap(), + event: StreamEvent::ClientHello { + ja3: Ja3::from_digest([0u8; 16]), + ja3_raw: "raw".into(), + ja4: Ja4Family::new(ja4.into(), "raw".into()), + sni: None, + alpn: None, + }, + } +} + +fn http_request(ip: &str, ts: u64, ua: &str) -> FingerprintEvent { + FingerprintEvent { + ts_nanos: ts, + src: format!("{ip}:1000").parse().unwrap(), + dst: "10.0.0.250:80".parse().unwrap(), + event: StreamEvent::HttpRequest { + ja4h: Ja4Family::new( + "ge20nn000000_000000000000_000000000000".into(), + "raw".into(), + ), + method: "GET".into(), + host: Some("example.com".into()), + user_agent: Some(ua.into()), + }, + } +} + +fn tcp_syn(ip: &str, ts: u64, ja4t: &str) -> FingerprintEvent { + FingerprintEvent { + ts_nanos: ts, + src: format!("{ip}:1000").parse().unwrap(), + dst: "10.0.0.250:443".parse().unwrap(), + event: StreamEvent::TcpSyn { ja4t: ja4t.into() }, + } +} + +fn fires(alerts: &[tlsfp_intel::Alert], rule: Rule) -> bool { + alerts.iter().any(|alert| alert.rule == rule) +} + +#[test] +fn known_bad_fires_on_malicious_fingerprint() { + let mut store = store(); + let alerts = store + .detect_with( + &client_hello("10.0.0.1", 1_000_000_000, MALWARE_JA4), + &cfg(), + ) + .unwrap(); + let known_bad = alerts + .iter() + .find(|alert| alert.rule == Rule::KnownBad) + .expect("a malicious fingerprint should raise known_bad"); + assert_eq!(known_bad.severity, AlertSeverity::High); + assert!(known_bad.detail.contains("Loader")); + assert!(known_bad.detail.contains("prevalence")); + assert_eq!(known_bad.score, Some(1.0)); +} + +#[test] +fn ua_mismatch_browser_claim_over_tool_handshake() { + let mut store = store(); + let config = cfg(); + store + .detect_with(&client_hello("10.0.0.2", 1_000_000_000, CURL_JA4), &config) + .unwrap(); + let alerts = store + .detect_with( + &http_request("10.0.0.2", 2_000_000_000, BROWSER_UA), + &config, + ) + .unwrap(); + let mismatch = alerts + .iter() + .find(|alert| alert.rule == Rule::UaMismatch) + .expect("a browser User-Agent over a curl handshake is the headline mismatch"); + assert_eq!(mismatch.severity, AlertSeverity::High); + assert!(mismatch.detail.to_lowercase().contains("curl")); + assert!(mismatch.title.to_lowercase().contains("chrome")); +} + +#[test] +fn ua_mismatch_fires_from_the_tls_side_too() { + let mut store = store(); + let config = cfg(); + store + .detect_with( + &http_request("10.0.0.3", 1_000_000_000, BROWSER_UA), + &config, + ) + .unwrap(); + let alerts = store + .detect_with(&client_hello("10.0.0.3", 2_000_000_000, CURL_JA4), &config) + .unwrap(); + assert!(fires(&alerts, Rule::UaMismatch)); +} + +#[test] +fn an_honest_tool_user_agent_does_not_mismatch() { + let mut store = store(); + let config = cfg(); + store + .detect_with(&client_hello("10.0.0.4", 1_000_000_000, CURL_JA4), &config) + .unwrap(); + let alerts = store + .detect_with(&http_request("10.0.0.4", 2_000_000_000, CURL_UA), &config) + .unwrap(); + assert!(!fires(&alerts, Rule::UaMismatch)); +} + +#[test] +fn a_real_browser_does_not_mismatch_its_own_handshake() { + let mut store = store(); + let config = cfg(); + store + .detect_with( + &client_hello("10.0.0.5", 1_000_000_000, CHROME_JA4), + &config, + ) + .unwrap(); + let alerts = store + .detect_with( + &http_request("10.0.0.5", 2_000_000_000, BROWSER_UA), + &config, + ) + .unwrap(); + assert!(!fires(&alerts, Rule::UaMismatch)); +} + +#[test] +fn os_mismatch_windows_claim_over_unix_stack() { + let mut store = store(); + let config = cfg(); + store + .detect_with(&tcp_syn("10.0.0.6", 1_000_000_000, UNIX_JA4T), &config) + .unwrap(); + let alerts = store + .detect_with( + &http_request("10.0.0.6", 2_000_000_000, BROWSER_UA), + &config, + ) + .unwrap(); + let mismatch = alerts + .iter() + .find(|alert| alert.rule == Rule::OsMismatch) + .expect("a Windows User-Agent over a Unix SYN should raise os_mismatch"); + assert_eq!(mismatch.severity, AlertSeverity::Medium); + assert!(mismatch.title.contains("windows")); + assert!(mismatch.title.contains("unix")); +} + +#[test] +fn os_mismatch_fires_from_the_syn_side_too() { + let mut store = store(); + let config = cfg(); + store + .detect_with( + &http_request("10.0.0.7", 1_000_000_000, BROWSER_UA), + &config, + ) + .unwrap(); + let alerts = store + .detect_with(&tcp_syn("10.0.0.7", 2_000_000_000, UNIX_JA4T), &config) + .unwrap(); + assert!(fires(&alerts, Rule::OsMismatch)); +} + +#[test] +fn a_consistent_operating_system_does_not_mismatch() { + let mut store = store(); + let config = cfg(); + store + .detect_with(&tcp_syn("10.0.0.8", 1_000_000_000, WINDOWS_JA4T), &config) + .unwrap(); + let alerts = store + .detect_with( + &http_request("10.0.0.8", 2_000_000_000, BROWSER_UA), + &config, + ) + .unwrap(); + assert!(!fires(&alerts, Rule::OsMismatch)); +} + +#[test] +fn first_seen_fires_once_per_fingerprint() { + let mut store = store(); + let config = cfg(); + let first = store + .detect_with( + &client_hello("10.0.0.9", 1_000_000_000, CHROME_JA4), + &config, + ) + .unwrap(); + assert!(fires(&first, Rule::FirstSeen)); + let again = store + .detect_with( + &client_hello("10.0.0.10", 2_000_000_000, CHROME_JA4), + &config, + ) + .unwrap(); + assert!(!fires(&again, Rule::FirstSeen)); +} + +#[test] +fn rotation_fires_when_one_address_cycles_fingerprints() { + let mut store = store(); + let config = cfg(); + let ip = "10.0.0.11"; + let first = store + .detect_with( + &client_hello(ip, 1_000_000_000, "t13d1516h2_111111111111_222222222222"), + &config, + ) + .unwrap(); + let second = store + .detect_with( + &client_hello(ip, 2_000_000_000, "t13d1516h2_333333333333_444444444444"), + &config, + ) + .unwrap(); + let third = store + .detect_with( + &client_hello(ip, 3_000_000_000, "t13d1516h2_555555555555_666666666666"), + &config, + ) + .unwrap(); + assert!(!fires(&first, Rule::FpRotation)); + assert!(!fires(&second, Rule::FpRotation)); + assert!(fires(&third, Rule::FpRotation)); +} + +#[test] +fn monoculture_fires_when_one_fingerprint_spans_addresses() { + let mut store = store(); + let config = cfg(); + let ja4 = "t13d1516h2_777777777777_888888888888"; + let first = store + .detect_with(&client_hello("10.1.0.1", 1_000_000_000, ja4), &config) + .unwrap(); + let second = store + .detect_with(&client_hello("10.1.0.2", 2_000_000_000, ja4), &config) + .unwrap(); + let third = store + .detect_with(&client_hello("10.1.0.3", 3_000_000_000, ja4), &config) + .unwrap(); + assert!(!fires(&first, Rule::Monoculture)); + assert!(!fires(&second, Rule::Monoculture)); + assert!(fires(&third, Rule::Monoculture)); +} + +#[test] +fn alerts_persist_and_round_trip_through_the_store() { + let mut store = store(); + store + .detect_with( + &client_hello("10.2.0.1", 1_000_000_000, MALWARE_JA4), + &cfg(), + ) + .unwrap(); + let recent = store.recent_alerts(10).unwrap(); + assert!( + recent + .iter() + .any(|alert| alert.rule == Rule::KnownBad && alert.severity == AlertSeverity::High) + ); + let counts = store.alert_counts().unwrap(); + assert!( + counts + .iter() + .any(|(rule, count)| *rule == Rule::KnownBad && *count >= 1) + ); +} + +#[test] +fn correlation_respects_the_time_window() { + let mut store = store(); + let config = DetectConfig { + window_secs: 1, + rotation_threshold: 2, + monoculture_threshold: 2, + }; + store + .detect_with(&client_hello("10.3.0.1", 0, CURL_JA4), &config) + .unwrap(); + let alerts = store + .detect_with( + &http_request("10.3.0.1", 10_000_000_000, BROWSER_UA), + &config, + ) + .unwrap(); + assert!(!fires(&alerts, Rule::UaMismatch)); +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/Cargo.toml b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/Cargo.toml index b6a77ed3..1c3b35a8 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/Cargo.toml +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/Cargo.toml @@ -21,6 +21,7 @@ workspace = true [dependencies] tlsfp-core.workspace = true +tlsfp-intel.workspace = true anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true @@ -28,7 +29,6 @@ pcap-parser.workspace = true pcap.workspace = true tokio.workspace = true flume.workspace = true -rusqlite.workspace = true axum.workspace = true tower-http.workspace = true tower.workspace = true diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/src/cli.rs b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/src/cli.rs index 5442e42f..904fedbd 100644 --- a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/src/cli.rs +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp/src/cli.rs @@ -1,6 +1,9 @@ // ©AngelaMos | 2026 // cli.rs +use std::io::Write as _; +use std::path::{Path, PathBuf}; + use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; @@ -8,6 +11,11 @@ use tracing_subscriber::EnvFilter; use tlsfp_core::{FingerprintEvent, PcapFileSource, Pipeline, PipelineConfig, SourceError}; use crate::live::{DEFAULT_BPF_FILTER, LiveConfig, LiveSource}; +use tlsfp_intel::{Alert, FpKind, IntelStore, MatchReport, MatchStrength, default_db_path}; + +/// How many alerts `intel alerts` shows when no count is given, and the floor a +/// zero or negative count is raised to. +const DEFAULT_ALERT_LIMIT: i64 = 50; /// JA3/JA4 TLS fingerprinting tool. /// @@ -30,11 +38,23 @@ pub enum Command { /// Fingerprint every TLS and QUIC handshake in a packet capture file. Pcap { /// Path to a pcap or pcapng file. - path: std::path::PathBuf, + path: PathBuf, /// Emit one JSON object per event instead of readable lines. #[arg(long)] json: bool, + + /// Match each fingerprint against the intelligence database. + #[arg(long)] + intel: bool, + + /// Run the detection rules, recording observations and raising alerts. + #[arg(long)] + detect: bool, + + /// Path to the intelligence database, defaulting to the data directory. + #[arg(long)] + db: Option, }, /// Capture live from a network interface and fingerprint in real time. @@ -76,6 +96,18 @@ pub enum Command { /// instead of switching it to promiscuous mode. #[arg(long)] no_promisc: bool, + + /// Match each fingerprint against the intelligence database. + #[arg(long)] + intel: bool, + + /// Run the detection rules, recording observations and raising alerts. + #[arg(long)] + detect: bool, + + /// Path to the intelligence database, defaulting to the data directory. + #[arg(long)] + db: Option, }, /// Serve the web dashboard and HTTP API. @@ -84,6 +116,76 @@ pub enum Command { #[arg(default_value = "127.0.0.1:8080")] bind: String, }, + + /// Manage the local threat intelligence database. + Intel { + #[command(subcommand)] + action: IntelCommand, + }, +} + +/// The subcommands under `tlsfp intel`. +#[derive(Debug, Subcommand)] +pub enum IntelCommand { + /// Create the database if needed and load the three bundled feeds. + Seed { + /// Path to the intelligence database, defaulting to the data directory. + #[arg(long)] + db: Option, + }, + + /// Import a ja4db.com JSON export, validating every record on the way in. + Import { + /// Path to the JSON file, or - to read standard input. + path: PathBuf, + + /// Path to the intelligence database, defaulting to the data directory. + #[arg(long)] + db: Option, + }, + + /// Look up one fingerprint and print its verdict. + Lookup { + /// Fingerprint kind: ja3, ja3s, ja4, ja4s, ja4h, ja4x, ja4t, or ja4ts. + kind: String, + + /// The fingerprint value to look up. + value: String, + + /// Emit the report as JSON instead of readable lines. + #[arg(long)] + json: bool, + + /// Path to the intelligence database, defaulting to the data directory. + #[arg(long)] + db: Option, + }, + + /// Show what the database holds, by feed and by category. + Stats { + /// Emit the summary as JSON instead of readable lines. + #[arg(long)] + json: bool, + + /// Path to the intelligence database, defaulting to the data directory. + #[arg(long)] + db: Option, + }, + + /// Show the most recent alerts the detection rules have raised. + Alerts { + /// Emit the alerts as JSON instead of readable lines. + #[arg(long)] + json: bool, + + /// How many alerts to show, newest first. + #[arg(long, default_value_t = DEFAULT_ALERT_LIMIT)] + limit: i64, + + /// Path to the intelligence database, defaulting to the data directory. + #[arg(long)] + db: Option, + }, } impl Cli { @@ -102,16 +204,51 @@ impl Cli { pub fn run(self) -> Result<()> { match self.command { - Command::Pcap { path, json } => run_pcap(&path, json), + Command::Pcap { + path, + json, + intel, + detect, + db, + } => run_pcap(&path, json, intel, detect, db.as_deref()), Command::Live { interface, json, filter, no_promisc, - } => run_live(&interface, json, filter, !no_promisc), + intel, + detect, + db, + } => run_live( + &interface, + json, + filter, + !no_promisc, + intel, + detect, + db.as_deref(), + ), Command::Serve { bind } => { anyhow::bail!("dashboard on {bind} is not wired up yet") } + Command::Intel { action } => action.run(), + } + } +} + +impl IntelCommand { + fn run(self) -> Result<()> { + match self { + IntelCommand::Seed { db } => run_intel_seed(db), + IntelCommand::Import { path, db } => run_intel_import(&path, db), + IntelCommand::Lookup { + kind, + value, + json, + db, + } => run_intel_lookup(&kind, &value, json, db), + IntelCommand::Stats { json, db } => run_intel_stats(json, db), + IntelCommand::Alerts { json, limit, db } => run_intel_alerts(json, limit, db), } } } @@ -121,36 +258,33 @@ impl Cli { /// The summary goes to the log rather than stdout so that piping the output /// into a tool sees only events, while a human still learns how much of the /// capture was readable and whether the file was cut short mid packet. -fn run_pcap(path: &std::path::Path, json: bool) -> Result<()> { +fn run_pcap(path: &Path, json: bool, intel: bool, detect: bool, db: Option<&Path>) -> Result<()> { let mut source = PcapFileSource::open(path) .with_context(|| format!("cannot open capture {}", path.display()))?; + let mut store = open_for_run(intel, detect, db)?; let mut pipeline = Pipeline::new(PipelineConfig::default()); let stdout = std::io::stdout().lock(); let mut out = std::io::BufWriter::new(stdout); - let mut write_failure = None; + let mut write_failure: Option = None; pipeline.run(&mut source, |event| { - use std::io::Write as _; - let result = if json { - serde_json::to_writer(&mut out, &event) - .map_err(anyhow::Error::from) - .and_then(|()| writeln!(out).map_err(anyhow::Error::from)) + if write_failure.is_some() { + return; + } + let reports = if intel { + enrich(store.as_ref(), &event) } else { - writeln!(out, "{event}").map_err(anyhow::Error::from) + Vec::new() }; - if write_failure.is_none() { - if let Err(error) = result { - write_failure = Some(error); - } + let alerts = detect_event(store.as_mut(), detect, &event); + if let Err(error) = write_event(&mut out, &event, reports, alerts, json) { + write_failure = Some(error); } })?; if let Some(error) = write_failure { - return Err(error.context("writing events to stdout")); - } - { - use std::io::Write as _; - out.flush().context("flushing events to stdout")?; + return Err(anyhow::Error::from(error).context("writing events to stdout")); } + out.flush().context("flushing events to stdout")?; let counters = pipeline.counters(); tracing::info!( @@ -178,11 +312,21 @@ fn run_pcap(path: &std::path::Path, json: bool) -> Result<()> { /// this function owns the tokio side of the bridge. The runtime is built /// here rather than in main so the file path stays a plain synchronous /// program. -fn run_live(interface: &str, json: bool, filter: String, promiscuous: bool) -> Result<()> { +#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] +fn run_live( + interface: &str, + json: bool, + filter: String, + promiscuous: bool, + intel: bool, + detect: bool, + db: Option<&Path>, +) -> Result<()> { let config = LiveConfig { filter, promiscuous, }; + let store = open_for_run(intel, detect, db)?; let source = LiveSource::open(interface, &config)?; tracing::info!(interface, filter = %config.filter, "live capture started"); @@ -190,7 +334,7 @@ fn run_live(interface: &str, json: bool, filter: String, promiscuous: bool) -> R .enable_all() .build() .context("building the async runtime")?; - runtime.block_on(drive_live(source, json)) + runtime.block_on(drive_live(source, json, intel, detect, store)) } /// Drains the live source through the same pipeline the file path uses. @@ -201,7 +345,13 @@ fn run_live(interface: &str, json: bool, filter: String, promiscuous: bool) -> R /// exits without ceremony. A closed stdout pipe is a normal way for a /// live session to end, so it stops the capture instead of reporting an /// error. -async fn drive_live(mut source: LiveSource, json: bool) -> Result<()> { +async fn drive_live( + mut source: LiveSource, + json: bool, + intel: bool, + detect: bool, + mut store: Option, +) -> Result<()> { let stop = source.stop_handle(); tokio::spawn(async move { if tokio::signal::ctrl_c().await.is_ok() { @@ -226,7 +376,15 @@ async fn drive_live(mut source: LiveSource, json: bool) -> Result<()> { Err(error) => break Some(error), }; pipeline.feed(&frame, &mut |event| { - write_live_event(&mut out, &event, json, &mut write_failure); + write_live_event( + &mut out, + &mut store, + intel, + detect, + &event, + json, + &mut write_failure, + ); }); if write_failure.is_some() { break None; @@ -274,8 +432,12 @@ async fn drive_live(mut source: LiveSource, json: bool) -> Result<()> { /// Writes one event and flushes it immediately, recording the first /// failure instead of panicking inside the pipeline's sink. Later calls /// become no-ops once a write has failed. +#[allow(clippy::too_many_arguments)] fn write_live_event( out: &mut impl std::io::Write, + store: &mut Option, + intel: bool, + detect: bool, event: &FingerprintEvent, json: bool, failure: &mut Option, @@ -283,15 +445,345 @@ fn write_live_event( if failure.is_some() { return; } - let result = if json { - serde_json::to_writer(&mut *out, event) - .map_err(std::io::Error::from) - .and_then(|()| writeln!(out)) - .and_then(|()| out.flush()) + let reports = if intel { + enrich(store.as_ref(), event) } else { - writeln!(out, "{event}").and_then(|()| out.flush()) + Vec::new() }; + let alerts = detect_event(store.as_mut(), detect, event); + let result = write_event(out, event, reports, alerts, json).and_then(|()| out.flush()); if let Err(error) = result { *failure = Some(error); } } + +/// One event plus any intelligence that matched it, the shape both the file and +/// the live path serialise. The intel field is omitted when nothing matched, so +/// a run without enrichment produces exactly the same JSON as before. +#[derive(serde::Serialize)] +struct EnrichedEvent<'a> { + #[serde(flatten)] + event: &'a FingerprintEvent, + #[serde(rename = "intel", skip_serializing_if = "Vec::is_empty")] + reports: Vec, + #[serde(rename = "alerts", skip_serializing_if = "Vec::is_empty")] + alerts: Vec, +} + +/// Looks every fingerprint in an event up against the store, returning the +/// reports that found intelligence. A lookup error degrades to no enrichment +/// with a warning rather than ending the capture. +fn enrich(store: Option<&IntelStore>, event: &FingerprintEvent) -> Vec { + let Some(store) = store else { + return Vec::new(); + }; + match store.match_event(event) { + Ok(reports) => reports, + Err(error) => { + tracing::warn!(%error, "intelligence lookup failed for an event"); + Vec::new() + } + } +} + +/// Runs the detection rules for one event when detection is enabled, recording +/// the observation and any alerts. A per-event failure degrades to a warning so +/// one bad record cannot end the capture. +fn detect_event( + store: Option<&mut IntelStore>, + detect: bool, + event: &FingerprintEvent, +) -> Vec { + if !detect { + return Vec::new(); + } + let Some(store) = store else { + return Vec::new(); + }; + match store.detect(event) { + Ok(alerts) => alerts, + Err(error) => { + tracing::warn!(%error, "detection failed for an event"); + Vec::new() + } + } +} + +/// Writes one event, as JSON or as a readable line, followed by any intel. +fn write_event( + out: &mut impl std::io::Write, + event: &FingerprintEvent, + reports: Vec, + alerts: Vec, + json: bool, +) -> std::io::Result<()> { + if json { + let enriched = EnrichedEvent { + event, + reports, + alerts, + }; + serde_json::to_writer(&mut *out, &enriched).map_err(std::io::Error::from)?; + writeln!(out) + } else { + writeln!(out, "{event}")?; + write_intel_lines(out, &reports)?; + write_alert_lines(out, &alerts) + } +} + +/// Writes one indented pair of lines per alert beneath its event: the rule and +/// severity that name it, then the evidence that tripped it. +fn write_alert_lines(out: &mut impl std::io::Write, alerts: &[Alert]) -> std::io::Result<()> { + for alert in alerts { + writeln!( + out, + " alert [{}] {}: {}", + alert.severity.as_str(), + alert.rule.as_str(), + alert.title, + )?; + writeln!(out, " {}", alert.detail)?; + } + Ok(()) +} + +/// Writes one indented line per intel report beneath its event. +fn write_intel_lines( + out: &mut impl std::io::Write, + reports: &[MatchReport], +) -> std::io::Result<()> { + for report in reports { + let labels = report + .hits + .iter() + .map(|hit| { + if hit.strength == MatchStrength::Exact { + format!("{} ({})", hit.label, hit.source) + } else { + format!("{} ({}, {})", hit.label, hit.source, hit.strength.as_str()) + } + }) + .collect::>() + .join(", "); + writeln!( + out, + " intel {}={} score={:.2} confidence={:.2} {labels}", + report.kind.as_str(), + report.verdict.as_str(), + report.threat_score, + report.confidence, + )?; + } + Ok(()) +} + +/// Opens the store for a capture run. Enrichment that finds no database runs +/// without annotation rather than failing. Detection needs somewhere to record +/// observations, so it creates the database, warning that known-bad matching +/// stays dark until the feeds are seeded. +fn open_for_run(intel: bool, detect: bool, db: Option<&Path>) -> Result> { + if !intel && !detect { + return Ok(None); + } + let path = db.map_or_else(default_db_path, Path::to_path_buf); + if !path.exists() { + if detect { + tracing::warn!( + path = %path.display(), + "no intelligence database found; creating one to record detections, run 'tlsfp intel seed' to enable known-bad matching" + ); + return Ok(Some(open_or_create(&path)?)); + } + tracing::warn!( + path = %path.display(), + "no intelligence database found; run 'tlsfp intel seed' first, continuing without it" + ); + return Ok(None); + } + Ok(Some(open_or_create(&path)?)) +} + +/// Resolves the database path from the flag or the default data directory. +fn resolve_db(db: Option) -> PathBuf { + db.unwrap_or_else(default_db_path) +} + +/// Opens or creates a store at `path`, used by the commands allowed to build +/// the database from scratch. +fn open_or_create(path: &Path) -> Result { + IntelStore::open(path) + .with_context(|| format!("opening intelligence database {}", path.display())) +} + +/// Opens a store that is expected to already exist, with a hint to seed first. +fn open_existing(path: &Path) -> Result { + if !path.exists() { + anyhow::bail!( + "no intelligence database at {}; run 'tlsfp intel seed' first", + path.display() + ); + } + open_or_create(path) +} + +fn run_intel_seed(db: Option) -> Result<()> { + let path = resolve_db(db); + let mut store = open_or_create(&path)?; + let summary = store.seed_bundled()?; + println!( + "seeded {} fingerprints into {}", + summary.parsed(), + path.display() + ); + for feed in &summary.feeds { + println!( + " {:<24} {} new, {} total", + feed.name, feed.inserted, feed.parsed + ); + } + Ok(()) +} + +fn run_intel_import(path: &Path, db: Option) -> Result<()> { + let json = read_input(path)?; + let mut store = open_or_create(&resolve_db(db))?; + let summary = store.import_ja4db(&json)?; + println!( + "imported {} fingerprints from {} ja4db records, {} skipped as invalid", + summary.imported, summary.records, summary.skipped + ); + Ok(()) +} + +fn run_intel_lookup(kind: &str, value: &str, json: bool, db: Option) -> Result<()> { + let kind = FpKind::from_token(&kind.to_ascii_lowercase()).with_context(|| { + format!( + "unknown fingerprint kind '{kind}'; expected ja3, ja3s, ja4, ja4s, ja4h, ja4x, ja4t, or ja4ts" + ) + })?; + let store = open_existing(&resolve_db(db))?; + let report = store.match_fingerprint(kind, value)?; + if json { + let stdout = std::io::stdout().lock(); + serde_json::to_writer_pretty(stdout, &report).context("writing report as JSON")?; + println!(); + } else { + print_report(&report); + } + Ok(()) +} + +fn run_intel_stats(json: bool, db: Option) -> Result<()> { + let store = open_existing(&resolve_db(db))?; + let stats = store.stats()?; + if json { + let stdout = std::io::stdout().lock(); + serde_json::to_writer_pretty(stdout, &stats).context("writing stats as JSON")?; + println!(); + return Ok(()); + } + println!("{} fingerprints total", stats.total); + println!("feeds:"); + for source in &stats.sources { + println!( + " {:<24} {:<8} {:<14} {}", + source.name, + source.kind, + source.license.as_deref().unwrap_or("-"), + source.records, + ); + } + println!("by category:"); + for category in &stats.by_category { + println!(" {:<10} {}", category.category, category.records); + } + Ok(()) +} + +fn run_intel_alerts(json: bool, limit: i64, db: Option) -> Result<()> { + let store = open_existing(&resolve_db(db))?; + let limit = if limit <= 0 { + DEFAULT_ALERT_LIMIT + } else { + limit + }; + let alerts = store.recent_alerts(limit)?; + if json { + let stdout = std::io::stdout().lock(); + serde_json::to_writer_pretty(stdout, &alerts).context("writing alerts as JSON")?; + println!(); + return Ok(()); + } + if alerts.is_empty() { + println!("no alerts recorded; run a capture with --detect first"); + return Ok(()); + } + for alert in &alerts { + print_alert(alert); + } + let counts = store.alert_counts()?; + if !counts.is_empty() { + println!("by rule:"); + for (rule, count) in &counts { + println!(" {:<14} {count}", rule.as_str()); + } + } + Ok(()) +} + +/// Prints one alert as readable lines: a header naming when, how urgent, which +/// rule, and the subject, then the evidence beneath it. +fn print_alert(alert: &Alert) { + let secs = alert.ts_nanos / 1_000_000_000; + let millis = alert.ts_nanos % 1_000_000_000 / 1_000_000; + let target = alert.ip.as_deref().unwrap_or("-"); + println!( + "{secs}.{millis:03} [{}] {} {target} {}", + alert.severity.as_str(), + alert.rule.as_str(), + alert.title, + ); + println!(" {}", alert.detail); +} + +/// Prints a lookup report as readable lines. +fn print_report(report: &MatchReport) { + println!( + "{} {} => {} (threat {:.2}, confidence {:.2})", + report.kind.as_str(), + report.observed, + report.verdict.as_str(), + report.threat_score, + report.confidence, + ); + if report.hits.is_empty() { + println!(" no intelligence on this fingerprint"); + return; + } + for hit in &report.hits { + println!( + " {:<8} {:<18} {:<22} {}", + hit.category.as_str(), + hit.strength.as_str(), + hit.label, + hit.source, + ); + if let Some(reference) = &hit.reference { + println!(" {reference}"); + } + } +} + +/// Reads the importer's input from a file or from standard input for `-`. +fn read_input(path: &Path) -> Result { + if path.as_os_str() == "-" { + let mut buffer = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer) + .context("reading ja4db JSON from standard input")?; + Ok(buffer) + } else { + std::fs::read_to_string(path) + .with_context(|| format!("reading ja4db JSON from {}", path.display())) + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/dev.compose.yml b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/dev.compose.yml new file mode 100644 index 00000000..342e5917 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/dev.compose.yml @@ -0,0 +1,74 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# dev.compose.yml +# ============================================================================= +# Development compose: nginx fronts the Vite dev server (HMR) and proxies /api +# to the tlsfp backend. The backend lives under the `backend` profile and is +# not started by default until the serve command is implemented. +# docker compose -f dev.compose.yml up +# docker compose -f dev.compose.yml --profile backend up +# Uses .env.development +# ============================================================================= + +name: ${APP_NAME:-tlsfp}-dev + +services: + nginx: + image: nginx:1.27-alpine + container_name: ${APP_NAME:-tlsfp}-nginx-dev + ports: + - "${NGINX_HOST_PORT:-48418}:80" + volumes: + - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./infra/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro + depends_on: + frontend: + condition: service_started + networks: + - app + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: ../infra/docker/vite.dev + container_name: ${APP_NAME:-tlsfp}-frontend-dev + ports: + - "${FRONTEND_HOST_PORT:-46494}:5173" + volumes: + - ./frontend:/app + - frontend_modules:/app/node_modules + environment: + - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_API_TARGET=${VITE_API_TARGET:-http://tlsfp:8080} + - VITE_APP_TITLE=${VITE_APP_TITLE:-JA3/JA4 TLS Fingerprinting (Dev)} + networks: + - app + restart: unless-stopped + + tlsfp: + build: + context: . + dockerfile: infra/docker/tlsfp.dev + container_name: ${APP_NAME:-tlsfp}-backend-dev + command: ["cargo", "watch", "-x", "run --bin tlsfp -- serve 0.0.0.0:8080"] + profiles: + - backend + environment: + - RUST_LOG=${RUST_LOG:-tlsfp=debug} + volumes: + - .:/workspace + - tlsfp_target:/workspace/target + - tlsfp_data:/data + networks: + - app + restart: unless-stopped + +networks: + app: + driver: bridge + +volumes: + frontend_modules: + tlsfp_target: + tlsfp_data: diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.dockerignore b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.dockerignore new file mode 100644 index 00000000..a0256ec7 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +build +dist +.git +.gitignore +*.md +.env* +.vscode +.idea +*.log +npm-debug.log* +pnpm-debug.log* +.DS_Store +coverage +.nyc_output diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.gitignore b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.gitignore new file mode 100644 index 00000000..61cb0c2e --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.vite + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.npmrc b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.npmrc new file mode 100644 index 00000000..a3eb4719 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.npmrc @@ -0,0 +1,4 @@ +# ©AngelaMos | 2026 +# .npmrc +strict-dep-builds=false +auto-install-peers=true diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.stylelintignore b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.stylelintignore new file mode 100644 index 00000000..37da03e1 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/.stylelintignore @@ -0,0 +1,22 @@ +# ©AngelaMos | 2025 +# .stylelintignore + +# Dependencies +node_modules/ + +# Production builds +dist/ +build/ +out/ + +# JS/TS files +**/*.js +**/*.jsx +**/*.ts +**/*.tsx + +# Generated files +*.min.css + +# Error system styles - ignore from linting +src/core/app/_toastStyles.scss diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/biome.json b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/biome.json new file mode 100644 index 00000000..7f29029a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/biome.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 82, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5", + "arrowParentheses": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 25 } + }, + "noForEach": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "noUndeclaredVariables": "error" + }, + "style": { + "useImportType": "error", + "useConst": "error", + "useTemplate": "error", + "useSelfClosingElements": "error", + "useFragmentSyntax": "error", + "noNonNullAssertion": "error", + "useConsistentArrayType": { + "level": "error", + "options": { "syntax": "shorthand" } + }, + "useNamingConvention": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noConsole": "warn", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noDoubleEquals": "error", + "noRedeclare": "error", + "noVar": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error" + }, + "a11y": { + "useAltText": "error", + "useAnchorContent": "error", + "useKeyWithClickEvents": "error", + "noStaticElementInteractions": "error", + "useButtonType": "error", + "useValidAnchor": "error" + } + } + }, + "overrides": [ + { + "includes": ["src/main.tsx"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/index.html b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/index.html new file mode 100644 index 00000000..a37f09fd --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/index.html @@ -0,0 +1,48 @@ + + + + + + + + + + + JA3/JA4 TLS Fingerprinting + + + + + + + + diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/package.json b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/package.json new file mode 100644 index 00000000..482f5a8f --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "tlsfp", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc -b", + "lint:scss": "stylelint '**/*.scss'", + "lint:scss:fix": "stylelint '**/*.scss' --fix" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.12", + "axios": "^1.13.0", + "react": "^19.2.1", + "react-dom": "^19.2.0", + "react-error-boundary": "^6.0.0", + "react-icon": "^1.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.1.1", + "sonner": "^2.0.7", + "zod": "^4.1.13", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tanstack/react-query-devtools": "^5.91.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "sass": "^1.95.0", + "stylelint": "^16.26.1", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^16.0.0", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.2.5", + "vite-tsconfig-paths": "^5.1.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/pnpm-lock.yaml b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..d04a7163 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/pnpm-lock.yaml @@ -0,0 +1,2603 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.2.5 + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.2.1) + axios: + specifier: ^1.13.0 + version: 1.13.2 + react: + specifier: ^19.2.1 + version: 19.2.1 + react-dom: + specifier: ^19.2.0 + version: 19.2.1(react@19.2.1) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.2.1) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@19.2.1) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.1) + react-router-dom: + specifier: ^7.1.1 + version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + zod: + specifier: ^4.1.13 + version: 4.1.13 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.1) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.8 + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + '@types/node': + specifier: ^24.10.2 + version: 24.10.2 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + sass: + specifier: ^1.95.0 + version: 1.95.0 + stylelint: + specifier: ^16.26.1 + version: 16.26.1(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^16.0.0 + version: 16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.2.5 + version: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + vite-tsconfig-paths: + specifier: ^5.1.0 + version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.6': + resolution: {integrity: sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==} + + '@cacheable/utils@2.3.2': + resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.0': + resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.5.4 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.1.0': + resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.97.0': + resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.97.0': + resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.50': + resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@tanstack/query-core@5.90.12': + resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + + '@tanstack/query-devtools@5.91.1': + resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + + '@tanstack/react-query-devtools@5.91.1': + resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.12': + resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/node@24.10.2': + resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.9.5: + resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.0: + resolution: {integrity: sha512-HHiAvOBmlcR2f3SQ7kdlYD8+AUJG+wlFZ/Ze8tl1Vzvz0MdOh8IYA/EFU4ve8t1/sZ0j4MGi7ST5MoTwHessQA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.1: + resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.19: + resolution: {integrity: sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.3.0: + resolution: {integrity: sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.14.0: + resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.5.5: + resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdn-data@2.25.0: + resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qified@0.5.3: + resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.10.1: + resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.10.1: + resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-vite@7.2.5: + resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.50: + resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.95.0: + resolution: {integrity: sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@16.0.2: + resolution: {integrity: sha512-aUTHhPPWCvFyWaxtckJlCPaXTDFsp4pKO8evXNCsW9OwsaUWyMd6jvcUhSmfGWPrTddvzNqK4rS/UuSLcbVGdQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.24.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@17.0.0: + resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-config-standard-scss@16.0.0: + resolution: {integrity: sha512-/FHECLUu+med/e6OaPFpprG86ShC4SYT7Tzb2PTVdDjJsehhFBOioSlWqYFqJxmGPIwO3AMBxNo+kY3dxrbczA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.23.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@39.0.1: + resolution: {integrity: sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-scss@6.13.0: + resolution: {integrity: sha512-kZPwFUJkfup2gP1enlrS2h9U5+T5wFoqzJ1n/56AlpwSj28kmFe7ww/QFydvPsg5gLjWchAwWWBLtterynZrOw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.3.8': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 + + '@biomejs/cli-darwin-arm64@2.3.8': + optional: true + + '@biomejs/cli-darwin-x64@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64@2.3.8': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-x64@2.3.8': + optional: true + + '@biomejs/cli-win32-arm64@2.3.8': + optional: true + + '@biomejs/cli-win32-x64@2.3.8': + optional: true + + '@cacheable/memory@2.0.6': + dependencies: + '@cacheable/utils': 2.3.2 + '@keyv/bigmap': 1.3.0(keyv@5.5.5) + hookified: 1.14.0 + keyv: 5.5.5 + + '@cacheable/utils@2.3.2': + dependencies: + hashery: 1.3.0 + keyv: 5.5.5 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.0(keyv@5.5.5)': + dependencies: + hashery: 1.3.0 + hookified: 1.14.0 + keyv: 5.5.5 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.1.0': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@oxc-project/runtime@0.97.0': {} + + '@oxc-project/types@0.97.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.50': {} + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@tanstack/query-core@5.90.12': {} + + '@tanstack/query-devtools@5.91.1': {} + + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)': + dependencies: + '@tanstack/query-devtools': 5.91.1 + '@tanstack/react-query': 5.90.12(react@19.2.1) + react: 19.2.1 + + '@tanstack/react-query@5.90.12(react@19.2.1)': + dependencies: + '@tanstack/query-core': 5.90.12 + react: 19.2.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/node@24.10.2': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.9.5: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.5 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + + cacheable@2.3.0: + dependencies: + '@cacheable/memory': 2.0.6 + '@cacheable/utils': 2.3.2 + hookified: 1.14.0 + keyv: 5.5.5 + qified: 0.5.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001759: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js@1.2.7: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.2.3: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@11.1.1: + dependencies: + flat-cache: 6.1.19 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.19: + dependencies: + cacheable: 2.3.0 + flatted: 3.3.3 + hookified: 1.14.0 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.3.0: + dependencies: + hookified: 1.14.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.14.0: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.5.5: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + mdn-data@2.25.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + qified@0.5.3: + dependencies: + hookified: 1.14.0 + + queue-microtask@1.2.3: {} + + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react-error-boundary@6.0.0(react@19.2.1): + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.1 + + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.1 + + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + + react-refresh@0.18.0: {} + + react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + + react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + cookie: 1.1.1 + react: 19.2.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.1(react@19.2.1) + + react@19.2.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0): + dependencies: + '@oxc-project/runtime': 0.97.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.50 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + sass: 1.95.0 + + rolldown@1.0.0-beta.50: + dependencies: + '@oxc-project/types': 0.97.0 + '@rolldown/pluginutils': 1.0.0-beta.50 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-x64': 1.0.0-beta.50 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.50 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.95.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylelint-config-prettier-scss@1.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-recommended-scss@16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.6) + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-scss: 6.13.0(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-recommended@17.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-standard-scss@16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended-scss: 16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard: 39.0.1(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-standard@39.0.1(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + + stylelint-scss@6.13.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.25.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.9.3) + + stylelint@16.26.1(typescript@5.9.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-tsconfig-paths@5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + - typescript + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + yallist@3.1.1: {} + + zod@4.1.13: {} + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.1): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.1 diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 00000000..6a31898f Binary files /dev/null and b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/android-chrome-192x192.png differ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 00000000..e79bc036 Binary files /dev/null and b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/android-chrome-512x512.png differ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/apple-touch-icon.png b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 00000000..be0c040a Binary files /dev/null and b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/apple-touch-icon.png differ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon-16x16.png b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon-16x16.png new file mode 100644 index 00000000..32c2cf96 Binary files /dev/null and b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon-16x16.png differ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon-32x32.png b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon-32x32.png new file mode 100644 index 00000000..87c75f42 Binary files /dev/null and b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon-32x32.png differ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon.ico b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon.ico new file mode 100644 index 00000000..6c43eeac Binary files /dev/null and b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/favicon.ico differ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/site.webmanifest b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/site.webmanifest new file mode 100644 index 00000000..1dd91123 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/public/assets/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/App.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/App.tsx new file mode 100644 index 00000000..a7d82d6a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/App.tsx @@ -0,0 +1,36 @@ +// =========================== +// ©AngelaMos | 2026 +// App.tsx +// =========================== + +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { RouterProvider } from 'react-router-dom' +import { Toaster } from 'sonner' + +import { queryClient } from '@/core/api' +import { router } from '@/core/app/routers' +import '@/core/app/toast.module.scss' + +export default function App(): React.ReactElement { + return ( + + + + + + + + ) +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/hooks/index.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..6221af9a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/hooks/index.ts @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// index.ts +// =================== diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/index.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/index.ts new file mode 100644 index 00000000..0840a7c1 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './hooks' +export * from './types' diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/types/index.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/types/index.ts new file mode 100644 index 00000000..6221af9a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/api/types/index.ts @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// index.ts +// =================== diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/components/index.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/components/index.tsx new file mode 100644 index 00000000..6b0a7329 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/components/index.tsx @@ -0,0 +1,4 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/config.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/config.ts new file mode 100644 index 00000000..87c9647a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/config.ts @@ -0,0 +1,68 @@ +// =================== +// © AngelaMos | 2026 +// config.ts +// =================== +const API_VERSION = 'v1' + +export const API_ENDPOINTS = { + USERS: { + BASE: `/${API_VERSION}/users`, + BY_ID: (id: string) => `/${API_VERSION}/users/${id}`, + }, +} as const + +export const QUERY_KEYS = { + USERS: { + ALL: ['users'] as const, + BY_ID: (id: string) => [...QUERY_KEYS.USERS.ALL, 'detail', id] as const, + }, +} as const + +export const ROUTES = { + HOME: '/', + DASHBOARD: '/dashboard', + SETTINGS: '/settings', +} as const + +export const STORAGE_KEYS = { + UI: 'ui-storage', +} as const + +export const QUERY_CONFIG = { + STALE_TIME: { + USER: 1000 * 60 * 5, + STATIC: Infinity, + FREQUENT: 1000 * 30, + }, + GC_TIME: { + DEFAULT: 1000 * 60 * 30, + LONG: 1000 * 60 * 60, + }, + RETRY: { + DEFAULT: 3, + NONE: 0, + }, +} as const + +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER: 500, +} as const + +export const PAGINATION = { + DEFAULT_PAGE: 1, + DEFAULT_SIZE: 20, + MAX_SIZE: 100, +} as const + +export type ApiEndpoint = typeof API_ENDPOINTS +export type QueryKey = typeof QUERY_KEYS +export type Route = typeof ROUTES diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/api.config.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/api.config.ts new file mode 100644 index 00000000..f48a4d08 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/api.config.ts @@ -0,0 +1,17 @@ +// =================== +// © AngelaMos | 2026 +// api.config.ts +// =================== + +import axios, { type AxiosInstance } from 'axios' + +const getBaseURL = (): string => { + return import.meta.env.VITE_API_URL ?? '/api' +} + +export const apiClient: AxiosInstance = axios.create({ + baseURL: getBaseURL(), + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, +}) diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/errors.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/errors.ts new file mode 100644 index 00000000..fde5ec65 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/errors.ts @@ -0,0 +1,114 @@ +/** + * ©AngelaMos | 2025 + * errors.ts + */ + +import type { AxiosError } from 'axios' + +export const ApiErrorCode = { + NETWORK_ERROR: 'NETWORK_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR', + AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + SERVER_ERROR: 'SERVER_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const + +export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode] + +export class ApiError extends Error { + readonly code: ApiErrorCode + readonly statusCode: number + readonly details?: Record + + constructor( + message: string, + code: ApiErrorCode, + statusCode: number, + details?: Record + ) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + this.details = details + } + + getUserMessage(): string { + const messages: Record = { + [ApiErrorCode.NETWORK_ERROR]: + 'Unable to connect. Please check your internet connection.', + [ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.', + [ApiErrorCode.AUTHENTICATION_ERROR]: + 'Your session has expired. Please log in again.', + [ApiErrorCode.AUTHORIZATION_ERROR]: + 'You do not have permission to perform this action.', + [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.', + [ApiErrorCode.CONFLICT]: + 'This operation conflicts with an existing resource.', + [ApiErrorCode.RATE_LIMITED]: + 'Too many requests. Please wait a moment and try again.', + [ApiErrorCode.SERVER_ERROR]: + 'Something went wrong on our end. Please try again later.', + [ApiErrorCode.UNKNOWN_ERROR]: + 'An unexpected error occurred. Please try again.', + } + return messages[this.code] + } +} + +interface ApiErrorResponse { + detail?: string | { msg: string; type: string }[] + message?: string +} + +export function transformAxiosError(error: AxiosError): ApiError { + if (!error.response) { + return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) + } + + const { status } = error.response + const data = error.response.data as ApiErrorResponse | undefined + let message = 'An error occurred' + let details: Record | undefined + + if (data?.detail) { + if (typeof data.detail === 'string') { + message = data.detail + } else if (Array.isArray(data.detail)) { + details = { validation: [] } + data.detail.forEach((err) => { + details?.validation.push(err.msg) + }) + message = 'Validation error' + } + } else if (data?.message) { + message = data.message + } + + const codeMap: Record = { + 400: ApiErrorCode.VALIDATION_ERROR, + 401: ApiErrorCode.AUTHENTICATION_ERROR, + 403: ApiErrorCode.AUTHORIZATION_ERROR, + 404: ApiErrorCode.NOT_FOUND, + 409: ApiErrorCode.CONFLICT, + 429: ApiErrorCode.RATE_LIMITED, + 500: ApiErrorCode.SERVER_ERROR, + 502: ApiErrorCode.SERVER_ERROR, + 503: ApiErrorCode.SERVER_ERROR, + 504: ApiErrorCode.SERVER_ERROR, + } + + const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR + + return new ApiError(message, code, status, details) +} + +declare module '@tanstack/react-query' { + interface Register { + defaultError: ApiError + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/index.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/index.ts new file mode 100644 index 00000000..1818cd7e --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './api.config' +export * from './errors' +export * from './query.config' diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/query.config.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/query.config.ts new file mode 100644 index 00000000..8244b22d --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/api/query.config.ts @@ -0,0 +1,105 @@ +// =================== +// © AngelaMos | 2025 +// query.config.ts +// =================== + +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { QUERY_CONFIG } from '@/config' +import { ApiError, ApiErrorCode } from './errors' + +const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [ + ApiErrorCode.AUTHENTICATION_ERROR, + ApiErrorCode.AUTHORIZATION_ERROR, + ApiErrorCode.NOT_FOUND, + ApiErrorCode.VALIDATION_ERROR, +] as const + +const shouldRetryQuery = (failureCount: number, error: Error): boolean => { + if (error instanceof ApiError) { + if (NO_RETRY_ERROR_CODES.includes(error.code)) { + return false + } + } + return failureCount < QUERY_CONFIG.RETRY.DEFAULT +} + +const calculateRetryDelay = (attemptIndex: number): number => { + const baseDelay = 1000 + const maxDelay = 30000 + return Math.min(baseDelay * 2 ** attemptIndex, maxDelay) +} + +const handleQueryCacheError = ( + error: Error, + query: { state: { data: unknown } } +): void => { + if (query.state.data !== undefined) { + const message = + error instanceof ApiError + ? error.getUserMessage() + : 'Background update failed' + toast.error(message) + } +} + +const handleMutationCacheError = ( + error: Error, + _variables: unknown, + _context: unknown, + mutation: { options: { onError?: unknown } } +): void => { + if (mutation.options.onError === undefined) { + const message = + error instanceof ApiError ? error.getUserMessage() : 'Operation failed' + toast.error(message) + } +} + +export const QUERY_STRATEGIES = { + standard: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + }, + frequent: { + staleTime: QUERY_CONFIG.STALE_TIME.FREQUENT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + refetchInterval: QUERY_CONFIG.STALE_TIME.FREQUENT, + }, + static: { + staleTime: QUERY_CONFIG.STALE_TIME.STATIC, + gcTime: QUERY_CONFIG.GC_TIME.LONG, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + auth: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: QUERY_CONFIG.RETRY.NONE, + }, +} as const + +export type QueryStrategy = keyof typeof QUERY_STRATEGIES + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: shouldRetryQuery, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }, + mutations: { + retry: QUERY_CONFIG.RETRY.NONE, + }, + }, + queryCache: new QueryCache({ + onError: handleQueryCacheError, + }), + mutationCache: new MutationCache({ + onError: handleMutationCacheError, + }), +}) diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/routers.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/routers.tsx new file mode 100644 index 00000000..d17138ce --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/routers.tsx @@ -0,0 +1,34 @@ +// =================== +// © AngelaMos | 2026 +// routers.tsx +// =================== + +import { createBrowserRouter, type RouteObject } from 'react-router-dom' +import { ROUTES } from '@/config' +import { Shell } from './shell' + +const routes: RouteObject[] = [ + { + path: ROUTES.HOME, + lazy: () => import('@/pages/landing'), + }, + { + element: , + children: [ + { + path: ROUTES.DASHBOARD, + lazy: () => import('@/pages/dashboard'), + }, + { + path: ROUTES.SETTINGS, + lazy: () => import('@/pages/settings'), + }, + ], + }, + { + path: '*', + lazy: () => import('@/pages/landing'), + }, +] + +export const router = createBrowserRouter(routes) diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/shell.module.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/shell.module.scss new file mode 100644 index 00000000..3466896a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/shell.module.scss @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// shell.module.scss +// =================== diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/shell.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/shell.tsx new file mode 100644 index 00000000..3296f38f --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/shell.tsx @@ -0,0 +1,116 @@ +/** + * ©AngelaMos | 2026 + * shell.tsx + */ + +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { GiCardAceClubs, GiCardJoker } from 'react-icons/gi' +import { LuChevronLeft, LuChevronRight, LuMenu } from 'react-icons/lu' +import { NavLink, Outlet, useLocation } from 'react-router-dom' +import { ROUTES } from '@/config' +import { useUIStore } from '@/core/lib' +import styles from './shell.module.scss' + +const NAV_ITEMS = [ + { path: ROUTES.DASHBOARD, label: 'Dashboard', icon: GiCardJoker }, + { path: ROUTES.SETTINGS, label: 'Settings', icon: GiCardAceClubs }, +] + +function ShellErrorFallback({ error }: { error: Error }): React.ReactElement { + return ( + + Something went wrong + {error.message} + + ) +} + +function ShellLoading(): React.ReactElement { + return Loading... +} + +function getPageTitle(pathname: string): string { + const item = NAV_ITEMS.find((i) => i.path === pathname) + return item?.label ?? 'Dashboard' +} + +export function Shell(): React.ReactElement { + const location = useLocation() + const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } = + useUIStore() + + const pageTitle = getPageTitle(location.pathname) + + return ( + + + + {sidebarOpen && ( + e.key === 'Escape' && toggleSidebar()} + aria-label="Close sidebar" + /> + )} + + + + + + + + {pageTitle} + + + + + + }> + + + + + + + ) +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/toast.module.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/toast.module.scss new file mode 100644 index 00000000..62a8ee32 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/app/toast.module.scss @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// toast.module.scss +// =================== diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/lib/index.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/lib/index.ts new file mode 100644 index 00000000..cdee46e2 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/lib/index.ts @@ -0,0 +1,6 @@ +// =================== +// © AngelaMos | 2026 +// index.ts +// =================== + +export * from './shell.ui.store' diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/lib/shell.ui.store.ts new file mode 100644 index 00000000..d601a53b --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/core/lib/shell.ui.store.ts @@ -0,0 +1,63 @@ +/** + * ©AngelaMos | 2025 + * ui.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +type Theme = 'light' | 'dark' | 'system' + +interface UIState { + theme: Theme + sidebarOpen: boolean + sidebarCollapsed: boolean + setTheme: (theme: Theme) => void + toggleSidebar: () => void + setSidebarOpen: (open: boolean) => void + toggleSidebarCollapsed: () => void +} + +export const useUIStore = create()( + devtools( + persist( + (set) => ({ + theme: 'dark', + sidebarOpen: false, + sidebarCollapsed: false, + + setTheme: (theme) => set({ theme }, false, 'ui/setTheme'), + + toggleSidebar: () => + set( + (state) => ({ sidebarOpen: !state.sidebarOpen }), + false, + 'ui/toggleSidebar' + ), + + setSidebarOpen: (open) => + set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'), + + toggleSidebarCollapsed: () => + set( + (state) => ({ sidebarCollapsed: !state.sidebarCollapsed }), + false, + 'ui/toggleSidebarCollapsed' + ), + }), + { + name: 'ui-storage', + partialize: (state) => ({ + theme: state.theme, + sidebarCollapsed: state.sidebarCollapsed, + }), + } + ), + { name: 'UIStore' } + ) +) + +export const useTheme = (): Theme => useUIStore((s) => s.theme) +export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen) +export const useSidebarCollapsed = (): boolean => + useUIStore((s) => s.sidebarCollapsed) diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/main.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/main.tsx new file mode 100644 index 00000000..1b1f2c8a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/main.tsx @@ -0,0 +1,15 @@ +// =========================== +// ©AngelaMos | 2025 +// main.tsx +// =========================== + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.scss' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/dashboard/dashboard.module.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/dashboard/dashboard.module.scss new file mode 100644 index 00000000..cbf1f90a --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/dashboard/dashboard.module.scss @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// dashboard.module.scss +// =================== diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/dashboard/index.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/dashboard/index.tsx new file mode 100644 index 00000000..ca908891 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/dashboard/index.tsx @@ -0,0 +1,62 @@ +/** + * ©AngelaMos | 2026 + * index.tsx + */ + +import styles from './dashboard.module.scss' + +const AVAILABLE_STORES = [ + { + name: 'useUIStore()', + file: 'core/lib/shell.ui.store.ts', + description: 'Theme, sidebar open/collapsed state', + }, +] + +const SUGGESTED_FEATURES = [ + 'Stats and metrics', + 'Recent activity feed', + 'Quick actions', + 'Charts and analytics', + 'Notifications overview', + 'Task/project summary', +] + +export function Component(): React.ReactElement { + return ( + + + + Welcome + + Template page — build your dashboard here + + + + + Available Stores + + {AVAILABLE_STORES.map((store) => ( + + {store.name} + {store.description} + {store.file} + + ))} + + + + + Suggested Features + + {SUGGESTED_FEATURES.map((feature) => ( + {feature} + ))} + + + + + ) +} + +Component.displayName = 'Dashboard' diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/landing/index.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/landing/index.tsx new file mode 100644 index 00000000..7ec81be5 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/landing/index.tsx @@ -0,0 +1,12 @@ +// =================== +// © AngelaMos | 2026 +// index.tsx +// =================== + +import styles from './landing.module.scss' + +export function Component(): React.ReactElement { + return +} + +Component.displayName = 'Landing' diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/landing/landing.module.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/landing/landing.module.scss new file mode 100644 index 00000000..164f39a0 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/landing/landing.module.scss @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// landing.module.scss +// =================== diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/settings/index.tsx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/settings/index.tsx new file mode 100644 index 00000000..9c8dc9a5 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/settings/index.tsx @@ -0,0 +1,56 @@ +/** + * ©AngelaMos | 2026 + * index.tsx + */ + +import styles from './settings.module.scss' + +const AVAILABLE_STORES = [ + { + name: 'useUIStore()', + file: 'core/lib/shell.ui.store.ts', + description: 'Theme, sidebar open/collapsed state', + }, +] + +export function Component(): React.ReactElement { + return ( + + + + Settings + + Template page — available stores for building your settings UI + + + + + Available Stores + + {AVAILABLE_STORES.map((store) => ( + + {store.name} + {store.description} + + {store.file} + + + ))} + + + + + Suggested Features + + Profile form + Theme toggle (dark/light) + Notification settings + Application preferences + + + + + ) +} + +Component.displayName = 'Settings' diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/settings/settings.module.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/settings/settings.module.scss new file mode 100644 index 00000000..e95e0f88 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/pages/settings/settings.module.scss @@ -0,0 +1,4 @@ +// =================== +// © AngelaMos | 2026 +// settings.module.scss +// =================== diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles.scss new file mode 100644 index 00000000..240ec555 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles.scss @@ -0,0 +1,23 @@ +// =================== +// © AngelaMos | 2026 +// styles.scss +// =================== + +@forward 'styles/tokens'; +@forward 'styles/fonts'; +@forward 'styles/mixins'; + +@use 'styles/reset'; + +#root { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +.app { + flex: 1; + display: flex; + flex-direction: column; +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_fonts.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_fonts.scss new file mode 100644 index 00000000..4f517884 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_fonts.scss @@ -0,0 +1,12 @@ +// =================== +// © AngelaMos | 2025 +// _fonts.scss +// =================== + +@use 'tokens' as *; + +$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, + 'Helvetica Neue', Arial, sans-serif; + +$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_index.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_index.scss new file mode 100644 index 00000000..ac5a8266 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_index.scss @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// _index.scss +// =================== + +@forward 'tokens'; +@forward 'fonts'; +@forward 'mixins'; diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_mixins.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_mixins.scss new file mode 100644 index 00000000..6b5b1ba9 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_mixins.scss @@ -0,0 +1,120 @@ +// =================== +// © AngelaMos | 2025 +// _mixins.scss +// =================== + +@use 'sass:map'; +@use 'sass:list'; +@use 'tokens' as *; + +$breakpoints: ( + 'xs': $breakpoint-xs, + 'sm': $breakpoint-sm, + 'md': $breakpoint-md, + 'lg': $breakpoint-lg, + 'xl': $breakpoint-xl, + '2xl': $breakpoint-2xl, +); + +@mixin breakpoint-up($size) { + @media (min-width: map.get($breakpoints, $size)) { + @content; + } +} + +@mixin breakpoint-down($size) { + @media (width < map.get($breakpoints, $size)) { + @content; + } +} + +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +@mixin flex-column-center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +@mixin sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +@mixin truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@mixin line-clamp($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: #{$lines}; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@mixin transition-fast { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-fast; + transition-timing-function: $ease-out; +} + +@mixin transition-normal { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-normal; + transition-timing-function: $ease-out; +} + +@mixin absolute-fill { + position: absolute; + inset: 0; +} + +@mixin absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +@mixin hover { + @media (hover: hover) and (pointer: fine) { + &:hover { + @content; + } + } +} + +@mixin hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_reset.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_reset.scss new file mode 100644 index 00000000..a9cbdc17 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_reset.scss @@ -0,0 +1,105 @@ +// =================== +// © AngelaMos | 2026 +// _reset.scss +// =================== + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html { + font-size: 16px; + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; + overflow-x: hidden; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + scroll-behavior: smooth; + } +} + +body { + min-height: 100vh; + min-height: 100dvh; + line-height: 1.5; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + background-color: #fff; + color: #000; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: 1.2; + text-wrap: balance; + overflow-wrap: break-word; +} + +p { + text-wrap: pretty; + overflow-wrap: break-word; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; + height: auto; +} + +input, +button, +textarea, +select { + font: inherit; + color: inherit; +} + +button { + background: none; + border: none; + cursor: pointer; + text-align: inherit; + font-family: inherit; +} + +[hidden] { + display: none !important; +} + +[disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_tokens.scss b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_tokens.scss new file mode 100644 index 00000000..be1f6dd6 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/src/styles/_tokens.scss @@ -0,0 +1,168 @@ +// =================== +// © AngelaMos | 2025 +// _tokens.scss +// =================== + +// ============================================================================ +// SPACING (8px base system) +// ============================================================================ +$space-0: 0; +$space-px: 1px; +$space-0-5: 0.125rem; +$space-1: 0.25rem; +$space-1-5: 0.375rem; +$space-2: 0.5rem; +$space-2-5: 0.625rem; +$space-3: 0.75rem; +$space-3-5: 0.875rem; +$space-4: 1rem; +$space-5: 1.25rem; +$space-6: 1.5rem; +$space-7: 1.75rem; +$space-8: 2rem; +$space-9: 2.25rem; +$space-10: 2.5rem; +$space-11: 2.75rem; +$space-12: 3rem; +$space-14: 3.5rem; +$space-16: 4rem; +$space-20: 5rem; +$space-24: 6rem; +$space-28: 7rem; +$space-32: 8rem; + +// ============================================================================ +// TYPOGRAPHY SCALE +// ============================================================================ +$font-size-3xs: 0.625rem; +$font-size-2xs: 0.6875rem; +$font-size-xs: 0.75rem; +$font-size-sm: 0.875rem; +$font-size-base: 1rem; +$font-size-lg: 1.125rem; +$font-size-xl: 1.25rem; +$font-size-2xl: 1.5rem; +$font-size-3xl: 1.875rem; +$font-size-4xl: 2.25rem; +$font-size-5xl: 3rem; + +// ============================================================================ +// FONT WEIGHTS +// ============================================================================ +$font-weight-regular: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; + +// ============================================================================ +// LINE HEIGHTS +// ============================================================================ +$line-height-none: 1; +$line-height-tight: 1.2; +$line-height-snug: 1.375; +$line-height-normal: 1.5; +$line-height-relaxed: 1.625; + +// ============================================================================ +// LETTER SPACING +// ============================================================================ +$tracking-tighter: -0.05em; +$tracking-tight: -0.025em; +$tracking-normal: 0; +$tracking-wide: 0.025em; +$tracking-wider: 0.05em; + +// ============================================================================ +// COLORS +// ============================================================================ +$white: hsl(0, 0%, 100%); +$black: hsl(0, 0%, 0%); + +// Auth +$bg-page: hsl(0, 0%, 10.5%); +$bg-card: hsl(0, 0%, 6.2%); + +// Home/landing +$bg-landing: hsl(0, 0%, 10.8%); + +$bg-default: hsl(0, 0%, 7.1%); +$bg-alternative: hsl(0, 0%, 5.9%); +$bg-surface-75: hsl(0, 0%, 9%); +$bg-surface-100: hsl(0, 0%, 12.2%); +$bg-surface-200: hsl(0, 0%, 14.1%); +$bg-surface-300: hsl(0, 0%, 16.1%); +$bg-control: hsl(0, 0%, 10%); +$bg-selection: hsl(0, 0%, 19.2%); +$bg-overlay: hsl(0, 0%, 14.1%); +$bg-overlay-hover: hsl(0, 0%, 18%); + +$border-muted: hsl(0, 0%, 11.1%); +$border-default: hsl(0, 0%, 18%); +$border-strong: hsl(0, 0%, 22.4%); +$border-stronger: hsl(0, 0%, 27.1%); +$border-control: hsl(0, 0%, 22.4%); + +$text-default: hsl(0, 0%, 98%); +$text-light: hsl(0, 0%, 70.6%); +$text-lighter: hsl(0, 0%, 53.7%); +$text-muted: hsl(0, 0%, 30.2%); + +$error-default: hsl(0, 72%, 51%); +$error-light: hsl(0, 72%, 65%); + +// ============================================================================ +// BORDER RADIUS +// ============================================================================ +$radius-none: 0; +$radius-xs: 2px; +$radius-sm: 4px; +$radius-md: 6px; +$radius-lg: 8px; +$radius-xl: 12px; +$radius-full: 9999px; + +// ============================================================================ +// Z-INDEX SCALE +// ============================================================================ +$z-hide: -1; +$z-base: 0; +$z-dropdown: 100; +$z-sticky: 200; +$z-fixed: 300; +$z-overlay: 400; +$z-modal: 500; +$z-popover: 600; +$z-tooltip: 700; +$z-toast: 800; +$z-max: 9999; + +// ============================================================================ +// TRANSITIONS +// ============================================================================ +$duration-instant: 0ms; +$duration-fast: 100ms; +$duration-normal: 150ms; +$duration-slow: 200ms; + +$ease-out: cubic-bezier(0, 0, 0.2, 1); +$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + +// ============================================================================ +// BREAKPOINTS +// ============================================================================ +$breakpoint-xs: 360px; +$breakpoint-sm: 480px; +$breakpoint-md: 768px; +$breakpoint-lg: 1024px; +$breakpoint-xl: 1280px; +$breakpoint-2xl: 1536px; + +// ============================================================================ +// CONTAINER WIDTHS +// ============================================================================ +$container-xs: 20rem; +$container-sm: 24rem; +$container-md: 28rem; +$container-lg: 32rem; +$container-xl: 36rem; +$container-2xl: 42rem; +$container-full: 100%; diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/stylelint.config.js b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/stylelint.config.js new file mode 100644 index 00000000..56ebd7bc --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/stylelint.config.js @@ -0,0 +1,107 @@ +// ©AngelaMos | 2025 +// stylelint.config.js + +/** @type {import('stylelint').Config} */ +export default { + extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'], + rules: { + 'block-no-empty': true, + 'declaration-no-important': true, + 'color-no-invalid-hex': true, + 'property-no-unknown': true, + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global'], + }, + ], + + 'selector-class-pattern': [ + '^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$', + { + message: + 'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)', + }, + ], + + 'value-keyword-case': [ + 'lower', + { + camelCaseSvgKeywords: true, + ignoreKeywords: [ + 'BlinkMacSystemFont', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + 'Roboto', + 'Arial', + 'Helvetica', + 'Times', + 'Georgia', + 'Verdana', + 'Tahoma', + 'Trebuchet', + 'Impact', + 'Comic', + ], + }, + ], + + 'property-no-vendor-prefix': [ + true, + { + ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'], + }, + ], + 'value-no-vendor-prefix': true, + 'selector-no-vendor-prefix': true, + + 'property-no-deprecated': [ + true, + { + ignoreProperties: ['clip'], + }, + ], + + 'container-name-pattern': null, + 'layer-name-pattern': null, + + 'scss/at-rule-no-unknown': true, + 'scss/declaration-nested-properties-no-divided-groups': true, + 'scss/dollar-variable-no-missing-interpolation': true, + 'scss/dollar-variable-empty-line-before': null, + + 'declaration-empty-line-before': null, + 'custom-property-empty-line-before': null, + + 'no-descending-specificity': null, + + 'media-feature-name-no-unknown': [ + true, + { + ignoreMediaFeatureNames: ['map'], + }, + ], + + 'color-function-notation': null, + 'hue-degree-notation': null, + }, + ignoreFiles: [ + 'node_modules/**', + 'dist/**', + 'build/**', + '**/*.js', + '**/*.ts', + '**/*.tsx', + ], + overrides: [ + { + files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'], + rules: { + 'declaration-no-important': null, + 'scss/comment-no-empty': null, + }, + }, + ], +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.app.json b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.app.json new file mode 100644 index 00000000..afe17f60 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.json b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.node.json b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.node.json new file mode 100644 index 00000000..a96b3e59 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/vite.config.ts b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/vite.config.ts new file mode 100644 index 00000000..a8d5d478 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/frontend/vite.config.ts @@ -0,0 +1,71 @@ +/** + * ©AngelaMos | 2025 + * vite.config.ts + */ + +import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, path.resolve(__dirname, '..'), '') + const isDev = mode === 'development' + + return { + plugins: [react(), tsconfigPaths()], + + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + + css: { + preprocessorOptions: { + scss: {}, + }, + }, + + server: { + port: 5173, + host: '0.0.0.0', + proxy: { + '/api': { + target: env.VITE_API_TARGET || 'http://localhost:8000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ''), + }, + }, + }, + + build: { + target: 'esnext', + cssTarget: 'chrome100', + sourcemap: isDev ? true : 'hidden', + minify: 'oxc', + rollupOptions: { + output: { + manualChunks(id: string): string | undefined { + if (id.includes('node_modules')) { + if (id.includes('react-dom') || id.includes('react-router')) { + return 'vendor-react' + } + if (id.includes('@tanstack/react-query')) { + return 'vendor-query' + } + if (id.includes('zustand')) { + return 'vendor-state' + } + } + return undefined + }, + }, + }, + }, + + preview: { + port: 4173, + }, + } +}) diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/tlsfp.dev b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/tlsfp.dev new file mode 100644 index 00000000..8fe684cc --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/tlsfp.dev @@ -0,0 +1,23 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# tlsfp.dev +# ============================================================================= +# Development Dockerfile: toolchain plus cargo-watch, source mounted at runtime. +# The compose service mounts the workspace at /workspace and a cached target +# volume, so this image only provides the build environment, not the code. +# ============================================================================= +# syntax=docker/dockerfile:1 + +FROM rust:1-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpcap-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN cargo install cargo-watch + +WORKDIR /workspace + +EXPOSE 8080 + +CMD ["cargo", "watch", "-x", "run --bin tlsfp -- serve 0.0.0.0:8080"] diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/tlsfp.prod b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/tlsfp.prod new file mode 100644 index 00000000..7fc630a3 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/tlsfp.prod @@ -0,0 +1,51 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# tlsfp.prod +# ============================================================================= +# Production Dockerfile: builds the tlsfp binary, ships a slim runtime. +# The pcap crate links libpcap, so the builder needs libpcap-dev and the +# runtime needs libpcap0.8. rusqlite is bundled, so no system SQLite. +# ============================================================================= +# syntax=docker/dockerfile:1 + +# ============================================================================ +# BUILD STAGE +# ============================================================================ +FROM rust:1-bookworm AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpcap-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/build/target \ + cargo build --release --bin tlsfp \ + && cp target/release/tlsfp /usr/local/bin/tlsfp \ + && strip /usr/local/bin/tlsfp + +# ============================================================================ +# RUNTIME STAGE +# ============================================================================ +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpcap0.8 ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --no-create-home tlsfp \ + && mkdir -p /data \ + && chown tlsfp:tlsfp /data + +COPY --from=builder /usr/local/bin/tlsfp /usr/local/bin/tlsfp + +USER tlsfp +WORKDIR /data + +EXPOSE 8080 + +ENTRYPOINT ["tlsfp"] +CMD ["serve", "0.0.0.0:8080"] diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/vite.dev b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/vite.dev new file mode 100644 index 00000000..2c0ce699 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/vite.dev @@ -0,0 +1,25 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# vite.dev +# ============================================================================= +# Development Dockerfile for Vite/React frontend +# Features: pnpm, HMR support, polling for Docker file watching +# ============================================================================= +# syntax=docker/dockerfile:1 + +FROM node:22-slim + +RUN corepack enable && corepack prepare pnpm@10.29.1 --activate + +WORKDIR /app + +COPY package.json pnpm-lock.yaml* .npmrc* ./ + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile + +COPY . . + +EXPOSE 5173 + +CMD ["pnpm", "dev", "--host", "0.0.0.0"] diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/vite.prod b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/vite.prod new file mode 100644 index 00000000..69d025f4 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/docker/vite.prod @@ -0,0 +1,59 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# vite.prod +# ============================================================================= +# Production Dockerfile: builds Vite app, serves via Nginx +# ============================================================================= +# syntax=docker/dockerfile:1 + +# ============================================================================ +# BUILD STAGE +# ============================================================================ +FROM node:22-slim AS builder + +RUN corepack enable && corepack prepare pnpm@10.29.1 --activate + +WORKDIR /app + +COPY frontend/package.json frontend/pnpm-lock.yaml* frontend/.npmrc* ./ + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile + +COPY frontend/ . + +ARG VITE_API_URL=/api +ARG VITE_APP_TITLE="JA3/JA4 TLS Fingerprinting" + +ENV VITE_API_URL=${VITE_API_URL} \ + VITE_APP_TITLE=${VITE_APP_TITLE} + +RUN pnpm build + +# ============================================================================ +# PRODUCTION STAGE +# ============================================================================ +FROM nginx:1.27-alpine AS production + +RUN rm -rf /usr/share/nginx/html/* && \ + rm /etc/nginx/conf.d/default.conf + +COPY --from=builder /app/dist /usr/share/nginx/html + +COPY --chown=nginx:nginx infra/nginx/nginx.prod.conf /etc/nginx/nginx.conf +COPY --chown=nginx:nginx infra/nginx/prod.nginx /etc/nginx/conf.d/default.conf + +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + touch /var/run/nginx.pid && \ + chown -R nginx:nginx /var/run/nginx.pid + +USER nginx + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/dev.nginx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/dev.nginx new file mode 100644 index 00000000..fff68d6b --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/dev.nginx @@ -0,0 +1,49 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# dev.nginx +# ============================================================================= +# Development server block: proxies to Vite dev server with HMR. +# /api is resolved at request time so nginx still boots when the backend +# (the `backend` compose profile) is not running, returning 502 until it is. + +server { + listen 80; + listen [::]:80; + server_name _; + + access_log /var/log/nginx/access.log main_timed; + error_log /var/log/nginx/error.log debug; + + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location /api/ { + resolver 127.0.0.11 ipv6=off valid=10s; + set $tlsfp_upstream tlsfp; + proxy_pass http://$tlsfp_upstream:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } + + location / { + proxy_pass http://frontend_dev; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 60s; + proxy_buffering off; + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/nginx.conf b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/nginx.conf new file mode 100644 index 00000000..eddade54 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/nginx.conf @@ -0,0 +1,76 @@ +# AngelaMos | 2026 +# nginx.conf +# Development nginx configuration + +user nginx; +worker_processes auto; +worker_rlimit_nofile 65535; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 4096; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream frontend_dev { + server frontend:5173; + keepalive 8; + } + + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + limit_req_status 429; + + log_format main_timed '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + client_body_buffer_size 128k; + client_header_buffer_size 16k; + client_max_body_size 10m; + large_client_header_buffers 4 16k; + + client_body_timeout 12s; + client_header_timeout 12s; + send_timeout 10s; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + gzip_disable "msie6"; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/nginx.prod.conf b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/nginx.prod.conf new file mode 100644 index 00000000..f73ad69b --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/nginx.prod.conf @@ -0,0 +1,65 @@ +# AngelaMos | 2026 +# nginx.prod.conf +# Production nginx configuration + +worker_processes auto; +worker_rlimit_nofile 65535; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 4096; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + limit_req_status 429; + + log_format main_timed '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + client_body_buffer_size 128k; + client_header_buffer_size 16k; + client_max_body_size 10m; + large_client_header_buffers 4 16k; + + client_body_timeout 12s; + client_header_timeout 12s; + send_timeout 10s; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + gzip_disable "msie6"; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/prod.nginx b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/prod.nginx new file mode 100644 index 00000000..9469a1e4 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/infra/nginx/prod.nginx @@ -0,0 +1,70 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# prod.nginx +# ============================================================================= +# Production server block: serves built static files and proxies /api. +# Note: SSL handled by Cloudflare, not here. +# /api is resolved at request time so nginx still boots when the backend +# (the `backend` compose profile) is not running, returning 502 until it is. + +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s; + error_log /var/log/nginx/error.log warn; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location /api/ { + resolver 127.0.0.11 ipv6=off valid=10s; + set $tlsfp_upstream tlsfp; + proxy_pass http://$tlsfp_upstream:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + proxy_read_timeout 30s; + limit_req zone=api_limit burst=20 nodelay; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif|woff|woff2|ttf|eot|otf)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + location / { + add_header Cache-Control "no-cache, must-revalidate"; + try_files $uri $uri/ /index.html; + } + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/install.sh b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/install.sh new file mode 100755 index 00000000..a6e80c7b --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/install.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh + +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { printf "${CYAN}==>${NC} %s\n" "$1"; } +ok() { printf "${GREEN}OK${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}WARN${NC} %s\n" "$1"; } +fail() { printf "${RED}ERROR${NC} %s\n" "$1" >&2; exit 1; } + +JA4DB_URL="${JA4DB_URL:-https://ja4db.com/api/read/}" +JA4DB_TIMEOUT="${JA4DB_TIMEOUT:-180}" + +SETCAP=0 +for arg in "$@"; do + case "$arg" in + --live) SETCAP=1 ;; + -h|--help) + cat <<'EOF' +Usage: ./install.sh [--live] + +Builds the release binary, seeds the bundled intelligence feeds, and pulls the +ja4db.com enrichment feed. Pass --live to also grant the capabilities live +capture needs (this step uses sudo). +EOF + exit 0 + ;; + *) fail "unknown argument: $arg (try --help)" ;; + esac +done + +cd "$(dirname "$0")" + +command -v cargo >/dev/null 2>&1 || fail "cargo not found; install Rust from https://rustup.rs" + +info "Building the release binary" +cargo build --release +BIN="$PWD/target/release/tlsfp" +[ -x "$BIN" ] || fail "build succeeded but $BIN is missing" +ok "built $BIN" + +info "Seeding the bundled feeds (abuse.ch SSLBL, salesforce/ja3, curated C2)" +"$BIN" intel seed +ok "bundled feeds loaded" + +info "Fetching the ja4db.com enrichment feed" +if ! command -v curl >/dev/null 2>&1; then + warn "curl not found; skipping ja4db. The bundled feeds still work." +else + TMP="$(mktemp)" + trap 'rm -f "$TMP"' EXIT + if curl -fsSL --max-time "$JA4DB_TIMEOUT" "$JA4DB_URL" -o "$TMP"; then + "$BIN" intel import "$TMP" + ok "ja4db imported" + else + warn "could not reach $JA4DB_URL (it is large and often slow)." + warn "the bundled feeds still work; retry later with:" + printf " %s intel import <(curl -fsSL %s)\n" "$BIN" "$JA4DB_URL" + fi +fi + +if [ "$SETCAP" -eq 1 ]; then + info "Granting live capture capabilities (needs sudo)" + if command -v setcap >/dev/null 2>&1; then + if sudo setcap cap_net_raw,cap_net_admin=eip "$BIN"; then + ok "live capture enabled for $BIN" + else + warn "setcap failed; run live capture under sudo, or grant it later with:" + printf " sudo setcap cap_net_raw,cap_net_admin=eip %s\n" "$BIN" + fi + else + warn "setcap not found (install libcap2-bin); run live capture under sudo instead" + fi +fi + +printf "\n" +ok "Done. The binary is at $BIN" +cat < look one fingerprint up + $BIN pcap --intel fingerprint a capture and flag known intel + $BIN live --intel the same, live (needs --live above or sudo) + +To put tlsfp on your PATH: + cargo install --path crates/tlsfp +EOF diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/justfile b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/justfile new file mode 100644 index 00000000..53103bce --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/justfile @@ -0,0 +1,199 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# justfile +# ============================================================================= + +set dotenv-filename := ".env.development" +set dotenv-load +set export +set shell := ["bash", "-uc"] +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Frontend Linting +# ============================================================================= + +[group('lint')] +biome *ARGS: + cd frontend && pnpm biome check . {{ARGS}} + +[group('lint')] +biome-fix: + cd frontend && pnpm biome check --write . + +[group('lint')] +stylelint *ARGS: + cd frontend && pnpm stylelint '**/*.scss' {{ARGS}} + +[group('lint')] +stylelint-fix: + cd frontend && pnpm stylelint '**/*.scss' --fix + +[group('lint')] +tsc *ARGS: + cd frontend && pnpm tsc --noEmit {{ARGS}} + +# ============================================================================= +# Backend (Rust) +# ============================================================================= + +[group('backend')] +build-backend: + cargo build --release --bin tlsfp + +[group('backend')] +run-backend *ARGS: + cargo run --bin tlsfp -- {{ARGS}} + +[group('backend')] +test-backend: + cargo test --workspace + +[group('backend')] +clippy: + cargo clippy --workspace --all-targets -- -D warnings + +[group('backend')] +fmt: + cargo fmt --all + +[group('backend')] +fmt-check: + cargo fmt --all -- --check + +# ============================================================================= +# Docker Compose (Production) +# ============================================================================= + +[group('prod')] +up *ARGS: + docker compose --env-file .env up {{ARGS}} + +[group('prod')] +start *ARGS: + docker compose --env-file .env up -d {{ARGS}} + +[group('prod')] +down *ARGS: + docker compose --env-file .env down {{ARGS}} + +[group('prod')] +stop: + docker compose --env-file .env stop + +[group('prod')] +build *ARGS: + docker compose --env-file .env build {{ARGS}} + +[group('prod')] +rebuild: + docker compose --env-file .env build --no-cache + +[group('prod')] +logs *SERVICE: + docker compose --env-file .env logs -f {{SERVICE}} + +[group('prod')] +ps: + docker compose --env-file .env ps + +# ============================================================================= +# Docker Compose (Production + Cloudflare Tunnel) +# ============================================================================= + +[group('tunnel')] +tunnel-up *ARGS: + docker compose --env-file .env -f compose.yml -f cloudflared.compose.yml up {{ARGS}} + +[group('tunnel')] +tunnel-start *ARGS: + docker compose --env-file .env -f compose.yml -f cloudflared.compose.yml up -d {{ARGS}} + +[group('tunnel')] +tunnel-down *ARGS: + docker compose --env-file .env -f compose.yml -f cloudflared.compose.yml down {{ARGS}} + +[group('tunnel')] +tunnel-logs: + docker compose --env-file .env -f compose.yml -f cloudflared.compose.yml logs -f cloudflared + +# ============================================================================= +# Docker Compose (Development) +# ============================================================================= + +[group('dev')] +dev-up *ARGS: + docker compose -f dev.compose.yml up {{ARGS}} + +[group('dev')] +dev-start *ARGS: + docker compose -f dev.compose.yml up -d {{ARGS}} + +[group('dev')] +dev-down *ARGS: + docker compose -f dev.compose.yml down {{ARGS}} + +[group('dev')] +dev-stop: + docker compose -f dev.compose.yml stop + +[group('dev')] +dev-build *ARGS: + docker compose -f dev.compose.yml build {{ARGS}} + +[group('dev')] +dev-rebuild: + docker compose -f dev.compose.yml build --no-cache + +[group('dev')] +dev-logs *SERVICE: + docker compose -f dev.compose.yml logs -f {{SERVICE}} + +[group('dev')] +dev-ps: + docker compose -f dev.compose.yml ps + +# ============================================================================= +# Docker Compose (with backend profile) +# ============================================================================= + +[group('backend')] +dev-up-backend *ARGS: + docker compose -f dev.compose.yml --profile backend up {{ARGS}} + +[group('backend')] +up-backend *ARGS: + docker compose --env-file .env --profile backend up {{ARGS}} + +# ============================================================================= +# Utilities +# ============================================================================= + +[group('util')] +init: + bash scripts/init.sh + +[group('util')] +ports: + bash scripts/randomize-ports.sh + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "OS: {{os()}} ({{arch()}})" + +[group('util')] +clean: + -rm -rf frontend/.biome_cache + @echo "Cache directories cleaned" diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/scripts/init.sh b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/scripts/init.sh new file mode 100755 index 00000000..d258d33e --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/scripts/init.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# ============================================================================= +# AngelaMos | 2026 +# init.sh +# ============================================================================= +# Run once after copying the template. +# Prompts for a project name, randomizes ports, updates all the right files. + +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# ── prompt ──────────────────────────────────────────────────────────────────── +echo "" +read -rp "Project name (kebab-case): " RAW + +SLUG="$(echo "$RAW" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd '[:alnum:]-')" +[[ -z "$SLUG" ]] && { echo "Error: empty name" >&2; exit 1; } + +TITLE="$(echo "$SLUG" | tr '-' ' ' | python3 -c "import sys; print(sys.stdin.read().strip().title())")" + +# ── ports ───────────────────────────────────────────────────────────────────── +mapfile -t ports < <(python3 -c " +import random +for p in random.sample(range(10000, 65001), 4): print(p) +") + +PROD_NGINX=${ports[0]} +PROD_FRONTEND=${ports[1]} +DEV_NGINX=${ports[2]} +DEV_FRONTEND=${ports[3]} + +echo "" +echo " slug: $SLUG" +echo " title: $TITLE" +echo " prod nginx: $PROD_NGINX" +echo " prod frontend: $PROD_FRONTEND" +echo " dev nginx: $DEV_NGINX" +echo " dev frontend: $DEV_FRONTEND" +echo "" + +# ── .env (prod) ─────────────────────────────────────────────────────────────── +sed -i "s|^APP_NAME=.*|APP_NAME=$SLUG|" "$DIR/.env" +sed -i "s|^VITE_APP_TITLE=.*|VITE_APP_TITLE=$TITLE|" "$DIR/.env" +sed -i "s|^NGINX_HOST_PORT=.*|NGINX_HOST_PORT=$PROD_NGINX|" "$DIR/.env" +sed -i "s|^FRONTEND_HOST_PORT=.*|FRONTEND_HOST_PORT=$PROD_FRONTEND|" "$DIR/.env" +echo " updated .env" + +# ── .env.development ────────────────────────────────────────────────────────── +sed -i "s|^APP_NAME=.*|APP_NAME=$SLUG-dev|" "$DIR/.env.development" +sed -i "s|^VITE_APP_TITLE=.*|VITE_APP_TITLE=\"$TITLE (Dev)\"|" "$DIR/.env.development" +sed -i "s|^NGINX_HOST_PORT=.*|NGINX_HOST_PORT=$DEV_NGINX|" "$DIR/.env.development" +sed -i "s|^FRONTEND_HOST_PORT=.*|FRONTEND_HOST_PORT=$DEV_FRONTEND|" "$DIR/.env.development" +echo " updated .env.development" + +# ── .env.example ────────────────────────────────────────────────────────────── +sed -i "s|^APP_NAME=.*|APP_NAME=$SLUG|" "$DIR/.env.example" +sed -i "s|^VITE_APP_TITLE=.*|VITE_APP_TITLE=$TITLE|" "$DIR/.env.example" +sed -i "s|^NGINX_HOST_PORT=.*|NGINX_HOST_PORT=$PROD_NGINX|" "$DIR/.env.example" +sed -i "s|^FRONTEND_HOST_PORT=.*|FRONTEND_HOST_PORT=$PROD_FRONTEND|" "$DIR/.env.example" +echo " updated .env.example" + +# ── compose.yml ─────────────────────────────────────────────────────────────── +sed -i "s|\${APP_NAME:-[a-z0-9-]*}|\${APP_NAME:-$SLUG}|g" "$DIR/compose.yml" +sed -i "s|\${NGINX_HOST_PORT:-[0-9]\+}|\${NGINX_HOST_PORT:-$PROD_NGINX}|g" "$DIR/compose.yml" +echo " updated compose.yml" + +# ── dev.compose.yml ─────────────────────────────────────────────────────────── +sed -i "s|\${APP_NAME:-[a-z0-9-]*}|\${APP_NAME:-$SLUG}|g" "$DIR/dev.compose.yml" +sed -i "s|\${NGINX_HOST_PORT:-[0-9]\+}|\${NGINX_HOST_PORT:-$DEV_NGINX}|g" "$DIR/dev.compose.yml" +sed -i "s|\${FRONTEND_HOST_PORT:-[0-9]\+}|\${FRONTEND_HOST_PORT:-$DEV_FRONTEND}|g" "$DIR/dev.compose.yml" +echo " updated dev.compose.yml" + +# ── cloudflared.compose.yml ─────────────────────────────────────────────────── +sed -i "s|\${APP_NAME:-[a-z0-9-]*}|\${APP_NAME:-$SLUG}|g" "$DIR/cloudflared.compose.yml" +echo " updated cloudflared.compose.yml" + +# ── frontend/package.json ───────────────────────────────────────────────────── +sed -i "s|\"name\": \".*\"|\"name\": \"$SLUG\"|" "$DIR/frontend/package.json" +echo " updated frontend/package.json" + +# ── frontend/index.html ─────────────────────────────────────────────────────── +sed -i "s|Full Stack Template No Auth|$TITLE|g" "$DIR/frontend/index.html" +echo " updated frontend/index.html" + +echo "" +echo "Done. Go build something." diff --git a/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/scripts/randomize-ports.sh b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/scripts/randomize-ports.sh new file mode 100755 index 00000000..95348d01 --- /dev/null +++ b/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/scripts/randomize-ports.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ============================================================================= +# AngelaMos | 2026 +# randomize-ports.sh +# ============================================================================= +# Picks 4 unique random ports (10000-65000) and updates: +# .env -> prod nginx + frontend ports +# .env.development -> dev nginx + frontend ports +# compose.yml -> prod default fallbacks +# dev.compose.yml -> dev default fallbacks + +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +mapfile -t ports < <(python3 -c " +import random +sample = random.sample(range(10000, 65001), 4) +for p in sample: print(p) +") + +PROD_NGINX=${ports[0]} +PROD_FRONTEND=${ports[1]} +DEV_NGINX=${ports[2]} +DEV_FRONTEND=${ports[3]} + +echo "New ports:" +echo " prod nginx: $PROD_NGINX" +echo " prod frontend: $PROD_FRONTEND" +echo " dev nginx: $DEV_NGINX" +echo " dev frontend: $DEV_FRONTEND" +echo "" + +update_env() { + local file="$1" nginx_port="$2" frontend_port="$3" + sed -i "s/^NGINX_HOST_PORT=.*/NGINX_HOST_PORT=$nginx_port/" "$file" + sed -i "s/^FRONTEND_HOST_PORT=.*/FRONTEND_HOST_PORT=$frontend_port/" "$file" + echo " updated $file" +} + +update_compose_defaults() { + local file="$1" nginx_port="$2" frontend_port="${3:-}" + sed -i "s/\${NGINX_HOST_PORT:-[0-9]\+}/\${NGINX_HOST_PORT:-$nginx_port}/g" "$file" + if [[ -n "$frontend_port" ]]; then + sed -i "s/\${FRONTEND_HOST_PORT:-[0-9]\+}/\${FRONTEND_HOST_PORT:-$frontend_port}/g" "$file" + fi + echo " updated $file" +} + +update_env "$DIR/.env" "$PROD_NGINX" "$PROD_FRONTEND" +update_env "$DIR/.env.development" "$DEV_NGINX" "$DEV_FRONTEND" +update_compose_defaults "$DIR/compose.yml" "$PROD_NGINX" +update_compose_defaults "$DIR/dev.compose.yml" "$DEV_NGINX" "$DEV_FRONTEND" + +echo "" +echo "Done."
{error.message}
+ Template page — build your dashboard here +
{store.name}
{store.description}
+ Template page — available stores for building your settings UI +