feat(ja3-ja4-tls-fingerprinting): M8 SQLite intel store + M9 detection engine
M8: tlsfp-intel crate with seeded fingerprint DB, JA4 fuzzy matcher, prevalence scoring. M9: six detection rules (known_bad, ua_mismatch, os_mismatch, first_seen, fp_rotation, monoculture) over an observation/alert store (migration v2); --detect on pcap/live plus an intel alerts feed; user_agent plumbed through StreamEvent::HttpRequest. M10 scaffolding: cybercore frontend, compose/infra/install groundwork (serve not yet wired). Relicense MIT -> AGPL-3.0; ignore env/node_modules/dist; exclude seed CSVs from whitespace hooks.
This commit is contained in:
parent
7845d9a1d9
commit
1bb94b08eb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .dockerignore
|
||||
|
||||
target/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
.git/
|
||||
docs/
|
||||
*.db
|
||||
*.sqlite
|
||||
.env
|
||||
.env.development
|
||||
|
|
@ -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=
|
||||
|
|
@ -7,3 +7,11 @@ target/
|
|||
*.sqlite
|
||||
fuzz/corpus/
|
||||
fuzz/artifacts/
|
||||
|
||||
.env
|
||||
.env.development
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
.biome_cache/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -36,6 +36,8 @@ pub enum StreamEvent {
|
|||
ja4h: Ja4Family,
|
||||
method: String,
|
||||
host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_agent: Option<String>,
|
||||
},
|
||||
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}"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- PROVENANCE.md -->
|
||||
|
||||
# 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.
|
||||
|
|
@ -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"
|
||||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
File diff suppressed because it is too large
Load Diff
|
|
@ -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<ImportSummary> {
|
||||
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<String> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
parts.len() == 3 && parts.iter().all(|part| is_hex(part, 12))
|
||||
}
|
||||
FpKind::Ja4t | FpKind::Ja4ts => {
|
||||
let parts = value.split('_').collect::<Vec<_>>();
|
||||
(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::<Vec<_>>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self> {
|
||||
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<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
Self::migrate(conn)
|
||||
}
|
||||
|
||||
fn migrate(mut conn: Connection) -> Result<Self> {
|
||||
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<SeedSummary> {
|
||||
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<ImportSummary> {
|
||||
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<MatchReport> {
|
||||
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<Vec<MatchReport>> {
|
||||
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<Vec<Alert>> {
|
||||
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<Vec<Alert>> {
|
||||
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<Vec<Alert>> {
|
||||
detect::recent(&self.conn, limit)
|
||||
}
|
||||
|
||||
/// A count of recorded alerts per rule, for the stats summary.
|
||||
pub fn alert_counts(&self) -> Result<Vec<(Rule, i64)>> {
|
||||
detect::counts_by_rule(&self.conn)
|
||||
}
|
||||
|
||||
/// Summarises what the store holds, by feed and by category.
|
||||
pub fn stats(&self) -> Result<Stats> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
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<String>,
|
||||
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<SourceStat>,
|
||||
pub by_category: Vec<CategoryStat>,
|
||||
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<i64> {
|
||||
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<bool> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MatchReport> {
|
||||
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<Vec<IntelHit>> {
|
||||
let mut statement = conn.prepare(sql)?;
|
||||
let hits = statement
|
||||
.query_map(params, |row| map_row(row, strength))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
fn map_row(row: &Row, strength: MatchStrength) -> rusqlite::Result<IntelHit> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self> {
|
||||
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<String>,
|
||||
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<IntelHit>,
|
||||
}
|
||||
|
||||
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<IntelHit>) -> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FeedLoad>,
|
||||
}
|
||||
|
||||
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<SeedSummary> {
|
||||
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<String> {
|
||||
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<String>, Option<String>) = 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<String>, Option<String>) = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OsClass> {
|
||||
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<OsClass> {
|
||||
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<OsClass> {
|
||||
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<Family> {
|
||||
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<Family> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
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<std::io::Error> = 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<IntelStore>,
|
||||
) -> 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<IntelStore>,
|
||||
intel: bool,
|
||||
detect: bool,
|
||||
event: &FingerprintEvent,
|
||||
json: bool,
|
||||
failure: &mut Option<std::io::Error>,
|
||||
|
|
@ -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<MatchReport>,
|
||||
#[serde(rename = "alerts", skip_serializing_if = "Vec::is_empty")]
|
||||
alerts: Vec<Alert>,
|
||||
}
|
||||
|
||||
/// 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<MatchReport> {
|
||||
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<Alert> {
|
||||
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<MatchReport>,
|
||||
alerts: Vec<Alert>,
|
||||
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::<Vec<_>>()
|
||||
.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<Option<IntelStore>> {
|
||||
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>) -> 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> {
|
||||
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<IntelStore> {
|
||||
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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<String> {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .npmrc
|
||||
strict-dep-builds=false
|
||||
auto-install-peers=true
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--
|
||||
JA3/JA4 TLS Fingerprinting
|
||||
Author(s): © AngelaMos, CarterPerez-dev
|
||||
-->
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="/assets/favicon.ico"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
type="image/png"
|
||||
href="/assets/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/assets/site.webmanifest"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' github.com api.github.com; form-action 'self' github.com; worker-src 'self' blob:;"
|
||||
/>
|
||||
<title>JA3/JA4 TLS Fingerprinting</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="*Cracked*"
|
||||
/>
|
||||
<meta
|
||||
name="author"
|
||||
content=" ©AngelaMos | 2026 | CarterPerez-dev"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 891 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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"}
|
||||
|
|
@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="app">
|
||||
<RouterProvider router={router} />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
duration={2000}
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: 'hsl(0, 0%, 12.2%)',
|
||||
border: '1px solid hsl(0, 0%, 18%)',
|
||||
color: 'hsl(0, 0%, 98%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* ©AngelaMos | 2025
|
||||
* index.tsx
|
||||
*/
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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<string, string[]>
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiErrorCode,
|
||||
statusCode: number,
|
||||
details?: Record<string, string[]>
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
this.details = details
|
||||
}
|
||||
|
||||
getUserMessage(): string {
|
||||
const messages: Record<ApiErrorCode, string> = {
|
||||
[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<unknown>): 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<string, string[]> | 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<number, ApiErrorCode> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './api.config'
|
||||
export * from './errors'
|
||||
export * from './query.config'
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
|
|
@ -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: <Shell />,
|
||||
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)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// shell.module.scss
|
||||
// ===================
|
||||
|
|
@ -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 (
|
||||
<div className={styles.error}>
|
||||
<h2>Something went wrong</h2>
|
||||
<pre>{error.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShellLoading(): React.ReactElement {
|
||||
return <div className={styles.loading}>Loading...</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.shell}>
|
||||
<aside
|
||||
className={`${styles.sidebar} ${sidebarOpen ? styles.open : ''} ${sidebarCollapsed ? styles.collapsed : ''}`}
|
||||
>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<span className={styles.logo}>NavBar Template</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseBtn}
|
||||
onClick={toggleSidebarCollapsed}
|
||||
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? <LuChevronRight /> : <LuChevronLeft />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
onClick={() => sidebarOpen && toggleSidebar()}
|
||||
>
|
||||
<item.icon className={styles.navIcon} />
|
||||
<span className={styles.navLabel}>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.overlay}
|
||||
onClick={toggleSidebar}
|
||||
onKeyDown={(e) => e.key === 'Escape' && toggleSidebar()}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`${styles.main} ${sidebarCollapsed ? styles.collapsed : ''}`}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuBtn}
|
||||
onClick={toggleSidebar}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<LuMenu />
|
||||
</button>
|
||||
<h1 className={styles.pageTitle}>{pageTitle}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<ErrorBoundary FallbackComponent={ShellErrorFallback}>
|
||||
<Suspense fallback={<ShellLoading />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// toast.module.scss
|
||||
// ===================
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './shell.ui.store'
|
||||
|
|
@ -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<UIState>()(
|
||||
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)
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// dashboard.module.scss
|
||||
// ===================
|
||||
|
|
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>Welcome</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Template page — build your dashboard here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Available Stores</h2>
|
||||
<div className={styles.grid}>
|
||||
{AVAILABLE_STORES.map((store) => (
|
||||
<div key={store.name} className={styles.card}>
|
||||
<code className={styles.hookName}>{store.name}</code>
|
||||
<p className={styles.description}>{store.description}</p>
|
||||
<span className={styles.file}>{store.file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Suggested Features</h2>
|
||||
<ul className={styles.list}>
|
||||
{SUGGESTED_FEATURES.map((feature) => (
|
||||
<li key={feature}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'Dashboard'
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import styles from './landing.module.scss'
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
return <div className={styles.page} />
|
||||
}
|
||||
|
||||
Component.displayName = 'Landing'
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// landing.module.scss
|
||||
// ===================
|
||||
|
|
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>Settings</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Template page — available stores for building your settings UI
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Available Stores</h2>
|
||||
<div className={styles.grid}>
|
||||
{AVAILABLE_STORES.map((store) => (
|
||||
<div key={store.name} className={styles.card}>
|
||||
<code className={styles.hookName}>{store.name}</code>
|
||||
<p className={styles.description}>{store.description}</p>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.file}>{store.file}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Suggested Features</h2>
|
||||
<ul className={styles.list}>
|
||||
<li>Profile form</li>
|
||||
<li>Theme toggle (dark/light)</li>
|
||||
<li>Notification settings</li>
|
||||
<li>Application preferences</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'Settings'
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// settings.module.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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// _index.scss
|
||||
// ===================
|
||||
|
||||
@forward 'tokens';
|
||||
@forward 'fonts';
|
||||
@forward 'mixins';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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%;
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <<EOF
|
||||
|
||||
Next steps:
|
||||
$BIN intel stats show what the database holds
|
||||
$BIN intel lookup ja3 <hash> look one fingerprint up
|
||||
$BIN pcap --intel <file.pcap> fingerprint a capture and flag known intel
|
||||
$BIN live --intel <iface> the same, live (needs --live above or sudo)
|
||||
|
||||
To put tlsfp on your PATH:
|
||||
cargo install --path crates/tlsfp
|
||||
EOF
|
||||
|
|
@ -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"
|
||||
|
|
@ -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."
|
||||
|
|
@ -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."
|
||||
Loading…
Reference in New Issue