Compare commits
159 Commits
v0.9.0
...
9e14d3886a
Author | SHA1 | Date | |
---|---|---|---|
|
9e14d3886a | ||
|
abaa098099 | ||
|
62b166c62a | ||
|
53fd03cd46 | ||
|
a1dfb7f648 | ||
|
540b0b940c | ||
|
7bc73f6695 | ||
|
d0f046593e | ||
|
444a446b0f | ||
|
0462ed7b4c | ||
|
ecbe1d4972 | ||
|
4eebe8029f | ||
|
0e66c52a16 | ||
|
7334aac745 | ||
|
fd60dc89eb | ||
|
35c0eb06f8 | ||
|
d3f7cb4e86 | ||
|
c3ca87c7af | ||
|
6ba9f0acf5 | ||
|
94dce2c13a | ||
|
4fd048c287 | ||
|
7bad11a4a7 | ||
|
30c3095541 | ||
|
920759db70 | ||
|
7dc7b1ee83 | ||
|
f6cd4c783e | ||
|
4cf8d15402 | ||
|
f5d6f9f78b | ||
|
60ab753c42 | ||
|
b23e9b4a9e | ||
|
f72cf4ddba | ||
|
d50bce3fbf | ||
|
5b7618ad5e | ||
|
764188cf2b | ||
|
00b747341b | ||
|
97cddb8cfe | ||
|
9384834da1 | ||
|
0e0a18e1f1 | ||
|
115efa5b69 | ||
|
5dc1a6d91c | ||
|
0d09a59d3c | ||
|
86eb9b773a | ||
|
182262d474 | ||
|
bc7fcef4b5 | ||
|
857966808c | ||
|
70e8180b2b | ||
|
1bde74187f | ||
|
96b5be5758 | ||
|
3e72919170 | ||
|
122e1ca83d | ||
|
c96313242a | ||
|
bfc121ce06 | ||
|
99b9760830 | ||
|
126577d842 | ||
|
6f4d1d9921 | ||
|
849709ae01 | ||
|
ef2bc057f4 | ||
|
ae596b82e8 | ||
|
646c09a4fa | ||
|
0269fe34d2 | ||
|
563c76696b | ||
|
1a631e7e14 | ||
|
079becbd82 | ||
|
b47d5278f4 | ||
|
505e1fa2e0 | ||
|
45bb7e48b9 | ||
|
73e2dd1c4b | ||
|
1df1a045d0 | ||
|
41eab266fa | ||
|
c35c8ebda9 | ||
|
c120b2633f | ||
|
3d727ff0cf | ||
|
183e078671 | ||
|
f5561776a7 | ||
|
d728a9a500 | ||
|
1b76ac5251 | ||
|
e3261088f4 | ||
|
ae670554e8 | ||
|
060027d2ee | ||
|
e269e8289e | ||
|
1bfcd140e7 | ||
|
e3a4a1ade6 | ||
|
3af87f4a1d | ||
|
f90ff62af2 | ||
|
9c19418d1f | ||
|
bd7212fa92 | ||
|
9b6524be40 | ||
|
f6a4f1ff5f | ||
|
0c46913497 | ||
|
865d06dd21 | ||
|
16f7b6a86d | ||
|
19b61b90e7 | ||
|
94bf060035 | ||
|
c952356879 | ||
|
0db1b81617 | ||
|
0421fd66ba | ||
|
64cd270d9b | ||
|
b7ed763661 | ||
|
716988a546 | ||
|
6d98f7c4d2 | ||
|
f009206dbf | ||
|
324064ff11 | ||
|
2d3bc23a50 | ||
|
dd0b273601 | ||
|
eca338336c | ||
|
0f1af8704d | ||
|
b68ad408ce | ||
|
e442edf0aa | ||
|
c9d1b06680 | ||
|
51a0447e7d | ||
|
0ffbfae468 | ||
|
dead75f037 | ||
|
b7a9eaced2 | ||
|
55366a2cb3 | ||
|
8b49b6f042 | ||
|
f5a2ec3f97 | ||
|
74ba8c648b | ||
|
c8aa491b5b | ||
|
4e596a9d20 | ||
|
774eb787f9 | ||
|
0cb24c3b19 | ||
|
1c50279b29 | ||
|
01de73c9f0 | ||
|
4fdee88948 | ||
|
df83ee2c87 | ||
|
8820970e33 | ||
|
090868a5dd | ||
|
e2b3dc51c5 | ||
|
744717886b | ||
|
927903e47a | ||
|
a8ee70ae3e | ||
|
c12f0d7746 | ||
|
c920d9a4a8 | ||
|
6b2088b5bf | ||
|
fc43f99af7 | ||
|
7cd36a5018 | ||
|
12ada38faa | ||
|
a99f03768b | ||
|
70db9094cd | ||
|
cfc9650085 | ||
|
2274ebbc29 | ||
|
50dfbe2d6b | ||
|
57dc10edb0 | ||
|
c84a1e60a5 | ||
|
324dd67ff0 | ||
|
368914b566 | ||
|
496b211243 | ||
|
aa61a99c8a | ||
|
59414fd00e | ||
|
6485bbf3ff | ||
|
d230c6721d | ||
|
9e3e3303f5 | ||
|
16971d7c0d | ||
|
855d913ade | ||
|
0bf7cbf694 | ||
|
cbb18f4189 | ||
|
5a1095b7e8 | ||
|
a2c97a5900 | ||
|
6bf0ba6331 |
2
.gitignore
vendored
@@ -27,4 +27,4 @@ deceptifeed.*
|
||||
deceptifeed-*.*
|
||||
|
||||
# Ignore build output directory used by Makefile.
|
||||
out/
|
||||
bin/
|
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM golang:latest AS build-stage
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN make
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache tzdata
|
||||
WORKDIR /data
|
||||
COPY --from=build-stage /build/bin /
|
||||
ENTRYPOINT ["/deceptifeed"]
|
49
Makefile
@@ -1,22 +1,55 @@
|
||||
# Makefile for Deceptifeed
|
||||
|
||||
TARGET_BINARY := ./out/deceptifeed
|
||||
SOURCE := ./cmd/deceptifeed/
|
||||
BIN_DIRECTORY := ./bin/
|
||||
BIN_DEFAULT := deceptifeed
|
||||
BIN_LINUX := $(BIN_DEFAULT)_linux_amd64
|
||||
BIN_FREEBSD := $(BIN_DEFAULT)_freebsd_amd64
|
||||
BIN_WINDOWS := $(BIN_DEFAULT)_windows_amd64.exe
|
||||
INSTALL_SCRIPT := ./scripts/install.sh
|
||||
UNINSTALL_SCRIPT := ./scripts/install.sh --uninstall
|
||||
VERSION := $(shell git describe --tags --dirty --broken)
|
||||
BUILD_OPTIONS := -trimpath -ldflags="-s -w -X 'github.com/r-smith/deceptifeed/internal/config.Version=$(VERSION:v%=%)'"
|
||||
GO := go
|
||||
CGO_ENABLED := 0
|
||||
GO111MODULE := on
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
@echo "Building to: ./out/"
|
||||
@mkdir --parents ./out/
|
||||
GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -o $(TARGET_BINARY) $(SOURCE)
|
||||
@echo "Build complete."
|
||||
@echo "Building for current operating system..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_DEFAULT) $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_DEFAULT)"
|
||||
@echo
|
||||
|
||||
.PHONY: all
|
||||
all: build build-linux build-freebsd build-windows
|
||||
|
||||
.PHONY: build-linux
|
||||
build-linux:
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_LINUX) $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_LINUX)"
|
||||
@echo
|
||||
|
||||
.PHONY: build-freebsd
|
||||
build-freebsd:
|
||||
@echo "Building for FreeBSD..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_FREEBSD) $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_FREEBSD)"
|
||||
@echo
|
||||
|
||||
.PHONY: build-windows
|
||||
build-windows:
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_WINDOWS) $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_WINDOWS)"
|
||||
@echo
|
||||
|
||||
.PHONY: install
|
||||
install: $(TARGET_BINARY)
|
||||
install: $(BIN_DIRECTORY)$(BIN_DEFAULT)
|
||||
@bash $(INSTALL_SCRIPT)
|
||||
|
||||
.PHONY: uninstall
|
||||
@@ -27,5 +60,5 @@ uninstall:
|
||||
clean:
|
||||
@echo "Cleaning started."
|
||||
-@$(GO) clean
|
||||
@rm --recursive --force ./out/
|
||||
@rm --recursive --force $(BIN_DIRECTORY)
|
||||
@echo "Cleaning complete."
|
||||
|
418
README.md
Normal file
@@ -0,0 +1,418 @@
|
||||
<p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
|
||||
<img alt="Deceptifeed logo" src="assets/logo-light.svg">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
`Deceptifeed` is a honeypot and threat feed server. It runs multiple deceptive network services (honeypots), while the threat feed lists IP addresses that have interacted with the honeypots. Additionally, `Deceptifeed` provides real-time visibility into honeypot activity, allowing you to monitor logs and interactions as they occur.
|
||||
|
||||
When an IP address interacts with a fake server on your network, why should it be allowed to access your real servers? `Deceptifeed` helps you build an automated defense system to reduce such risks. In a typical deployment, it runs alongside your real servers. The honeypots are exposed to the internet, while the threat feed remains private for use with your internal tools.
|
||||
|
||||
Most enterprise firewalls support ingesting threat feeds. By pointing to `Deceptifeed`, your firewall can automatically block IP addresses that interact with the honeypots. For other security tools, the threat feed is available in several formats, including plain text, CSV, JSON, and TAXII.
|
||||
|
||||
|
||||
## Visuals
|
||||
|
||||
*Deployment diagram*
|
||||
|
||||
<a href="assets/diagram-light.svg?raw=true">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/diagram-dark.png" width="884">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/diagram-light.png" width="884">
|
||||
<img alt="Deceptifeed deployment diagram" src="assets/diagram-light.png" width="884">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
<img alt="Example of the threat feed web interface" src="assets/screenshot-webfeed.png" width="860" />
|
||||
|
||||
<br>
|
||||
<img alt="Example showing real-time honeypot log monitoring" src="assets/screenshot-live.png" width="860" />
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
This section guides you through trying Deceptifeed as quickly as possible. There are no dependencies, configuration, or installation required. Refer to the [Installation section](#installation) when you're ready to set up a production environment.
|
||||
|
||||
### Option 1: Download the binary
|
||||
|
||||
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
|
||||
2. Extract and run the `deceptifeed` binary.
|
||||
|
||||
```shell
|
||||
# Extract:
|
||||
tar xvzf <release>.tar.gz
|
||||
|
||||
# Change into the extracted directory:
|
||||
cd deceptifeed
|
||||
|
||||
# Run:
|
||||
./deceptifeed
|
||||
```
|
||||
|
||||
A `default-config.xml` file is included with the release but is not used by default. Instead, Deceptifeed starts with sensible defaults, and you can customize options using command-line flags. Run `./deceptifeed --help` to view the available options. If you want to use a configuration file, rename `default-config.xml` to `config.xml`, and Deceptifeed will automatically use it. You'll need to update several paths in the configuration. Search for occurrences of `/opt/deceptifeed/` in the file and adjust the paths as needed.
|
||||
|
||||
### Option 2: Docker
|
||||
|
||||
```shell
|
||||
# Pull and run the latest Deceptifeed Docker image:
|
||||
docker run -d --name deceptifeed -p 2222:2222 -p 8080:8080 -p 9000:9000 deceptifeed/server:latest
|
||||
|
||||
# (Optional) Delete the container when you're finished testing:
|
||||
docker rm -f deceptifeed
|
||||
```
|
||||
|
||||
### Try it out
|
||||
|
||||
```shell
|
||||
# Trigger login attempts on the SSH honeypot:
|
||||
ssh -p 2222 root@<your-ip-address>
|
||||
|
||||
# Trigger requests to the HTTP honeypot:
|
||||
curl -v http://<your-ip-address>:8080
|
||||
|
||||
# Retrieve the threat feed in JSON format:
|
||||
curl http://<your-ip-address>:9000/json
|
||||
|
||||
# View the threat feed web interface:
|
||||
# From a web browser, navigate to `http://<your-ip-address>:9000`
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Install on a Linux system
|
||||
|
||||
An installation script is available to quickly configure a production setup on a Linux system. The script supports only Linux distributions that use **systemd** (Debian, Ubuntu, Red Hat, Arch, SUSE, etc.).
|
||||
|
||||
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
|
||||
2. Extract and run the `install.sh` script.
|
||||
|
||||
```shell
|
||||
# Extract:
|
||||
tar xvzf <release>.tar.gz
|
||||
|
||||
# Change into the extracted directory:
|
||||
cd deceptifeed
|
||||
|
||||
# Install:
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
<img alt="" src="assets/install.gif" width="600" />
|
||||
|
||||
The installation script performs the following tasks:
|
||||
- Creates a low-privilege `deceptifeed` user and group to run Deceptifeed.
|
||||
- Sets up a directory structure under `/opt/deceptifeed/` to organize everything.
|
||||
- Registers Deceptifeed as a background service and configures it to start automatically at boot.
|
||||
|
||||
Once installed:
|
||||
- Run `systemctl status deceptifeed` to check the status of the background service.
|
||||
- To modify the configuration, edit `/opt/deceptifeed/etc/config.xml`, then restart the service with `sudo systemctl restart deceptifeed`.
|
||||
|
||||
**_Directory structure_**
|
||||
|
||||
```
|
||||
/opt/deceptifeed/
|
||||
├── bin/
|
||||
│ └── deceptifeed
|
||||
├── certs/
|
||||
│ ├── https-cert.pem
|
||||
│ ├── https-key.pem
|
||||
│ └── ssh-key.pem
|
||||
├── etc/
|
||||
│ └── config.xml
|
||||
└── logs/
|
||||
├── honeypot.log
|
||||
└── threatfeed.csv
|
||||
```
|
||||
|
||||
### Option 2: Docker
|
||||
|
||||
1. Create a directory on your host system (for example, `/opt/deceptifeed/`) to store your configuration file and persistent data.
|
||||
```shell
|
||||
mkdir /opt/deceptifeed/
|
||||
```
|
||||
2. Download the default configuration file to the directory you created in step 1. The configuration file must be named `config.xml`.
|
||||
```
|
||||
curl https://raw.githubusercontent.com/r-smith/deceptifeed/main/configs/docker-config.xml -o /opt/deceptifeed/config.xml
|
||||
```
|
||||
3. Edit the configuration file to suit your needs. The default configuration file is production-ready.
|
||||
4. Run the Deceptifeed Docker container.
|
||||
```shell
|
||||
docker run --detach --name deceptifeed \
|
||||
--env "TZ=America/Los_Angeles" \
|
||||
--publish 2222:2222 \
|
||||
--publish 8080:8080 \
|
||||
--publish 8443:8443 \
|
||||
--publish 9000:9000 \
|
||||
--restart unless-stopped \
|
||||
--volume /opt/deceptifeed/:/data/ \
|
||||
deceptifeed/server:latest
|
||||
```
|
||||
|
||||
Here is a breakdown of the arguments:
|
||||
- `--detach` instructs Docker to run the Deceptifeed container in the background.
|
||||
- `--env "TZ=xxx/yyy"` sets the time zone. Replace `xxx/yyy` with the TZ identifier for your local time zone. If you prefer UTC time, don't include this argument. Refer to this [list of time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for valid TZ identifiers.
|
||||
- `--publish ####:####` opens a network port on your host machine and maps it to Deceptifeed's Docker container. The first number specifies the port your host system listens on. You can set it to any open port. The second number specifies the port used by Deceptifeed inside the Docker container, which should match the ports configured in `config.xml`. There are multiple `--publish` arguments because Deceptifeed runs multiple network services. The default configuration includes an SSH honeypot on port 2222, an HTTP honeypot on port 8080, an HTTPS honeypot on port 8443, and the threat feed on port 9000. If you want your host machine to listen on port 443 for the HTTPS honeypot, for example, you would use the following line `--publish 443:8443 \`. This makes your host system listen on port 443 and maps it to the HTTPS honeypot defined for port 8443 in `config.xml`.
|
||||
- `--restart unless-stopped` ensures Deceptifeed starts automatically when the host boots.
|
||||
- `--volume /opt/deceptifeed/:/data/` specifies the directory on your host machine where persistent data is stored. If you used a different directory, adjust the path accordingly, but keep `:/data/` unchanged. For example: `--volume /path/to/deceptifeed/directory/:/data/ \`.
|
||||
- `deceptifeed/server:latest` is the latest Docker image for Deceptifeed, hosted on *Docker Hub*. The image is updated with each official release and can be viewed on [Docker Hub](https://hub.docker.com/r/deceptifeed/server).
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Honeypot Servers:**
|
||||
- Run any number of honeypot services simultaneously.
|
||||
- Honeypots are low interaction (no simulated environments for attackers to access).
|
||||
- **SSH honeypot:** Record and reject login attempts to a fake SSH service.
|
||||
- **HTTP/HTTPS honeypot:** Record requested URLs and HTTP headers.
|
||||
- **Generic TCP/UDP services:** Record data sent by connecting clients.
|
||||
- **Threat Feed Server:**
|
||||
- A feed of IP addresses that have accessed your honeypots, delivered over HTTP.
|
||||
- Available in plain text, CSV, JSON, STIX, and TAXII.
|
||||
- Includes a friendly web interface for browsing feed and honeypot data.
|
||||
- **Rich Structured Logging:**
|
||||
- Honeypot interactions are logged in a structured JSON format.
|
||||
- Logs can be optionally forwarded to the SIEM of your choice.
|
||||
- Automatic log file rollover ensures the system is self-managing.
|
||||
- **Security:**
|
||||
- The honeypot services never process or respond to client input.
|
||||
- Attackers are not given simulated or virtual environments.
|
||||
- Deceptifeed is self-contained and does **not** use any external libraries, frameworks, plugins, third-party modules, or GitHub actions.
|
||||
- **Cross-platform:**
|
||||
- Supports Linux, macOS, Windows, and *BSD.
|
||||
- Available as a Docker container.
|
||||
|
||||
|
||||
## Threat Feed
|
||||
|
||||
The threat feed provides a list of IP addresses that have interacted with your honeypot services. It is delivered over HTTP for easy integration with firewalls. Most enterprise firewalls support ingesting custom threat feeds, allowing them to automatically block communication with the listed IP addresses.
|
||||
|
||||
Configure your firewall to use Deceptifeed as a custom threat feed and set your blocking rules accordingly. Ideally, exclude your honeypot services from any automatic blocking rules.
|
||||
|
||||
The threat feed is available in plain text, CSV, JSON, STIX, and TAXII.
|
||||
|
||||
**_Sample threat feed in plain text_**
|
||||
|
||||
```shell
|
||||
$ curl http://threatfeed.example.com:9000/plain
|
||||
```
|
||||
```
|
||||
10.30.16.110
|
||||
10.30.21.79
|
||||
10.99.17.38
|
||||
10.99.17.54
|
||||
172.16.1.9
|
||||
172.16.2.30
|
||||
172.16.3.2
|
||||
172.18.0.208
|
||||
172.18.5.7
|
||||
172.18.5.15
|
||||
192.168.0.4
|
||||
192.168.1.17
|
||||
192.168.1.113
|
||||
192.168.2.21
|
||||
192.168.3.8
|
||||
```
|
||||
|
||||
**_Sample threat feed in JSON format_**
|
||||
|
||||
```shell
|
||||
$ curl http://threatfeed.example.com:9000/json
|
||||
```
|
||||
```json
|
||||
{
|
||||
"threat_feed": [
|
||||
{
|
||||
"ip": "10.32.16.110",
|
||||
"added": "2025-02-12T16:18:36-08:00",
|
||||
"last_seen": "2025-03-15T04:27:59-08:00",
|
||||
"observations": 27
|
||||
},
|
||||
{
|
||||
"ip": "192.168.2.21",
|
||||
"added": "2025-04-02T23:09:11-08:00",
|
||||
"last_seen": "2025-04-08T00:40:51-08:00",
|
||||
"observations": 51
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Honeypots
|
||||
|
||||
### SSH
|
||||
|
||||
The SSH honeypot server responds to SSH authentication requests. Each attempt is automatically rejected, while the submitted credentials are logged. There is no actual shell for attackers to access.
|
||||
|
||||
**_Sample log from SSH honeypot_**
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2024-10-23T23:08:29.423821763-07:00",
|
||||
"event_type": "ssh",
|
||||
"source_ip": "172.16.44.209",
|
||||
"server_ip": "192.168.0.15",
|
||||
"server_port": "22",
|
||||
"server_name": "honeypot01",
|
||||
"event_details": {
|
||||
"username": "root",
|
||||
"password": "Password1",
|
||||
"ssh_client": "SSH-2.0-libssh2_1.10.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP/HTTPS
|
||||
|
||||
The HTTP honeypot server responds to all HTTP requests. Requests to the *root* or `/index.html` return a customizable HTML page. Requests outside of that return a 404 error.
|
||||
|
||||
**_Sample log from HTTP honeypot_**
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2024-10-23T23:01:38.989334656-07:00",
|
||||
"event_type": "http",
|
||||
"source_ip": "10.20.89.2",
|
||||
"server_ip": "192.168.0.15",
|
||||
"server_port": "443",
|
||||
"server_name": "honeypot01",
|
||||
"event_details": {
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"query": "",
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0) like Gecko",
|
||||
"protocol": "HTTP/1.1",
|
||||
"host": "www.example.com",
|
||||
"headers": {
|
||||
"accept-encoding": "gzip, br",
|
||||
"x-forwarded-for":"10.254.33.179",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TCP
|
||||
|
||||
The TCP honeypot server lets you create customizable honeypot services that log data from connecting clients. You can define prompts that wait for and record input. For example, you can mimic a Telnet server by showing a welcome banner and then prompting for a username. When data is received, it's logged, and you can follow up with a password prompt. You can include any number of prompts to resemble FTP, SMTP, or other services. The client is disconnected after responding to all the prompts.
|
||||
|
||||
**_Sample log from TCP honeypot_**
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2024-10-23T23:41:43.3235296-07:00",
|
||||
"event_type": "tcp",
|
||||
"source_ip": "172.18.206.66",
|
||||
"server_ip": "192.168.0.15",
|
||||
"server_port": "25",
|
||||
"server_name": "honeypot01",
|
||||
"event_details": {
|
||||
"helo": "HELO example.com",
|
||||
"mail_from": "MAIL FROM:<spammer@example.com>",
|
||||
"rcpt_to": "RCPT TO:<recipient@example.com>",
|
||||
"line1": "Subject: Congratualtions! You've won!",
|
||||
"line2": "From: Customer Support <spammer@example.com>",
|
||||
"line3": "To: recipient@example.com",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UDP
|
||||
|
||||
The UDP honeypot server records incoming data on the listening port. It does not respond to clients.
|
||||
|
||||
Due to the connectionless nature of UDP and the possibility of spoofed source information, UDP honeypots do not integrate with the threat feed. Data is logged, but no further action is taken.
|
||||
|
||||
**_Sample log from UDP honeypot_**
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2024-10-23T21:28:58.223738796-07:00",
|
||||
"event_type": "udp",
|
||||
"source_ip": "127.217.96.21 [unreliable]",
|
||||
"source_reliability": "unreliable",
|
||||
"server_ip": "192.168.0.15",
|
||||
"server_port": "5060",
|
||||
"server_name": "honeypot01",
|
||||
"event_details": {
|
||||
"data": "OPTIONS sip:nm SIP/2.0\r\nVia: SIP/2.0/UDP nm;branch=foo;rport\r\nMax-Forwards: 70\r\nTo: <sip:nm@nm>\r\nFrom: <sip:nm@nm>;tag=root\r\nCall-ID: 50000\r\nCSeq: 63104 OPTIONS\r\nContact: <sip:nm@nm>\r\nAccept: application/sdp\r\nContent-Length: 0\r\n\r\n"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Upgrading
|
||||
|
||||
### Binary
|
||||
|
||||
**If you originally installed using the installation script:**
|
||||
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
|
||||
2. Extract the files.
|
||||
3. Run `install.sh`. The script will detect the existing installation and prompt you to upgrade.
|
||||
- Optionally, you can add `--yes` to automatically confirm the upgrade prompt.
|
||||
```shell
|
||||
# Extract:
|
||||
tar xvzf <release>.tar.gz
|
||||
|
||||
# Change into the extracted directory:
|
||||
cd deceptifeed
|
||||
|
||||
# Install (add `--yes` to auto-confirm the upgrade):
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
**If you did not use the installation script:**
|
||||
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
|
||||
2. Extract the files.
|
||||
3. Replace the existing `deceptifeed` binary with the new version.
|
||||
|
||||
### Docker
|
||||
|
||||
1. Pull the latest version of the Deceptifeed image:
|
||||
```shell
|
||||
docker pull deceptifeed/server:latest
|
||||
```
|
||||
2. Stop and remove the existing container:
|
||||
```shell
|
||||
docker stop deceptifeed
|
||||
docker rm deceptifeed
|
||||
```
|
||||
3. Recreate the container with the new image:
|
||||
```shell
|
||||
# The `docker run` command will vary depending on how you originally ran the container.
|
||||
# If you used the example from this documentation, it will look like this:
|
||||
docker run --detach --name deceptifeed \
|
||||
--env "TZ=America/Los_Angeles" \
|
||||
--publish 2222:2222 \
|
||||
--publish 8080:8080 \
|
||||
--publish 8443:8443 \
|
||||
--publish 9000:9000 \
|
||||
--restart unless-stopped \
|
||||
--volume /opt/deceptifeed/:/data/ \
|
||||
deceptifeed/server:latest
|
||||
```
|
||||
|
||||
|
||||
## Uninstalling
|
||||
|
||||
### Binary
|
||||
|
||||
**If you originally installed using the installation script:**
|
||||
1. Re-run `install.sh` with the `--uninstall` option.
|
||||
```shell
|
||||
sudo ./install.sh --uninstall
|
||||
```
|
||||
|
||||
**If you did not use the installation script:**
|
||||
1. Delete the `deceptifeed` binary and any generated files.
|
||||
|
||||
### Docker
|
||||
|
||||
```shell
|
||||
docker stop deceptifeed
|
||||
docker rm deceptifeed
|
||||
```
|
112
assets/diagram-dark.drawio
Normal file
@@ -0,0 +1,112 @@
|
||||
<mxfile>
|
||||
<diagram name="Page-1" id="2Eu4RQjrI3PlWwBZhT0F">
|
||||
<mxGraphModel dx="1434" dy="774" grid="0" gridSize="4" guides="1" tooltips="1" connect="1" arrows="0" fold="1" page="1" pageScale="1" pageWidth="926" pageHeight="496" background="#000000" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-55" value="" style="rounded=1;whiteSpace=wrap;labelBackgroundColor=none;fillStyle=auto;glass=0;shadow=0;fillColor=#1B1B1B;fontColor=#333333;strokeColor=none;perimeterSpacing=0;textShadow=0;html=1;horizontal=1;arcSize=0;fontSize=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="184" y="20" width="520" height="428" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-57" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#001933;strokeColor=#0066CC;arcSize=13;" parent="1" vertex="1">
|
||||
<mxGeometry x="196" y="32" width="496" height="153" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-7" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="244" y="248" as="targetPoint" />
|
||||
<mxPoint x="244" y="283" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-4" value="<div><font face="Nunito" data-font-src="https://fonts.googleapis.com/css?family=Nunito" style="font-size: 14px;">Web Server</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">www.example.com</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">198.51.100.38</font><br></div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#3C91FF;shape=mxgraph.mscae.enterprise.server_generic;horizontal=1;labelBorderColor=none;fontSize=15;strokeWidth=1;perimeterSpacing=0;spacingLeft=-5;spacingBottom=0;spacingTop=2;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="232" y="283" width="24" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-5" value="<div><font face="Nunito" data-font-src="https://fonts.googleapis.com/css?family=Nunito" style="font-size: 14px;">VPN Server</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">vpn.example.com</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">198.51.100.2</font><br></div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#3C91FF;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="412" y="283" width="24" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-67" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#CC0000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="123" y="225.25" width="50" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-110" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="284" y="246" as="targetPoint" />
|
||||
<mxPoint x="283" y="205" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-96" value="DMZ" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=24;fontColor=#999999;fontStyle=0;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;" parent="1" vertex="1">
|
||||
<mxGeometry x="414" y="415" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-105" value="<div><font face="Nunito" data-font-src="https://fonts.googleapis.com/css?family=Nunito" style="font-size: 14px;">File Server</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">ftp.example.com</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">198.51.100.17</font><br></div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#3C91FF;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="595" y="283" width="24" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-123" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.733;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#999999;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-67" target="em4zL2hBkfHOsg3rVNTH-68" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-68" value="<div>Internet</div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;fontFamily=Nunito;strokeWidth=3;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="8" y="224" width="75" height="46.5" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-124" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.507;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#999999;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-3" target="em4zL2hBkfHOsg3rVNTH-8" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-8" value="<div>Internal</div><div>Network<br></div>" style="pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;aspect=fixed;strokeWidth=3;perimeterSpacing=0;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="806" y="224" width="75" height="46.5" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-1" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
|
||||
<mxGeometry x="274" y="175" width="20" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-2" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="444" y="246" as="targetPoint" />
|
||||
<mxPoint x="443" y="205" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-3" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
|
||||
<mxGeometry x="434" y="175" width="20" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-4" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-5" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="595" y="246" as="targetPoint" />
|
||||
<mxPoint x="594" y="205" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-5" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
|
||||
<mxGeometry x="585" y="175" width="20" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="424" y="248" as="targetPoint" />
|
||||
<mxPoint x="424" y="283" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="607" y="248" as="targetPoint" />
|
||||
<mxPoint x="607" y="283" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-23" value="Deceptifeed" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=32;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;fontStyle=1;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="412" y="57" width="92" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;strokeWidth=6;" parent="1" target="em4zL2hBkfHOsg3rVNTH-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="174" y="247.2500000000001" as="sourcePoint" />
|
||||
<mxPoint x="731" y="247.2500000000001" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-1" value="<div><font face="Nunito" data-font-src="https://fonts.googleapis.com/css?family=Nunito" style="font-size: 14px;"><b>Honeypot</b> (SSH)</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">dev.example.com</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">198.51.100.24</font><br></div>" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontColor=#FFFFFF;" vertex="1" parent="1">
|
||||
<mxGeometry x="228" y="121" width="132" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-2" value="<div><font face="Nunito" data-font-src="https://fonts.googleapis.com/css?family=Nunito" style="font-size: 14px;"><b>Threat Feed</b></font></div><div><font face="Nunito" data-font-src="https://fonts.googleapis.com/css?family=Nunito" style="font-size: 14px;">- Private -</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">10.15.80.5</font><br></div>" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontColor=#FFFFFF;" vertex="1" parent="1">
|
||||
<mxGeometry x="398" y="121" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-3" value="<div><font face="Nunito" data-font-src="https://fonts.googleapis.com/css?family=Nunito" style="font-size: 14px;"><b>Honeypot</b> (HTTP)</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">api.example.com</font></div><div><font face="Roboto Mono" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" style="font-size: 13px;">198.51.100.29</font><br></div>" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontColor=#FFFFFF;" vertex="1" parent="1">
|
||||
<mxGeometry x="537" y="121" width="132" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-5" value="" style="sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;fillColor=#7967E8;aspect=fixed;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;shape=mxgraph.vvd.cpu;" vertex="1" parent="1">
|
||||
<mxGeometry x="282" y="48" width="50" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-3" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#CC0000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
|
||||
<mxGeometry x="716" y="225.25" width="50" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
BIN
assets/diagram-dark.png
Normal file
After Width: | Height: | Size: 37 KiB |
3
assets/diagram-dark.svg
Normal file
After Width: | Height: | Size: 26 KiB |
112
assets/diagram-light.drawio
Normal file
@@ -0,0 +1,112 @@
|
||||
<mxfile>
|
||||
<diagram name="Page-1" id="2Eu4RQjrI3PlWwBZhT0F">
|
||||
<mxGraphModel dx="1434" dy="774" grid="0" gridSize="4" guides="1" tooltips="1" connect="1" arrows="0" fold="1" page="1" pageScale="1" pageWidth="926" pageHeight="496" background="#ffffff" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-55" value="" style="rounded=1;whiteSpace=wrap;labelBackgroundColor=none;fillStyle=auto;glass=0;shadow=0;fillColor=#E6E6E6;fontColor=#333333;strokeColor=none;perimeterSpacing=0;textShadow=0;html=1;horizontal=1;arcSize=0;fontSize=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="184" y="20" width="520" height="428" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-57" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#cce5ff;strokeColor=#66B2FF;arcSize=13;" parent="1" vertex="1">
|
||||
<mxGeometry x="196" y="32" width="496" height="153" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-7" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="244" y="248" as="targetPoint" />
|
||||
<mxPoint x="244" y="283" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-4" value="<div><font style="font-size: 14px;" data-font-src="https://fonts.googleapis.com/css?family=Nunito" face="Nunito">Web Server</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">www.example.com</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">198.51.100.38</font><br></div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#004C99;shape=mxgraph.mscae.enterprise.server_generic;horizontal=1;labelBorderColor=none;fontSize=15;strokeWidth=1;perimeterSpacing=0;spacingLeft=-5;spacingBottom=0;spacingTop=2;" parent="1" vertex="1">
|
||||
<mxGeometry x="232" y="283" width="24" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-5" value="<div><font style="font-size: 14px;" data-font-src="https://fonts.googleapis.com/css?family=Nunito" face="Nunito">VPN Server</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">vpn.example.com</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">198.51.100.2</font><br></div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#004C99;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;" parent="1" vertex="1">
|
||||
<mxGeometry x="412" y="283" width="24" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-67" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#990000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
|
||||
<mxGeometry x="123" y="225.25" width="50" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-110" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="284" y="246" as="targetPoint" />
|
||||
<mxPoint x="283" y="205" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-96" value="DMZ" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=24;fontColor=#666666;fontStyle=0;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;" parent="1" vertex="1">
|
||||
<mxGeometry x="414" y="415" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-105" value="<div><font style="font-size: 14px;" data-font-src="https://fonts.googleapis.com/css?family=Nunito" face="Nunito">File Server</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">ftp.example.com</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">198.51.100.17</font><br></div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#004C99;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;" parent="1" vertex="1">
|
||||
<mxGeometry x="595" y="283" width="24" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-123" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.733;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#333333;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-67" target="em4zL2hBkfHOsg3rVNTH-68" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-68" value="<div>Internet</div>" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;fontFamily=Nunito;strokeWidth=3;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
|
||||
<mxGeometry x="8" y="224" width="75" height="46.5" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-124" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.507;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#333333;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-3" target="em4zL2hBkfHOsg3rVNTH-8" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-8" value="<div>Internal</div><div>Network<br></div>" style="pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;aspect=fixed;strokeWidth=3;perimeterSpacing=0;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
|
||||
<mxGeometry x="806" y="224" width="75" height="46.5" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-1" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
|
||||
<mxGeometry x="274" y="175" width="20" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-2" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="444" y="246" as="targetPoint" />
|
||||
<mxPoint x="443" y="205" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-3" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
|
||||
<mxGeometry x="434" y="175" width="20" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-4" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-5" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="595" y="246" as="targetPoint" />
|
||||
<mxPoint x="594" y="205" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-5" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
|
||||
<mxGeometry x="585" y="175" width="20" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="424" y="248" as="targetPoint" />
|
||||
<mxPoint x="424" y="283" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="607" y="248" as="targetPoint" />
|
||||
<mxPoint x="607" y="283" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="N2PlaIGEcuWRkpGyRWMP-23" value="Deceptifeed" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=32;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;fontStyle=1" parent="1" vertex="1">
|
||||
<mxGeometry x="412" y="57" width="92" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;strokeWidth=6;" parent="1" target="em4zL2hBkfHOsg3rVNTH-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="174" y="247.2500000000001" as="sourcePoint" />
|
||||
<mxPoint x="731" y="247.2500000000001" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-1" value="<div><font style="font-size: 14px;" data-font-src="https://fonts.googleapis.com/css?family=Nunito" face="Nunito"><b>Honeypot</b> (SSH)</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">dev.example.com</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">198.51.100.24</font><br></div>" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="228" y="121" width="132" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-2" value="<div><font style="font-size: 14px;" data-font-src="https://fonts.googleapis.com/css?family=Nunito" face="Nunito"><b>Threat Feed</b></font></div><div><font style="font-size: 14px;" data-font-src="https://fonts.googleapis.com/css?family=Nunito" face="Nunito">- Private -</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">10.15.80.5</font><br></div>" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="398" y="121" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-3" value="<div><font style="font-size: 14px;" data-font-src="https://fonts.googleapis.com/css?family=Nunito" face="Nunito"><b>Honeypot</b> (HTTP)</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">api.example.com</font></div><div><font style="font-size: 13px;" data-font-src="https://fonts.googleapis.com/css?family=Roboto+Mono" face="Roboto Mono">198.51.100.29</font><br></div>" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="537" y="121" width="132" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ir_BUrcSTQge2nd-7u8N-5" value="" style="sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;fillColor=#660066;aspect=fixed;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;shape=mxgraph.vvd.cpu;" vertex="1" parent="1">
|
||||
<mxGeometry x="282" y="48" width="50" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="em4zL2hBkfHOsg3rVNTH-3" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#990000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
|
||||
<mxGeometry x="716" y="225.25" width="50" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
BIN
assets/diagram-light.png
Normal file
After Width: | Height: | Size: 38 KiB |
3
assets/diagram-light.svg
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
assets/install.gif
Normal file
After Width: | Height: | Size: 246 KiB |
65
assets/install.tape
Normal file
@@ -0,0 +1,65 @@
|
||||
# ==============================================================================
|
||||
# install.tape:
|
||||
# Generate a GIF of the Deceptifeed installation process using Charm's VHS
|
||||
#
|
||||
# - VHS is available here: https://github.com/charmbracelet/vhs
|
||||
#
|
||||
# Notes:
|
||||
# - Build using: `vhs install.tape`
|
||||
# - Built with VHS version 0.8.0.
|
||||
# - Since this tape file performs the installation process, Deceptifeed must not
|
||||
# be installed.
|
||||
# - To avoid password prompts for `sudo` commands, temporarily disable `sudo`
|
||||
# password requirements for your user:
|
||||
# - Run `visudo`
|
||||
# - Add the following line, replacing `username` with the name of the user
|
||||
# that runs VHS: `username ALL=(ALL) NOPASSWD:ALL`
|
||||
# ==============================================================================
|
||||
|
||||
# =====
|
||||
# Setup
|
||||
# =====
|
||||
Output install.gif
|
||||
|
||||
Set FontSize 16
|
||||
Set Width 735
|
||||
Set Height 509
|
||||
Set Padding 50
|
||||
Set PlaybackSpeed 1.1
|
||||
Set LoopOffset 80%
|
||||
Set Theme { "name": "CGA Custom", "black": "#000000", "red": "#aa0000", "green": "#00aa00", "yellow": "#aa5500", "blue": "#0000aa", "magenta": "#aa00aa", "cyan": "#00aaaa", "white": "#aaaaaa", "brightBlack": "#555555", "brightRed": "#EF2929", "brightGreen": "#55ff55", "brightYellow": "#FCE94F", "brightBlue": "#5555ff", "brightMagenta": "#ff55ff", "brightCyan": "#55ffff", "brightWhite": "#ffffff", "background": "#000000", "foreground": "#aaaaaa", "cursor": "#b8b8b8", "selection": "#c1deff" }
|
||||
|
||||
# ====
|
||||
# Hide
|
||||
# ====
|
||||
# Download the Deceptifeed package.
|
||||
Hide
|
||||
Type "wget https://github.com/r-smith/deceptifeed/releases/download/v0.9.0/deceptifeed_0.9.0_linux_amd64.tar.gz"
|
||||
Enter Sleep 10s
|
||||
Ctrl+L Sleep 0.5s
|
||||
Show
|
||||
|
||||
# ====
|
||||
# Show
|
||||
# ====
|
||||
# Extract the files.
|
||||
Type "tar xvzf deceptifeed_0.9.0_linux_amd64.tar.gz" Sleep 1s
|
||||
Enter Sleep 1s
|
||||
|
||||
# Enter the extracted directory.
|
||||
Type "cd deceptifeed" Sleep 1s
|
||||
Enter Sleep 1s
|
||||
|
||||
# Run the installation script, then an extended pause to showcase the output.
|
||||
Type "sudo ./install.sh" Sleep 1s
|
||||
Enter Sleep 18s
|
||||
|
||||
# ====
|
||||
# Hide
|
||||
# ====
|
||||
# Uninstall and cleanup.
|
||||
Hide
|
||||
Type "sudo ./install.sh --uninstall" Enter Sleep 5s
|
||||
Type "yes" Enter Sleep 3s
|
||||
Type "cd .." Enter
|
||||
Type "rm -rf deceptifeed deceptifeed_0.9.0_linux_amd64.tar.gz" Enter
|
6
assets/logo-dark.svg
Normal file
After Width: | Height: | Size: 39 KiB |
6
assets/logo-light.svg
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
assets/screenshot-live.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
assets/screenshot-webfeed.png
Normal file
After Width: | Height: | Size: 58 KiB |
@@ -1,8 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
@@ -21,16 +26,16 @@ func main() {
|
||||
ssh := config.Server{Type: config.SSH}
|
||||
|
||||
// Parse command line flags.
|
||||
configFile := flag.String("config", "", "Path to optional XML configuration file")
|
||||
configPath := flag.String("config", "", "Path to optional XML configuration file")
|
||||
flag.BoolVar(&http.Enabled, "enable-http", config.DefaultEnableHTTP, "Enable HTTP server")
|
||||
flag.BoolVar(&https.Enabled, "enable-https", config.DefaultEnableHTTPS, "Enable HTTPS server")
|
||||
flag.BoolVar(&ssh.Enabled, "enable-ssh", config.DefaultEnableSSH, "Enable SSH server")
|
||||
flag.BoolVar(&cfg.ThreatFeed.Enabled, "enable-threatfeed", config.DefaultEnableThreatFeed, "Enable threat feed server")
|
||||
flag.StringVar(&cfg.LogPath, "log", config.DefaultLogPath, "Path to log file")
|
||||
flag.StringVar(&cfg.ThreatFeed.DatabasePath, "threat-database", config.DefaultThreatDatabasePath, "Path to threat feed database file")
|
||||
flag.UintVar(&cfg.ThreatFeed.ExpiryHours, "threat-expiry-hours", config.DefaultThreatExpiryHours, "Remove inactive IPs from threat feed after specified hours")
|
||||
flag.IntVar(&cfg.ThreatFeed.ExpiryHours, "threat-expiry-hours", config.DefaultThreatExpiryHours, "Remove inactive IPs from threat feed after specified hours")
|
||||
flag.BoolVar(&cfg.ThreatFeed.IsPrivateIncluded, "threat-include-private", config.DefaultThreatIncludePrivate, "Include private IPs in threat feed")
|
||||
flag.StringVar(&http.HtmlPath, "html", config.DefaultHtmlPath, "Path to optional HTML file to serve")
|
||||
flag.StringVar(&http.HomePagePath, "html", config.DefaultHomePagePath, "Path to optional HTML file to serve")
|
||||
flag.StringVar(&http.Port, "port-http", config.DefaultPortHTTP, "Port number to listen on for HTTP server")
|
||||
flag.StringVar(&https.Port, "port-https", config.DefaultPortHTTPS, "Port number to listen on for HTTPS server")
|
||||
flag.StringVar(&ssh.Port, "port-ssh", config.DefaultPortSSH, "Port number to listen on for SSH server")
|
||||
@@ -38,35 +43,66 @@ func main() {
|
||||
flag.StringVar(&https.CertPath, "https-cert", config.DefaultCertPathHTTPS, "Path to optional TLS public certificate")
|
||||
flag.StringVar(&https.KeyPath, "https-key", config.DefaultKeyPathHTTPS, "Path to optional TLS private key")
|
||||
flag.StringVar(&ssh.KeyPath, "ssh-key", config.DefaultKeyPathSSH, "Path to optional SSH private key")
|
||||
ver := flag.Bool("version", false, "Output the version number and exit")
|
||||
flag.Parse()
|
||||
|
||||
// If the '-config' flag is provided, the specified configuration file is
|
||||
// loaded. When a config file is used, all other command-line flags are
|
||||
// ignored. The 'cfg' variable will contain all settings parsed from the
|
||||
// configuration file.
|
||||
if *configFile != "" {
|
||||
// Load the specified config file.
|
||||
cfgFromFile, err := config.Load(*configFile)
|
||||
// If the `-version` flag is provided, output the version number and exit.
|
||||
if *ver {
|
||||
fmt.Println(config.Version)
|
||||
return
|
||||
}
|
||||
|
||||
// If the `-config` flag is not provided, use "config.xml" from the current
|
||||
// directory if the file exists.
|
||||
if len(*configPath) == 0 {
|
||||
if _, err := os.Stat("config.xml"); err == nil {
|
||||
*configPath = "config.xml"
|
||||
fmt.Printf("Using configuration file: '%v'\n", *configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// If a config file is specified (via the `-config` flag or "config.xml"),
|
||||
// load it. Otherwise, configure the app using the command line flags and
|
||||
// default settings.
|
||||
if len(*configPath) > 0 {
|
||||
cfgFromFile, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to load config:", err)
|
||||
log.Fatalln("Shutting down. Failed to load configuration file:", err)
|
||||
}
|
||||
cfg = *cfgFromFile
|
||||
} else {
|
||||
// No config file specified. Use command line args.
|
||||
https.HtmlPath = http.HtmlPath
|
||||
https.HomePagePath = http.HomePagePath
|
||||
cfg.Servers = append(cfg.Servers, http, https, ssh)
|
||||
// Set defaults.
|
||||
for i := range cfg.Servers {
|
||||
cfg.Servers[i].LogPath = cfg.LogPath
|
||||
cfg.Servers[i].LogEnabled = true
|
||||
cfg.Servers[i].SendToThreatFeed = true
|
||||
cfg.Servers[i].ThreatScore = 1
|
||||
if cfg.Servers[i].Type == config.SSH {
|
||||
cfg.Servers[i].Banner = config.DefaultBannerSSH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the servers by port number. This is for cosmetic reasons to format
|
||||
// the output when querying / viewing the active configuration.
|
||||
slices.SortFunc(cfg.Servers, func(a, b config.Server) int {
|
||||
p1, err := strconv.Atoi(a.Port)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
p2, err := strconv.Atoi(b.Port)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
t := cmp.Compare(p1, p2)
|
||||
return t
|
||||
})
|
||||
|
||||
// Initialize structured loggers for each honeypot server.
|
||||
err := cfg.InitializeLoggers()
|
||||
if err != nil {
|
||||
log.Fatal("Shutting down. Error: ", err)
|
||||
log.Fatalln("Shutting down. Failed to initialize logging:", err)
|
||||
}
|
||||
defer cfg.CloseLogFiles()
|
||||
|
||||
@@ -84,7 +120,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
threatfeed.StartThreatFeed(&cfg.ThreatFeed)
|
||||
threatfeed.Start(&cfg)
|
||||
}()
|
||||
|
||||
// Start the honeypot servers.
|
||||
@@ -97,16 +133,14 @@ func main() {
|
||||
}
|
||||
|
||||
switch server.Type {
|
||||
case config.HTTP:
|
||||
httpserver.StartHTTP(&server)
|
||||
case config.HTTPS:
|
||||
httpserver.StartHTTPS(&server)
|
||||
case config.HTTP, config.HTTPS:
|
||||
httpserver.Start(&server)
|
||||
case config.SSH:
|
||||
sshserver.StartSSH(&server)
|
||||
sshserver.Start(&server)
|
||||
case config.TCP:
|
||||
tcpserver.StartTCP(&server)
|
||||
tcpserver.Start(&server)
|
||||
case config.UDP:
|
||||
udpserver.StartUDP(&server)
|
||||
udpserver.Start(&server)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
@@ -1,45 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- Deceptifeed Configuration -->
|
||||
<config>
|
||||
|
||||
<!-- The default log path for honeypot servers -->
|
||||
<defaultLogPath>/opt/deceptifeed/logs/honeypot.log</defaultLogPath>
|
||||
|
||||
<!-- Threat Feed Configuration -->
|
||||
<threatFeed>
|
||||
<enabled>true</enabled>
|
||||
<port>9000</port>
|
||||
<databasePath>/opt/deceptifeed/logs/threatfeed.csv</databasePath>
|
||||
<threatExpiryHours>336</threatExpiryHours>
|
||||
<includePrivateIPs>false</includePrivateIPs>
|
||||
<excludeListPath></excludeListPath>
|
||||
</threatFeed>
|
||||
|
||||
<!-- Honeypot Server Configuration -->
|
||||
<honeypotServers>
|
||||
|
||||
<!-- SSH honeypot server on port 2222 -->
|
||||
<server type="ssh">
|
||||
<enabled>true</enabled>
|
||||
<port>2222</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
|
||||
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
||||
</server>
|
||||
|
||||
<!-- HTTP honeypot server on port 8080 -->
|
||||
<server type="http">
|
||||
<enabled>true</enabled>
|
||||
<port>8080</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<rules>
|
||||
<!-- Update the threat feed if any of the following rules match: -->
|
||||
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
|
||||
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
|
||||
<include target="authorization">.*</include>
|
||||
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
|
||||
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
|
||||
<include target="user-agent">^$</include>
|
||||
</rules>
|
||||
</server>
|
||||
|
||||
<!-- HTTPS honeypot server on port 8443 -->
|
||||
<server type="https">
|
||||
<enabled>true</enabled>
|
||||
<port>8443</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<certPath>/opt/deceptifeed/certs/https-cert.pem</certPath>
|
||||
<keyPath>/opt/deceptifeed/certs/https-key.pem</keyPath>
|
||||
<rules>
|
||||
<!-- Update the threat feed if any of the following rules match: -->
|
||||
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
|
||||
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
|
||||
<include target="authorization">.*</include>
|
||||
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
|
||||
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
|
||||
<include target="user-agent">^$</include>
|
||||
</rules>
|
||||
</server>
|
||||
|
||||
<server type="ssh">
|
||||
<enabled>true</enabled>
|
||||
<port>2022</port>
|
||||
<!-- DISABLED: -->
|
||||
<!-- Example TCP honeypot server to simulate a Cisco router -->
|
||||
<server type="tcp">
|
||||
<enabled>false</enabled>
|
||||
<port>2323</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
|
||||
<banner>SSH-2.0-OpenSSH_9.3 FreeBSD-20230316</banner>
|
||||
<banner>\nUser Access Verification\n\n</banner>
|
||||
<prompts>
|
||||
<prompt log="username">Username: </prompt>
|
||||
<prompt log="password">Password: </prompt>
|
||||
</prompts>
|
||||
</server>
|
||||
|
||||
<!-- DISABLED: -->
|
||||
<!-- Example UDP honeypot server to capture SIP scans -->
|
||||
<server type="udp">
|
||||
<enabled>false</enabled>
|
||||
<port>5060</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
</server>
|
||||
</honeypotServers>
|
||||
|
||||
<threatFeed>
|
||||
<enabled>true</enabled>
|
||||
<port>8081</port>
|
||||
<databasePath>/opt/deceptifeed/logs/threatfeed.csv</databasePath>
|
||||
<threatExpiryHours>168</threatExpiryHours>
|
||||
<minimumThreatScore>0</minimumThreatScore>
|
||||
<isPrivateIncluded>false</isPrivateIncluded>
|
||||
</threatFeed>
|
||||
|
||||
</config>
|
91
configs/docker-config.xml
Normal file
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- Deceptifeed Configuration -->
|
||||
<config>
|
||||
|
||||
<!-- The default log path for honeypot servers -->
|
||||
<defaultLogPath>honeypot.log</defaultLogPath>
|
||||
|
||||
<!-- Threat Feed Configuration -->
|
||||
<threatFeed>
|
||||
<enabled>true</enabled>
|
||||
<port>9000</port>
|
||||
<databasePath>threatfeed.csv</databasePath>
|
||||
<threatExpiryHours>336</threatExpiryHours>
|
||||
<includePrivateIPs>false</includePrivateIPs>
|
||||
<excludeListPath></excludeListPath>
|
||||
</threatFeed>
|
||||
|
||||
<!-- Honeypot Server Configuration -->
|
||||
<honeypotServers>
|
||||
|
||||
<!-- SSH honeypot server on port 2222 -->
|
||||
<server type="ssh">
|
||||
<enabled>true</enabled>
|
||||
<port>2222</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<keyPath>key-ssh-private.pem</keyPath>
|
||||
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
||||
</server>
|
||||
|
||||
<!-- HTTP honeypot server on port 8080 -->
|
||||
<server type="http">
|
||||
<enabled>true</enabled>
|
||||
<port>8080</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<rules>
|
||||
<!-- Update the threat feed if any of the following rules match: -->
|
||||
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
|
||||
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
|
||||
<include target="authorization">.*</include>
|
||||
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
|
||||
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
|
||||
<include target="user-agent">^$</include>
|
||||
</rules>
|
||||
</server>
|
||||
|
||||
<!-- HTTPS honeypot server on port 8443 -->
|
||||
<server type="https">
|
||||
<enabled>true</enabled>
|
||||
<port>8443</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<certPath>key-https-public.pem</certPath>
|
||||
<keyPath>key-https-private.pem</keyPath>
|
||||
<rules>
|
||||
<!-- Update the threat feed if any of the following rules match: -->
|
||||
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
|
||||
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
|
||||
<include target="authorization">.*</include>
|
||||
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
|
||||
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
|
||||
<include target="user-agent">^$</include>
|
||||
</rules>
|
||||
</server>
|
||||
|
||||
<!-- DISABLED: -->
|
||||
<!-- Example TCP honeypot server to simulate a Cisco router -->
|
||||
<server type="tcp">
|
||||
<enabled>false</enabled>
|
||||
<port>2323</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<banner>\nUser Access Verification\n\n</banner>
|
||||
<prompts>
|
||||
<prompt log="username">Username: </prompt>
|
||||
<prompt log="password">Password: </prompt>
|
||||
</prompts>
|
||||
</server>
|
||||
|
||||
<!-- DISABLED: -->
|
||||
<!-- Example UDP honeypot server to capture SIP scans -->
|
||||
<server type="udp">
|
||||
<enabled>false</enabled>
|
||||
<port>5060</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
</server>
|
||||
</honeypotServers>
|
||||
|
||||
</config>
|
9
go.mod
@@ -1,7 +1,10 @@
|
||||
module github.com/r-smith/deceptifeed
|
||||
|
||||
go 1.22
|
||||
go 1.24
|
||||
|
||||
require golang.org/x/crypto v0.28.0
|
||||
require (
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/net v0.38.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.26.0 // indirect
|
||||
require golang.org/x/sys v0.31.0 // indirect
|
||||
|
14
go.sum
@@ -1,6 +1,8 @@
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
|
@@ -6,9 +6,20 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/logmonitor"
|
||||
"github.com/r-smith/deceptifeed/internal/logrotate"
|
||||
)
|
||||
|
||||
// This block of constants defines the application default settings.
|
||||
// Version stores Deceptifeed's version number. This variable is set at build
|
||||
// time using the `-X` option with `-ldflags` and is assigned the latest Git
|
||||
// tag. Refer to the Makefile in the project root for details on how it's set.
|
||||
var Version = "undefined"
|
||||
|
||||
// This block of constants defines the default application settings when no
|
||||
// configuration file is provided.
|
||||
const (
|
||||
DefaultEnableHTTP = true
|
||||
DefaultEnableHTTPS = true
|
||||
@@ -16,17 +27,17 @@ const (
|
||||
DefaultEnableThreatFeed = true
|
||||
DefaultPortHTTP = "8080"
|
||||
DefaultPortHTTPS = "8443"
|
||||
DefaultPortSSH = "2022"
|
||||
DefaultPortThreatFeed = "8081"
|
||||
DefaultThreatExpiryHours = 168
|
||||
DefaultPortSSH = "2222"
|
||||
DefaultPortThreatFeed = "9000"
|
||||
DefaultThreatExpiryHours = 336
|
||||
DefaultThreatDatabasePath = "deceptifeed-database.csv"
|
||||
DefaultThreatIncludePrivate = true
|
||||
DefaultLogPath = "deceptifeed-log.txt"
|
||||
DefaultHtmlPath = ""
|
||||
DefaultHomePagePath = ""
|
||||
DefaultCertPathHTTPS = "deceptifeed-https.crt"
|
||||
DefaultKeyPathHTTPS = "deceptifeed-https.key"
|
||||
DefaultKeyPathSSH = "deceptifeed-ssh.key"
|
||||
DefaultBannerSSH = "SSH-2.0-OpenSSH_9.3 FreeBSD-20230316" // SSH banner for FreeBSD 13.2
|
||||
DefaultBannerSSH = "SSH-2.0-OpenSSH_9.6"
|
||||
)
|
||||
|
||||
// ServerType represents the different types of honeypot servers that can be
|
||||
@@ -73,27 +84,43 @@ func (t *ServerType) UnmarshalXMLAttr(attr xml.Attr) error {
|
||||
// logger, settings for managing a threat feed, and the collection of honeypot
|
||||
// servers that are configured to run.
|
||||
type Config struct {
|
||||
LogPath string `xml:"defaultLogPath"`
|
||||
Servers []Server `xml:"honeypotServers>server"`
|
||||
ThreatFeed ThreatFeed `xml:"threatFeed"`
|
||||
LogPath string `xml:"defaultLogPath"`
|
||||
Servers []Server `xml:"honeypotServers>server"`
|
||||
ThreatFeed ThreatFeed `xml:"threatFeed"`
|
||||
FilePath string `xml:"-"`
|
||||
Monitor *logmonitor.Monitor `xml:"-"`
|
||||
}
|
||||
|
||||
// Server represents a honeypot server with its relevant settings.
|
||||
type Server struct {
|
||||
Type ServerType `xml:"type,attr"`
|
||||
Enabled bool `xml:"enabled"`
|
||||
Port string `xml:"port"`
|
||||
CertPath string `xml:"certPath"`
|
||||
KeyPath string `xml:"keyPath"`
|
||||
HtmlPath string `xml:"htmlPath"`
|
||||
Banner string `xml:"banner"`
|
||||
Prompts []Prompt `xml:"prompt"`
|
||||
SendToThreatFeed bool `xml:"sendToThreatFeed"`
|
||||
ThreatScore int `xml:"threatScore"`
|
||||
LogPath string `xml:"logPath"`
|
||||
LogEnabled bool `xml:"logEnabled"`
|
||||
LogFile *os.File
|
||||
Logger *slog.Logger
|
||||
Type ServerType `xml:"type,attr"`
|
||||
Enabled bool `xml:"enabled"`
|
||||
Port string `xml:"port"`
|
||||
CertPath string `xml:"certPath"`
|
||||
KeyPath string `xml:"keyPath"`
|
||||
HomePagePath string `xml:"homePagePath"`
|
||||
ErrorPagePath string `xml:"errorPagePath"`
|
||||
Banner string `xml:"banner"`
|
||||
Headers []string `xml:"headers>header"`
|
||||
Prompts []Prompt `xml:"prompts>prompt"`
|
||||
SendToThreatFeed bool `xml:"sendToThreatFeed"`
|
||||
Rules Rules `xml:"rules"`
|
||||
SourceIPHeader string `xml:"sourceIpHeader"`
|
||||
LogPath string `xml:"logPath"`
|
||||
LogEnabled bool `xml:"logEnabled"`
|
||||
LogFile *logrotate.File `xml:"-"`
|
||||
Logger *slog.Logger `xml:"-"`
|
||||
}
|
||||
|
||||
type Rules struct {
|
||||
Include []Rule `xml:"include"`
|
||||
Exclude []Rule `xml:"exclude"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
Target string `xml:"target,attr"`
|
||||
Pattern string `xml:",chardata"`
|
||||
Negate bool `xml:"negate,attr"`
|
||||
}
|
||||
|
||||
// Prompt represents a text prompt that can be displayed to connecting clients
|
||||
@@ -112,14 +139,12 @@ type Prompt struct {
|
||||
// can be configured to automatically block communication with IP addresses
|
||||
// appearing in the threat feed.
|
||||
type ThreatFeed struct {
|
||||
Enabled bool `xml:"enabled"`
|
||||
Port string `xml:"port"`
|
||||
DatabasePath string `xml:"databasePath"`
|
||||
ExpiryHours uint `xml:"threatExpiryHours"`
|
||||
IsPrivateIncluded bool `xml:"isPrivateIncluded"`
|
||||
MinimumThreatScore int `xml:"minimumThreatScore"`
|
||||
CustomThreatsPath string `xml:"customThreatsPath"`
|
||||
ExcludeListPath string `xml:"excludeListPath"`
|
||||
Enabled bool `xml:"enabled"`
|
||||
Port string `xml:"port"`
|
||||
DatabasePath string `xml:"databasePath"`
|
||||
ExpiryHours int `xml:"threatExpiryHours"`
|
||||
IsPrivateIncluded bool `xml:"includePrivateIPs"`
|
||||
ExcludeListPath string `xml:"excludeListPath"`
|
||||
}
|
||||
|
||||
// Load reads an optional XML configuration file and unmarshals its contents
|
||||
@@ -134,61 +159,116 @@ func Load(filename string) (*Config, error) {
|
||||
defer file.Close()
|
||||
|
||||
var config Config
|
||||
absPath, err := filepath.Abs(filename)
|
||||
if err != nil {
|
||||
config.FilePath = filename
|
||||
} else {
|
||||
config.FilePath = absPath
|
||||
}
|
||||
|
||||
xmlBytes, _ := io.ReadAll(file)
|
||||
err = xml.Unmarshal(xmlBytes, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode XML file: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure a minimum threat score of 1.
|
||||
for i := range config.Servers {
|
||||
if config.Servers[i].ThreatScore < 1 {
|
||||
config.Servers[i].ThreatScore = 1
|
||||
// Use the global log path if the server log path is not specified.
|
||||
if len(config.Servers[i].LogPath) == 0 {
|
||||
config.Servers[i].LogPath = config.LogPath
|
||||
}
|
||||
|
||||
// Validate regex rules.
|
||||
if err := validateRegexRules(config.Servers[i].Rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use the default SSH banner if no banner is specified.
|
||||
if config.Servers[i].Type == SSH && len(config.Servers[i].Banner) == 0 {
|
||||
config.Servers[i].Banner = DefaultBannerSSH
|
||||
}
|
||||
|
||||
// Explicitly disable threat feed for UDP honeypots.
|
||||
if config.Servers[i].Type == UDP {
|
||||
config.Servers[i].SendToThreatFeed = false
|
||||
}
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// validateRegexRules checks the validity of regex patterns in the rules.
|
||||
func validateRegexRules(rules Rules) error {
|
||||
for _, rule := range rules.Include {
|
||||
if _, err := regexp.Compile(rule.Pattern); err != nil {
|
||||
return fmt.Errorf("invalid regex pattern: %s", rule.Pattern)
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Exclude {
|
||||
if _, err := regexp.Compile(rule.Pattern); err != nil {
|
||||
return fmt.Errorf("invalid regex pattern: %s", rule.Pattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitializeLoggers creates structured loggers for each server. It opens log
|
||||
// files using the server's specified log path, defaulting to the global log
|
||||
// path if none is provided.
|
||||
func (c *Config) InitializeLoggers() error {
|
||||
const maxSize = 50
|
||||
c.Monitor = logmonitor.New()
|
||||
|
||||
openedLogFiles := make(map[string]*slog.Logger)
|
||||
|
||||
for i := range c.Servers {
|
||||
if !c.Servers[i].Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the global log path if the server log path is not specified.
|
||||
var logPath string
|
||||
if len(c.Servers[i].LogPath) > 0 {
|
||||
logPath = c.Servers[i].LogPath
|
||||
} else {
|
||||
logPath = c.LogPath
|
||||
}
|
||||
logPath := c.Servers[i].LogPath
|
||||
|
||||
// If no log path is given or if logging is disabled, set up a dummy
|
||||
// logger to discard output.
|
||||
// If no log path is specified or if logging is disabled, discard logs.
|
||||
if len(logPath) == 0 || !c.Servers[i].LogEnabled {
|
||||
c.Servers[i].Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
c.Servers[i].Logger = slog.New(slog.DiscardHandler)
|
||||
continue
|
||||
}
|
||||
|
||||
// Open the specified log file and create a new logger.
|
||||
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
// Check if this log path has already been opened. If so, reuse the
|
||||
// logger.
|
||||
if logger, exists := openedLogFiles[logPath]; exists {
|
||||
c.Servers[i].Logger = logger
|
||||
continue
|
||||
}
|
||||
c.Servers[i].LogFile = logFile
|
||||
c.Servers[i].Logger = slog.New(slog.NewJSONHandler(c.Servers[i].LogFile, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
// Remove 'message' and 'log level' fields from output.
|
||||
if a.Key == slog.MessageKey || a.Key == slog.LevelKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}))
|
||||
|
||||
// Open the specified log file.
|
||||
file, err := logrotate.OpenFile(logPath, maxSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a JSON logger with two writers: one writes to disk using file
|
||||
// rotation, the other writes to a channel for live monitoring.
|
||||
logger := slog.New(
|
||||
slog.NewJSONHandler(
|
||||
io.MultiWriter(file, c.Monitor),
|
||||
&slog.HandlerOptions{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
// Remove 'message' and 'log level' fields from output.
|
||||
if a.Key == slog.MessageKey || a.Key == slog.LevelKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
c.Servers[i].Logger = logger
|
||||
c.Servers[i].LogFile = file
|
||||
|
||||
// Store the logger for reuse.
|
||||
openedLogFiles[logPath] = logger
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -199,7 +279,7 @@ func (c *Config) InitializeLoggers() error {
|
||||
func (c *Config) CloseLogFiles() {
|
||||
for i := range c.Servers {
|
||||
if c.Servers[i].LogFile != nil {
|
||||
c.Servers[i].LogFile.Close()
|
||||
_ = c.Servers[i].LogFile.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -16,6 +16,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,57 +24,72 @@ import (
|
||||
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
||||
)
|
||||
|
||||
// StartHTTP initializes and starts an HTTP honeypot server. This is a fully
|
||||
// functional HTTP server designed to log all incoming requests for analysis.
|
||||
func StartHTTP(srv *config.Server) {
|
||||
// Get any custom headers, if provided.
|
||||
headers := parseCustomHeaders(srv.Banner)
|
||||
|
||||
// Setup handler.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleConnection(srv, headers))
|
||||
|
||||
// Start the HTTP server.
|
||||
fmt.Printf("Starting HTTP server on port: %s\n", srv.Port)
|
||||
if err := http.ListenAndServe(":"+srv.Port, mux); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The HTTP server has terminated:", err)
|
||||
// Start initializes and starts an HTTP or HTTPS honeypot server. The server
|
||||
// is a simple HTTP server designed to log all details from incoming requests.
|
||||
// Optionally, a single static HTML file can be served as the homepage,
|
||||
// otherwise, the server will return only HTTP status codes to clients.
|
||||
// Interactions with the HTTP server are sent to the threat feed.
|
||||
func Start(cfg *config.Server) {
|
||||
switch cfg.Type {
|
||||
case config.HTTP:
|
||||
listenHTTP(cfg)
|
||||
case config.HTTPS:
|
||||
listenHTTPS(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// StartHTTPS initializes and starts an HTTPS honeypot server. This is a fully
|
||||
// functional HTTPS server designed to log all incoming requests for analysis.
|
||||
func StartHTTPS(srv *config.Server) {
|
||||
// Get any custom headers, if provided.
|
||||
headers := parseCustomHeaders(srv.Banner)
|
||||
|
||||
// Setup handler and initialize HTTPS config.
|
||||
// listenHTTP initializes and starts an HTTP (plaintext) honeypot server.
|
||||
func listenHTTP(cfg *config.Server) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleConnection(srv, headers))
|
||||
server := &http.Server{
|
||||
Addr: ":" + srv.Port,
|
||||
Handler: mux,
|
||||
ErrorLog: log.New(io.Discard, "", log.LstdFlags),
|
||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: mux,
|
||||
ErrorLog: log.New(io.Discard, "", log.LstdFlags),
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 0,
|
||||
}
|
||||
|
||||
// Start the HTTP server.
|
||||
fmt.Printf("Starting HTTP server on port: %s\n", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "The HTTP server on port %s has stopped: %v\n", cfg.Port, err)
|
||||
}
|
||||
}
|
||||
|
||||
// listenHTTP initializes and starts an HTTPS (encrypted) honeypot server.
|
||||
func listenHTTPS(cfg *config.Server) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: mux,
|
||||
ErrorLog: log.New(io.Discard, "", log.LstdFlags),
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 0,
|
||||
}
|
||||
|
||||
// If the cert and key aren't found, generate a self-signed certificate.
|
||||
if _, err := os.Stat(srv.CertPath); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(srv.KeyPath); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(cfg.CertPath); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(cfg.KeyPath); os.IsNotExist(err) {
|
||||
// Generate a self-signed certificate.
|
||||
cert, err := generateSelfSignedCert(srv.CertPath, srv.KeyPath)
|
||||
cert, err := generateSelfSignedCert(cfg.CertPath, cfg.KeyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Add cert to server config.
|
||||
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the HTTPS server.
|
||||
fmt.Printf("Starting HTTPS server on port: %s\n", srv.Port)
|
||||
if err := server.ListenAndServeTLS(srv.CertPath, srv.KeyPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The HTTPS server has terminated:", err)
|
||||
fmt.Printf("Starting HTTPS server on port: %s\n", cfg.Port)
|
||||
if err := srv.ListenAndServeTLS(cfg.CertPath, cfg.KeyPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "The HTTPS server on port %s has stopped: %v\n", cfg.Port, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,17 +99,16 @@ func StartHTTPS(srv *config.Server) {
|
||||
// HTML file specified in the configuration or a default page prompting for
|
||||
// basic HTTP authentication. Requests for any other URLs will return a 404
|
||||
// error to the client.
|
||||
func handleConnection(srv *config.Server, customHeaders map[string]string) http.HandlerFunc {
|
||||
func handleConnection(cfg *config.Server, customHeaders map[string]string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Log details of the incoming HTTP request.
|
||||
dst_ip, dst_port := getLocalAddr(r)
|
||||
src_ip, src_port, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
src_ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
username, password, isAuth := r.BasicAuth()
|
||||
if isAuth {
|
||||
srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
slog.String("event_type", "http"),
|
||||
slog.String("source_ip", src_ip),
|
||||
slog.String("source_port", src_port),
|
||||
slog.String("server_ip", dst_ip),
|
||||
slog.String("server_port", dst_port),
|
||||
slog.String("server_name", config.GetHostname()),
|
||||
@@ -108,14 +123,13 @@ func handleConnection(srv *config.Server, customHeaders map[string]string) http.
|
||||
slog.String("username", username),
|
||||
slog.String("password", password),
|
||||
),
|
||||
slog.Any("request_headers", flattenHeaders(r.Header)),
|
||||
slog.Any("headers", flattenHeaders(r.Header)),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
slog.String("event_type", "http"),
|
||||
slog.String("source_ip", src_ip),
|
||||
slog.String("source_port", src_port),
|
||||
slog.String("server_ip", dst_ip),
|
||||
slog.String("server_port", dst_port),
|
||||
slog.String("server_name", config.GetHostname()),
|
||||
@@ -126,7 +140,7 @@ func handleConnection(srv *config.Server, customHeaders map[string]string) http.
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("protocol", r.Proto),
|
||||
slog.String("host", r.Host),
|
||||
slog.Any("request_headers", flattenHeaders(r.Header)),
|
||||
slog.Any("headers", flattenHeaders(r.Header)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -135,55 +149,114 @@ func handleConnection(srv *config.Server, customHeaders map[string]string) http.
|
||||
fmt.Printf("[HTTP] %s %s %s %s\n", src_ip, r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
|
||||
// Update the threat feed with the source IP address from the request.
|
||||
if srv.SendToThreatFeed {
|
||||
threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
|
||||
// If the configuration specifies an HTTP header to be used for the
|
||||
// source IP, retrieve the header value and use it instead of the
|
||||
// connecting IP.
|
||||
if shouldUpdateThreatFeed(cfg, r) {
|
||||
src := src_ip
|
||||
if len(cfg.SourceIPHeader) > 0 {
|
||||
if header := r.Header.Get(cfg.SourceIPHeader); len(header) > 0 {
|
||||
src = header
|
||||
}
|
||||
}
|
||||
threatfeed.Update(src)
|
||||
}
|
||||
|
||||
// If custom headers are provided, add each header and its value to the
|
||||
// HTTP response.
|
||||
for key, value := range customHeaders {
|
||||
w.Header().Set(key, value)
|
||||
// Apply any custom HTTP response headers.
|
||||
for header, value := range customHeaders {
|
||||
w.Header().Set(header, value)
|
||||
}
|
||||
|
||||
// Serve the web content to the client based on the requested URL. If
|
||||
// the root or /index.html is requested, serve the specified content.
|
||||
// For any other requests, return a '404 Not Found' response.
|
||||
// Serve a response based on the requested URL. If the root URL or
|
||||
// /index.html is requested, serve the homepage. For all other
|
||||
// requests, serve the error page with a 404 Not Found response.
|
||||
// Optionally, a single static HTML file may be specified for both the
|
||||
// homepage and the error page. If no custom files are provided,
|
||||
// default minimal responses will be served.
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
// The request is for the root or /index.html.
|
||||
if len(srv.HtmlPath) > 0 {
|
||||
// Serve the custom HTML file specified in the configuration.
|
||||
http.ServeFile(w, r, srv.HtmlPath)
|
||||
// Serve the homepage response.
|
||||
if len(cfg.HomePagePath) > 0 {
|
||||
http.ServeFile(w, r, cfg.HomePagePath)
|
||||
} else {
|
||||
// Serve the default page that prompts the client for basic
|
||||
// authentication.
|
||||
w.Header().Set("WWW-Authenticate", "Basic")
|
||||
w.Header()["WWW-Authenticate"] = []string{"Basic"}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintln(w, http.StatusText(http.StatusUnauthorized))
|
||||
}
|
||||
} else {
|
||||
// The request is outside the root or /index.html. Respond with a
|
||||
// 404 error.
|
||||
// Serve the error page response.
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintln(w, http.StatusText(http.StatusNotFound))
|
||||
if len(cfg.ErrorPagePath) > 0 {
|
||||
http.ServeFile(w, r, cfg.ErrorPagePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseCustomHeaders parses a string of custom headers, if provided in the
|
||||
// configuration, into a map[string]string. The keys in the map are the custom
|
||||
// header names. For example, given the input:
|
||||
// "Server: Microsoft-IIS/8.5, X-Powered-By: ASP.NET", the function would
|
||||
// return a map with "Server" and "X-Powered-By" as keys, each linked to their
|
||||
// corresponding values.
|
||||
func parseCustomHeaders(headers string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
// shouldUpdateThreatFeed determines if the threat feed should be updated based
|
||||
// on the server's configured rules.
|
||||
func shouldUpdateThreatFeed(cfg *config.Server, r *http.Request) bool {
|
||||
// Return false if `sendToThreatFeed`` is disabled, or if the request
|
||||
// matches an `exclude` rule.
|
||||
if !cfg.SendToThreatFeed || checkRuleMatches(cfg.Rules.Exclude, r) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Return true if no `include` rules are defined. Otherwise, return whether
|
||||
// the request matches any of the `include` rules.
|
||||
return len(cfg.Rules.Include) == 0 || checkRuleMatches(cfg.Rules.Include, r)
|
||||
}
|
||||
|
||||
// checkRuleMatches checks if a request matches any of the specified rules.
|
||||
func checkRuleMatches(rules []config.Rule, r *http.Request) bool {
|
||||
match := false
|
||||
for _, rule := range rules {
|
||||
// Ignore errors from regexp.Compile. Regular expression patterns are
|
||||
// validated at application startup.
|
||||
rx, _ := regexp.Compile(rule.Pattern)
|
||||
|
||||
switch strings.ToLower(rule.Target) {
|
||||
case "path":
|
||||
match = rx.MatchString(r.URL.Path)
|
||||
case "query":
|
||||
match = rx.MatchString(r.URL.RawQuery)
|
||||
case "method":
|
||||
match = rx.MatchString(r.Method)
|
||||
case "host":
|
||||
match = rx.MatchString(r.Host)
|
||||
case "user-agent":
|
||||
match = rx.MatchString(r.UserAgent())
|
||||
default:
|
||||
header, ok := r.Header[http.CanonicalHeaderKey(rule.Target)]
|
||||
if ok {
|
||||
for _, v := range header {
|
||||
if rx.MatchString(v) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rule.Negate {
|
||||
match = !match
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCustomHeaders takes a slice of header strings in the format of
|
||||
// "Name: Value", and returns a map of the Name-Value pairs. For example, given
|
||||
// the input:
|
||||
// `[]{"Server: Microsoft-IIS/8.5", "X-Powered-By: ASP.NET"}`, the function
|
||||
// would return a map with "Server" and "X-Powered-By" as keys, each linked to
|
||||
// their corresponding values.
|
||||
func parseCustomHeaders(headers []string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
kvPairs := strings.Split(headers, ",")
|
||||
for _, pair := range kvPairs {
|
||||
kv := strings.Split(strings.TrimSpace(pair), ":")
|
||||
|
||||
for _, header := range headers {
|
||||
kv := strings.SplitN(header, ":", 2)
|
||||
if len(kv) == 2 {
|
||||
result[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
|
||||
}
|
||||
@@ -193,20 +266,21 @@ func parseCustomHeaders(headers string) map[string]string {
|
||||
|
||||
// flattenHeaders converts HTTP headers from an http.Request from the format of
|
||||
// map[string][]string to map[string]string. This results in a cleaner format
|
||||
// for logging, where each headers values are represented as a single string
|
||||
// for logging, where each header's values are represented as a single string
|
||||
// instead of a slice. When a header contains multiple values, they are
|
||||
// combined into a single string, separated by commas.
|
||||
// combined into a single string, separated by commas. Additionally, header
|
||||
// names are converted to lowercase.
|
||||
func flattenHeaders(headers map[string][]string) map[string]string {
|
||||
newHeaders := make(map[string]string, len(headers))
|
||||
for header, values := range headers {
|
||||
if len(values) == 1 {
|
||||
newHeaders[header] = values[0]
|
||||
newHeaders[strings.ToLower(header)] = values[0]
|
||||
} else {
|
||||
newHeaders[header] = "[" + strings.Join(values, ", ") + "]"
|
||||
newHeaders[strings.ToLower(header)] = "[" + strings.Join(values, ", ") + "]"
|
||||
}
|
||||
}
|
||||
// Delete the User-Agent header, as it is managed separately.
|
||||
delete(newHeaders, "User-Agent")
|
||||
delete(newHeaders, "user-agent")
|
||||
return newHeaders
|
||||
}
|
||||
|
||||
@@ -301,7 +375,7 @@ func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath s
|
||||
defer keyFile.Close()
|
||||
|
||||
// Limit key access to the owner only.
|
||||
keyFile.Chmod(0600)
|
||||
_ = keyFile.Chmod(0600)
|
||||
|
||||
if err := pem.Encode(keyFile, key); err != nil {
|
||||
return err
|
||||
|
34
internal/logmonitor/logmonitor.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package logmonitor
|
||||
|
||||
// Monitor is an io.Writer that sends bytes written to its Write method to an
|
||||
// underlying byte channel. This allows other packages to receive the data from
|
||||
// the channel. Writes are non-blocking. If there is no receiver, the data is
|
||||
// silently discarded.
|
||||
//
|
||||
// Monitor does not implement io.Closer. Once initialized, it is meant to run
|
||||
// for the duration of the program. If needed, manually close `Channel` when
|
||||
// finished.
|
||||
type Monitor struct {
|
||||
Channel chan []byte
|
||||
}
|
||||
|
||||
// New creates a new Monitor ready for I/O operations. The underlying `Channel`
|
||||
// should have a receiver to capture and process the data.
|
||||
func New() *Monitor {
|
||||
channel := make(chan []byte, 2)
|
||||
return &Monitor{
|
||||
Channel: channel,
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends the bytes from p to the underlying Monitor's channel. If there
|
||||
// is no receiver for the channel, the data is silently discarded. Write always
|
||||
// returns n = len(p) and err = nil.
|
||||
func (m *Monitor) Write(p []byte) (n int, err error) {
|
||||
select {
|
||||
case m.Channel <- p:
|
||||
return len(p), nil
|
||||
default:
|
||||
return len(p), nil
|
||||
}
|
||||
}
|
123
internal/logrotate/logrotate.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package logrotate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// File is an io.WriteCloser that supports appending data to a file and file
|
||||
// rotation.
|
||||
//
|
||||
// The file is automatically rotated once the file size exceeds the maximum
|
||||
// size limit (specified in megabytes). `File` should be created using the
|
||||
// `OpenFile` function.
|
||||
type File struct {
|
||||
name string
|
||||
file *os.File
|
||||
maxSize int64
|
||||
size int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// OpenFile opens the named file for appending. If successful, methods on the
|
||||
// returned File can be used for I/O. When writing to the file, it will
|
||||
// automatically rotate once the file size exceeds the maxSize (specified in
|
||||
// megabytes).
|
||||
func OpenFile(name string, maxSize int) (*File, error) {
|
||||
if maxSize < 1 {
|
||||
return nil, fmt.Errorf("maxSize must be greater than 0")
|
||||
}
|
||||
|
||||
// Open the file for appending.
|
||||
file, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the current file size.
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &File{
|
||||
file: file,
|
||||
name: name,
|
||||
maxSize: int64(maxSize) * 1024 * 1024, // Convert to megabytes
|
||||
size: stat.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rotate checks if the file size exceeds the maximum allowed size. If so, it
|
||||
// renames the current file by appending ".1" to its name and opens a new file
|
||||
// with the original name. If a file with the ".1" suffix already exists, it is
|
||||
// replaced.
|
||||
func (f *File) rotate() error {
|
||||
if f.size > f.maxSize {
|
||||
// Retrieve the file information for the current file to capture its
|
||||
// permissions. Any errors encountered are handled later and do not
|
||||
// affect the rotation process.
|
||||
info, statErr := f.file.Stat()
|
||||
|
||||
// Close the current file.
|
||||
if err := f.file.Close(); err != nil {
|
||||
return fmt.Errorf("can't close file: %w", err)
|
||||
}
|
||||
|
||||
// Rename the file with a ".1" suffix.
|
||||
if err := os.Rename(f.name, f.name+".1"); err != nil {
|
||||
return fmt.Errorf("can't rename file: %w", err)
|
||||
}
|
||||
|
||||
// Open a new file with the original name.
|
||||
file, err := os.OpenFile(f.name, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't create new file: %w", err)
|
||||
}
|
||||
|
||||
// Apply the original permissions to the new file. This is a
|
||||
// best-effort operation that only runs if the previous os.Stat call
|
||||
// was successful. Any errors from chmod are ignored.
|
||||
if statErr == nil {
|
||||
_ = file.Chmod(info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Reassign file and reset the size.
|
||||
f.file = file
|
||||
f.size = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write writes len(b) bytes from b to the File. If the File's size exceeds its
|
||||
// maxSize, the file is renamed, a new file is opened with the orginal name,
|
||||
// and the write is applied to the new file. Write returns the number of bytes
|
||||
// written and an error, if any. Write returns a non-nil error when n != len(b).
|
||||
func (f *File) Write(b []byte) (n int, err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Rotate the log file if needed.
|
||||
err = f.rotate()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("log rotate: %w", err)
|
||||
}
|
||||
|
||||
// Write the data and update the size.
|
||||
n, err = f.file.Write(b)
|
||||
f.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the File, rendering it unusable for I/O. Close will return an
|
||||
// error if it has already been called.
|
||||
func (f *File) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
err := f.file.Close()
|
||||
f.file = nil
|
||||
return err
|
||||
}
|
@@ -10,36 +10,32 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// StartSSH serves as a wrapper to initialize and start an SSH honeypot server.
|
||||
// The SSH server is designed to log the usernames and passwords submitted in
|
||||
// authentication requests. It is not possible for clients to log in to the
|
||||
// honeypot server, as authentication is the only function handled by the
|
||||
// server. Clients receive authentication failure responses for every login
|
||||
// attempt. This function calls the underlying startSSH function to perform the
|
||||
// actual server startup.
|
||||
func StartSSH(srv *config.Server) {
|
||||
fmt.Printf("Starting SSH server on port: %s\n", srv.Port)
|
||||
if err := startSSH(srv); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The SSH server has terminated:", err)
|
||||
}
|
||||
}
|
||||
// serverTimeout defines the duration after which connected clients are
|
||||
// automatically disconnected, set to 30 seconds.
|
||||
const serverTimeout = 30 * time.Second
|
||||
|
||||
// startSSH starts the SSH honeypot server. It handles the server's main loop,
|
||||
// authentication callback, and logging.
|
||||
func startSSH(srv *config.Server) error {
|
||||
// Create a new SSH server configuration.
|
||||
// Start initializes and starts an SSH honeypot server. The SSH server is
|
||||
// designed to log the usernames and passwords submitted in authentication
|
||||
// requests. It is not possible for clients to log in to the honeypot server,
|
||||
// as authentication is the only function handled by the server. Clients
|
||||
// receive authentication failure responses for every login attempt.
|
||||
// Interactions with the SSH server are sent to the threat feed.
|
||||
func Start(cfg *config.Server) {
|
||||
fmt.Printf("Starting SSH server on port: %s\n", cfg.Port)
|
||||
sshConfig := &ssh.ServerConfig{}
|
||||
|
||||
// Load or generate a private key and add it to the SSH configuration.
|
||||
privateKey, err := loadOrGeneratePrivateKey(srv.KeyPath)
|
||||
privateKey, err := loadOrGeneratePrivateKey(cfg.KeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(os.Stderr, "The SSH server on port %s has stopped: %v\n", cfg.Port, err)
|
||||
return
|
||||
}
|
||||
sshConfig.AddHostKey(privateKey)
|
||||
|
||||
@@ -47,21 +43,33 @@ func startSSH(srv *config.Server) error {
|
||||
// server version string advertised to connecting clients. This allows
|
||||
// the honeypot server to mimic the appearance of other common SSH servers,
|
||||
// such as OpenSSH on Debian, Ubuntu, FreeBSD, or Raspberry Pi.
|
||||
if len(srv.Banner) > 0 {
|
||||
sshConfig.ServerVersion = srv.Banner
|
||||
if len(cfg.Banner) > 0 {
|
||||
sshConfig.ServerVersion = cfg.Banner
|
||||
} else {
|
||||
sshConfig.ServerVersion = config.DefaultBannerSSH
|
||||
}
|
||||
|
||||
// Define the password callback function for the SSH server.
|
||||
// Define the public key authentication callback function.
|
||||
sshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
// This public key authentication function rejects all requests.
|
||||
// Currently, no data is logged. Useful information may include:
|
||||
// `key.Type()` and `ssh.FingerprintSHA256(key)`.
|
||||
|
||||
// Short, intentional delay.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Reject the authentication request.
|
||||
return nil, fmt.Errorf("permission denied")
|
||||
}
|
||||
|
||||
// Define the password authentication callback function.
|
||||
sshConfig.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
// Log the the username and password submitted by the client.
|
||||
dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||
src_ip, src_port, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
src_ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
slog.String("event_type", "ssh"),
|
||||
slog.String("source_ip", src_ip),
|
||||
slog.String("source_port", src_port),
|
||||
slog.String("server_ip", dst_ip),
|
||||
slog.String("server_port", dst_port),
|
||||
slog.String("server_name", config.GetHostname()),
|
||||
@@ -73,21 +81,25 @@ func startSSH(srv *config.Server) error {
|
||||
)
|
||||
|
||||
// Print a simplified version of the request to the console.
|
||||
fmt.Printf("[SSH] %s Username: %s Password: %s\n", src_ip, conn.User(), string(password))
|
||||
fmt.Printf("[SSH] %s Username: %q Password: %q\n", src_ip, conn.User(), string(password))
|
||||
|
||||
// Update the threat feed with the source IP address from the request.
|
||||
if srv.SendToThreatFeed {
|
||||
threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
|
||||
if cfg.SendToThreatFeed {
|
||||
threatfeed.Update(src_ip)
|
||||
}
|
||||
|
||||
// Return an invalid username or password error to the client.
|
||||
// Insert fixed delay to mimic PAM.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Reject the authentication request.
|
||||
return nil, fmt.Errorf("invalid username or password")
|
||||
}
|
||||
|
||||
// Start the SSH server.
|
||||
listener, err := net.Listen("tcp", ":"+srv.Port)
|
||||
listener, err := net.Listen("tcp", ":"+cfg.Port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port '%s': %w", srv.Port, err)
|
||||
fmt.Fprintf(os.Stderr, "The SSH server on port %s has stopped: %v\n", cfg.Port, err)
|
||||
return
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
@@ -103,28 +115,20 @@ func startSSH(srv *config.Server) error {
|
||||
}
|
||||
|
||||
// handleConnection manages incoming SSH client connections. It performs the
|
||||
// handshake and establishes communication channels.
|
||||
// handshake and handles authentication callbacks.
|
||||
func handleConnection(conn net.Conn, config *ssh.ServerConfig) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(serverTimeout))
|
||||
|
||||
// Perform handshake on incoming connection.
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
|
||||
// Perform handshake and authentication. Authentication callbacks are
|
||||
// defined in the SSH server configuration. Since authentication requests
|
||||
// are always rejected, this function will consistently return an error,
|
||||
// and no further connection handling is necessary.
|
||||
sshConn, _, _, err := ssh.NewServerConn(conn, config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
|
||||
// Handle SSH requests and channels.
|
||||
go ssh.DiscardRequests(reqs)
|
||||
go handleChannels(chans)
|
||||
}
|
||||
|
||||
// handleChannels processes SSH channels for the connected client.
|
||||
func handleChannels(chans <-chan ssh.NewChannel) {
|
||||
for newChannel := range chans {
|
||||
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// loadOrGeneratePrivateKey attempts to load a private key from the specified
|
||||
@@ -183,7 +187,7 @@ func writePrivateKey(path string, privateKey *rsa.PrivateKey) error {
|
||||
defer file.Close()
|
||||
|
||||
// Limit key access to the owner only.
|
||||
file.Chmod(0600)
|
||||
_ = file.Chmod(0600)
|
||||
|
||||
if err := pem.Encode(file, privPem); err != nil {
|
||||
return err
|
||||
|
125
internal/stix/stix.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package stix
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DeceptifeedID is a deterministic identifier for the Deceptifeed Identity
|
||||
// object. STIX objects should reference this ID using the `created_by_ref`
|
||||
// property to show the object was created by Deceptifeed. This constant is
|
||||
// the result of:
|
||||
// DeterministicID("identity", "{"identity_class":"system","name":"deceptifeed"}")
|
||||
DeceptifeedID = "identity--370c0cfb-3203-5ca4-b8a9-b1aeef9d6fb3"
|
||||
|
||||
// SpecVersion is the version of the STIX specification being implemented.
|
||||
SpecVersion = "2.1"
|
||||
|
||||
// ContentType is the `Content-Type` HTTP response header used when
|
||||
// returning STIX objects.
|
||||
ContentType = "application/stix+json;version=2.1"
|
||||
)
|
||||
|
||||
// Object represents a STIX Object, a general term for a STIX Domain Object
|
||||
// (SDO), STIX Cyber-observable Object (SCO), STIX Relationship Object (SRO),
|
||||
// or STIX Meta Object.
|
||||
type Object interface{}
|
||||
|
||||
// Bundle represents a STIX Bundle Object. A Bundle is a collection of
|
||||
// arbitrary STIX Objects grouped together in a single container.
|
||||
type Bundle struct {
|
||||
Type string `json:"type"` // Required
|
||||
ID string `json:"id"` // Required
|
||||
Objects []Object `json:"objects,omitempty"` // Optional
|
||||
}
|
||||
|
||||
// Indicator represents a STIX Indicator SDO.
|
||||
type Indicator struct {
|
||||
Type string `json:"type"` // Required
|
||||
SpecVersion string `json:"spec_version"` // Required
|
||||
ID string `json:"id"` // Required
|
||||
IndicatorTypes []string `json:"indicator_types"` // Required
|
||||
Pattern string `json:"pattern"` // Required
|
||||
PatternType string `json:"pattern_type"` // Required
|
||||
Created time.Time `json:"created"` // Required
|
||||
Modified time.Time `json:"modified"` // Required
|
||||
ValidFrom time.Time `json:"valid_from"` // Required
|
||||
ValidUntil *time.Time `json:"valid_until,omitempty"` // Optional
|
||||
Name string `json:"name,omitempty"` // Optional
|
||||
Confidence int `json:"confidence,omitempty"` // Optional
|
||||
Description string `json:"description,omitempty"` // Optional
|
||||
KillChains []KillChain `json:"kill_chain_phases,omitempty"` // Optional
|
||||
Labels []string `json:"labels,omitempty"` // Optional
|
||||
Lang string `json:"lang,omitempty"` // Optional
|
||||
CreatedByRef string `json:"created_by_ref,omitempty"` // Optional
|
||||
}
|
||||
|
||||
// Sighting represents a STIX Sighting SRO.
|
||||
type Sighting struct {
|
||||
Type string `json:"type"` // Required
|
||||
SpecVersion string `json:"spec_version"` // Required
|
||||
ID string `json:"id"` // Required
|
||||
Created time.Time `json:"created"` // Required
|
||||
Modified time.Time `json:"modified"` // Required
|
||||
FirstSeen time.Time `json:"first_seen"` // Optional
|
||||
LastSeen time.Time `json:"last_seen"` // Optional
|
||||
Count int `json:"count"` // Optional
|
||||
Confidence int `json:"confidence,omitempty"` // Optional
|
||||
Description string `json:"description,omitempty"` // Optional
|
||||
Lang string `json:"lang,omitempty"` // Optional
|
||||
SightingOfRef string `json:"sighting_of_ref"` // Required
|
||||
WhereSightedRefs []string `json:"where_sighted_refs,omitempty"` // Optional
|
||||
CreatedByRef string `json:"created_by_ref,omitempty"` // Optional
|
||||
}
|
||||
|
||||
// KillChain represents a STIX `kill-chain-phase` type, which represents a
|
||||
// phase in a kill chain.
|
||||
type KillChain struct {
|
||||
KillChain string `json:"kill_chain_name"` // Required
|
||||
Phase string `json:"phase_name"` // Required
|
||||
}
|
||||
|
||||
// ObservableIP represents a STIX IP Address SCO.
|
||||
type ObservableIP struct {
|
||||
Type string `json:"type"` // Required
|
||||
SpecVersion string `json:"spec_version,omitempty"` // Optional
|
||||
ID string `json:"id"` // Required
|
||||
Value string `json:"value"` // Required
|
||||
CreatedByRef string `json:"created_by_ref,omitempty"` // Optional
|
||||
}
|
||||
|
||||
// Identity represents a STIX Identity SDO, used to represent individuals,
|
||||
// organizations, groups, or systems.
|
||||
type Identity struct {
|
||||
Type string `json:"type"` // Required
|
||||
SpecVersion string `json:"spec_version"` // Required
|
||||
ID string `json:"id"` // Required
|
||||
Class string `json:"identity_class"` // Required
|
||||
Name string `json:"name"` // Required
|
||||
Description string `json:"description,omitempty"` // Optional
|
||||
Contact string `json:"contact_information,omitempty"` // Optional
|
||||
Created time.Time `json:"created"` // Required
|
||||
Modified time.Time `json:"modified"` // Required
|
||||
}
|
||||
|
||||
// DeceptifeedIdentity returns a STIX Identity object representing the
|
||||
// Deceptifeed application.
|
||||
func DeceptifeedIdentity() Identity {
|
||||
const initialCommitTime = "2024-10-16T18:48:00.000Z"
|
||||
created, err := time.Parse(time.RFC3339, initialCommitTime)
|
||||
if err != nil {
|
||||
created = time.Now()
|
||||
}
|
||||
|
||||
return Identity{
|
||||
Type: "identity",
|
||||
SpecVersion: SpecVersion,
|
||||
ID: DeceptifeedID,
|
||||
Class: "system",
|
||||
Name: "Deceptifeed",
|
||||
Description: "Deceptifeed is a defense system that combines honeypot servers with an integrated threat feed.",
|
||||
Contact: "deceptifeed.com",
|
||||
Created: created,
|
||||
Modified: created,
|
||||
}
|
||||
}
|
95
internal/stix/uuid.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package stix
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// nsSTIX is the byte representation of the STIX UUIDv5 namespace:
|
||||
// {00abedb4-aa42-466c-9c01-fed23315a9b7}
|
||||
nsSTIX = [16]byte{
|
||||
0x00, 0xab, 0xed, 0xb4,
|
||||
0xaa, 0x42,
|
||||
0x46, 0x6c,
|
||||
0x9c, 0x01,
|
||||
0xfe, 0xd2, 0x33, 0x15, 0xa9, 0xb7,
|
||||
}
|
||||
)
|
||||
|
||||
// NewID returns a new random unique identifier for a STIX Object. Identifiers
|
||||
// follow the form `objectType--UUID` where `objectType` is the exact value
|
||||
// from the `type` property of the object and where `UUID` is an RFC
|
||||
// 4122-compliant UUID. Random identifiers use UUIDv4.
|
||||
func NewID(objectType string) string {
|
||||
return objectType + "--" + newUUIDv4()
|
||||
}
|
||||
|
||||
// DeterministicID returns a deterministic unique identifier for a STIX Object.
|
||||
// Identifiers follow the form `objectType--UUID` where `objectType` is the
|
||||
// exact value from the `type` property of the object and where `UUID` is an RFC
|
||||
// 4122-compliant UUID. Deterministic identifiers use UUIDv5 with the STIX
|
||||
// namespace and select properties represented in JSON.
|
||||
func DeterministicID(objectType string, jsonValues string) string {
|
||||
return objectType + "--" + newUUIDv5(nsSTIX, jsonValues)
|
||||
}
|
||||
|
||||
// newUUIDv5 returns a string representation of a Universally Unique Identifier
|
||||
// (UUID) RFC 4122 version 5 value. Version 5 UUIDs are a SHA-1 hash of a
|
||||
// namespace identifier and a name.
|
||||
func newUUIDv5(ns [16]byte, name string) string {
|
||||
// A version 5 UUID is generated by hashing a namespace identifier (itself,
|
||||
// a UUID) and a name (value) using SHA-1. Then specific bits are
|
||||
// overwritten to indicate version 5 and the variant (the format of the
|
||||
// UUID).
|
||||
|
||||
// As per STIX 2.1, STIX Cyber-observable Objects using deterministic
|
||||
// identifiers should use UUIDv5 and the STIX 2.1 namespace:
|
||||
// {00abedb4-aa42-466c-9c01-fed23315a9b7}. The value of the name portion
|
||||
// should be the list of "ID Contributing Properties" defined for the SCO
|
||||
// and their values and represented as a JSON object. Example for IPv4
|
||||
// Address Object: {"value":"127.0.0.1"}
|
||||
|
||||
// STIX Domain Objects may use UUIDv5 for the UUID portion of the
|
||||
// identifier, but must not use the STIX namespace.
|
||||
|
||||
// Get the SHA-1 hash of `namespace` + `name`.
|
||||
h := sha1.New()
|
||||
_, _ = h.Write(ns[:])
|
||||
_, _ = h.Write([]byte(name))
|
||||
|
||||
// Use only the first 16-bytes of the hash.
|
||||
b := h.Sum(nil)[:16]
|
||||
|
||||
// Overwrite the version bits with 0b0101 (UUID version 5).
|
||||
b[6] = (b[6] & 0x0f) | 0x50
|
||||
|
||||
// Overwrite the variant bits with 0b10.
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
|
||||
// Return as UUID string representation.
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
|
||||
// newUUIDv4 returns a string representation of a Universally Unique Identifier
|
||||
// (UUID) RFC 4122 version 4 value. Version 4 UUIDs are random values.
|
||||
func newUUIDv4() string {
|
||||
// A version 4 UUID is randomly generated. Then specific bits are
|
||||
// overwritten to indicate version 4 and the variant (the format of the
|
||||
// UUID).
|
||||
|
||||
// Get 16 random bytes. crypto/rand.Read is guaranteed to never return an
|
||||
// error (as of Go 1.24).
|
||||
var b = [16]byte{}
|
||||
_, _ = rand.Read(b[:])
|
||||
|
||||
// Overwrite the version bits with 0b0100 (UUID version 4).
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
|
||||
// Overwrite the variant bits with 0b10.
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
|
||||
// Return as UUID string representation.
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
102
internal/taxii/taxii.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package taxii
|
||||
|
||||
import "github.com/r-smith/deceptifeed/internal/stix"
|
||||
|
||||
const (
|
||||
// APIRoot is the part of the URL that makes up the TAXII API root.
|
||||
APIRoot = "/taxii2/api/"
|
||||
|
||||
// ContentType is the `Content-Type` HTTP response header used when
|
||||
// returning TAXII responses.
|
||||
ContentType = "application/taxii+json;version=2.1"
|
||||
|
||||
// IndicatorsID is a fixed (random) identifier for the indicators
|
||||
// collection.
|
||||
IndicatorsID = "2cc72f88-8d92-4745-9c00-ea0deac18163"
|
||||
|
||||
// IndicatorsAlias is the friendly alias for the indicators collection.
|
||||
IndicatorsAlias = "indicators"
|
||||
|
||||
// ObservablesID is a fixed (random) identifier for the observables
|
||||
// collection.
|
||||
ObservablesID = "8aaff655-40de-41e2-9064-3dc1620d6420"
|
||||
|
||||
// ObservablesAlias is the friendly alias for the observables collection.
|
||||
ObservablesAlias = "observables"
|
||||
|
||||
// SightingsID is a fixed (random) identifier for the sightings collection.
|
||||
SightingsID = "2b27973a-5891-4883-89aa-b7141e78e3e1"
|
||||
|
||||
// SightingsAlias is the friendly alias for the sightings collection.
|
||||
SightingsAlias = "sightings"
|
||||
)
|
||||
|
||||
// ImplementedCollections returns the collections that are available for use.
|
||||
func ImplementedCollections() []Collection {
|
||||
return []Collection{
|
||||
{
|
||||
ID: IndicatorsID,
|
||||
Title: "Deceptifeed Indicators",
|
||||
Description: "This collection contains IP addresses observed interacting with honeypots, represented as STIX Indicators",
|
||||
Alias: IndicatorsAlias,
|
||||
CanRead: true,
|
||||
CanWrite: false,
|
||||
MediaTypes: []string{ContentType},
|
||||
},
|
||||
{
|
||||
ID: ObservablesID,
|
||||
Title: "Deceptifeed Observables",
|
||||
Description: "This collection contains IP addresses observed interacting with honeypots, represented as STIX Observables",
|
||||
Alias: ObservablesAlias,
|
||||
CanRead: true,
|
||||
CanWrite: false,
|
||||
MediaTypes: []string{ContentType},
|
||||
},
|
||||
{
|
||||
ID: SightingsID,
|
||||
Title: "Deceptifeed Sightings",
|
||||
Description: "This collection contains Sightings of Indicators observed interacting with honeypots",
|
||||
Alias: SightingsAlias,
|
||||
CanRead: true,
|
||||
CanWrite: false,
|
||||
MediaTypes: []string{ContentType},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope represents a TAXII envelope resource, which is a simple wrapper for
|
||||
// STIX 2 content.
|
||||
type Envelope struct {
|
||||
More bool `json:"more"` // Optional
|
||||
Next string `json:"next,omitempty"` // Optional
|
||||
Objects []stix.Object `json:"objects"` // Optional
|
||||
}
|
||||
|
||||
// Collection represents a TAXII collection resource, which contains general
|
||||
// information about a collection.
|
||||
type Collection struct {
|
||||
ID string `json:"id"` // Required
|
||||
Title string `json:"title"` // Required
|
||||
Description string `json:"description,omitempty"` // Optional
|
||||
Alias string `json:"alias,omitempty"` // Optional
|
||||
CanRead bool `json:"can_read"` // Required
|
||||
CanWrite bool `json:"can_write"` // Required
|
||||
MediaTypes []string `json:"media_types,omitempty"` // Optional
|
||||
}
|
||||
|
||||
// DiscoveryResource represents a TAXII discovery resource, which contains
|
||||
// information about a TAXII server.
|
||||
type DiscoveryResource struct {
|
||||
Title string `json:"title"` // Required
|
||||
Description string `json:"description,omitempty"` // Optional
|
||||
Default string `json:"default,omitempty"` // Optional
|
||||
APIRoots []string `json:"api_roots,omitempty"` // Optional
|
||||
}
|
||||
|
||||
// APIRootResource represents a TAXII api-root resource, which contains general
|
||||
// information about the API root.
|
||||
type APIRootResource struct {
|
||||
Title string `json:"title"` // Required
|
||||
Versions []string `json:"versions"` // Required
|
||||
MaxContentLength int `json:"max_content_length"` // Required
|
||||
}
|
@@ -19,23 +19,15 @@ import (
|
||||
// automatically disconnected, set to 30 seconds.
|
||||
const serverTimeout = 30 * time.Second
|
||||
|
||||
// StartTCP serves as a wrapper to initialize and start a generic TCP honeypot
|
||||
// server. It presents custom prompts to connected clients and logs their
|
||||
// responses. This function calls the underlying startTCP function to
|
||||
// perform the actual server startup.
|
||||
func StartTCP(srv *config.Server) {
|
||||
fmt.Printf("Starting TCP server on port: %s\n", srv.Port)
|
||||
if err := startTCP(srv); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The TCP server has terminated:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startTCP starts the TCP honeypot server. It handles the server's main loop.
|
||||
func startTCP(srv *config.Server) error {
|
||||
// Start the TCP server.
|
||||
listener, err := net.Listen("tcp", ":"+srv.Port)
|
||||
// Start initializes and starts a generic TCP honeypot server. It presents
|
||||
// custom prompts to connected clients and logs their responses. Interactions
|
||||
// with the TCP server are sent to the threat feed.
|
||||
func Start(cfg *config.Server) {
|
||||
fmt.Printf("Starting TCP server on port: %s\n", cfg.Port)
|
||||
listener, err := net.Listen("tcp", ":"+cfg.Port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port '%s': %w", srv.Port, err)
|
||||
fmt.Fprintf(os.Stderr, "The TCP server on port %s has stopped: %v\n", cfg.Port, err)
|
||||
return
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
@@ -46,7 +38,7 @@ func startTCP(srv *config.Server) error {
|
||||
continue
|
||||
}
|
||||
|
||||
go handleConnection(conn, srv)
|
||||
go handleConnection(conn, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,23 +46,23 @@ func startTCP(srv *config.Server) error {
|
||||
// server. It presents custom prompts to the client, records and logs their
|
||||
// responses, and then disconnects the client. This function manages the entire
|
||||
// client interaction.
|
||||
func handleConnection(conn net.Conn, srv *config.Server) {
|
||||
func handleConnection(conn net.Conn, cfg *config.Server) {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(serverTimeout))
|
||||
_ = conn.SetDeadline(time.Now().Add(serverTimeout))
|
||||
|
||||
// Print an optional banner. Replace any occurrences of the newline escape
|
||||
// sequence "\\n" with "\r\n" (carriage return, line feed), used by
|
||||
// protocols such as Telnet and SMTP.
|
||||
if len(srv.Banner) > 0 {
|
||||
conn.Write([]byte(strings.ReplaceAll(srv.Banner, "\\n", "\r\n")))
|
||||
if len(cfg.Banner) > 0 {
|
||||
_, _ = conn.Write([]byte(strings.ReplaceAll(cfg.Banner, "\\n", "\r\n")))
|
||||
}
|
||||
|
||||
// Present the prompts from the server configuration to the connected
|
||||
// client and record their responses.
|
||||
scanner := bufio.NewScanner(conn)
|
||||
responses := make(map[string]string)
|
||||
for i, prompt := range srv.Prompts {
|
||||
conn.Write([]byte(strings.ReplaceAll(prompt.Text, "\\n", "\r\n")))
|
||||
for i, prompt := range cfg.Prompts {
|
||||
_, _ = conn.Write([]byte(strings.ReplaceAll(prompt.Text, "\\n", "\r\n")))
|
||||
scanner.Scan()
|
||||
var key string
|
||||
// Each prompt includes an optional Log field that serves as the key
|
||||
@@ -90,7 +82,7 @@ func handleConnection(conn net.Conn, srv *config.Server) {
|
||||
|
||||
// If no prompts are provided in the configuration, wait for the client to
|
||||
// send data then record the received input.
|
||||
if len(srv.Prompts) == 0 {
|
||||
if len(cfg.Prompts) == 0 {
|
||||
scanner.Scan()
|
||||
responses["data"] = scanner.Text()
|
||||
}
|
||||
@@ -109,11 +101,10 @@ func handleConnection(conn net.Conn, srv *config.Server) {
|
||||
|
||||
// Log the connection along with all responses received from the client.
|
||||
dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||
src_ip, src_port, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
src_ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
slog.String("event_type", "tcp"),
|
||||
slog.String("source_ip", src_ip),
|
||||
slog.String("source_port", src_port),
|
||||
slog.String("server_ip", dst_ip),
|
||||
slog.String("server_port", dst_port),
|
||||
slog.String("server_name", config.GetHostname()),
|
||||
@@ -121,11 +112,11 @@ func handleConnection(conn net.Conn, srv *config.Server) {
|
||||
)
|
||||
|
||||
// Print a simplified version of the interaction to the console.
|
||||
fmt.Printf("[TCP] %s %v\n", src_ip, responsesToString(responses))
|
||||
fmt.Printf("[TCP] %s %q\n", src_ip, responsesToString(responses))
|
||||
|
||||
// Update the threat feed with the source IP address from the interaction.
|
||||
if srv.SendToThreatFeed {
|
||||
threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
|
||||
if cfg.SendToThreatFeed {
|
||||
threatfeed.Update(src_ip)
|
||||
}
|
||||
}
|
||||
|
||||
|
209
internal/threatfeed/data.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IOC represents an Indicator of Compromise (IOC) entry that stores
|
||||
// information about IP addresses that interact with the honeypot servers.
|
||||
type IOC struct {
|
||||
// added records the time when an IP address is added to the threat feed.
|
||||
added time.Time
|
||||
|
||||
// lastSeen records the last time an IP was observed interacting with a
|
||||
// honeypot server.
|
||||
lastSeen time.Time
|
||||
|
||||
// observations tracks the total number of interactions an IP has had with
|
||||
// the honeypot servers.
|
||||
observations int
|
||||
}
|
||||
|
||||
const (
|
||||
// dateFormat specifies the timestamp format used for threat feed entries.
|
||||
dateFormat = time.RFC3339Nano
|
||||
|
||||
// maxObservations is the maximum number of interactions the threat feed
|
||||
// will record for each IP.
|
||||
maxObservations = 999_999_999
|
||||
)
|
||||
|
||||
var (
|
||||
// iocData stores Indicator of Compromise (IOC) entries, keyed by IP
|
||||
// address. This map represents the internal structure of the threat feed.
|
||||
// It is populated with existing threat data when the server starts. The
|
||||
// map is then updated by `Update` whenever a potential attacker interacts
|
||||
// with a honeypot server. The threat feed served to clients is generated
|
||||
// based on the data in this map.
|
||||
iocData = make(map[string]*IOC)
|
||||
|
||||
// mu is to ensure thread-safe access to iocData.
|
||||
mu sync.Mutex
|
||||
|
||||
// dataChanged indicates whether the IoC map has been modified since the
|
||||
// last time it was saved to disk.
|
||||
dataChanged = false
|
||||
|
||||
// csvHeader defines the header row for saved threat feed data.
|
||||
csvHeader = []string{"ip", "added", "last_seen", "observations"}
|
||||
)
|
||||
|
||||
// Update updates the threat feed with the provided source IP address. This
|
||||
// function should be called by honeypot servers whenever a client interacts
|
||||
// with the honeypot. If the source IP address is already in the threat feed,
|
||||
// its last-seen timestamp is updated, and its observation count is
|
||||
// incremented. Otherwise, the IP address is added as a new entry.
|
||||
func Update(ip string) {
|
||||
// Check if the given IP string is a private address. The threat feed may
|
||||
// be configured to include or exclude private IPs.
|
||||
netIP := net.ParseIP(ip)
|
||||
if netIP == nil || netIP.IsLoopback() {
|
||||
return
|
||||
}
|
||||
if !cfg.ThreatFeed.IsPrivateIncluded && netIP.IsPrivate() {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
if ioc, exists := iocData[ip]; exists {
|
||||
// Update existing entry.
|
||||
ioc.lastSeen = now
|
||||
if ioc.observations < maxObservations {
|
||||
ioc.observations++
|
||||
}
|
||||
} else {
|
||||
// Create a new entry.
|
||||
iocData[ip] = &IOC{
|
||||
added: now,
|
||||
lastSeen: now,
|
||||
observations: 1,
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
dataChanged = true
|
||||
}
|
||||
|
||||
// deleteExpired deletes expired threat feed entries from the IoC map.
|
||||
func deleteExpired() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
for key, value := range iocData {
|
||||
if value.expired() {
|
||||
delete(iocData, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expired returns whether an IoC is considered expired based on the last
|
||||
// seen date and the configured expiry hours.
|
||||
func (ioc *IOC) expired() bool {
|
||||
if cfg.ThreatFeed.ExpiryHours <= 0 {
|
||||
return false
|
||||
}
|
||||
return ioc.lastSeen.Before(time.Now().Add(-time.Hour * time.Duration(cfg.ThreatFeed.ExpiryHours)))
|
||||
}
|
||||
|
||||
// loadCSV loads existing threat feed data from a CSV file. If found, it
|
||||
// populates iocData which represents the active threat feed. This function is
|
||||
// called once during the initialization of the threat feed server.
|
||||
func loadCSV() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
f, err := os.Open(cfg.ThreatFeed.DatabasePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := csv.NewReader(f)
|
||||
reader.FieldsPerRecord = -1
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var added time.Time
|
||||
var lastSeen time.Time
|
||||
var count int
|
||||
for _, record := range records[1:] {
|
||||
ip := record[0]
|
||||
|
||||
// Parse added, defaulting to current time.
|
||||
added = time.Now()
|
||||
if len(record) > 1 && record[1] != "" {
|
||||
added, _ = time.Parse(dateFormat, record[1])
|
||||
}
|
||||
|
||||
// Parse lastSeen, defaulting to current time.
|
||||
lastSeen = time.Now()
|
||||
if len(record) > 2 && record[2] != "" {
|
||||
lastSeen, _ = time.Parse(dateFormat, record[2])
|
||||
}
|
||||
|
||||
// Parse observation count, defaulting to 1.
|
||||
count = 1
|
||||
if len(record) > 3 && record[3] != "" {
|
||||
if parsedCount, err := strconv.Atoi(record[3]); err == nil {
|
||||
count = parsedCount
|
||||
}
|
||||
}
|
||||
|
||||
iocData[ip] = &IOC{added: added, lastSeen: lastSeen, observations: count}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveCSV writes the current threat feed to a CSV file. This CSV file ensures
|
||||
// the threat feed data persists across application restarts. It is not the
|
||||
// active threat feed.
|
||||
func saveCSV() error {
|
||||
f, err := os.OpenFile(cfg.ThreatFeed.DatabasePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriterSize(f, 65536)
|
||||
_, err = w.WriteString(strings.Join(csvHeader, ",") + "\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
for ip, ioc := range iocData {
|
||||
_, err = w.WriteString(
|
||||
fmt.Sprintf(
|
||||
"%s,%s,%s,%d\n",
|
||||
ip,
|
||||
ioc.added.Format(dateFormat),
|
||||
ioc.lastSeen.Format(dateFormat),
|
||||
ioc.observations,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
return w.Flush()
|
||||
}
|
@@ -1,162 +0,0 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IoC represents an Indicator of Compromise (IoC) entry in the threat feed
|
||||
// database. The database is in CSV format, with each row containing an IP
|
||||
// address and its associated IoC data.
|
||||
type IoC struct {
|
||||
// LastSeen records the last time an IP was observed interacting with a
|
||||
// honeypot server.
|
||||
LastSeen time.Time
|
||||
|
||||
// ThreatScore represents a score for a given IP address. It is incremented
|
||||
// based on the configured threat score of the honeypot server that the IP
|
||||
// interacted with.
|
||||
ThreatScore int
|
||||
}
|
||||
|
||||
const (
|
||||
// csvHeader defines the header row for the threat feed database.
|
||||
csvHeader = "ip,last_seen,threat_score"
|
||||
|
||||
// dateFormat specifies the timestamp format used for CSV data.
|
||||
dateFormat = time.RFC3339
|
||||
)
|
||||
|
||||
// loadIoC reads IoC data from an existing CSV database. If found, it
|
||||
// populates iocMap. This function is called once during the initialization of
|
||||
// the threat feed server.
|
||||
func loadIoC() error {
|
||||
file, err := os.Open(configuration.DatabasePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastSeen time.Time
|
||||
var threatScore int
|
||||
for _, record := range records[1:] {
|
||||
ip := record[0]
|
||||
|
||||
// Parse lastSeen, if available.
|
||||
if len(record) > 1 && record[1] != "" {
|
||||
lastSeen, _ = time.Parse(dateFormat, record[1])
|
||||
}
|
||||
|
||||
// Parse threat score, defaulting to 1.
|
||||
threatScore = 1
|
||||
if len(record) > 2 && record[2] != "" {
|
||||
if parsedLevel, err := strconv.Atoi(record[2]); err == nil {
|
||||
threatScore = parsedLevel
|
||||
}
|
||||
}
|
||||
|
||||
iocMap[ip] = &IoC{LastSeen: lastSeen, ThreatScore: threatScore}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateIoC updates the IoC map. This function is called by honeypot servers
|
||||
// each time a client interacts with the honeypot.
|
||||
func UpdateIoC(ip string, threatScore int) {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// Check if the given IP string is a private address. The threat feed may
|
||||
// be configured to include or exclude private IPs.
|
||||
netIP := net.ParseIP(ip)
|
||||
if netIP == nil {
|
||||
return
|
||||
}
|
||||
if !configuration.IsPrivateIncluded && netIP.IsPrivate() {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
hasMapChanged = true
|
||||
if ioc, exists := iocMap[ip]; exists {
|
||||
// Update existing entry.
|
||||
ioc.LastSeen = now
|
||||
if ioc.ThreatScore+threatScore <= math.MaxInt {
|
||||
ioc.ThreatScore += threatScore
|
||||
}
|
||||
} else {
|
||||
// Create a new entry.
|
||||
iocMap[ip] = &IoC{
|
||||
LastSeen: now,
|
||||
ThreatScore: threatScore,
|
||||
}
|
||||
}
|
||||
|
||||
// Remove expired entries from iocMap.
|
||||
removeExpired()
|
||||
}
|
||||
|
||||
// removeExpired checks the IoC map for entries that have expired based on
|
||||
// their last seen date and the configured expiry hours. It deletes any expired
|
||||
// entries from the map. This function should be called exclusively by
|
||||
// UpdateIoC, which manages the mutex lock.
|
||||
func removeExpired() {
|
||||
// If expiryHours is set to 0, entries never expire and will remain
|
||||
// indefinitely.
|
||||
if configuration.ExpiryHours <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var iocToRemove []string
|
||||
expirtyTime := time.Now().Add(-time.Hour * time.Duration(configuration.ExpiryHours))
|
||||
|
||||
for key, value := range iocMap {
|
||||
if value.LastSeen.Before(expirtyTime) {
|
||||
iocToRemove = append(iocToRemove, key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range iocToRemove {
|
||||
delete(iocMap, key)
|
||||
}
|
||||
}
|
||||
|
||||
// saveIoC writes the current IoC map to a CSV file, ensuring the threat feed
|
||||
// database persists across application restarts.
|
||||
func saveIoC() error {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
writer := csv.NewWriter(buf)
|
||||
writer.Write(strings.Split(csvHeader, ","))
|
||||
for ip, ioc := range iocMap {
|
||||
writer.Write([]string{ip, ioc.LastSeen.Format(dateFormat), strconv.Itoa(ioc.ThreatScore)})
|
||||
}
|
||||
writer.Flush()
|
||||
|
||||
if err := os.WriteFile(configuration.DatabasePath, buf.Bytes(), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
337
internal/threatfeed/feed.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/stix"
|
||||
)
|
||||
|
||||
// feedEntry represents an individual entry in the threat feed.
|
||||
type feedEntry struct {
|
||||
IP string `json:"ip"`
|
||||
IPBytes net.IP `json:"-"`
|
||||
Added time.Time `json:"added"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Observations int `json:"observations"`
|
||||
}
|
||||
|
||||
// feedEntries is a slice of feedEntry structs. It represents the threat feed
|
||||
// served to clients. When clients request the feed, this structure is built
|
||||
// from the `iocData` map. The data is then formatted and served to clients in
|
||||
// the requested format.
|
||||
type feedEntries []feedEntry
|
||||
|
||||
// sortMethod represents the method used for sorting the threat feed.
|
||||
type sortMethod int
|
||||
|
||||
// Constants representing the possible values for sortMethod.
|
||||
const (
|
||||
byIP sortMethod = iota
|
||||
byAdded
|
||||
byLastSeen
|
||||
byObservations
|
||||
)
|
||||
|
||||
// sortDirection represents the direction of sorting (ascending or descending).
|
||||
type sortDirection int
|
||||
|
||||
// Constants representing the possible values for sortDirection.
|
||||
const (
|
||||
ascending sortDirection = iota
|
||||
descending
|
||||
)
|
||||
|
||||
// feedOptions define configurable options for serving the threat feed.
|
||||
type feedOptions struct {
|
||||
sortMethod sortMethod
|
||||
sortDirection sortDirection
|
||||
seenAfter time.Time
|
||||
limit int
|
||||
page int
|
||||
}
|
||||
|
||||
// prepareFeed filters, processes, and sorts IP addresses from the threat feed.
|
||||
// The resulting slice of `net.IP` represents the current threat feed to be
|
||||
// served to clients.
|
||||
func prepareFeed(options ...feedOptions) feedEntries {
|
||||
// Set default feed options.
|
||||
opt := feedOptions{
|
||||
sortMethod: byIP,
|
||||
sortDirection: ascending,
|
||||
}
|
||||
// Override default options if provided.
|
||||
if len(options) > 0 {
|
||||
opt = options[0]
|
||||
}
|
||||
|
||||
excludedIPs, excludedCIDR, err := parseExcludeList(cfg.ThreatFeed.ExcludeListPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to read threat feed exclude list:", err)
|
||||
}
|
||||
|
||||
// Parse and filter IPs from iocData into the threat feed.
|
||||
mu.Lock()
|
||||
threats := make(feedEntries, 0, len(iocData))
|
||||
loop:
|
||||
for ip, ioc := range iocData {
|
||||
if ioc.expired() || !ioc.lastSeen.After(opt.seenAfter) {
|
||||
continue
|
||||
}
|
||||
|
||||
parsedIP := net.ParseIP(ip)
|
||||
if parsedIP == nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ipnet := range excludedCIDR {
|
||||
if ipnet.Contains(parsedIP) {
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
|
||||
if _, found := excludedIPs[ip]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
threats = append(threats, feedEntry{
|
||||
IP: ip,
|
||||
IPBytes: parsedIP,
|
||||
Added: ioc.added,
|
||||
LastSeen: ioc.lastSeen,
|
||||
Observations: ioc.observations,
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
threats.applySort(opt.sortMethod, opt.sortDirection)
|
||||
|
||||
return threats
|
||||
}
|
||||
|
||||
// parseExcludeList reads IP addresses and CIDR ranges from a file. Each line
|
||||
// should contain an IP address or CIDR. It returns a map of the unique IPs and
|
||||
// a slice of the CIDR ranges found in the file. The file may include comments
|
||||
// using "#". The "#" symbol on a line and everything after is ignored.
|
||||
func parseExcludeList(filepath string) (map[string]struct{}, []*net.IPNet, error) {
|
||||
if len(filepath) == 0 {
|
||||
return map[string]struct{}{}, []*net.IPNet{}, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// `ips` stores individual IPs to exclude, and `cidr` stores CIDR networks
|
||||
// to exclude.
|
||||
ips := make(map[string]struct{})
|
||||
cidr := []*net.IPNet{}
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Remove comments from text.
|
||||
if i := strings.Index(line, "#"); i != -1 {
|
||||
line = strings.TrimSpace(line[:i])
|
||||
}
|
||||
|
||||
if len(line) > 0 {
|
||||
if _, ipnet, err := net.ParseCIDR(line); err == nil {
|
||||
cidr = append(cidr, ipnet)
|
||||
} else {
|
||||
ips[line] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return ips, cidr, nil
|
||||
}
|
||||
|
||||
// applySort sorts the threat feed based on the specified sort method and
|
||||
// direction.
|
||||
func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
|
||||
switch method {
|
||||
case byIP:
|
||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
||||
})
|
||||
case byLastSeen:
|
||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||
t := a.LastSeen.Compare(b.LastSeen)
|
||||
if t == 0 {
|
||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
||||
}
|
||||
return t
|
||||
})
|
||||
case byAdded:
|
||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||
t := a.Added.Compare(b.Added)
|
||||
if t == 0 {
|
||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
||||
}
|
||||
return t
|
||||
})
|
||||
case byObservations:
|
||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||
t := cmp.Compare(a.Observations, b.Observations)
|
||||
if t == 0 {
|
||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
if direction == descending {
|
||||
slices.Reverse(f)
|
||||
}
|
||||
}
|
||||
|
||||
// convertToIndicators converts IP addresses from the threat feed into a
|
||||
// collection of STIX Indicator objects.
|
||||
func (f feedEntries) convertToIndicators() []stix.Object {
|
||||
if len(f) == 0 {
|
||||
return []stix.Object{}
|
||||
}
|
||||
|
||||
const indicator = "indicator"
|
||||
result := make([]stix.Object, 0, len(f)+1)
|
||||
|
||||
// Add the Deceptifeed `Identity` as the first object in the collection.
|
||||
// All objects in the collection will reference this identity as the
|
||||
// creator.
|
||||
result = append(result, stix.DeceptifeedIdentity())
|
||||
|
||||
for _, entry := range f {
|
||||
pattern := "[ipv4-addr:value = '"
|
||||
if strings.Contains(entry.IP, ":") {
|
||||
pattern = "[ipv6-addr:value = '"
|
||||
}
|
||||
pattern = pattern + entry.IP + "']"
|
||||
|
||||
// Fixed expiration: 2 months since last seen.
|
||||
validUntil := new(time.Time)
|
||||
*validUntil = entry.LastSeen.AddDate(0, 2, 0).UTC()
|
||||
|
||||
// Generate a deterministic identifier using the IP address represented
|
||||
// as a STIX IP pattern and structured as a JSON string. Example:
|
||||
// {"pattern":"[ipv4-addr:value='127.0.0.1']"}
|
||||
patternJSON := fmt.Sprintf("{\"pattern\":\"%s\"}", pattern)
|
||||
|
||||
result = append(result, stix.Indicator{
|
||||
Type: indicator,
|
||||
SpecVersion: stix.SpecVersion,
|
||||
ID: stix.DeterministicID(indicator, patternJSON),
|
||||
IndicatorTypes: []string{"malicious-activity"},
|
||||
Pattern: pattern,
|
||||
PatternType: "stix",
|
||||
Created: entry.Added.UTC(),
|
||||
Modified: entry.LastSeen.UTC(),
|
||||
ValidFrom: entry.Added.UTC(),
|
||||
ValidUntil: validUntil,
|
||||
Name: "Honeypot interaction: " + entry.IP,
|
||||
Description: "This IP was observed interacting with a honeypot server.",
|
||||
KillChains: []stix.KillChain{{KillChain: "mitre-attack", Phase: "reconnaissance"}},
|
||||
Confidence: 100,
|
||||
Lang: "en",
|
||||
Labels: []string{"honeypot-interaction"},
|
||||
CreatedByRef: stix.DeceptifeedID,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// convertToSightings converts IP addresses from the threat feed into a
|
||||
// collection of STIX Sighting objects.
|
||||
func (f feedEntries) convertToSightings() []stix.Object {
|
||||
if len(f) == 0 {
|
||||
return []stix.Object{}
|
||||
}
|
||||
|
||||
const indicator = "indicator"
|
||||
const sighting = "sighting"
|
||||
const maxCount = 999_999_999 // Maximum count according to STIX 2.1 specification.
|
||||
result := make([]stix.Object, 0, len(f)+1)
|
||||
|
||||
// Add the Deceptifeed `Identity` as the first object in the collection.
|
||||
// All objects in the collection will reference this identity as the
|
||||
// creator.
|
||||
result = append(result, stix.DeceptifeedIdentity())
|
||||
|
||||
for _, entry := range f {
|
||||
pattern := "[ipv4-addr:value = '"
|
||||
if strings.Contains(entry.IP, ":") {
|
||||
pattern = "[ipv6-addr:value = '"
|
||||
}
|
||||
pattern = pattern + entry.IP + "']"
|
||||
|
||||
count := min(entry.Observations, maxCount)
|
||||
|
||||
// Generate a deterministic identifier using the IP address represented
|
||||
// as a STIX IP pattern and structured as a JSON string. Example:
|
||||
// {"pattern":"[ipv4-addr:value='127.0.0.1']"}
|
||||
indicatorJSON := fmt.Sprintf("{\"pattern\":\"%s\"}", pattern)
|
||||
indicatorID := stix.DeterministicID(indicator, indicatorJSON)
|
||||
|
||||
result = append(result, stix.Sighting{
|
||||
Type: sighting,
|
||||
SpecVersion: stix.SpecVersion,
|
||||
ID: stix.DeterministicID(sighting, "{\"sighting_of_ref\":\""+indicatorID+"\"}"),
|
||||
Created: entry.Added.UTC(),
|
||||
Modified: entry.LastSeen.UTC(),
|
||||
FirstSeen: entry.Added.UTC(),
|
||||
LastSeen: entry.LastSeen.UTC(),
|
||||
Count: count,
|
||||
Description: "This IP was observed interacting with a honeypot server.",
|
||||
Confidence: 100,
|
||||
Lang: "en",
|
||||
SightingOfRef: indicatorID,
|
||||
WhereSightedRefs: []string{stix.DeceptifeedID},
|
||||
CreatedByRef: stix.DeceptifeedID,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// convertToObservables converts IP addresses from the threat feed into a
|
||||
// collection of STIX Cyber-observable Objects.
|
||||
func (f feedEntries) convertToObservables() []stix.Object {
|
||||
if len(f) == 0 {
|
||||
return []stix.Object{}
|
||||
}
|
||||
|
||||
result := make([]stix.Object, 0, len(f)+1)
|
||||
|
||||
// Add the Deceptifeed `Identity` as the first object in the collection.
|
||||
// All objects in the collection will reference this identity as the
|
||||
// creator.
|
||||
result = append(result, stix.DeceptifeedIdentity())
|
||||
|
||||
for _, entry := range f {
|
||||
t := "ipv4-addr"
|
||||
if strings.Contains(entry.IP, ":") {
|
||||
t = "ipv6-addr"
|
||||
}
|
||||
|
||||
// Generate a deterministic identifier for each IP address in the
|
||||
// threat feed using the IP value represented as a JSON string. For
|
||||
// example: {"value":"127.0.0.1"}
|
||||
result = append(result, stix.ObservableIP{
|
||||
Type: t,
|
||||
SpecVersion: stix.SpecVersion,
|
||||
ID: stix.DeterministicID(t, "{\"value\":\""+entry.IP+"\"}"),
|
||||
Value: entry.IP,
|
||||
CreatedByRef: stix.DeceptifeedID,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
485
internal/threatfeed/handler-logs.go
Normal file
@@ -0,0 +1,485 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleLogsMain serves a static page listing honeypot logs available for
|
||||
// viewing.
|
||||
func handleLogsMain(w http.ResponseWriter, r *http.Request) {
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "logs.html", "logs")
|
||||
}
|
||||
|
||||
// handleLogs directs the request to the appropriate log parser based on the
|
||||
// request path.
|
||||
func handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.PathValue("logtype") {
|
||||
case "http":
|
||||
switch r.PathValue("subtype") {
|
||||
case "":
|
||||
handleLogHTTP(w)
|
||||
case "ip":
|
||||
displayStats(w, httpIPStats{})
|
||||
case "useragent":
|
||||
displayStats(w, httpUserAgentStats{})
|
||||
case "path":
|
||||
displayStats(w, httpPathStats{})
|
||||
case "query":
|
||||
displayStats(w, httpQueryStats{})
|
||||
case "method":
|
||||
displayStats(w, httpMethodStats{})
|
||||
case "host":
|
||||
displayStats(w, httpHostStats{})
|
||||
default:
|
||||
handleNotFound(w, r)
|
||||
}
|
||||
case "ssh":
|
||||
switch r.PathValue("subtype") {
|
||||
case "":
|
||||
handleLogSSH(w)
|
||||
case "ip":
|
||||
displayStats(w, sshIPStats{})
|
||||
case "client":
|
||||
displayStats(w, sshClientStats{})
|
||||
case "username":
|
||||
displayStats(w, sshUsernameStats{})
|
||||
case "password":
|
||||
displayStats(w, sshPasswordStats{})
|
||||
default:
|
||||
handleNotFound(w, r)
|
||||
}
|
||||
default:
|
||||
handleNotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// displayLogErrorPage servers an error page when there is a problem parsing
|
||||
// log files.
|
||||
func displayLogErrorPage(w http.ResponseWriter, err error) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "logs-error.html", map[string]any{"Error": err, "NavData": "logs"})
|
||||
}
|
||||
|
||||
// handleLogSSH serves the SSH honeypot logs as a web page. It opens the
|
||||
// honeypot log files, parses the data to JSON, and passes the result to an
|
||||
// HTML template for rendering.
|
||||
func handleLogSSH(w http.ResponseWriter) {
|
||||
l := logFiles{}
|
||||
reader, err := l.open()
|
||||
if err != nil {
|
||||
displayLogErrorPage(w, err)
|
||||
return
|
||||
}
|
||||
defer l.close()
|
||||
|
||||
type Log struct {
|
||||
Time time.Time `json:"time"`
|
||||
EventType string `json:"event_type"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Details struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
const maxResults = 25_000
|
||||
d := json.NewDecoder(reader)
|
||||
data := make([]Log, 0, maxResults+1)
|
||||
for d.More() {
|
||||
var entry Log
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "ssh" {
|
||||
continue
|
||||
}
|
||||
data = append(data, entry)
|
||||
if len(data) > maxResults {
|
||||
data = data[1:]
|
||||
}
|
||||
}
|
||||
slices.Reverse(data)
|
||||
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "logs-ssh.html", map[string]any{"Data": data, "NavData": "logs"})
|
||||
}
|
||||
|
||||
// handleLogHTTP serves the HTTP honeypot logs as a web page. It opens the
|
||||
// honeypot log files, parses the data to JSON, and passes the result to an
|
||||
// HTML template for rendering.
|
||||
func handleLogHTTP(w http.ResponseWriter) {
|
||||
l := logFiles{}
|
||||
reader, err := l.open()
|
||||
if err != nil {
|
||||
displayLogErrorPage(w, err)
|
||||
return
|
||||
}
|
||||
defer l.close()
|
||||
|
||||
type Log struct {
|
||||
Time time.Time `json:"time"`
|
||||
EventType string `json:"event_type"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Details struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
const maxResults = 25_000
|
||||
d := json.NewDecoder(reader)
|
||||
data := make([]Log, 0, maxResults+1)
|
||||
for d.More() {
|
||||
var entry Log
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "http" {
|
||||
continue
|
||||
}
|
||||
data = append(data, entry)
|
||||
if len(data) > maxResults {
|
||||
data = data[1:]
|
||||
}
|
||||
}
|
||||
slices.Reverse(data)
|
||||
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "logs-http.html", map[string]any{"Data": data, "NavData": "logs"})
|
||||
}
|
||||
|
||||
// displayStats handles the processing and rendering of statistics for a given
|
||||
// field. It reads honeypot log data, counts the occurrences of `field` and
|
||||
// displays the results.
|
||||
func displayStats(w http.ResponseWriter, field fieldCounter) {
|
||||
l := logFiles{}
|
||||
reader, err := l.open()
|
||||
if err != nil {
|
||||
displayLogErrorPage(w, err)
|
||||
return
|
||||
}
|
||||
defer l.close()
|
||||
|
||||
fieldCounts := field.count(reader)
|
||||
|
||||
results := []statsResult{}
|
||||
for k, v := range fieldCounts {
|
||||
results = append(results, statsResult{Field: k, Count: v})
|
||||
}
|
||||
slices.SortFunc(results, func(a, b statsResult) int {
|
||||
return cmp.Or(
|
||||
-cmp.Compare(a.Count, b.Count),
|
||||
cmp.Compare(a.Field, b.Field),
|
||||
)
|
||||
})
|
||||
|
||||
_ = parsedTemplates.ExecuteTemplate(
|
||||
w,
|
||||
"logs-stats.html",
|
||||
map[string]any{
|
||||
"Data": results,
|
||||
"Header": field.fieldName(),
|
||||
"NavData": "logs",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// statsResult holds a specific value for field and its associated count.
|
||||
type statsResult struct {
|
||||
Field string
|
||||
Count int
|
||||
}
|
||||
|
||||
// fieldCounter is an interface that defines methods for counting occurrences
|
||||
// of specific fields.
|
||||
type fieldCounter interface {
|
||||
count(io.Reader) map[string]int
|
||||
fieldName() string
|
||||
}
|
||||
|
||||
// sshIPStats is the log structure for extracting SSH IP data.
|
||||
type sshIPStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
}
|
||||
|
||||
func (sshIPStats) fieldName() string { return "Source IP" }
|
||||
|
||||
func (sshIPStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry sshIPStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "ssh" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.SourceIP]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// sshClientStats is the log structure for extracting SSH client data.
|
||||
type sshClientStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
Client string `json:"ssh_client"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (sshClientStats) fieldName() string { return "SSH Client" }
|
||||
|
||||
func (sshClientStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry sshClientStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "ssh" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.Client]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// sshUsernameStats is the log structure for extracting SSH username data.
|
||||
type sshUsernameStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (sshUsernameStats) fieldName() string { return "Username" }
|
||||
|
||||
func (sshUsernameStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry sshUsernameStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "ssh" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.Username]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// sshPasswordStats is the log structure for extracting SSH password data.
|
||||
type sshPasswordStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
Password string `json:"password"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (sshPasswordStats) fieldName() string { return "Password" }
|
||||
|
||||
func (sshPasswordStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry sshPasswordStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "ssh" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.Password]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// httpIPStats is the log structure for extracting HTTP IP data.
|
||||
type httpIPStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
}
|
||||
|
||||
func (httpIPStats) fieldName() string { return "Source IP" }
|
||||
|
||||
func (httpIPStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry httpIPStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "http" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.SourceIP]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// httpUserAgentStats is the log structure for extracting HTTP user-agent data.
|
||||
type httpUserAgentStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
UserAgent string `json:"user_agent"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (httpUserAgentStats) fieldName() string { return "User-Agent" }
|
||||
|
||||
func (httpUserAgentStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry httpUserAgentStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "http" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.UserAgent]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// httpPathStats is the log structure for extracting HTTP path data.
|
||||
type httpPathStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (httpPathStats) fieldName() string { return "Path" }
|
||||
|
||||
func (httpPathStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry httpPathStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "http" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.Path]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// httpQueryStats is the log structure for extracting HTTP query string data.
|
||||
type httpQueryStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
Query string `json:"query"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (httpQueryStats) fieldName() string { return "Query String" }
|
||||
|
||||
func (httpQueryStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry httpQueryStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "http" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.Query]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// httpMethodStats is the log structure for extracting HTTP method data.
|
||||
type httpMethodStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
Method string `json:"method"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (httpMethodStats) fieldName() string { return "HTTP Method" }
|
||||
|
||||
func (httpMethodStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry httpMethodStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "http" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.Method]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// httpHostStats is the log structure for extracting HTTP host header data.
|
||||
type httpHostStats struct {
|
||||
EventType string `json:"event_type"`
|
||||
Details struct {
|
||||
Host string `json:"host"`
|
||||
} `json:"event_details"`
|
||||
}
|
||||
|
||||
func (httpHostStats) fieldName() string { return "Host Header" }
|
||||
|
||||
func (httpHostStats) count(r io.Reader) map[string]int {
|
||||
fieldCounts := map[string]int{}
|
||||
d := json.NewDecoder(r)
|
||||
for d.More() {
|
||||
var entry httpHostStats
|
||||
err := d.Decode(&entry)
|
||||
if err != nil || entry.EventType != "http" {
|
||||
continue
|
||||
}
|
||||
fieldCounts[entry.Details.Host]++
|
||||
}
|
||||
return fieldCounts
|
||||
}
|
||||
|
||||
// logFiles represents open honeypot log files and their associate io.Reader.
|
||||
type logFiles struct {
|
||||
files []*os.File
|
||||
readers []io.Reader
|
||||
}
|
||||
|
||||
// open opens all honeypot log files and returns an io.MultiReader that
|
||||
// combines all of the logs.
|
||||
func (l *logFiles) open() (io.Reader, error) {
|
||||
paths := []string{}
|
||||
seenPaths := make(map[string]bool)
|
||||
|
||||
// Helper function to ensure only unique paths are added to the slice.
|
||||
add := func(p string) {
|
||||
if seenPaths[p] {
|
||||
return
|
||||
}
|
||||
// New path. Add both the path and the path with ".1" to the slice.
|
||||
paths = append(paths, p+".1", p)
|
||||
seenPaths[p] = true
|
||||
}
|
||||
|
||||
for _, s := range cfg.Servers {
|
||||
add(s.LogPath)
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
l.files = append(l.files, f)
|
||||
}
|
||||
|
||||
for _, f := range l.files {
|
||||
l.readers = append(l.readers, f)
|
||||
}
|
||||
|
||||
return io.MultiReader(l.readers...), nil
|
||||
}
|
||||
|
||||
// close closes all honeypot log files.
|
||||
func (l *logFiles) close() {
|
||||
for _, f := range l.files {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
94
internal/threatfeed/handler-websocket.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// maxRecentMessages is the maximum number of recent log messages to store.
|
||||
const maxRecentMessages = 100
|
||||
|
||||
var (
|
||||
// muWSClients is to ensure threat-safe access to wsClients.
|
||||
muWSClients sync.Mutex
|
||||
|
||||
// wsClients holds the connected WebSocket clients and is used to broadcast
|
||||
// messages to all clients.
|
||||
wsClients = make(map[*websocket.Conn]bool)
|
||||
|
||||
// wsRecentMessages stores the most recent log messages. These messages
|
||||
// are sent to clients when they first connect.
|
||||
wsRecentMessages = make([]string, 0, maxRecentMessages*1.5)
|
||||
)
|
||||
|
||||
// handleLiveIndex serves a web page that displays honeypot log data in
|
||||
// real-time through a WebSocket connection.
|
||||
func handleLiveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "live.html", "live")
|
||||
}
|
||||
|
||||
// broadcastLogsToClients receives honeypot log data through a byte channel
|
||||
// configured to monitor the logs. When log data is received, the data is
|
||||
// sent to all connected WebSocket clients. It also stores recent log data in a
|
||||
// cache for newly connected clients.
|
||||
func broadcastLogsToClients() {
|
||||
for msg := range cfg.Monitor.Channel {
|
||||
wsRecentMessages = append(wsRecentMessages, string(msg))
|
||||
if len(wsRecentMessages) > maxRecentMessages {
|
||||
wsRecentMessages = wsRecentMessages[1:]
|
||||
}
|
||||
|
||||
muWSClients.Lock()
|
||||
for client := range wsClients {
|
||||
_ = websocket.Message.Send(client, string(msg))
|
||||
}
|
||||
muWSClients.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebSocket establishes and maintains WebSocket connections with clients
|
||||
// and performs cleanup when clients disconnect.
|
||||
func handleWebSocket(ws *websocket.Conn) {
|
||||
defer func() {
|
||||
muWSClients.Lock()
|
||||
delete(wsClients, ws)
|
||||
muWSClients.Unlock()
|
||||
_ = ws.Close()
|
||||
}()
|
||||
|
||||
// Enforce private IPs.
|
||||
ip, _, err := net.SplitHostPort(ws.Request().RemoteAddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
|
||||
return
|
||||
}
|
||||
fmt.Println("[Threat Feed]", ip, "established WebSocket connection")
|
||||
|
||||
// Add newly connected client to map.
|
||||
muWSClients.Lock()
|
||||
wsClients[ws] = true
|
||||
muWSClients.Unlock()
|
||||
|
||||
// Send the cache of recent log messages to the new client.
|
||||
for _, msg := range wsRecentMessages {
|
||||
_ = websocket.Message.Send(ws, msg)
|
||||
}
|
||||
// Send a message informing the client that we're done sending the initial
|
||||
// cache of log messages.
|
||||
_ = websocket.Message.Send(ws, "---end---")
|
||||
|
||||
// Keep WebSocket open.
|
||||
var message string
|
||||
for {
|
||||
err := websocket.Message.Receive(ws, &message)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
464
internal/threatfeed/handler.go
Normal file
@@ -0,0 +1,464 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
"github.com/r-smith/deceptifeed/internal/stix"
|
||||
"github.com/r-smith/deceptifeed/internal/taxii"
|
||||
)
|
||||
|
||||
// templates embeds .html and template files in the `./templates/` folder.
|
||||
//
|
||||
//go:embed templates
|
||||
var templates embed.FS
|
||||
|
||||
// parsedTemplates pre-parses and caches all HTML templates when the threat
|
||||
// feed server starts. This eliminates the need for HTTP handlers to re-parse
|
||||
// templates on each request.
|
||||
var parsedTemplates = template.Must(template.ParseFS(templates, "templates/*.html"))
|
||||
|
||||
// handlePlain handles HTTP requests to serve the threat feed in plain text. It
|
||||
// returns a list of IP addresses that interacted with the honeypot servers.
|
||||
func handlePlain(w http.ResponseWriter, r *http.Request) {
|
||||
opt, err := parseParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
for _, entry := range prepareFeed(opt) {
|
||||
_, err := w.Write([]byte(entry.IP + "\n"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to serve threat feed:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleJSON handles HTTP requests to serve the full threat feed in JSON
|
||||
// format. It returns a JSON array containing all IoC data (IP addresses and
|
||||
// their associated data).
|
||||
func handleJSON(w http.ResponseWriter, r *http.Request) {
|
||||
opt, err := parseParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
if err := e.Encode(map[string]any{"threat_feed": prepareFeed(opt)}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to JSON:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleCSV handles HTTP requests to serve the full threat feed in CSV format.
|
||||
// It returns a CSV file containing all IoC data (IP addresses and their
|
||||
// associated data).
|
||||
func handleCSV(w http.ResponseWriter, r *http.Request) {
|
||||
opt, err := parseParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"threat-feed-"+time.Now().Format("20060102-150405")+".csv\"")
|
||||
|
||||
c := csv.NewWriter(w)
|
||||
if err := c.Write(csvHeader); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range prepareFeed(opt) {
|
||||
if err := c.Write([]string{
|
||||
entry.IP,
|
||||
entry.Added.Format(dateFormat),
|
||||
entry.LastSeen.Format(dateFormat),
|
||||
strconv.Itoa(entry.Observations),
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Flush()
|
||||
if err := c.Error(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSTIX handles HTTP requests to serve the full threat feed in STIX 2.1
|
||||
// format. The response includes all IoC data (IP addresses and their
|
||||
// associated data). The response is structured as a STIX Bundle containing
|
||||
// `Indicators` (STIX Domain Objects) for each IP address in the threat feed.
|
||||
func handleSTIX(w http.ResponseWriter, r *http.Request) {
|
||||
opt, err := parseParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
const bundle = "bundle"
|
||||
result := stix.Bundle{
|
||||
Type: bundle,
|
||||
ID: stix.NewID(bundle),
|
||||
Objects: prepareFeed(opt).convertToIndicators(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", stix.ContentType)
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to STIX:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTAXIIDiscovery handles the TAXII server discovery endpoint, defined as
|
||||
// `/taxii2/`. It returns a list of API root URLs available on the TAXII server.
|
||||
// Deceptifeed has a single API root at `/taxii2/api/`
|
||||
func handleTAXIIDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
result := taxii.DiscoveryResource{
|
||||
Title: "Deceptifeed TAXII Server",
|
||||
Description: "This TAXII server contains IP addresses observed interacting with honeypots",
|
||||
Default: taxii.APIRoot,
|
||||
APIRoots: []string{taxii.APIRoot},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", taxii.ContentType)
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
if err := e.Encode(result); err != nil {
|
||||
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTAXIIRoot returns general information about the requested API root.
|
||||
func handleTAXIIRoot(w http.ResponseWriter, r *http.Request) {
|
||||
result := taxii.APIRootResource{
|
||||
Title: "Deceptifeed TAXII Server",
|
||||
Versions: []string{taxii.ContentType},
|
||||
MaxContentLength: 1,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", taxii.ContentType)
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
if err := e.Encode(result); err != nil {
|
||||
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTAXIICollections returns details about available TAXII collections
|
||||
// hosted under the API root. Requests for `{api-root}/collections/` return a
|
||||
// list of all available collections. Requests for
|
||||
// `{api-root}/collections/{id}/` return information about the requested
|
||||
// collection ID.
|
||||
func handleTAXIICollections(w http.ResponseWriter, r *http.Request) {
|
||||
// Depending on the request, the result may be a single Collection or a
|
||||
// slice of Collections.
|
||||
var result any
|
||||
collections := taxii.ImplementedCollections()
|
||||
|
||||
if id := r.PathValue("id"); len(id) > 0 {
|
||||
found := false
|
||||
for i, c := range collections {
|
||||
if id == c.ID || id == c.Alias {
|
||||
found = true
|
||||
result = collections[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
result = map[string]any{"collections": collections}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", taxii.ContentType)
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
if err := e.Encode(result); err != nil {
|
||||
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTAXIIObjects returns the threat feed as STIX objects. The objects are
|
||||
// structured according to the requested TAXII collection and wrapped in a
|
||||
// TAXII Envelope. Request URL format: `{api-root}/collections/{id}/objects/`.
|
||||
func handleTAXIIObjects(w http.ResponseWriter, r *http.Request) {
|
||||
opt, err := parseParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure a minimum page number of 1.
|
||||
if opt.page < 1 {
|
||||
opt.page = 1
|
||||
}
|
||||
|
||||
// Build the requested collection.
|
||||
result := taxii.Envelope{}
|
||||
switch r.PathValue("id") {
|
||||
case taxii.IndicatorsID, taxii.IndicatorsAlias:
|
||||
result.Objects = prepareFeed(opt).convertToIndicators()
|
||||
case taxii.ObservablesID, taxii.ObservablesAlias:
|
||||
result.Objects = prepareFeed(opt).convertToObservables()
|
||||
case taxii.SightingsID, taxii.SightingsAlias:
|
||||
result.Objects = prepareFeed(opt).convertToSightings()
|
||||
default:
|
||||
handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Paginate. result.Objects may be resliced depending on the requested
|
||||
// limit and page number.
|
||||
result.Objects, result.More = paginate(result.Objects, opt.limit, opt.page)
|
||||
|
||||
// If more results are available, include the `next` property in the
|
||||
// response with the next page number.
|
||||
if result.More {
|
||||
if opt.page+1 > 0 {
|
||||
result.Next = strconv.Itoa(opt.page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the `last seen` timestamps of the first and last objects in the
|
||||
// results for setting `X-TAXII-Date-Added-` headers.
|
||||
first := time.Time{}
|
||||
last := time.Time{}
|
||||
objectCount := len(result.Objects)
|
||||
if objectCount > 0 {
|
||||
// Loop twice: the first iteration accesses the first element of the
|
||||
// Objects slice, and the second iteration accesses the last element.
|
||||
for i := 0; i < 2; i++ {
|
||||
element := 0
|
||||
if i == 1 {
|
||||
element = len(result.Objects) - 1
|
||||
}
|
||||
timestamp := time.Time{}
|
||||
switch v := result.Objects[element].(type) {
|
||||
case stix.Indicator:
|
||||
timestamp = v.Modified
|
||||
case stix.Sighting:
|
||||
timestamp = v.LastSeen
|
||||
case stix.ObservableIP:
|
||||
if ioc, found := iocData[v.Value]; found {
|
||||
timestamp = ioc.lastSeen
|
||||
}
|
||||
case stix.Identity:
|
||||
timestamp = v.Created
|
||||
}
|
||||
if i == 0 {
|
||||
first = timestamp
|
||||
} else {
|
||||
last = timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", taxii.ContentType)
|
||||
if objectCount > 0 {
|
||||
w.Header()["X-TAXII-Date-Added-First"] = []string{first.UTC().Format(time.RFC3339)}
|
||||
w.Header()["X-TAXII-Date-Added-Last"] = []string{last.UTC().Format(time.RFC3339)}
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleHome serves as the default landing page for the threat feed. It
|
||||
// delivers a static HTML document with information on accessing the threat
|
||||
// feed.
|
||||
func handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "home.html", "home")
|
||||
}
|
||||
|
||||
// handleDocs serves a static page with documentation for accessing the threat
|
||||
// feed.
|
||||
func handleDocs(w http.ResponseWriter, r *http.Request) {
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "docs.html", "docs")
|
||||
}
|
||||
|
||||
// handleCSS serves a CSS stylesheet for styling HTML templates.
|
||||
func handleCSS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
data, err := templates.ReadFile("templates/css/style.css")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// handleConfig serves a page that displays the Deceptifeed configuration.
|
||||
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
type templateData struct {
|
||||
C config.Config
|
||||
Version string
|
||||
NavData string
|
||||
}
|
||||
d := templateData{C: cfg, Version: config.Version, NavData: "config"}
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "config.html", d)
|
||||
}
|
||||
|
||||
// handleHTML returns the threat feed as a web page for viewing in a browser.
|
||||
func handleHTML(w http.ResponseWriter, r *http.Request) {
|
||||
opt, err := parseParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Set default sort if no query parameters are provided.
|
||||
if len(r.URL.Query()) == 0 {
|
||||
opt.sortMethod = byLastSeen
|
||||
opt.sortDirection = descending
|
||||
}
|
||||
|
||||
var d string
|
||||
switch opt.sortDirection {
|
||||
case ascending:
|
||||
d = "asc"
|
||||
case descending:
|
||||
d = "desc"
|
||||
}
|
||||
var m string
|
||||
switch opt.sortMethod {
|
||||
case byIP:
|
||||
m = "ip"
|
||||
case byAdded:
|
||||
m = "added"
|
||||
case byLastSeen:
|
||||
m = "last_seen"
|
||||
case byObservations:
|
||||
m = "observations"
|
||||
}
|
||||
|
||||
_ = parsedTemplates.ExecuteTemplate(
|
||||
w,
|
||||
"webfeed.html",
|
||||
map[string]any{"Data": prepareFeed(opt), "SortDirection": d, "SortMethod": m, "NavData": "webfeed"},
|
||||
)
|
||||
}
|
||||
|
||||
// paginate returns a slice of stix.Objects for the requested page, based on
|
||||
// the provided limit and page numbers. It also returns whether more items are
|
||||
// available.
|
||||
func paginate(items []stix.Object, limit int, page int) ([]stix.Object, bool) {
|
||||
if limit <= 0 {
|
||||
return items, false
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// Determine the start index. Return an empty collection if out of bounds
|
||||
// or if the calculation overflows.
|
||||
start := (page - 1) * limit
|
||||
if start >= len(items) || start < 0 {
|
||||
return []stix.Object{}, false
|
||||
}
|
||||
|
||||
// Determine the end index and whether more items are remaining.
|
||||
end := start + limit
|
||||
more := end < len(items)
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
|
||||
return items[start:end], more
|
||||
}
|
||||
|
||||
// parseParams extracts HTTP query parameters and maps them to options for
|
||||
// controlling the threat feed output.
|
||||
func parseParams(r *http.Request) (feedOptions, error) {
|
||||
opt := feedOptions{}
|
||||
|
||||
// Handle TAXII parameters.
|
||||
if strings.HasPrefix(r.URL.Path, taxii.APIRoot) {
|
||||
// While TAXII requires sorting by creation date, we sort by `LastSeen`
|
||||
// instead. This is because the threat feed is dynamic and IPs may be
|
||||
// updated. This ensures clients don't miss updates if they are only
|
||||
// looking for new entries.
|
||||
opt.sortMethod = byLastSeen
|
||||
|
||||
var err error
|
||||
if len(r.URL.Query().Get("added_after")) > 0 {
|
||||
opt.seenAfter, err = time.Parse(time.RFC3339, r.URL.Query().Get("added_after"))
|
||||
if err != nil {
|
||||
return feedOptions{}, fmt.Errorf("invalid 'added_after' value")
|
||||
}
|
||||
}
|
||||
if len(r.URL.Query().Get("limit")) > 0 {
|
||||
opt.limit, err = strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
return feedOptions{}, fmt.Errorf("invalid 'limit' value")
|
||||
}
|
||||
}
|
||||
if len(r.URL.Query().Get("next")) > 0 {
|
||||
opt.page, err = strconv.Atoi(r.URL.Query().Get("next"))
|
||||
if err != nil {
|
||||
return feedOptions{}, fmt.Errorf("invalid 'next' value")
|
||||
}
|
||||
}
|
||||
return opt, nil
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("sort") {
|
||||
case "ip":
|
||||
opt.sortMethod = byIP
|
||||
case "last_seen":
|
||||
opt.sortMethod = byLastSeen
|
||||
case "added":
|
||||
opt.sortMethod = byAdded
|
||||
case "observations":
|
||||
opt.sortMethod = byObservations
|
||||
case "":
|
||||
// No sort option specified.
|
||||
default:
|
||||
return feedOptions{}, fmt.Errorf("invalid 'sort' value")
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("direction") {
|
||||
case "asc":
|
||||
opt.sortDirection = ascending
|
||||
case "desc":
|
||||
opt.sortDirection = descending
|
||||
case "":
|
||||
// No direction option specified.
|
||||
default:
|
||||
return feedOptions{}, fmt.Errorf("invalid 'direction' value")
|
||||
}
|
||||
|
||||
if len(r.URL.Query().Get("last_seen_hours")) > 0 {
|
||||
hours, err := strconv.Atoi(r.URL.Query().Get("last_seen_hours"))
|
||||
if err != nil {
|
||||
return feedOptions{}, fmt.Errorf("invalid 'last_seen_hours' value")
|
||||
}
|
||||
opt.seenAfter = time.Now().Add(-time.Hour * time.Duration(hours))
|
||||
}
|
||||
|
||||
return opt, nil
|
||||
}
|
||||
|
||||
// handleNotFound returns a 404 Not Found response. This is the default
|
||||
// response when a request is made to an undefined path.
|
||||
func handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "404.html", nil)
|
||||
}
|
38
internal/threatfeed/middleware.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// enforcePrivateIP is a middleware that restricts access to the HTTP server
|
||||
// based on the client's IP address. It allows only requests from private IP
|
||||
// addresses. Any other requests are denied with a 403 Forbidden error.
|
||||
func enforcePrivateIP(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, "Could not get IP", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
|
||||
http.Error(w, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// disableCache is a middleware that sets HTTP response headers to prevent
|
||||
// clients from caching the threat feed.
|
||||
func disableCache(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
99
internal/threatfeed/server.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// saveInterval represents how frequently the threat feed is saved to disk.
|
||||
// The saved file ensures threat feed data persists across application
|
||||
// restarts. It is not the active threat feed.
|
||||
saveInterval = 20 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
// cfg contains the application configuration. This includes settings for
|
||||
// the threat feed server as well as for each individual honeypot server.
|
||||
cfg config.Config
|
||||
)
|
||||
|
||||
// Start initializes and starts the threat feed server. The server provides a
|
||||
// list of IP addresses observed interacting with the honeypot servers in
|
||||
// various formats.
|
||||
func Start(c *config.Config) {
|
||||
cfg = *c
|
||||
|
||||
// Check for and open an existing threat feed CSV file, if available.
|
||||
if err := loadCSV(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped: Failed to open Threat Feed data:", err)
|
||||
return
|
||||
}
|
||||
deleteExpired()
|
||||
|
||||
// Periodically delete expired entries and save the current threat feed to
|
||||
// disk.
|
||||
ticker := time.NewTicker(saveInterval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if dataChanged {
|
||||
dataChanged = false
|
||||
deleteExpired()
|
||||
if err := saveCSV(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error saving Threat Feed data:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Monitor honeypot log data and broadcast to connected WebSocket clients.
|
||||
go broadcastLogsToClients()
|
||||
|
||||
// Setup handlers and server configuration.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", enforcePrivateIP(handleNotFound))
|
||||
mux.HandleFunc("GET /{$}", enforcePrivateIP(handleHome))
|
||||
mux.HandleFunc("GET /css/style.css", enforcePrivateIP(handleCSS))
|
||||
mux.HandleFunc("GET /docs", enforcePrivateIP(handleDocs))
|
||||
mux.HandleFunc("GET /config", enforcePrivateIP(handleConfig))
|
||||
mux.HandleFunc("GET /live", enforcePrivateIP(handleLiveIndex))
|
||||
mux.Handle("GET /live-ws", websocket.Handler(handleWebSocket))
|
||||
// Threat feed handlers.
|
||||
mux.HandleFunc("GET /webfeed", enforcePrivateIP(disableCache(handleHTML)))
|
||||
mux.HandleFunc("GET /plain", enforcePrivateIP(disableCache(handlePlain)))
|
||||
mux.HandleFunc("GET /csv", enforcePrivateIP(disableCache(handleCSV)))
|
||||
mux.HandleFunc("GET /json", enforcePrivateIP(disableCache(handleJSON)))
|
||||
mux.HandleFunc("GET /stix", enforcePrivateIP(disableCache(handleSTIX)))
|
||||
// TAXII 2.1 handlers.
|
||||
mux.HandleFunc("GET /taxii2/", enforcePrivateIP(handleNotFound))
|
||||
mux.HandleFunc("POST /taxii2/", enforcePrivateIP(handleNotFound))
|
||||
mux.HandleFunc("DELETE /taxii2/", enforcePrivateIP(handleNotFound))
|
||||
mux.HandleFunc("GET /taxii2/{$}", enforcePrivateIP(handleTAXIIDiscovery))
|
||||
mux.HandleFunc("GET /taxii2/api/{$}", enforcePrivateIP(handleTAXIIRoot))
|
||||
mux.HandleFunc("GET /taxii2/api/collections/{$}", enforcePrivateIP(handleTAXIICollections))
|
||||
mux.HandleFunc("GET /taxii2/api/collections/{id}/{$}", enforcePrivateIP(handleTAXIICollections))
|
||||
mux.HandleFunc("GET /taxii2/api/collections/{id}/objects/{$}", enforcePrivateIP(disableCache(handleTAXIIObjects)))
|
||||
// Honeypot log handlers.
|
||||
mux.HandleFunc("GET /logs", enforcePrivateIP(handleLogsMain))
|
||||
mux.HandleFunc("GET /logs/{logtype}", enforcePrivateIP(handleLogs))
|
||||
mux.HandleFunc("GET /logs/{logtype}/{subtype}", enforcePrivateIP(handleLogs))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + c.ThreatFeed.Port,
|
||||
Handler: mux,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 0,
|
||||
}
|
||||
|
||||
// Start the threat feed HTTP server.
|
||||
fmt.Printf("Starting Threat Feed server on port: %s\n", c.ThreatFeed.Port)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped:", err)
|
||||
}
|
||||
}
|
83
internal/threatfeed/templates/404.html
Normal file
66
internal/threatfeed/templates/config.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "nav" .NavData}}
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h2>Configuration</h2>
|
||||
<table class="config-info server-info">
|
||||
<tbody>
|
||||
<tr><th>Deceptifeed Version</th></tr>
|
||||
<tr><td class="yellow">{{.Version}}</td></tr>
|
||||
<tr><th>Configuration File</th></tr>
|
||||
<tr><td>{{if .C.FilePath}}<span class="gray">{{.C.FilePath}}{{else}}<span class="red">(not set){{end}}</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="server-info">
|
||||
<thead>
|
||||
<tr><th class="cyan" colspan="2">Threat Feed</th></tr>
|
||||
<tr><th class="gray" colspan="2">Port: <span class="orange">{{.C.ThreatFeed.Port}}</span></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><th>State</th><td>{{if .C.ThreatFeed.Enabled}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
|
||||
<tr><th>Database</th><td class="blue">{{if .C.ThreatFeed.DatabasePath}}{{.C.ThreatFeed.DatabasePath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
|
||||
<tr><th>Include Private IPs</th><td>{{if .C.ThreatFeed.IsPrivateIncluded}}<span class="red">Yes{{else}}<span class="green">No{{end}}</span></td></tr>
|
||||
<tr><th>Expiry Hours</th><td class="orange">{{if eq .C.ThreatFeed.ExpiryHours 0}}<span class="gray">(never expire)</span>{{else}}{{.C.ThreatFeed.ExpiryHours}}{{end}}</td></tr>
|
||||
<tr><th>Exclude List</th><td class="blue">{{if .C.ThreatFeed.ExcludeListPath}}{{.C.ThreatFeed.ExcludeListPath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{range .C.Servers}}
|
||||
<table class="server-info">
|
||||
<thead>
|
||||
<tr><th class="cyan" colspan="2"><span style="text-transform: uppercase;">{{.Type}}</span> Honeypot</th></tr>
|
||||
<tr><th class="gray" colspan="2">Port: <span class="orange">{{.Port}}</span></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><th>State</th><td>{{if .Enabled}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
|
||||
<tr><th>Send to Threat Feed</th><td>{{if .SendToThreatFeed}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
|
||||
<tr><th>Log State</th><td>{{if .LogEnabled}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
|
||||
<tr><th>Log Path</th><td class="blue">{{if .LogPath}}{{.LogPath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
|
||||
{{if eq .Type.String "https"}}<tr><th>Certificate</th><td class="blue">{{if .CertPath}}{{.CertPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
|
||||
{{if or (eq .Type.String "https") (eq .Type.String "ssh")}}<tr><th>Private Key</th><td class="blue">{{if .KeyPath}}{{.KeyPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
|
||||
{{if .HomePagePath}}<tr><th>Home Page</th><td class="blue">{{.HomePagePath}}</td></tr>{{end}}
|
||||
{{if .ErrorPagePath}}<tr><th>Error Page</th><td class="blue">{{.ErrorPagePath}}</td></tr>{{end}}
|
||||
{{if .Banner}}<tr><th>Banner</th><td class="magenta">{{.Banner}}</td></tr>{{end}}
|
||||
{{if .Headers}}<tr><th>Headers</th><td class="magenta">{{range .Headers}}{{.}}<br />{{end}}</td></tr>{{end}}
|
||||
{{if .Prompts}}<tr><th>Prompts</th><td class="magenta">{{range .Prompts}}{{if .Text}}{{.Text}}<br />{{end}}{{end}}</td></tr>{{end}}
|
||||
{{if .SourceIPHeader}}<tr><th>Source IP Header</th><td class="magenta">{{.SourceIPHeader}}</td></tr>{{end}}
|
||||
{{if .Rules.Include}}{{range .Rules.Include}}<tr><th>Include Rule</th><td><span class="gray">Target:</span> <span class="white">{{.Target}}</span><br /><span class="gray">Negate:</span> <span class="white">{{.Negate}}</span><br /><span class="gray">Regex:</span> <span class="yellow">{{.Pattern}}</span></td></tr>{{end}}{{end}}
|
||||
{{if .Rules.Exclude}}{{range .Rules.Exclude}}<tr><th>Exclude Rule</th><td><span class="gray">Target:</span> <span class="white">{{.Target}}</span><br /><span class="gray">Negate:</span> <span class="white">{{.Negate}}</span><br /><span class="gray">Regex:</span> <span class="yellow">{{.Pattern}}</span></td></tr>{{end}}{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
1013
internal/threatfeed/templates/css/style.css
Normal file
129
internal/threatfeed/templates/docs.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "nav" .}}
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h2>Threat Feed API</h2>
|
||||
<table class="api-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Endpoint</th>
|
||||
<th>Format</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td row-header="Endpoint"><a class="endpoint" href="/plain">/plain</a></td>
|
||||
<td row-header="Format"><span class="badge">Plain</span></td>
|
||||
<td>One IP address per line. Suitable for firewall integration.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td row-header="Endpoint"><a class="endpoint" href="/csv">/csv</a></td>
|
||||
<td row-header="Format"><span class="badge">CSV</span></td>
|
||||
<td>CSV format containing full threat feed details.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td row-header="Endpoint"><a class="endpoint" href="/json">/json</a></td>
|
||||
<td row-header="Format"><span class="badge">JSON</span></td>
|
||||
<td>JSON format containing full threat feed details.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td row-header="Endpoint"><a class="endpoint" href="/stix">/stix</a></td>
|
||||
<td row-header="Format"><span class="badge">STIX</span></td>
|
||||
<td>STIX <em>Indicators</em> containing full threat feed details.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td row-header="Endpoint"><a class="endpoint" href="/taxii2">/taxii2</a></td>
|
||||
<td row-header="Format"><span class="badge">TAXII</span></td>
|
||||
<td>TAXII server. See the <em>TAXII</em> section for usage instructions.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure>
|
||||
<figcaption><b>Example:</b> Retrieve the threat feed formatted as plain text:</figcaption>
|
||||
<pre>curl "http://threatfeed.example.com:9000/plain"</pre>
|
||||
</figure>
|
||||
<figure>
|
||||
<figcaption><b>Example:</b> Retrieve the threat feed formatted as JSON:</figcaption>
|
||||
<pre>curl "http://threatfeed.example.com:9000/json"</pre>
|
||||
</figure>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Query Parameters</h2>
|
||||
<p>All endpoints support optional query parameters to customize how the threat feed is formatted.
|
||||
The following query parameters are supported:</p>
|
||||
<table class="docs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="align-top">
|
||||
<tr>
|
||||
<td><code>sort</code></td>
|
||||
<td>Sort the results by a specific field. Valid values are:
|
||||
<ul class="no-bullets">
|
||||
<li><code>added</code></li>
|
||||
<li><code>ip</code></li>
|
||||
<li><code>last_seen</code></li>
|
||||
<li><code>observations</code></li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>direction</code></td>
|
||||
<td>Specify the sorting direction. Valid values are:
|
||||
<ul class="no-bullets">
|
||||
<li><code>asc</code> - Ascending order</li>
|
||||
<li><code>desc</code> - Descending order</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>last_seen_hours</code></td>
|
||||
<td>Filter results to only include entries seen within the last specified number of hours.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure>
|
||||
<figcaption><b>Example:</b> Retrieve the JSON feed, sorted by the last seen date in descending order:</figcaption>
|
||||
<pre>curl "http://threatfeed.example.com:9000/json?sort=last_seen&direction=desc"</pre>
|
||||
</figure>
|
||||
<figure>
|
||||
<figcaption><b>Example:</b> Retrieve the plain text feed, filtered to include only IP addresses seen within the last 24 hours:</figcaption>
|
||||
<pre>curl "http://threatfeed.example.com:9000/plain?last_seen_hours=24"</pre>
|
||||
</figure>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>TAXII</h2>
|
||||
<p>The threat feed is accessible via TAXII 2.1.
|
||||
This allows for integration with Threat Intelligence Platforms (TIPs) like <i>OpenCTI</i> and <i>Microsoft Sentinel</i>.</p>
|
||||
<p>To access via TAXII, clients require the API root URL and a collection ID.
|
||||
The API root URL is available at the path <b>/taxii2/api/</b>.
|
||||
Three collections are available: <b>indicators</b>, <b>sightings</b>, and <b>observables</b>.</p>
|
||||
<h3>Key Details</h3>
|
||||
<ul class="no-bullets">
|
||||
<li>API root URL: <code>http://threatfeed.example.com:9000/taxii2/api/</code></li>
|
||||
<li>Collection ID: <code>indicators</code></li>
|
||||
<li>Collection ID: <code>observables</code></li>
|
||||
<li>Collection ID: <code>sightings</code></li>
|
||||
</ul>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
48
internal/threatfeed/templates/home.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "nav" .}}
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h2>Threat Feed</h2>
|
||||
<p>The threat feed lists IP addresses that have interacted with Deceptifeed's honeypots. Its goal is to
|
||||
help you build an automated defense system.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/webfeed">View the threat feed</a> from your browser, or
|
||||
<a href="/docs">see the documentation</a> for other ways to access the data.
|
||||
</p>
|
||||
<p>
|
||||
Supported formats include:
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="/plain">Plain text</a> is the most commonly used format for integration with firewalls.</li>
|
||||
<li><a href="/json">JSON</a> and <a href="/csv">CSV</a> formats offer flexibility for integrating with various tools and workflows.</li>
|
||||
<li><a href="/taxii2/">TAXII</a> is designed for integration with Threat Intelligence Platforms (TIPs).</li>
|
||||
</ul>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
// This script ensures the hover effect on the Deceptifeed logo
|
||||
// doesn't restart if you navigate home from a different page.
|
||||
// Initially, the logo is in the 'no-hover' class which disables
|
||||
// the hover effect. After a short delay, this script removes the
|
||||
// class which re-enables the effect.
|
||||
const logoLink = document.getElementById('logo-link');
|
||||
setTimeout(function() {
|
||||
logoLink.classList.remove('no-hover');
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
198
internal/threatfeed/templates/live.html
Normal file
@@ -0,0 +1,198 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="full-width">
|
||||
<header>
|
||||
{{template "nav" .}}
|
||||
</header>
|
||||
<main>
|
||||
<div id="ws-status"></div>
|
||||
<table id="logs" class="live-logs"></table>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const maxLogs = 200;
|
||||
const logs = document.getElementById('logs');
|
||||
const wsStatus = document.getElementById('ws-status');
|
||||
const wsURL = '/live-ws';
|
||||
const initialReconnectDelay = 1000;
|
||||
const maxReconnectDelay = 15000;
|
||||
const maxReconnectAttempts = 100;
|
||||
const timeFormat = new Intl.DateTimeFormat([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
let ws;
|
||||
let reconnectAttempts = 0;
|
||||
let isInitialBatchProcessed = false;
|
||||
|
||||
function handleWS() {
|
||||
ws = new WebSocket(wsURL);
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
wsStatus.textContent = '';
|
||||
wsStatus.className = '';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data === '---end---') {
|
||||
isInitialBatchProcessed = true;
|
||||
return;
|
||||
}
|
||||
handleMessage(event.data, isInitialBatchProcessed);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
reconnectWS();
|
||||
};
|
||||
}
|
||||
|
||||
function reconnectWS() {
|
||||
if (reconnectAttempts > maxReconnectAttempts) {
|
||||
wsStatus.textContent = 'Failed connecting to Deceptifeed';
|
||||
wsStatus.className = 'red';
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
maxReconnectDelay,
|
||||
initialReconnectDelay * (2 ** reconnectAttempts),
|
||||
);
|
||||
const finalDelay = delay + (Math.random() * delay * 0.2);
|
||||
|
||||
setTimeout(() => {
|
||||
reconnectAttempts++;
|
||||
wsStatus.textContent = 'Connecting... ';
|
||||
wsStatus.classList.add('gray', 'connecting');
|
||||
handleWS();
|
||||
}, finalDelay);
|
||||
}
|
||||
|
||||
function handleMessage(data, shouldAnimate) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
|
||||
const timeElement = document.createElement('td');
|
||||
const initialTime = new Date(d.time);
|
||||
timeElement.textContent = timeFormat.format(initialTime);
|
||||
timeElement.className = 'timestamp';
|
||||
|
||||
const srcIPElement = document.createElement('td');
|
||||
srcIPElement.textContent = d.source_ip;
|
||||
srcIPElement.className = 'source-ip';
|
||||
|
||||
const eventDetails = document.createElement('td');
|
||||
eventDetails.className = 'event-details';
|
||||
|
||||
switch (d.event_type) {
|
||||
case 'http': {
|
||||
const httpMethod = document.createElement('span');
|
||||
httpMethod.textContent = `${d.event_details.method} `;
|
||||
httpMethod.className = 'magenta';
|
||||
|
||||
const httpPath = document.createTextNode(d.event_details.path);
|
||||
|
||||
const tooltipContent = document.createElement('pre');
|
||||
tooltipContent.className = 'tooltip-content';
|
||||
let jsonDetails = JSON.stringify(d.event_details, null, 2);
|
||||
// Remove outer braces.
|
||||
jsonDetails = jsonDetails.slice(2, -1);
|
||||
// Remove initial 2-space indent.
|
||||
jsonDetails = jsonDetails.replace(/^ {2}/gm, '');
|
||||
jsonDetails = jsonDetails.replace(/"([^"]+)":/g, '$1:');
|
||||
tooltipContent.textContent = jsonDetails
|
||||
|
||||
eventDetails.classList.add('tooltip');
|
||||
eventDetails.appendChild(httpMethod);
|
||||
eventDetails.appendChild(httpPath);
|
||||
eventDetails.appendChild(tooltipContent);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ssh': {
|
||||
const usernameLabel = document.createElement('span');
|
||||
usernameLabel.textContent = 'User: ';
|
||||
usernameLabel.className = 'magenta';
|
||||
const username = document.createTextNode(d.event_details.username);
|
||||
|
||||
const br = document.createElement('br');
|
||||
|
||||
const passwordLabel = document.createElement('span');
|
||||
passwordLabel.textContent = 'Pass: ';
|
||||
passwordLabel.className = 'magenta';
|
||||
const password = document.createTextNode(d.event_details.password);
|
||||
|
||||
eventDetails.appendChild(usernameLabel);
|
||||
eventDetails.appendChild(username);
|
||||
eventDetails.appendChild(br);
|
||||
eventDetails.appendChild(passwordLabel);
|
||||
eventDetails.appendChild(password);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'udp': {
|
||||
// Remove '[unreliable]' string from IP found in UDP logs.
|
||||
const spaceIndex = d.source_ip.indexOf(' ');
|
||||
if (spaceIndex >= 0) {
|
||||
srcIPElement.textContent = d.source_ip.slice(0, spaceIndex);
|
||||
}
|
||||
|
||||
const udpLabel = document.createElement('span');
|
||||
udpLabel.textContent = `[UDP:${d.server_port}] `;
|
||||
udpLabel.className = 'magenta';
|
||||
|
||||
const udpData = document.createTextNode(d.event_details.data);
|
||||
|
||||
eventDetails.appendChild(udpLabel);
|
||||
eventDetails.appendChild(udpData);
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const protoLabel = document.createElement('span');
|
||||
protoLabel.textContent = `[${d.event_type.toUpperCase()}:${d.server_port}] `;
|
||||
protoLabel.className = 'magenta';
|
||||
|
||||
const protoData = document.createTextNode(JSON.stringify(d.event_details, null, 1));
|
||||
|
||||
eventDetails.appendChild(protoLabel);
|
||||
eventDetails.appendChild(protoData);
|
||||
}
|
||||
}
|
||||
|
||||
// Add log entry to table.
|
||||
const logEntry = document.createElement('tr');
|
||||
logEntry.appendChild(timeElement);
|
||||
logEntry.appendChild(srcIPElement);
|
||||
logEntry.appendChild(eventDetails);
|
||||
|
||||
if (shouldAnimate) {
|
||||
logEntry.className = 'fade-in';
|
||||
}
|
||||
|
||||
logs.insertBefore(logEntry, logs.firstChild);
|
||||
|
||||
if (logs.children.length > maxLogs) {
|
||||
logs.removeChild(logs.lastChild);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to parse log data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
handleWS();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
22
internal/threatfeed/templates/logs-error.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "nav" .NavData}}
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h1 class="error">Error opening log files</h1>
|
||||
{{if .Error}}
|
||||
<p class="error">{{.Error}}</p>
|
||||
{{end}}
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
29
internal/threatfeed/templates/logs-http.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="full-width">
|
||||
<header>
|
||||
{{template "nav" .NavData}}
|
||||
</header>
|
||||
<main class="full-width">
|
||||
{{if .Data}}
|
||||
<table class="logs logs-http">
|
||||
<thead>
|
||||
<tr><th>Time<th>Source IP<th>Method<th>Path</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data}}<tr><td>{{.Time.Format "2006-01-02 15:04:05"}}<td>{{.SourceIP}}<td>{{.Details.Method}}<td>{{.Details.Path}}</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="no-results">No log data found</p>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
29
internal/threatfeed/templates/logs-ssh.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="full-width">
|
||||
<header>
|
||||
{{template "nav" .NavData}}
|
||||
</header>
|
||||
<main class="full-width">
|
||||
{{if .Data}}
|
||||
<table class="logs logs-ssh">
|
||||
<thead>
|
||||
<tr><th>Time<th>Source IP<th>Username<th>Password</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data}}<tr><td class="time">{{.Time.Format "2006-01-02 15:04:05"}}<td>{{.SourceIP}}<td>{{.Details.Username}}<td>{{.Details.Password}}</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="no-results">No log data found</p>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
47
internal/threatfeed/templates/logs-stats.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="full-width">
|
||||
<header>
|
||||
{{template "nav" .NavData}}
|
||||
</header>
|
||||
<main class="full-width">
|
||||
{{if .Data}}
|
||||
<table id="stats" class="logs logs-stats">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortTable(0)">Count
|
||||
<th onclick="sortTable(1)">{{.Header}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data}}<tr><td>{{.Count}}<td>{{.Field}}</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="no-results">No log data found</p>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function applyNumberSeparator() {
|
||||
// Format 'Count' with a thousands separator based on user's locale.
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
document.querySelectorAll("#stats tbody tr").forEach(row => {
|
||||
const observationCount = parseInt(row.cells[0].textContent, 10);
|
||||
if (!isNaN(observationCount)) {
|
||||
row.cells[0].textContent = numberFormat.format(observationCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyNumberSeparator();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
35
internal/threatfeed/templates/logs.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "nav" .}}
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h2>Honeypot Logs</h2>
|
||||
<ul class="log-list">
|
||||
<li><a href="/logs/ssh">SSH Logs</a></li>
|
||||
<li><a href="/logs/http">HTTP Logs</a></li>
|
||||
</ul>
|
||||
<ul class="log-list">
|
||||
<li><a href="/logs/ssh/ip">Unique: SSH Source IPs</a></li>
|
||||
<li><a href="/logs/ssh/username">Unique: SSH Usernames</a></li>
|
||||
<li><a href="/logs/ssh/password">Unique: SSH Passwords</a></li>
|
||||
<li><a href="/logs/ssh/client">Unique: SSH Clients</a></li>
|
||||
<li><a href="/logs/http/ip">Unique: HTTP Source IPs</a></li>
|
||||
<li><a href="/logs/http/useragent">Unique: HTTP User-Agents</a></li>
|
||||
<li><a href="/logs/http/path">Unique: HTTP Paths</a></li>
|
||||
<li><a href="/logs/http/query">Unique: HTTP Queries</a></li>
|
||||
<li><a href="/logs/http/method">Unique: HTTP Methods</a></li>
|
||||
<li><a href="/logs/http/host">Unique: HTTP Host Headers</a></li>
|
||||
</ul>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
49
internal/threatfeed/templates/nav.html
Normal file
93
internal/threatfeed/templates/webfeed.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deceptifeed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="full-width">
|
||||
<header>
|
||||
{{template "nav" .NavData}}
|
||||
</header>
|
||||
<main class="full-width">
|
||||
{{if .Data}}
|
||||
<table id="webfeed" class="webfeed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><a href="?sort=ip&direction={{if and (eq .SortMethod "ip") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
|
||||
IP
|
||||
</a>{{if eq .SortMethod "ip"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
|
||||
</th>
|
||||
<th><a href="?sort=added&direction={{if and (eq .SortMethod "added") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
|
||||
Added
|
||||
</a>{{if eq .SortMethod "added"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
|
||||
</th>
|
||||
<th><a href="?sort=last_seen&direction={{if and (eq .SortMethod "last_seen") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
|
||||
Last Seen
|
||||
</a>{{if eq .SortMethod "last_seen"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
|
||||
</th>
|
||||
<th><a href="?sort=observations&direction={{if and (eq .SortMethod "observations") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
|
||||
Observations
|
||||
</a>{{if eq .SortMethod "observations"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data}}<tr><td>{{.IP}}<td>{{.Added.UTC.Format "2006-01-02T15:04:05.000Z"}}<td>{{.LastSeen.UTC.Format "2006-01-02T15:04:05.000Z"}}<td>{{.Observations}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="no-results">The threat feed is currently empty</p>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function formatDatesAndNumbers() {
|
||||
// Format 'Added' as YYYY-MM-DD.
|
||||
const addedDateFormat = new Intl.DateTimeFormat('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
// Format 'Last Seen' as YYYY-MM-DD hh:mm.
|
||||
const lastSeenDateFormat = new Intl.DateTimeFormat('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Format 'Observations' with a thousands separator based on user's locale.
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
// Apply formats to table.
|
||||
document.querySelectorAll("#webfeed tbody tr").forEach(row => {
|
||||
// Apply format to 'Added' cell (row.cells[1]).
|
||||
let date = new Date(row.cells[1].textContent);
|
||||
if (!isNaN(date.valueOf())) {
|
||||
row.cells[1].textContent = addedDateFormat.format(date);
|
||||
}
|
||||
|
||||
// Apply format to 'Last Seen' cell (row.cells[2]).
|
||||
date = new Date(row.cells[2].textContent);
|
||||
if (!isNaN(date.valueOf())) {
|
||||
row.cells[2].textContent = lastSeenDateFormat.format(date).replace(',', '');
|
||||
}
|
||||
|
||||
// Apply format to 'Observations' cell (row.cells[3]).
|
||||
const observationCount = parseInt(row.cells[3].textContent, 10);
|
||||
if (!isNaN(observationCount)) {
|
||||
row.cells[3].textContent = numberFormat.format(observationCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatDatesAndNumbers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -1,235 +0,0 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// configuration holds the global configuration for the threat feed server.
|
||||
// This variable is assigned the config.ThreatFeed value that's passed in
|
||||
// during the server's startup.
|
||||
configuration config.ThreatFeed
|
||||
|
||||
// iocMap stores the Indicator of Compromise (IoC) entries which makes up
|
||||
// the threat feed database. It is initially populated by loadIoC if an
|
||||
// existing CSV database file is provided. The map is subsequently updated
|
||||
// by UpdateIoC whenever a client interacts with a honeypot server. This
|
||||
// map is accessed and served by the threat feed HTTP server.
|
||||
iocMap = make(map[string]*IoC)
|
||||
|
||||
// mutex is to ensure thread-safe access to iocMap.
|
||||
mutex sync.Mutex
|
||||
|
||||
// ticker creates a new ticker for periodically writing the IoC map to
|
||||
// disk.
|
||||
ticker = time.NewTicker(10 * time.Second)
|
||||
|
||||
// hasMapChanged indicates whether the IoC map has been modified since the
|
||||
// last time it was saved to disk.
|
||||
hasMapChanged = false
|
||||
)
|
||||
|
||||
// StartThreatFeed initializes and starts the threat feed server. The server
|
||||
// provides a list of IP addresses observed interacting with the honeypot
|
||||
// servers. The data is served in a format compatible with most enterprise
|
||||
// firewalls.
|
||||
func StartThreatFeed(cfg *config.ThreatFeed) {
|
||||
// Assign the passed-in config.ThreatFeed to the global configuration
|
||||
// variable.
|
||||
configuration = *cfg
|
||||
|
||||
// Check for and open an existing threat feed CSV database, if available.
|
||||
err := loadIoC()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The Threat Feed server has terminated: Failed to open Threat Feed database:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Periodically save the current iocMap to disk.
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if hasMapChanged {
|
||||
if err := saveIoC(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error saving Threat Feed database:", err)
|
||||
}
|
||||
hasMapChanged = false
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup handlers.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", enforcePrivateIP(handleConnection))
|
||||
mux.HandleFunc("/empty/", enforcePrivateIP(serveEmpty))
|
||||
|
||||
// Start the threat feed HTTP server.
|
||||
fmt.Printf("Starting Threat Feed server on port: %s\n", cfg.Port)
|
||||
if err := http.ListenAndServe(":"+cfg.Port, mux); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The Threat Feed server has terminated:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnection processes incoming HTTP requests for the threat feed
|
||||
// server. It serves the sorted list of IP addresses observed interacting with
|
||||
// the honeypot servers.
|
||||
func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// Calculate expiry time.
|
||||
now := time.Now()
|
||||
expiryTime := now.Add(-time.Hour * time.Duration(configuration.ExpiryHours))
|
||||
|
||||
// Parse IPs from the iocMap to net.IP for filtering and sorting. Skip any
|
||||
// IPs that have expired or don't meet the minimum threat score.
|
||||
var netIPs []net.IP
|
||||
for ip, ioc := range iocMap {
|
||||
if ioc.LastSeen.After(expiryTime) && ioc.ThreatScore >= configuration.MinimumThreatScore {
|
||||
netIPs = append(netIPs, net.ParseIP(ip))
|
||||
}
|
||||
}
|
||||
|
||||
// If an exclude list is provided, filter the original IP list.
|
||||
var filteredIPList []net.IP
|
||||
if len(configuration.ExcludeListPath) > 0 {
|
||||
ipsToRemove, err := readIPsFromFile(configuration.ExcludeListPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to read threat feed exclude list:", err)
|
||||
filteredIPList = netIPs
|
||||
} else {
|
||||
filteredIPList = filterIPs(netIPs, ipsToRemove)
|
||||
}
|
||||
} else {
|
||||
filteredIPList = netIPs
|
||||
}
|
||||
|
||||
// Sort the IP addresses.
|
||||
sort.Slice(filteredIPList, func(i, j int) bool {
|
||||
return bytes.Compare(filteredIPList[i], filteredIPList[j]) < 0
|
||||
})
|
||||
|
||||
// Serve the sorted list of IP addresses.
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
for _, ip := range filteredIPList {
|
||||
if ip == nil || (!configuration.IsPrivateIncluded && ip.IsPrivate()) {
|
||||
// Skip IP addresses that failed parsing or are private, based on
|
||||
// the configuration.
|
||||
continue
|
||||
}
|
||||
_, err := w.Write([]byte(ip.String() + "\n"))
|
||||
if err != nil {
|
||||
http.Error(w, "Falled to write response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If a custom threat file is supplied in the configuration, append the
|
||||
// contents of the file to the HTTP response. To allow for flexibility, the
|
||||
// contents of the file are not parsed or validated.
|
||||
if len(configuration.CustomThreatsPath) > 0 {
|
||||
data, err := os.ReadFile(configuration.CustomThreatsPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to read custom threats file:", err)
|
||||
return
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
http.Error(w, "Falled to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enforcePrivateIP is a middleware that restricts access to the HTTP server
|
||||
// based on the client's IP address. It allows only requests from private IP
|
||||
// addresses. Any other requests are denied with a 403 Forbidden error.
|
||||
func enforcePrivateIP(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
http.Error(w, "Could not get IP", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !net.ParseIP(ip).IsPrivate() {
|
||||
http.Error(w, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// readIPsFromFile reads IP addresses and CIDR ranges from a file. Each line
|
||||
// should contain an IP address or CIDR. It returns a map of the unique IPs and
|
||||
// CIDR ranges found in the file.
|
||||
func readIPsFromFile(filepath string) (map[string]struct{}, error) {
|
||||
ips := make(map[string]struct{})
|
||||
|
||||
file, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) > 0 {
|
||||
ips[line] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// filterIPs removes IPs from ipList that are found in the ipsToRemove map. The
|
||||
// keys in ipsToRemove may be single IP addresses or CIDR ranges. If a key is a
|
||||
// CIDR range, an IP will be removed if it falls within that range.
|
||||
func filterIPs(ipList []net.IP, ipsToRemove map[string]struct{}) []net.IP {
|
||||
filtered := []net.IP{}
|
||||
|
||||
// If there's nothing to filter, return the original list.
|
||||
if len(ipsToRemove) == 0 {
|
||||
return ipList
|
||||
}
|
||||
|
||||
for _, ip := range ipList {
|
||||
if _, found := ipsToRemove[ip.String()]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for CIDR matches.
|
||||
for cidr := range ipsToRemove {
|
||||
_, netCIDR, err := net.ParseCIDR(cidr)
|
||||
if err == nil && netCIDR.Contains(ip) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ip)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// serveEmpty handles HTTP requests to /empty/. It returns an empty body with
|
||||
// status code 200. This endpoint is useful for clearing the threat feed in
|
||||
// firewalls, as many firewalls retain the last ingested feed. Firewalls can be
|
||||
// configured to point to this endpoint, effectively clearing all previous
|
||||
// threat feed data.
|
||||
func serveEmpty(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve an empty body with status code 200.
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
@@ -12,35 +12,26 @@ import (
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
)
|
||||
|
||||
// StartUDP serves as a wrapper to initialize and start a generic UDP honeypot
|
||||
// server. It listens on the specified port, logging any received data without
|
||||
// responding back to the client. Since UDP is connectionless, clients are
|
||||
// unaware of the server's existence and that it is actively listening and
|
||||
// recording data sent to the port. Note that source IP addresses for UDP
|
||||
// packets are unreliable due to potential spoofing. As a result, interactions
|
||||
// logged from the UDP server will not be added to the threat feed. This
|
||||
// function calls the underlying startUDP function to perform the actual server
|
||||
// startup.
|
||||
func StartUDP(srv *config.Server) {
|
||||
fmt.Printf("Starting UDP server on port: %s\n", srv.Port)
|
||||
if err := startUDP(srv); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The UDP server has terminated:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startUDP starts the UDP honeypot server. It handles the server's main loop
|
||||
// and logging.
|
||||
func startUDP(srv *config.Server) error {
|
||||
// Convert the specified port number to an integer.
|
||||
port, err := strconv.Atoi(srv.Port)
|
||||
// Start initializes and starts a generic UDP honeypot server. It listens on
|
||||
// the specified port, logging any received data without responding back to the
|
||||
// client. Since UDP is connectionless, clients are unaware of the server's
|
||||
// existence and that it is actively listening and recording data sent to the
|
||||
// port. Note that source IP addresses for UDP packets are unreliable due to
|
||||
// potential spoofing. As a result, interactions with the UDP server are not
|
||||
// added to the threat feed.
|
||||
func Start(cfg *config.Server) {
|
||||
fmt.Printf("Starting UDP server on port: %s\n", cfg.Port)
|
||||
port, err := strconv.Atoi(cfg.Port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port '%s': %w", srv.Port, err)
|
||||
fmt.Fprintf(os.Stderr, "The UDP server on port %s has stopped: %v\n", cfg.Port, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Start the UDP server.
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failure to listen on port '%s': %w", srv.Port, err)
|
||||
fmt.Fprintf(os.Stderr, "The UDP server on port %s has stopped: %v\n", cfg.Port, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
@@ -67,11 +58,10 @@ func startUDP(srv *config.Server) error {
|
||||
// received the UDP data. However, this limitation is acceptable as
|
||||
// the primary goal is to log the received data.
|
||||
_, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||
src_ip, src_port, _ := net.SplitHostPort(remoteAddr.String())
|
||||
srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
src_ip, _, _ := net.SplitHostPort(remoteAddr.String())
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
slog.String("event_type", "udp"),
|
||||
slog.String("source_ip", src_ip+" [unreliable]"),
|
||||
slog.String("source_port", src_port+" [unreliable]"),
|
||||
slog.String("source_reliability", "unreliable"),
|
||||
slog.String("server_ip", config.GetHostIP()),
|
||||
slog.String("server_port", dst_port),
|
||||
@@ -82,7 +72,7 @@ func startUDP(srv *config.Server) error {
|
||||
)
|
||||
|
||||
// Print a simplified version of the interaction to the console.
|
||||
fmt.Printf("[UDP] %s Data: %s\n", src_ip, strings.TrimSpace(string(buffer[:n])))
|
||||
fmt.Printf("[UDP] %s Data: %q\n", src_ip, strings.TrimSpace(string(buffer[:n])))
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
196
scripts/install.sh
Normal file → Executable file
@@ -14,6 +14,7 @@ systemd_check_dir="/run/systemd/system"
|
||||
systemd_dir="/etc/systemd/system"
|
||||
service_short_name="deceptifeed"
|
||||
systemd_unit="${service_short_name}.service"
|
||||
auto_confirm_prompts=false
|
||||
|
||||
# =============================================================================
|
||||
# startup_checks:
|
||||
@@ -24,16 +25,13 @@ systemd_unit="${service_short_name}.service"
|
||||
startup_checks() {
|
||||
# If supported, enable colored output.
|
||||
if [[ -t 1 ]]; then
|
||||
# Detect color support.
|
||||
n_colors=$(tput colors 2>/dev/null)
|
||||
if [[ -n "${n_colors}" ]] && [[ "${n_colors}" -ge 8 ]]; then
|
||||
# Color support detected. Enable colored output.
|
||||
red='\033[1;31m'
|
||||
green='\033[1;32m'
|
||||
yellow='\033[1;33m'
|
||||
blue='\033[1;34m'
|
||||
magenta='\033[1;35m'
|
||||
dmagenta='\033[0;35m'
|
||||
cyan='\033[1;36m'
|
||||
white='\033[1;37m'
|
||||
gray='\033[0;37m'
|
||||
@@ -42,9 +40,9 @@ startup_checks() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output aids.
|
||||
# Output helper messages.
|
||||
msg_error="${dgray}[${red}Error${dgray}]${clear}"
|
||||
msg_info="${dgray}${magenta}‣${dgray}${clear}"
|
||||
msg_info="${dgray}${magenta}•${dgray}${clear}"
|
||||
|
||||
# Require systemd.
|
||||
if [[ ! -d "${systemd_check_dir}" || ! -d "${systemd_dir}" ]] || ! command -v systemctl &>/dev/null; then
|
||||
@@ -52,26 +50,25 @@ startup_checks() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure the script is running as root.
|
||||
if [[ "$(id --user)" -ne 0 ]]; then
|
||||
# Require root privileges.
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo -e "\n${msg_error} ${white}This script must be run as root.${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# print_banner:
|
||||
# Prints the application's banner.
|
||||
# print_logo:
|
||||
# Prints the application's logo.
|
||||
# =============================================================================
|
||||
print_banner() {
|
||||
echo -e "${yellow} __ __ _ ${green}______ __"
|
||||
echo -e "${yellow} ____/ /__ ________ ____ / /_(_)${green} ____/__ ___ ____/ /"
|
||||
echo -e "${yellow} / __ / _ \/ ___/ _ \/ __ \/ __/ /${green} /_ / _ \/ _ \/ __ / "
|
||||
echo -e "${yellow} / /_/ / __/ /__/ __/ /_/ / /_/ /${green} __/ / __/ __/ /_/ / "
|
||||
echo -e "${yellow} \____/\___/\___/\___/ .___/\__/_/${green}_/ \___/\___/\____/ "
|
||||
echo -e "${dmagenta} ::::::::::::::::::::${yellow}/_/${dmagenta}::::::::::::::::::::::::::::::::::"
|
||||
echo -e "${clear}"
|
||||
echo
|
||||
print_logo() {
|
||||
echo -e "${red} __ __ _ ${yellow}______ __"
|
||||
echo -e "${red} ____/ /__ ________ ____ / /_(_)${yellow} ____/__ ___ ____/ /"
|
||||
echo -e "${red} / __ / _ \/ ___/ _ \/ __ \/ __/ /${yellow} /_ / _ \/ _ \/ __ /"
|
||||
echo -e "${red} / /_/ / __/ /__/ __/ /_/ / /_/ /${yellow} __/ / __/ __/ /_/ /"
|
||||
echo -e "${red} \____/\___/\___/\___/ .___/\__/_/${yellow}_/ \___/\___/\____/"
|
||||
echo -e "${blue} ═══════════════════${red}/_/${blue}══════════════════════════════════"
|
||||
echo -e "${clear}\n"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -90,28 +87,36 @@ upgrade_app() {
|
||||
echo -e " ${red}Deceptifeed is already installed to${gray}: ${blue}${install_dir}/${clear}"
|
||||
echo -e " ${red}Would you like to upgrade?${clear}"
|
||||
echo -en " ${gray}(${white}yes${gray}/${white}no${gray}) ${gray}[${yellow}no${gray}]${white}: ${green}"
|
||||
read -r response
|
||||
if [[ "${auto_confirm_prompts}" = true ]]; then
|
||||
echo "yes"
|
||||
response="yes"
|
||||
else
|
||||
read -r response
|
||||
fi
|
||||
echo -en "${clear}"
|
||||
if [[ ! "${response}" =~ ^[yY][eE][sS]$ && ! "${response}" =~ ^[yY]$ ]]; then
|
||||
echo
|
||||
echo -e " ${white}Upgrade canceled${clear}"
|
||||
echo
|
||||
echo
|
||||
echo -e "\n ${white}Upgrade canceled${clear}\n\n"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Print upgrade banner.
|
||||
print_banner
|
||||
# Print the application logo.
|
||||
print_logo
|
||||
|
||||
# Stop the service.
|
||||
echo -e " ${msg_info} ${gray}Stopping service: ${cyan}${systemd_unit}${clear}"
|
||||
systemctl stop "${systemd_unit}"
|
||||
|
||||
# Backup (rename) the original binary.
|
||||
echo -e " ${msg_info} ${gray}Moving old binary to: ${cyan}${target_bin}.bak${clear}"
|
||||
if ! mv -f "${target_bin}" "${target_bin}.bak"; then
|
||||
echo -e " ${msg_error} ${white}Failed to move file: ${yellow}'${target_bin}' ${white}to: ${yellow}'${target_bin}.bak'${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy the binary.
|
||||
echo -e " ${msg_info} ${gray}Replacing binary: ${cyan}${target_bin}${clear}"
|
||||
if ! cp --force "${source_bin}" "${target_bin}"; then
|
||||
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}" >&2
|
||||
echo
|
||||
echo -e " ${msg_info} ${gray}Copying new binary to: ${cyan}${target_bin}${clear}"
|
||||
if ! cp -f "${source_bin}" "${target_bin}"; then
|
||||
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -128,13 +133,10 @@ upgrade_app() {
|
||||
|
||||
# Upgrade complete.
|
||||
echo
|
||||
echo -e " ${green}✓ ${white}Upgrade complete${clear}"
|
||||
echo
|
||||
echo -e " ${green}✓ ${white}Upgrade complete${clear}\n"
|
||||
echo -e "${yellow} Check service status: ${cyan}systemctl status ${service_short_name}${clear}"
|
||||
echo -e "${yellow} Log location: ${cyan}${install_dir}/logs/${clear}"
|
||||
echo -e "${yellow} Configuration file: ${cyan}${target_cfg}${clear}"
|
||||
echo
|
||||
echo
|
||||
echo -e "${yellow} Configuration file: ${cyan}${target_cfg}${clear}\n\n"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -156,13 +158,12 @@ install_app() {
|
||||
if [[ -f "${script_dir}/${source_bin}" ]]; then
|
||||
# Found in the same directory as the script.
|
||||
source_bin="${script_dir}/${source_bin}"
|
||||
elif [[ -f "${script_dir}/../out/${source_bin}" ]]; then
|
||||
# Found in ../out relative to the script.
|
||||
source_bin="${script_dir}/../out/${source_bin}"
|
||||
elif [[ -f "${script_dir}/../bin/${source_bin}" ]]; then
|
||||
# Found in ../bin relative to the script.
|
||||
source_bin="${script_dir}/../bin/${source_bin}"
|
||||
else
|
||||
# Could not locate.
|
||||
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_bin}'${clear}" >&2
|
||||
echo
|
||||
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_bin}'${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -175,29 +176,26 @@ install_app() {
|
||||
source_cfg="${script_dir}/../configs/${source_cfg}"
|
||||
else
|
||||
# Could not locate.
|
||||
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_cfg}'${clear}" >&2
|
||||
echo
|
||||
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_cfg}'${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upgrade check.
|
||||
if [[ -f "${target_bin}" && -f "${systemd_dir}/${systemd_unit}" ]]; then
|
||||
# Call the upgrade function.
|
||||
upgrade_app
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Print install banner.
|
||||
print_banner
|
||||
# Print the application logo.
|
||||
print_logo
|
||||
echo -e " ${msg_info} ${gray}Installing to: ${cyan}${install_dir}/"
|
||||
|
||||
# Create the directory structure.
|
||||
mkdir --parents "${install_dir}/bin/" "${install_dir}/certs/" "${install_dir}/etc/" "${install_dir}/logs/"
|
||||
mkdir -p "${install_dir}/bin/" "${install_dir}/certs/" "${install_dir}/etc/" "${install_dir}/logs/"
|
||||
|
||||
# Copy the binary.
|
||||
if ! cp --force "${source_bin}" "${target_bin}"; then
|
||||
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}" >&2
|
||||
echo
|
||||
if ! cp -f "${source_bin}" "${target_bin}"; then
|
||||
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -206,9 +204,8 @@ install_app() {
|
||||
# Don't copy anything. An existing configuration file already exists.
|
||||
echo -e " ${msg_info} ${gray}Keeping existing configuration found at: ${cyan}${target_cfg}"
|
||||
else
|
||||
if ! cp --force "${source_cfg}" "${target_cfg}"; then
|
||||
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_cfg}' ${white}to: ${yellow}'${target_cfg}'${clear}" >&2
|
||||
echo
|
||||
if ! cp -f "${source_cfg}" "${target_cfg}"; then
|
||||
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_cfg}' ${white}to: ${yellow}'${target_cfg}'${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -225,15 +222,14 @@ install_app() {
|
||||
--system \
|
||||
--shell /usr/sbin/nologin \
|
||||
--user-group "${username}"; then
|
||||
echo -e " ${msg_error} ${white}Failed to create user: ${yellow}${username}${clear}" >&2
|
||||
echo
|
||||
echo -e " ${msg_error} ${white}Failed to create user: ${yellow}${username}${clear}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set file and directory permissions.
|
||||
echo -e " ${msg_info} ${gray}Setting file and directory permissions.${clear}"
|
||||
chown --recursive "${username}":"${username}" "${install_dir}"
|
||||
chown -R "${username}":"${username}" "${install_dir}"
|
||||
chmod 755 "${target_bin}"
|
||||
chmod 644 "${target_cfg}"
|
||||
|
||||
@@ -242,8 +238,7 @@ install_app() {
|
||||
|
||||
# Create a systemd unit file.
|
||||
echo -e " ${msg_info} ${gray}Creating service: ${cyan}${systemd_dir}/${systemd_unit}${clear}"
|
||||
if [[ ! -f "${systemd_dir}/${systemd_unit}" ]]; then
|
||||
cat > "${systemd_dir}/${systemd_unit}" << EOF
|
||||
cat > "${systemd_dir}/${systemd_unit}" << EOF
|
||||
[Unit]
|
||||
Description=Deceptifeed
|
||||
ConditionPathExists=${target_bin}
|
||||
@@ -261,23 +256,17 @@ ExecStart=${target_bin} -config ${target_cfg}
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Reload systemd, enable, and start the service.
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${systemd_unit}" &>/dev/null
|
||||
systemctl start "${systemd_unit}"
|
||||
else
|
||||
# Service already exists. Restart it.
|
||||
echo -e " ${msg_info} ${gray}Restarting the service.${clear}"
|
||||
systemctl restart "${systemd_unit}"
|
||||
fi
|
||||
echo
|
||||
echo -e " ${green}✓ ${white}Installation complete${clear}"
|
||||
# Reload systemd, enable, and start the service.
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${systemd_unit}" &>/dev/null
|
||||
systemctl start "${systemd_unit}"
|
||||
|
||||
# Installation complete.
|
||||
echo
|
||||
echo -e " ${green}✓ ${white}Installation complete${clear}\n"
|
||||
echo -e "${yellow} Check service status: ${cyan}systemctl status ${service_short_name}${clear}"
|
||||
echo -e "${yellow} Log location: ${cyan}${install_dir}/logs/${clear}"
|
||||
echo -e "${yellow} Configuration file: ${cyan}${target_cfg}${clear}"
|
||||
echo
|
||||
echo
|
||||
echo -e "${yellow} Configuration file: ${cyan}${target_cfg}${clear}\n\n"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -289,10 +278,8 @@ EOF
|
||||
# =============================================================================
|
||||
uninstall_app() {
|
||||
# Print uninstall banner.
|
||||
echo
|
||||
echo -e " ${white}Uninstalling Deceptifeed${clear}"
|
||||
echo -e " ${dgray}========================${clear}"
|
||||
echo
|
||||
echo -e "\n ${white}Uninstalling Deceptifeed${clear}"
|
||||
echo -e " ${dgray}========================${clear}\n"
|
||||
|
||||
# If the service exists: stop, disable, delete the service, and run daemon-reload.
|
||||
if [[ -f "${systemd_dir}/${systemd_unit}" ]]; then
|
||||
@@ -301,7 +288,7 @@ uninstall_app() {
|
||||
echo -e " ${msg_info} ${gray}Disabling service: ${cyan}${systemd_unit}${clear}"
|
||||
systemctl disable "${systemd_unit}" &>/dev/null
|
||||
echo -e " ${msg_info} ${gray}Deleting: ${cyan}${systemd_dir}/${systemd_unit}${clear}"
|
||||
rm --force "${systemd_dir}/${systemd_unit}"
|
||||
rm -f "${systemd_dir}/${systemd_unit}"
|
||||
echo -e " ${msg_info} ${gray}Reloading the systemd configuration.${clear}"
|
||||
systemctl daemon-reload
|
||||
else
|
||||
@@ -324,13 +311,18 @@ uninstall_app() {
|
||||
echo -e " ${red}The installation directory may contain logs and configuration files."
|
||||
echo -e " ${red}Are you ready to delete ${blue}'${install_dir}'${red}?${clear}"
|
||||
echo -en " ${gray}(${white}yes${gray}/${white}no${gray}) ${gray}[${yellow}no${gray}]${white}: ${green}"
|
||||
read -r response
|
||||
if [[ "${auto_confirm_prompts}" = true ]]; then
|
||||
echo "yes"
|
||||
response="yes"
|
||||
else
|
||||
read -r response
|
||||
fi
|
||||
echo -en "${clear}"
|
||||
if [[ "${response}" =~ ^[yY][eE][sS]$ || "${response}" =~ ^[yY]$ ]]; then
|
||||
# Confirmed. Delete directory.
|
||||
echo
|
||||
echo -e " ${msg_info} ${gray}Deleting installation directory: ${cyan}${install_dir}/${clear}"
|
||||
rm --recursive --force "${install_dir}"
|
||||
rm -rf "${install_dir}"
|
||||
else
|
||||
# Skip deleteion.
|
||||
echo
|
||||
@@ -341,26 +333,66 @@ uninstall_app() {
|
||||
fi
|
||||
|
||||
# Uninstall complete.
|
||||
echo -e "\n ${green}✓ ${white}Uninstallation complete${clear}\n\n"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# print_usage:
|
||||
# Show usage information.
|
||||
# =============================================================================
|
||||
print_usage() {
|
||||
echo "Usage: install.sh [options]"
|
||||
echo "Install, upgrade, or uninstall Deceptifeed"
|
||||
echo
|
||||
echo -e " ${green}✓ ${white}Uninstallation complete${clear}"
|
||||
echo "Options:"
|
||||
echo " -h, --help Display this help and exit"
|
||||
echo " -y, --yes Automatically confirm actions without prompting"
|
||||
echo " --uninstall Uninstall Deceptifeed"
|
||||
echo
|
||||
echo "Description:"
|
||||
echo "Run the script without options to install Deceptifeed or upgrade if it's"
|
||||
echo "already installed."
|
||||
echo
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# main:
|
||||
# The primary entry point of the script. This function:
|
||||
# 1. Calls the startup_checks function to perform initial setup and checks.
|
||||
# 2. Checks command-line arguments to determine whether to install (default)
|
||||
# 1. Checks command-line arguments to determine whether to install (default)
|
||||
# or uninstall the application.
|
||||
# 2. Calls the startup_checks function to perform initial setup and checks.
|
||||
# =============================================================================
|
||||
main() {
|
||||
startup_checks
|
||||
if [[ "$#" -gt 2 ]]; then
|
||||
print_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "--uninstall" ]]; then
|
||||
local uninstall_flag=false
|
||||
|
||||
while [[ -n "$1" ]]; do
|
||||
case "$1" in
|
||||
-y | --yes)
|
||||
auto_confirm_prompts=true
|
||||
shift
|
||||
;;
|
||||
--uninstall)
|
||||
uninstall_flag=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
print_usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${uninstall_flag}" == true ]; then
|
||||
startup_checks
|
||||
uninstall_app
|
||||
exit 0
|
||||
else
|
||||
startup_checks
|
||||
install_app
|
||||
exit 0
|
||||
fi
|
||||
|