feat: advanced honeypot-network project complete
This commit is contained in:
parent
cac4a65232
commit
e35d752289
|
|
@ -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
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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."
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
```regex
|
||||
██╗ ██╗██╗██╗ ██╗███████╗
|
||||
██║ ██║██║██║ ██║██╔════╝
|
||||
███████║██║██║ ██║█████╗
|
||||
██╔══██║██║╚██╗ ██╔╝██╔══╝
|
||||
██║ ██║██║ ╚████╔╝ ███████╗
|
||||
╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/honeypot-network)
|
||||
[](https://go.dev)
|
||||
[](https://react.dev)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://honeypot-network.carterperez-dev.com/)
|
||||
[](https://www.docker.com)
|
||||
[](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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--
|
||||
Hive Honeypot Network
|
||||
Author(s): © AngelaMos, CarterPerez-dev
|
||||
-->
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="/assets/favicon.ico"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
type="image/png"
|
||||
href="/assets/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/assets/site.webmanifest"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://unpkg.com https://fonts.googleapis.com; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:; form-action 'self'; worker-src 'self' blob:;"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
/>
|
||||
<title>Hive - Honeypot Dashboard</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="*Cracked*"
|
||||
/>
|
||||
<meta
|
||||
name="author"
|
||||
content="©AngelaMos | 2026 | CarterPerez-dev"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
crossorigin=""
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 681 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
|
@ -0,0 +1,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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="bottom-right" theme="dark" richColors />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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<Attacker[], Error> => {
|
||||
return useQuery({
|
||||
queryKey: attackerQueries.list(limit, since),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<Attacker[]>>(
|
||||
API_ENDPOINTS.ATTACKERS.LIST,
|
||||
{ params: { limit, since } }
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.slow,
|
||||
})
|
||||
}
|
||||
|
||||
export const useAttacker = (id: number): UseQueryResult<Attacker, Error> => {
|
||||
return useQuery({
|
||||
queryKey: attackerQueries.byId(id),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<Attacker>>(
|
||||
API_ENDPOINTS.ATTACKERS.BY_ID(id)
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<HoneypotEvent[], Error> => {
|
||||
return useQuery({
|
||||
queryKey: eventQueries.list(limit, ip),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<HoneypotEvent[]>>(
|
||||
API_ENDPOINTS.EVENTS.LIST,
|
||||
{ params: { limit, ip } }
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.live,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PaginatedResponse<IOC[]>, Error> => {
|
||||
return useQuery({
|
||||
queryKey: iocQueries.list(limit, offset),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<IOC[]>>(
|
||||
API_ENDPOINTS.IOCS.LIST,
|
||||
{ params: { limit, offset } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
...QUERY_STRATEGIES.standard,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<TechniqueInfo[], Error> => {
|
||||
return useQuery({
|
||||
queryKey: mitreQueries.techniques(),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<TechniqueInfo[]>>(
|
||||
API_ENDPOINTS.MITRE.TECHNIQUES
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export const useMitreHeatmap = (
|
||||
since = '24h'
|
||||
): UseQueryResult<HeatmapEntry[], Error> => {
|
||||
return useQuery({
|
||||
queryKey: mitreQueries.heatmap(since),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<HeatmapEntry[]>>(
|
||||
API_ENDPOINTS.MITRE.HEATMAP,
|
||||
{ params: { since } }
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.slow,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<SensorInfo[], Error> => {
|
||||
return useQuery({
|
||||
queryKey: sensorQueries.list(),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<SensorInfo[]>>(
|
||||
API_ENDPOINTS.SENSORS.LIST
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<PaginatedResponse<Session[]>, Error> => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueries.list(limit, offset, service),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Session[]>>(
|
||||
API_ENDPOINTS.SESSIONS.LIST,
|
||||
{ params: { limit, offset, service } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
...QUERY_STRATEGIES.standard,
|
||||
})
|
||||
}
|
||||
|
||||
export const useSession = (id: string): UseQueryResult<Session, Error> => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueries.byId(id),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<Session>>(
|
||||
API_ENDPOINTS.SESSIONS.BY_ID(id)
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
enabled: id.length > 0,
|
||||
})
|
||||
}
|
||||
|
||||
export const useSessionReplay = (id: string): UseQueryResult<string, Error> => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueries.replay(id),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<string>(
|
||||
API_ENDPOINTS.SESSIONS.REPLAY(id),
|
||||
{ responseType: 'text' }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
enabled: id.length > 0,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<OverviewStats, Error> => {
|
||||
return useQuery({
|
||||
queryKey: statsQueries.overview(since),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<OverviewStats>>(
|
||||
API_ENDPOINTS.STATS.OVERVIEW,
|
||||
{ params: { since } }
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.live,
|
||||
})
|
||||
}
|
||||
|
||||
export const useStatsCountries = (
|
||||
since = '24h'
|
||||
): UseQueryResult<Record<string, number>, Error> => {
|
||||
return useQuery({
|
||||
queryKey: statsQueries.countries(since),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<Record<string, number>>>(
|
||||
API_ENDPOINTS.STATS.COUNTRIES,
|
||||
{ params: { since } }
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.slow,
|
||||
})
|
||||
}
|
||||
|
||||
export const useStatsCredentials = (): UseQueryResult<CredentialStats, Error> => {
|
||||
return useQuery({
|
||||
queryKey: statsQueries.credentials(),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiResponse<CredentialStats>>(
|
||||
API_ENDPOINTS.STATS.CREDENTIALS
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
...QUERY_STRATEGIES.slow,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<typeof attackerSchema>
|
||||
|
||||
export const isValidAttacker = (data: unknown): data is Attacker => {
|
||||
return attackerSchema.safeParse(data).success
|
||||
}
|
||||
|
|
@ -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<typeof geoInfoSchema>
|
||||
|
||||
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
|
||||
z.object({
|
||||
data: dataSchema,
|
||||
})
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
data: T
|
||||
}
|
||||
|
||||
export const paginatedResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
|
||||
z.object({
|
||||
data: dataSchema,
|
||||
total: z.number(),
|
||||
limit: z.number(),
|
||||
offset: z.number(),
|
||||
})
|
||||
|
||||
export type PaginatedResponse<T> = {
|
||||
data: T
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const isValidGeoInfo = (data: unknown): data is GeoInfo => {
|
||||
return geoInfoSchema.safeParse(data).success
|
||||
}
|
||||
|
|
@ -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<typeof honeypotEventSchema>
|
||||
|
||||
export const isValidHoneypotEvent = (data: unknown): data is HoneypotEvent => {
|
||||
return honeypotEventSchema.safeParse(data).success
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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<typeof iocSchema>
|
||||
|
||||
export const isValidIOC = (data: unknown): data is IOC => {
|
||||
return iocSchema.safeParse(data).success
|
||||
}
|
||||
|
|
@ -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<typeof techniqueInfoSchema>
|
||||
|
||||
export const heatmapEntrySchema = z.object({
|
||||
technique_id: z.string(),
|
||||
name: z.string(),
|
||||
tactic: z.string(),
|
||||
count: z.number(),
|
||||
})
|
||||
|
||||
export type HeatmapEntry = z.infer<typeof heatmapEntrySchema>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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<typeof sensorInfoSchema>
|
||||
|
||||
export const isValidSensorInfo = (data: unknown): data is SensorInfo => {
|
||||
return sensorInfoSchema.safeParse(data).success
|
||||
}
|
||||
|
|
@ -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<typeof sessionSchema>
|
||||
|
||||
export const isValidSession = (data: unknown): data is Session => {
|
||||
return sessionSchema.safeParse(data).success
|
||||
}
|
||||
|
|
@ -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<typeof overviewStatsSchema>
|
||||
|
||||
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<typeof credentialStatsSchema>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>'
|
||||
|
||||
interface AttackMapProps {
|
||||
attackers: Attacker[]
|
||||
}
|
||||
|
||||
export function AttackMap({ attackers }: AttackMapProps) {
|
||||
const maxEvents = Math.max(...attackers.map((a) => a.total_events), 1)
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<MapContainer
|
||||
center={MAP_CENTER}
|
||||
zoom={MAP_ZOOM}
|
||||
className={styles.map}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
>
|
||||
<TileLayer url={TILE_URL} attribution={TILE_ATTR} />
|
||||
|
||||
{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 (
|
||||
<CircleMarker
|
||||
key={attacker.id}
|
||||
center={[attacker.geo.latitude, attacker.geo.longitude]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
color: 'oklch(0.6 0.22 25)',
|
||||
fillColor: 'oklch(0.6 0.22 25)',
|
||||
fillOpacity: 0.6,
|
||||
weight: 1,
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<strong>{attacker.ip}</strong>
|
||||
<br />
|
||||
{attacker.geo.country}{' '}
|
||||
{attacker.geo.city ? `- ${attacker.geo.city}` : ''}
|
||||
<br />
|
||||
Events: {attacker.total_events}
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
)
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <div className={styles.empty}>AWAITING SIGNAL ...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.feed}>
|
||||
{visible.map((ev) => (
|
||||
<div key={ev.id} className={styles.entry}>
|
||||
<span className={styles.time}>
|
||||
{new Date(ev.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<ServiceBadge service={ev.service_type} />
|
||||
<span className={styles.type}>{ev.event_type}</span>
|
||||
<span className={styles.ip}>{ev.source_ip}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<span className={styles.badge}>
|
||||
{service.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className={styles.player}>
|
||||
<div ref={containerRef} className={styles.terminal} />
|
||||
|
||||
<div className={styles.controls}>
|
||||
<button type="button" className={styles.btn} onClick={handlePlayPause}>
|
||||
{playing ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
|
||||
<button type="button" className={styles.btn} onClick={handleReset}>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<div className={styles.speedGroup}>
|
||||
{PLAYBACK_SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`${styles.speedBtn} ${s === speed ? styles.speedBtnActive : ''}`}
|
||||
onClick={() => setSpeed(s)}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.progress}>
|
||||
<div className={styles.progressBar} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<span className={styles.counter}>
|
||||
{frameIdx}/{frames.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.card}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span
|
||||
className={styles.value}
|
||||
style={accent ? { color: accent } : undefined}
|
||||
>
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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
|
||||
|
|
@ -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<never> => {
|
||||
return Promise.reject(transformAxiosError(error))
|
||||
}
|
||||
)
|
||||
|
|
@ -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, string> = {
|
||||
[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<unknown>): ApiError {
|
||||
if (!error.response) {
|
||||
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
|
||||
}
|
||||
|
||||
const { status } = error.response
|
||||
const data = error.response.data as ApiErrorResponse | undefined
|
||||
const message = data?.error ?? data?.message ?? 'An error occurred'
|
||||
|
||||
const codeMap: Record<number, ApiErrorCode> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
|
|
@ -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: <Shell />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'events', element: <EventsPage /> },
|
||||
{ path: 'sessions', element: <SessionsPage /> },
|
||||
{ path: 'sessions/:id', element: <SessionDetailPage /> },
|
||||
{ path: 'attackers', element: <AttackersPage /> },
|
||||
{ path: 'attackers/:id', element: <AttackerDetailPage /> },
|
||||
{ path: 'mitre', element: <MitrePage /> },
|
||||
{ path: 'intel', element: <IntelPage /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.layout}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandMark}>⬢</span>
|
||||
<span className={styles.brandName}>HIVE</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<nav className={styles.nav}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={'end' in item}
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.navActive : ''}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.status}>
|
||||
<span className={styles.statusLabel}>LINK</span>
|
||||
<span
|
||||
className={connected ? styles.statusOn : styles.statusOff}
|
||||
>
|
||||
{connected ? 'ACTIVE' : 'DOWN'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.substrip}>
|
||||
<span>HONEYPOT NETWORK MONITOR</span>
|
||||
<span>SYS-01 / REV 1.0</span>
|
||||
</div>
|
||||
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export { apiClient, QUERY_STRATEGIES } from '@/core/api'
|
||||
export { useWebSocketStore } from './websocket.store'
|
||||
|
|
@ -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<WebSocketState>((set, get) => {
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectDelay = RECONNECT_BASE_MS
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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 })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 <div className={styles.loading}>LOADING ...</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.page}>
|
||||
<Link to="/attackers" className={styles.back}>
|
||||
← ATTACKERS
|
||||
</Link>
|
||||
|
||||
<h1 className={styles.detailTitle}>{attacker.ip}</h1>
|
||||
|
||||
<div className={styles.dossier}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className={styles.dossierRow}>
|
||||
<span className={styles.dossierLabel}>{row.label}</span>
|
||||
<span className={styles.dossierValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={styles.dossierRow}>
|
||||
<span className={styles.dossierLabel}>THREAT SCORE</span>
|
||||
<span
|
||||
className={`${styles.dossierValue} ${styles.threatValue}`}
|
||||
>
|
||||
{attacker.threat_score}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{attacker.tags && attacker.tags.length > 0 && (
|
||||
<div className={styles.dossierRow}>
|
||||
<span className={styles.dossierLabel}>TAGS</span>
|
||||
<span className={styles.dossierValue}>
|
||||
<span className={styles.tags}>
|
||||
{attacker.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.heading}>
|
||||
<h1 className={styles.title}>Attackers</h1>
|
||||
<span className={styles.subtitle}>THREAT ACTOR DATABASE</span>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loading}>LOADING ...</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Country</th>
|
||||
<th>Events</th>
|
||||
<th>Sessions</th>
|
||||
<th>Tool Family</th>
|
||||
<th>Threat Score</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attackers?.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>
|
||||
<Link
|
||||
to={`/attackers/${a.id}`}
|
||||
className={styles.link}
|
||||
>
|
||||
{a.ip}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{a.geo.country_code}
|
||||
{a.geo.city ? ` \u2014 ${a.geo.city}` : ''}
|
||||
</td>
|
||||
<td>{a.total_events}</td>
|
||||
<td>{a.total_sessions}</td>
|
||||
<td>{a.tool_family || '\u2014'}</td>
|
||||
<td className={styles.threat}>{a.threat_score}</td>
|
||||
<td>{new Date(a.last_seen).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.heading}>
|
||||
<h1 className={styles.title}>Dashboard</h1>
|
||||
<span className={styles.subtitle}>
|
||||
NETWORK OPERATIONS / LIVE OVERVIEW
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className={styles.metrics}>
|
||||
<StatCard label="Total Events" value={stats?.total_events ?? 0} />
|
||||
<StatCard
|
||||
label="Active Sessions"
|
||||
value={stats?.active_sessions ?? 0}
|
||||
accent="var(--active)"
|
||||
/>
|
||||
<StatCard
|
||||
label="Live Events"
|
||||
value={wsEventCount}
|
||||
accent="var(--accent)"
|
||||
/>
|
||||
<StatCard
|
||||
label="Threat Actors"
|
||||
value={attackers?.length ?? 0}
|
||||
accent="var(--critical)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.rowWide}>
|
||||
<section className={styles.panel}>
|
||||
<h2 className={styles.panelLabel}>Attack Origins</h2>
|
||||
<AttackMap attackers={attackers ?? []} />
|
||||
</section>
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h2 className={styles.panelLabel}>Live Feed</h2>
|
||||
<EventFeed />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<section className={styles.panel}>
|
||||
<h2 className={styles.panelLabel}>Events by Service</h2>
|
||||
<div className={styles.chart}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={serviceData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="oklch(32% 0.005 55)"
|
||||
tick={{ fill: 'oklch(52% 0.005 55)', fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ strokeWidth: 2 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="oklch(32% 0.005 55)"
|
||||
tick={{ fill: 'oklch(52% 0.005 55)', fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} cursor={false} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="oklch(70% 0.19 55)"
|
||||
radius={0}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h2 className={styles.panelLabel}>Top Countries</h2>
|
||||
<div className={styles.list}>
|
||||
{countryData.map((c) => (
|
||||
<div key={c.code} className={styles.listRow}>
|
||||
<span className={styles.listLabel}>{c.code}</span>
|
||||
<span className={styles.listValue}>
|
||||
{c.count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{credentials && (
|
||||
<div className={styles.row}>
|
||||
<section className={styles.panel}>
|
||||
<h2 className={styles.panelLabel}>Top Usernames</h2>
|
||||
<div className={styles.list}>
|
||||
{credentials.top_usernames
|
||||
.slice(0, TOP_CREDS_LIMIT)
|
||||
.map((u) => (
|
||||
<div key={u.value} className={styles.listRow}>
|
||||
<span className={styles.listLabel}>{u.value}</span>
|
||||
<span className={styles.listValue}>{u.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h2 className={styles.panelLabel}>Top Passwords</h2>
|
||||
<div className={styles.list}>
|
||||
{credentials.top_passwords
|
||||
.slice(0, TOP_CREDS_LIMIT)
|
||||
.map((p) => (
|
||||
<div key={p.value} className={styles.listRow}>
|
||||
<span className={styles.listLabel}>{p.value}</span>
|
||||
<span className={styles.listValue}>{p.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.heading}>
|
||||
<div className={styles.headingLeft}>
|
||||
<h1 className={styles.title}>Events</h1>
|
||||
<span className={styles.subtitle}>SYSTEM EVENT LOG</span>
|
||||
</div>
|
||||
<div className={styles.filterGroup}>
|
||||
<span className={styles.filterLabel}>FILTER IP</span>
|
||||
<input
|
||||
className={styles.filter}
|
||||
type="text"
|
||||
placeholder="0.0.0.0"
|
||||
value={ipFilter}
|
||||
onChange={(e) => setIpFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loading}>LOADING ...</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Service</th>
|
||||
<th>Type</th>
|
||||
<th>Source IP</th>
|
||||
<th>Port</th>
|
||||
<th>Session</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events?.map((ev) => (
|
||||
<tr key={ev.id}>
|
||||
<td>{new Date(ev.timestamp).toLocaleString()}</td>
|
||||
<td>
|
||||
<ServiceBadge service={ev.service_type} />
|
||||
</td>
|
||||
<td>{ev.event_type}</td>
|
||||
<td>{ev.source_ip}</td>
|
||||
<td>{ev.dest_port}</td>
|
||||
<td className={styles.dim}>{ev.session_id.slice(0, 8)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.heading}>
|
||||
<div className={styles.headingLeft}>
|
||||
<h1 className={styles.title}>Intel</h1>
|
||||
<span className={styles.subtitle}>
|
||||
THREAT INTELLIGENCE PRODUCTS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.exports}>
|
||||
<span className={styles.exportLabel}>EXPORT</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.exportBtn}
|
||||
onClick={exportSTIX}
|
||||
>
|
||||
STIX 2.1
|
||||
</button>
|
||||
{BLOCKLIST_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
className={styles.exportBtn}
|
||||
onClick={() => exportBlocklist(fmt)}
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loading}>LOADING ...</div>
|
||||
) : (
|
||||
<>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
<th>Confidence</th>
|
||||
<th>Sightings</th>
|
||||
<th>Source</th>
|
||||
<th>First Seen</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{iocs.map((ioc) => (
|
||||
<tr key={ioc.id}>
|
||||
<td className={styles.iocType}>{ioc.type}</td>
|
||||
<td>{ioc.value}</td>
|
||||
<td>{ioc.confidence}%</td>
|
||||
<td>{ioc.sight_count}</td>
|
||||
<td>{ioc.source}</td>
|
||||
<td>
|
||||
{new Date(ioc.first_seen).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(ioc.last_seen).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={offset === 0}
|
||||
onClick={() =>
|
||||
setOffset(
|
||||
Math.max(0, offset - PAGINATION.DEFAULT_LIMIT)
|
||||
)
|
||||
}
|
||||
>
|
||||
◀ PREV
|
||||
</button>
|
||||
<span className={styles.pageInfo}>
|
||||
{offset + 1}–
|
||||
{Math.min(offset + PAGINATION.DEFAULT_LIMIT, total)} OF{' '}
|
||||
{total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={offset + PAGINATION.DEFAULT_LIMIT >= total}
|
||||
onClick={() =>
|
||||
setOffset(offset + PAGINATION.DEFAULT_LIMIT)
|
||||
}
|
||||
>
|
||||
NEXT ▶
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, typeof techniques>()
|
||||
for (const t of techniques ?? []) {
|
||||
const existing = byTactic.get(t.tactic) ?? []
|
||||
existing.push(t)
|
||||
byTactic.set(t.tactic, existing)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.heading}>
|
||||
<h1 className={styles.title}>MITRE ATT&CK</h1>
|
||||
<span className={styles.subtitle}>ADVERSARY TECHNIQUE HEATMAP</span>
|
||||
</header>
|
||||
|
||||
<div className={styles.legend}>
|
||||
<span className={styles.legendLabel}>INTENSITY</span>
|
||||
{HEAT_THRESHOLDS.map((t, i) => (
|
||||
<span key={t} className={styles.legendItem}>
|
||||
<span
|
||||
className={`${styles.legendSwatch} ${styles[`heat${i}`]}`}
|
||||
/>
|
||||
<span className={styles.legendText}>{t}+</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.matrix}>
|
||||
{TACTIC_ORDER.map((tactic) => {
|
||||
const techs = byTactic.get(tactic)
|
||||
if (!techs) return null
|
||||
|
||||
return (
|
||||
<div key={tactic} className={styles.tacticColumn}>
|
||||
<h3 className={styles.tacticLabel}>
|
||||
{tactic.replace(/-/g, ' ')}
|
||||
</h3>
|
||||
<div className={styles.techniques}>
|
||||
{techs.map((t) => {
|
||||
const count = countMap.get(t.id) ?? 0
|
||||
const level = heatLevel(count)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`${styles.technique} ${styles[`heat${level}`]}`}
|
||||
title={`${t.id}: ${t.name} (${count} detections)`}
|
||||
>
|
||||
<span className={styles.techId}>{t.id}</span>
|
||||
<span className={styles.techName}>{t.name}</span>
|
||||
{count > 0 && (
|
||||
<span className={styles.techCount}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 <div className={styles.loading}>LOADING ...</div>
|
||||
}
|
||||
|
||||
const rows = [
|
||||
{ label: 'SERVICE', value: <ServiceBadge service={session.service_type} /> },
|
||||
{ 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 (
|
||||
<div className={styles.page}>
|
||||
<Link to="/sessions" className={styles.back}>
|
||||
← SESSIONS
|
||||
</Link>
|
||||
|
||||
<h1 className={styles.detailTitle}>
|
||||
SESSION {session.id.slice(0, 8)}
|
||||
</h1>
|
||||
|
||||
<div className={styles.dossier}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className={styles.dossierRow}>
|
||||
<span className={styles.dossierLabel}>{row.label}</span>
|
||||
<span className={styles.dossierValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{replayData && (
|
||||
<>
|
||||
<h2 className={styles.replayHeading}>Session Replay</h2>
|
||||
<SessionPlayer castData={replayData} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.heading}>
|
||||
<h1 className={styles.title}>Sessions</h1>
|
||||
<span className={styles.subtitle}>RECORDED ENCOUNTERS</span>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loading}>LOADING ...</div>
|
||||
) : (
|
||||
<>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Service</th>
|
||||
<th>Source IP</th>
|
||||
<th>Username</th>
|
||||
<th>Commands</th>
|
||||
<th>Threat</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
className={styles.link}
|
||||
>
|
||||
{s.id.slice(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<ServiceBadge service={s.service_type} />
|
||||
</td>
|
||||
<td>{s.source_ip}</td>
|
||||
<td>{s.username || '\u2014'}</td>
|
||||
<td>{s.command_count}</td>
|
||||
<td className={styles.threat}>{s.threat_score}</td>
|
||||
<td>{new Date(s.started_at).toLocaleString()}</td>
|
||||
<td>
|
||||
{duration !== null ? `${duration}s` : 'active'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={offset === 0}
|
||||
onClick={() =>
|
||||
setOffset(Math.max(0, offset - PAGE_SIZE))
|
||||
}
|
||||
>
|
||||
◀ PREV
|
||||
</button>
|
||||
<span className={styles.pageInfo}>
|
||||
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)}{' '}
|
||||
OF {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
NEXT ▶
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// ©AngelaMos | 2026
|
||||
// global.scss
|
||||
|
||||
@use "tokens";
|
||||
@use "reset";
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.scss' {
|
||||
const classes: { readonly [key: string]: string }
|
||||
export default classes
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./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"]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue