mirror of
https://github.com/r-smith/deceptifeed.git
synced 2025-11-02 13:13:37 +00:00
Compare commits
53 Commits
v0.44.0
...
153191f6c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153191f6c5 | ||
|
|
c83ebcc342 | ||
|
|
a9dcc759f7 | ||
|
|
f9d7b767bc | ||
|
|
375da6eeac | ||
|
|
dc06d64b5b | ||
|
|
41345f04bd | ||
|
|
c0e6010143 | ||
|
|
2736c20158 | ||
|
|
8ebec3a8c4 | ||
|
|
650489bd5c | ||
|
|
da42f21f75 | ||
|
|
0a4d4536ba | ||
|
|
148d99876f | ||
|
|
90fbc24479 | ||
|
|
60fe095dff | ||
|
|
40dbc05d6f | ||
|
|
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 |
@@ -2,6 +2,7 @@
|
||||
FROM golang:latest AS build-stage
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN git update-index -q --refresh
|
||||
RUN make
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
57
Makefile
57
Makefile
@@ -1,55 +1,60 @@
|
||||
# Makefile for 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
|
||||
BUILD_OPTIONS = -trimpath -ldflags="-s -w"
|
||||
GO = go
|
||||
CGO_ENABLED = 0
|
||||
GO111MODULE = on
|
||||
SOURCE := ./cmd/deceptifeed/
|
||||
BIN_DIRECTORY := ./bin/
|
||||
BIN_DEFAULT := $(BIN_DIRECTORY)deceptifeed
|
||||
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
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
@echo "Building for current operating system to: $(BIN_DIRECTORY)$(BIN_DEFAULT)"
|
||||
@echo "Building for current operating system..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_DEFAULT) $(SOURCE)
|
||||
@echo "Build complete."
|
||||
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT) $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DEFAULT)"
|
||||
@echo
|
||||
|
||||
.PHONY: all
|
||||
all: build build-linux build-freebsd build-windows
|
||||
all: build build-linux build-linux-arm build-freebsd build-windows
|
||||
|
||||
.PHONY: build-linux
|
||||
build-linux:
|
||||
@echo "Building for Linux to: $(BIN_DIRECTORY)$(BIN_LINUX)"
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GOOS=linux GOARCH=amd64 GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_LINUX) $(SOURCE)
|
||||
@echo "Build complete."
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_linux_x64 $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DEFAULT)_linux_x64"
|
||||
@echo
|
||||
|
||||
.PHONY: build-linux-arm
|
||||
build-linux-arm:
|
||||
@echo "Building for Linux (ARM)..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_linux_ARM64 $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DEFAULT)_linux_ARM64"
|
||||
@echo
|
||||
|
||||
.PHONY: build-freebsd
|
||||
build-freebsd:
|
||||
@echo "Building for FreeBSD to: $(BIN_DIRECTORY)$(BIN_FREEBSD)"
|
||||
@echo "Building for FreeBSD..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GOOS=freebsd GOARCH=amd64 GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_FREEBSD) $(SOURCE)
|
||||
@echo "Build complete."
|
||||
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_freebsd_x64 $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DEFAULT)_freebsd_x64"
|
||||
@echo
|
||||
|
||||
.PHONY: build-windows
|
||||
build-windows:
|
||||
@echo "Building for Windows to: $(BIN_DIRECTORY)$(BIN_WINDOWS)"
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BIN_DIRECTORY)
|
||||
GOOS=windows GOARCH=amd64 GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_WINDOWS) $(SOURCE)
|
||||
@echo "Build complete."
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_windows_x64.exe $(SOURCE)
|
||||
@echo "Build complete: $(BIN_DEFAULT)_windows_x64.exe"
|
||||
@echo
|
||||
|
||||
.PHONY: install
|
||||
install: $(BIN_DIRECTORY)$(BIN_DEFAULT)
|
||||
install: $(BIN_DEFAULT)
|
||||
@bash $(INSTALL_SCRIPT)
|
||||
|
||||
.PHONY: uninstall
|
||||
|
||||
68
README.md
68
README.md
@@ -6,14 +6,16 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
`Deceptifeed` is a honeypot and threat feed server. It runs multiple honeypots (deceptive network services), while the threat feed lists IP addresses that have interacted with the honeypots.
|
||||
`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.
|
||||
|
||||
If 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.
|
||||
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 2.1.
|
||||
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.
|
||||
|
||||
|
||||
## Deployment Diagram
|
||||
## Visuals
|
||||
|
||||
*Deployment diagram*
|
||||
|
||||
<a href="assets/diagram-light.svg?raw=true">
|
||||
<picture>
|
||||
@@ -23,6 +25,14 @@ Most enterprise firewalls support ingesting threat feeds. By pointing to `Decept
|
||||
</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.
|
||||
@@ -154,28 +164,36 @@ Here is a breakdown of the arguments:
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Honeypot Servers:** Run any number of honeypot services simultaneously.
|
||||
- **Threat Feed Server:** A real-time feed of IP addresses that have accessed your honeypots, delivered over HTTP. Available in plain text, CSV, JSON, STIX, and TAXII 2.1.
|
||||
- **Rich Structured Logging:** Capture detailed logs of honeypot interactions in JSON format.
|
||||
- **Secure:** The honeypot services never process or respond to client input; they only log the data received. Attackers are not given simulated or virtual environments.
|
||||
- **Several Honeypot Types:**
|
||||
- **SSH Honeyot:** Record 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.
|
||||
- **Cross-platform:** Supports Linux, macOS, Windows, and *BSD.
|
||||
- **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 real-time 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.
|
||||
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 2.1.
|
||||
|
||||
**_Sample threat feed web interface_**
|
||||
|
||||
<img alt="Threat Feed Web Interface" src="assets/threatfeed-web-screenshot.png" width="881" />
|
||||
The threat feed is available in plain text, CSV, JSON, STIX, and TAXII.
|
||||
|
||||
**_Sample threat feed in plain text_**
|
||||
|
||||
@@ -210,15 +228,15 @@ $ curl http://threatfeed.example.com:9000/json
|
||||
"threat_feed": [
|
||||
{
|
||||
"ip": "10.32.16.110",
|
||||
"added": "2024-11-12T16:18:36-08:00",
|
||||
"last_seen": "2024-11-15T04:27:59-08:00",
|
||||
"threat_score": 27
|
||||
"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": "2024-11-14T23:09:11-08:00",
|
||||
"last_seen": "2024-11-17T00:40:51-08:00",
|
||||
"threat_score": 51
|
||||
"added": "2025-04-02T23:09:11-08:00",
|
||||
"last_seen": "2025-04-08T00:40:51-08:00",
|
||||
"observations": 51
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
assets/screenshot-live.png
Normal file
BIN
assets/screenshot-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
assets/screenshot-webfeed.png
Normal file
BIN
assets/screenshot-webfeed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,10 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
@@ -40,8 +43,15 @@ 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 `-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 {
|
||||
@@ -65,12 +75,30 @@ func main() {
|
||||
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 {
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
<port>9000</port>
|
||||
<databasePath>/opt/deceptifeed/logs/threatfeed.csv</databasePath>
|
||||
<threatExpiryHours>336</threatExpiryHours>
|
||||
<minimumThreatScore>0</minimumThreatScore>
|
||||
<includePrivateIPs>false</includePrivateIPs>
|
||||
<customThreatsPath></customThreatsPath>
|
||||
<excludeListPath></excludeListPath>
|
||||
<enableTLS>false</enableTLS>
|
||||
<certPath>/opt/deceptifeed/certs/threatfeed-cert.pem</certPath>
|
||||
<keyPath>/opt/deceptifeed/certs/threatfeed-key.pem</keyPath>
|
||||
</threatFeed>
|
||||
|
||||
<!-- Honeypot Server Configuration -->
|
||||
@@ -27,7 +28,6 @@
|
||||
<port>2222</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
|
||||
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
||||
</server>
|
||||
@@ -38,7 +38,6 @@
|
||||
<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>
|
||||
@@ -56,7 +55,6 @@
|
||||
<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>
|
||||
@@ -77,7 +75,6 @@
|
||||
<port>2323</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<banner>\nUser Access Verification\n\n</banner>
|
||||
<prompts>
|
||||
<prompt log="username">Username: </prompt>
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
<port>9000</port>
|
||||
<databasePath>threatfeed.csv</databasePath>
|
||||
<threatExpiryHours>336</threatExpiryHours>
|
||||
<minimumThreatScore>0</minimumThreatScore>
|
||||
<includePrivateIPs>false</includePrivateIPs>
|
||||
<customThreatsPath></customThreatsPath>
|
||||
<excludeListPath></excludeListPath>
|
||||
<enableTLS>false</enableTLS>
|
||||
<certPath>key-threatfeed-public.pem</certPath>
|
||||
<keyPath>key-threatfeed-private.pem</keyPath>
|
||||
</threatFeed>
|
||||
|
||||
<!-- Honeypot Server Configuration -->
|
||||
@@ -27,7 +28,6 @@
|
||||
<port>2222</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<keyPath>key-ssh-private.pem</keyPath>
|
||||
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
||||
</server>
|
||||
@@ -38,7 +38,6 @@
|
||||
<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>
|
||||
@@ -56,7 +55,6 @@
|
||||
<port>8443</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<certPath>key-https-public.pem</certPath>
|
||||
<keyPath>key-https-private.pem</keyPath>
|
||||
<rules>
|
||||
@@ -77,7 +75,6 @@
|
||||
<port>2323</port>
|
||||
<logEnabled>true</logEnabled>
|
||||
<sendToThreatFeed>true</sendToThreatFeed>
|
||||
<threatScore>1</threatScore>
|
||||
<banner>\nUser Access Verification\n\n</banner>
|
||||
<prompts>
|
||||
<prompt log="username">Username: </prompt>
|
||||
|
||||
7
go.mod
7
go.mod
@@ -2,6 +2,9 @@ module github.com/r-smith/deceptifeed
|
||||
|
||||
go 1.24
|
||||
|
||||
require golang.org/x/crypto v0.36.0
|
||||
require (
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/net v0.40.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.31.0 // indirect
|
||||
require golang.org/x/sys v0.33.0 // indirect
|
||||
|
||||
14
go.sum
14
go.sum
@@ -1,6 +1,8 @@
|
||||
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/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=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
|
||||
95
internal/certutil/certutil.go
Normal file
95
internal/certutil/certutil.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package certutil
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateSelfSigned creates a self-signed certificate and returns it as a
|
||||
// tls.Certificate. If certPath and keyPath are provided, the generated
|
||||
// certificate and private key are saved to disk.
|
||||
func GenerateSelfSigned(certPath string, keyPath string) (tls.Certificate, error) {
|
||||
// Generate 2048-bit RSA private key.
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
|
||||
// Set the certificate validity period to 10 years.
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.AddDate(10, 0, 0)
|
||||
|
||||
// Generate a random certificate serial number.
|
||||
serialNumber := make([]byte, 16)
|
||||
_, err = rand.Read(serialNumber)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("failed to generate certificate serial number: %w", err)
|
||||
}
|
||||
|
||||
// Configure the certificate template.
|
||||
template := x509.Certificate{
|
||||
SerialNumber: new(big.Int).SetBytes(serialNumber),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// Create the certificate.
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
certPEM := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}
|
||||
keyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
|
||||
|
||||
// Save the certificate and key to disk.
|
||||
if len(certPath) > 0 && len(keyPath) > 0 {
|
||||
// Silently ignore any potential errors and continue.
|
||||
_ = writeCertAndKey(certPEM, keyPEM, certPath, keyPath)
|
||||
}
|
||||
|
||||
return tls.X509KeyPair(pem.EncodeToMemory(certPEM), pem.EncodeToMemory(keyPEM))
|
||||
}
|
||||
|
||||
// writeCertAndKey saves the public certificate and private key in PEM format
|
||||
// to the specified paths.
|
||||
func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath string) error {
|
||||
// Save the certificate file to disk.
|
||||
certFile, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer certFile.Close()
|
||||
|
||||
if err := pem.Encode(certFile, cert); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save the private key file to disk.
|
||||
keyFile, err := os.Create(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyFile.Close()
|
||||
|
||||
// Limit key access to the owner only.
|
||||
_ = keyFile.Chmod(0600)
|
||||
|
||||
if err := pem.Encode(keyFile, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -6,11 +6,18 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/logmonitor"
|
||||
"github.com/r-smith/deceptifeed/internal/logrotate"
|
||||
)
|
||||
|
||||
// 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 (
|
||||
@@ -77,9 +84,11 @@ 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.
|
||||
@@ -95,7 +104,6 @@ type Server struct {
|
||||
Headers []string `xml:"headers>header"`
|
||||
Prompts []Prompt `xml:"prompts>prompt"`
|
||||
SendToThreatFeed bool `xml:"sendToThreatFeed"`
|
||||
ThreatScore int `xml:"threatScore"`
|
||||
Rules Rules `xml:"rules"`
|
||||
SourceIPHeader string `xml:"sourceIpHeader"`
|
||||
LogPath string `xml:"logPath"`
|
||||
@@ -131,14 +139,15 @@ 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 int `xml:"threatExpiryHours"`
|
||||
IsPrivateIncluded bool `xml:"includePrivateIPs"`
|
||||
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"`
|
||||
EnableTLS bool `xml:"enableTLS"`
|
||||
CertPath string `xml:"certPath"`
|
||||
KeyPath string `xml:"keyPath"`
|
||||
}
|
||||
|
||||
// Load reads an optional XML configuration file and unmarshals its contents
|
||||
@@ -153,6 +162,13 @@ 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 {
|
||||
@@ -165,15 +181,20 @@ func Load(filename string) (*Config, error) {
|
||||
config.Servers[i].LogPath = config.LogPath
|
||||
}
|
||||
|
||||
// Ensure a minimum threat score of 0.
|
||||
if config.Servers[i].ThreatScore < 0 {
|
||||
config.Servers[i].ThreatScore = 0
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -199,6 +220,8 @@ func validateRegexRules(rules Rules) error {
|
||||
// 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 {
|
||||
@@ -227,16 +250,22 @@ func (c *Config) InitializeLoggers() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a new logger.
|
||||
logger := slog.New(slog.NewJSONHandler(file, &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
|
||||
},
|
||||
}))
|
||||
// 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
|
||||
|
||||
29
internal/httpserver/fs.go
Normal file
29
internal/httpserver/fs.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package httpserver
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// noDirectoryFS is a wrapper around fs.FS that disables directory listings.
|
||||
type noDirectoryFS struct {
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
// Open opens the named file from the underlying fs.FS. The file is wrapped in
|
||||
// a noReadDirFile to disable directory listings.
|
||||
func (fs noDirectoryFS) Open(name string) (fs.File, error) {
|
||||
f, err := fs.fs.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return noReadDirFile{f}, nil
|
||||
}
|
||||
|
||||
// noReadDirFile wraps fs.File and overrides ReadDir to disable directory
|
||||
// listings.
|
||||
type noReadDirFile struct {
|
||||
fs.File
|
||||
}
|
||||
|
||||
// ReadDir always returns an error to disable directory listings.
|
||||
func (noReadDirFile) ReadDir(int) ([]fs.DirEntry, error) {
|
||||
return nil, fs.ErrInvalid
|
||||
}
|
||||
@@ -2,46 +2,98 @@ package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/certutil"
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// responseMode represents the HTTP response behavior for the honeypot.
|
||||
// Depending on the configuration, the honeypot can serve a built-in default
|
||||
// response, serve a specific file, or serve files from a specified directory.
|
||||
type responseMode int
|
||||
|
||||
const (
|
||||
modeDefault responseMode = iota // Serve the built-in default response.
|
||||
modeFile // Serve a specific file.
|
||||
modeDirectory // Serve files from a specified directory.
|
||||
)
|
||||
|
||||
// responseConfig defines how the honeypot serves HTTP responses. It includes
|
||||
// the response mode (default, file, or directory) and, for directory mode, an
|
||||
// http.FileServer and file descriptor to the directory.
|
||||
type responseConfig struct {
|
||||
mode responseMode
|
||||
fsRoot *os.Root
|
||||
fsHandler http.Handler
|
||||
}
|
||||
|
||||
// determineConfig reads the given configuration and returns a responseConfig,
|
||||
// selecting the honeypot's response mode based on whether the HomePagePath
|
||||
// setting is empty, a file, or a directory.
|
||||
func determineConfig(cfg *config.Server) *responseConfig {
|
||||
if len(cfg.HomePagePath) == 0 {
|
||||
return &responseConfig{mode: modeDefault}
|
||||
}
|
||||
|
||||
info, err := os.Stat(cfg.HomePagePath)
|
||||
if err != nil {
|
||||
return &responseConfig{mode: modeDefault}
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
root, err := os.OpenRoot(cfg.HomePagePath)
|
||||
if err != nil {
|
||||
return &responseConfig{mode: modeDefault}
|
||||
}
|
||||
return &responseConfig{
|
||||
mode: modeDirectory,
|
||||
fsRoot: root,
|
||||
fsHandler: withCustomError(http.FileServerFS(noDirectoryFS{root.FS()}), cfg.ErrorPagePath),
|
||||
}
|
||||
}
|
||||
|
||||
return &responseConfig{
|
||||
mode: modeFile,
|
||||
}
|
||||
}
|
||||
|
||||
// Start initializes and starts an HTTP or HTTPS honeypot server. It logs all
|
||||
// request details and updates the threat feed as needed. If a filesystem path
|
||||
// is specified in the configuration, the honeypot serves static content from
|
||||
// the path.
|
||||
func Start(cfg *config.Server) {
|
||||
response := determineConfig(cfg)
|
||||
if response.mode == modeDirectory {
|
||||
defer response.fsRoot.Close()
|
||||
}
|
||||
|
||||
switch cfg.Type {
|
||||
case config.HTTP:
|
||||
listenHTTP(cfg)
|
||||
listenHTTP(cfg, response)
|
||||
case config.HTTPS:
|
||||
listenHTTPS(cfg)
|
||||
listenHTTPS(cfg, response)
|
||||
}
|
||||
}
|
||||
|
||||
// listenHTTP initializes and starts an HTTP (plaintext) honeypot server.
|
||||
func listenHTTP(cfg *config.Server) {
|
||||
func listenHTTP(cfg *config.Server, response *responseConfig) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
|
||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers), response))
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: mux,
|
||||
@@ -58,10 +110,10 @@ func listenHTTP(cfg *config.Server) {
|
||||
}
|
||||
}
|
||||
|
||||
// listenHTTP initializes and starts an HTTPS (encrypted) honeypot server.
|
||||
func listenHTTPS(cfg *config.Server) {
|
||||
// listenHTTPS initializes and starts an HTTPS (encrypted) honeypot server.
|
||||
func listenHTTPS(cfg *config.Server, response *responseConfig) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
|
||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers), response))
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: mux,
|
||||
@@ -72,10 +124,9 @@ func listenHTTPS(cfg *config.Server) {
|
||||
}
|
||||
|
||||
// If the cert and key aren't found, generate a self-signed certificate.
|
||||
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(cfg.CertPath, cfg.KeyPath)
|
||||
if _, err := os.Stat(cfg.CertPath); errors.Is(err, fs.ErrNotExist) {
|
||||
if _, err := os.Stat(cfg.KeyPath); errors.Is(err, fs.ErrNotExist) {
|
||||
cert, err := certutil.GenerateSelfSigned(cfg.CertPath, cfg.KeyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err)
|
||||
return
|
||||
@@ -93,104 +144,150 @@ func listenHTTPS(cfg *config.Server) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnection is the handler for incoming HTTP and HTTPS client requests.
|
||||
// It logs the details of each request and generates responses based on the
|
||||
// requested URL. When the root or index.html is requested, it serves either an
|
||||
// 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(cfg *config.Server, customHeaders map[string]string) http.HandlerFunc {
|
||||
// handleConnection processes incoming HTTP and HTTPS client requests. It logs
|
||||
// the details of each request, updates the threat feed, and serves responses
|
||||
// based on the honeypot configuration.
|
||||
func handleConnection(cfg *config.Server, customHeaders map[string]string, response *responseConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Log details of the incoming HTTP request.
|
||||
// Log connection details. The log fields and format differ based on
|
||||
// whether a custom source IP header is configured.
|
||||
dst_ip, dst_port := getLocalAddr(r)
|
||||
src_ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
username, password, isAuth := r.BasicAuth()
|
||||
if isAuth {
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
logData := []slog.Attr{}
|
||||
if len(cfg.SourceIPHeader) > 0 {
|
||||
// A custom source IP header is configured. Set rem_ip to the
|
||||
// original connecting IP and src_ip to the IP from the header. If
|
||||
// the header is missing, invalid, contains multiple IPs, or if
|
||||
// there a multiple headers with the same name, parsing will fail,
|
||||
// and src_ip will fallback to the original connecting IP.
|
||||
rem_ip := src_ip
|
||||
header := r.Header[cfg.SourceIPHeader]
|
||||
parsed := false
|
||||
errMsg := ""
|
||||
switch len(header) {
|
||||
case 0:
|
||||
errMsg = "missing header " + cfg.SourceIPHeader
|
||||
case 1:
|
||||
v := header[0]
|
||||
if _, err := netip.ParseAddr(v); err != nil {
|
||||
if strings.Contains(v, ",") {
|
||||
errMsg = "multiple values in header " + cfg.SourceIPHeader
|
||||
} else {
|
||||
errMsg = "invalid IP in header " + cfg.SourceIPHeader
|
||||
}
|
||||
} else {
|
||||
parsed = true
|
||||
src_ip = v
|
||||
}
|
||||
default:
|
||||
errMsg = "multiple instances of header " + cfg.SourceIPHeader
|
||||
}
|
||||
|
||||
logData = append(logData,
|
||||
slog.String("event_type", "http"),
|
||||
slog.String("source_ip", src_ip),
|
||||
slog.Bool("source_ip_parsed", parsed),
|
||||
)
|
||||
if !parsed {
|
||||
logData = append(logData, slog.String("source_ip_error", errMsg))
|
||||
}
|
||||
logData = append(logData,
|
||||
slog.String("remote_ip", rem_ip),
|
||||
slog.String("server_ip", dst_ip),
|
||||
slog.String("server_port", dst_port),
|
||||
slog.String("server_name", config.GetHostname()),
|
||||
slog.Group("event_details",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.String("query", r.URL.RawQuery),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("protocol", r.Proto),
|
||||
slog.String("host", r.Host),
|
||||
slog.Group("basic_auth",
|
||||
slog.String("username", username),
|
||||
slog.String("password", password),
|
||||
),
|
||||
slog.Any("headers", flattenHeaders(r.Header)),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||
// No custom source IP header is configured. Log the standard
|
||||
// connection details, keeping src_ip as the remote connecting IP.
|
||||
logData = append(logData,
|
||||
slog.String("event_type", "http"),
|
||||
slog.String("source_ip", src_ip),
|
||||
slog.String("server_ip", dst_ip),
|
||||
slog.String("server_port", dst_port),
|
||||
slog.String("server_name", config.GetHostname()),
|
||||
slog.Group("event_details",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.String("query", r.URL.RawQuery),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("protocol", r.Proto),
|
||||
slog.String("host", r.Host),
|
||||
slog.Any("headers", flattenHeaders(r.Header)),
|
||||
)
|
||||
}
|
||||
|
||||
// Log standard HTTP request information.
|
||||
eventDetails := []any{
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.String("query", r.URL.RawQuery),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("protocol", r.Proto),
|
||||
slog.String("host", r.Host),
|
||||
slog.Any("headers", flattenHeaders(r.Header)),
|
||||
}
|
||||
|
||||
// If the request includes a "basic" Authorization header, decode and
|
||||
// log the credentials.
|
||||
if username, password, ok := r.BasicAuth(); ok {
|
||||
eventDetails = append(eventDetails,
|
||||
slog.Group("basic_auth",
|
||||
slog.String("username", username),
|
||||
slog.String("password", password),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Combine log data and write the final log entry.
|
||||
logData = append(logData, slog.Group("event_details", eventDetails...))
|
||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", logData...)
|
||||
|
||||
// Print a simplified version of the request to the console.
|
||||
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 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.
|
||||
// Update the threat feed using the source IP address (src_ip). If a
|
||||
// custom header is configured, src_ip contains the IP extracted from
|
||||
// the header. Otherwise, it contains the remote 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, cfg.ThreatScore)
|
||||
threatfeed.Update(src_ip)
|
||||
}
|
||||
|
||||
// Apply any custom HTTP response headers.
|
||||
// Apply optional custom HTTP response headers.
|
||||
for header, value := range customHeaders {
|
||||
w.Header().Set(header, value)
|
||||
}
|
||||
|
||||
// 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" {
|
||||
// Serve the homepage response.
|
||||
if len(cfg.HomePagePath) > 0 {
|
||||
http.ServeFile(w, r, cfg.HomePagePath)
|
||||
} else {
|
||||
// Serve a response based on the honeypot configuration.
|
||||
switch response.mode {
|
||||
case modeDefault:
|
||||
// Built-in default response.
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
if _, _, ok := r.BasicAuth(); ok {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
w.Header()["WWW-Authenticate"] = []string{"Basic"}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
serveErrorPage(w, r, cfg.ErrorPagePath)
|
||||
}
|
||||
} else {
|
||||
// Serve the error page response.
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
if len(cfg.ErrorPagePath) > 0 {
|
||||
http.ServeFile(w, r, cfg.ErrorPagePath)
|
||||
case modeFile:
|
||||
// Serve a single file.
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
http.ServeFile(w, r, cfg.HomePagePath)
|
||||
} else {
|
||||
serveErrorPage(w, r, cfg.ErrorPagePath)
|
||||
}
|
||||
case modeDirectory:
|
||||
// Serve files from a directory.
|
||||
response.fsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveErrorPage serves an error HTTP response code and optional html page.
|
||||
func serveErrorPage(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if len(path) == 0 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -295,91 +392,3 @@ func getLocalAddr(r *http.Request) (ip string, port string) {
|
||||
}
|
||||
return ip, port
|
||||
}
|
||||
|
||||
// generateSelfSignedCert creates a self-signed TLS certificate and private key
|
||||
// and returns the resulting tls.Certificate. If file paths are provided, the
|
||||
// certificate and key are also saved to disk.
|
||||
func generateSelfSignedCert(certPath string, keyPath string) (tls.Certificate, error) {
|
||||
// Generate 2048-bit RSA private key.
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
|
||||
// Set the certificate validity period to 10 years.
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.AddDate(10, 0, 0)
|
||||
|
||||
// Generate a random serial number for the certificate.
|
||||
serialNumber := make([]byte, 16)
|
||||
_, err = rand.Read(serialNumber)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("failed to generate certificate serial number: %w", err)
|
||||
}
|
||||
|
||||
// Set up the template for creating the certificate.
|
||||
template := x509.Certificate{
|
||||
SerialNumber: new(big.Int).SetBytes(serialNumber),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// Use the template to create a self-signed X.509 certificate.
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
certPEM := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}
|
||||
keyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
|
||||
|
||||
// Save the certificate and key to disk.
|
||||
if len(certPath) > 0 && len(keyPath) > 0 {
|
||||
_ = writeCertAndKey(certPEM, keyPEM, certPath, keyPath)
|
||||
// If saving fails, ignore the errors and use the in-memory
|
||||
// certificate.
|
||||
}
|
||||
|
||||
// Parse the public certificate and private key bytes into a tls.Certificate.
|
||||
cert, err := tls.X509KeyPair(pem.EncodeToMemory(certPEM), pem.EncodeToMemory(keyPEM))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("failed to load certificate and private key: %w", err)
|
||||
}
|
||||
|
||||
// Return the tls.Certificate.
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// writeCertAndKey saves the public certificate and private key in PEM format
|
||||
// to the specified file paths.
|
||||
func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath string) error {
|
||||
// Save the certificate file to disk.
|
||||
certFile, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer certFile.Close()
|
||||
|
||||
if err := pem.Encode(certFile, cert); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save the private key file to disk.
|
||||
keyFile, err := os.Create(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyFile.Close()
|
||||
|
||||
// Limit key access to the owner only.
|
||||
_ = keyFile.Chmod(0600)
|
||||
|
||||
if err := pem.Encode(keyFile, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
48
internal/httpserver/middleware.go
Normal file
48
internal/httpserver/middleware.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// withCustomError is a middleware that intercepts 4xx/5xx HTTP error responses
|
||||
// and replaces them with a custom error response.
|
||||
func withCustomError(next http.Handler, errorPath string) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
e := &errorInterceptor{origWriter: w, origRequest: r, errorPath: errorPath}
|
||||
next.ServeHTTP(e, r)
|
||||
})
|
||||
}
|
||||
|
||||
// errorInterceptor intercepts HTTP responses to override error status codes
|
||||
// and to serve a custom error response.
|
||||
type errorInterceptor struct {
|
||||
origWriter http.ResponseWriter
|
||||
origRequest *http.Request
|
||||
overridden bool
|
||||
errorPath string
|
||||
}
|
||||
|
||||
// WriteHeader intercepts error response codes (4xx or 5xx) to serve a custom
|
||||
// error response.
|
||||
func (e *errorInterceptor) WriteHeader(statusCode int) {
|
||||
if statusCode >= 400 && statusCode <= 599 {
|
||||
e.overridden = true
|
||||
serveErrorPage(e.origWriter, e.origRequest, e.errorPath)
|
||||
return
|
||||
}
|
||||
e.origWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
// Write writes the response body only if the response code was not overridden.
|
||||
// Otherwise, the body is discarded.
|
||||
func (e *errorInterceptor) Write(b []byte) (int, error) {
|
||||
if !e.overridden {
|
||||
return e.origWriter.Write(b)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Header returns the response headers from the original ResponseWriter.
|
||||
func (e *errorInterceptor) Header() http.Header {
|
||||
return e.origWriter.Header()
|
||||
}
|
||||
34
internal/logmonitor/logmonitor.go
Normal file
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
|
||||
}
|
||||
}
|
||||
178
internal/proxyproto/proxyproto.go
Normal file
178
internal/proxyproto/proxyproto.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package proxyproto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// v1Signature is the byte representation of "PROXY ", which is the start of a
|
||||
// Proxy Protocol v1 header.
|
||||
var v1Signature = []byte{
|
||||
0x50, 0x52, 0x4F, 0x58, 0x59, 0x20,
|
||||
}
|
||||
|
||||
// v2Signature is a 12-byte constant which is the start of a Proxy Protocol v2
|
||||
// header.
|
||||
var v2Signature = []byte{
|
||||
0x0D, 0x0A, 0x0D, 0x0A,
|
||||
0x00, 0x0D, 0x0A, 0x51,
|
||||
0x55, 0x49, 0x54, 0x0A,
|
||||
}
|
||||
|
||||
// serverTimeout defines the duration after which connected clients are
|
||||
// automatically disconnected, set to 2 seconds.
|
||||
const serverTimeout = 2 * time.Second
|
||||
|
||||
// ReadHeader reads and parses a Proxy Protocol v1 or v2 header from conn. It
|
||||
// extracts and returns the client IP address from the header. It sets a
|
||||
// 2-second deadline on conn. If parsing fails, it returns an error. Callers
|
||||
// should reset the deadline after this function returns to extend the timeout.
|
||||
func ReadHeader(conn net.Conn) (string, error) {
|
||||
conn.SetDeadline(time.Now().Add(serverTimeout))
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
peek, err := reader.Peek(12)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to read proxy header data")
|
||||
}
|
||||
|
||||
var clientIP string
|
||||
|
||||
// Determine the Proxy Protocol version and parse accordingly.
|
||||
if bytes.Equal(peek, v2Signature) {
|
||||
// Proxy Protocol version 2.
|
||||
clientIP, err = parseVersion2(reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("proxy protocol v2: %w", err)
|
||||
}
|
||||
} else if bytes.HasPrefix(peek, v1Signature) {
|
||||
// Proxy Protocol version 1.
|
||||
clientIP, err = parseVersion1(reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("proxy protocol v1: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Not a Proxy Protocol header.
|
||||
return "", errors.New("invalid or missing proxy protocol header")
|
||||
}
|
||||
|
||||
// Ensure the header data was provided by a private IP address.
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
if ip, err := netip.ParseAddr(host); err != nil || (!ip.IsPrivate() && !ip.IsLoopback()) {
|
||||
return "", errors.New("proxy connection must originate from a private IP address")
|
||||
}
|
||||
|
||||
return clientIP, nil
|
||||
}
|
||||
|
||||
// parseVersion1 reads and parses a Proxy Protocol vesion 1 text header and
|
||||
// returns the extracted source IP address.
|
||||
func parseVersion1(r *bufio.Reader) (string, error) {
|
||||
// Proxy Protocol v1 ends with a CRLF (\r\n) and contains no more than 108
|
||||
// bytes (including the CRLF). Read up to the newline. The presence of a
|
||||
// carriage return before the newline is not validated.
|
||||
buf := make([]byte, 0, 108)
|
||||
for {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("can't read header: %w", err)
|
||||
}
|
||||
buf = append(buf, b)
|
||||
if b == '\n' {
|
||||
break
|
||||
}
|
||||
if len(buf) == 108 {
|
||||
return "", errors.New("invalid header")
|
||||
}
|
||||
}
|
||||
|
||||
// Split into space-delimited parts. When address information is provided,
|
||||
// this should be exactly 6 parts. Other formats are not supported.
|
||||
parts := strings.Fields(string(buf))
|
||||
if len(parts) != 6 {
|
||||
return "", errors.New("invalid or unsupported format")
|
||||
}
|
||||
|
||||
// Read the protocol part and validate the address part. Protocols other
|
||||
// than TCP4 and TCP6 are not supported by this implementation.
|
||||
switch parts[1] {
|
||||
case "TCP4":
|
||||
// Parse and validate as an IPv4 address.
|
||||
if ip, err := netip.ParseAddr(parts[2]); err != nil || !ip.Is4() || !ip.IsValid() {
|
||||
return "", errors.New("invalid ipv4 source address")
|
||||
}
|
||||
case "TCP6":
|
||||
// Parse and validate as an IPv6 address.
|
||||
if ip, err := netip.ParseAddr(parts[2]); err != nil || !ip.Is6() || !ip.IsValid() {
|
||||
return "", errors.New("invalid ipv6 source address")
|
||||
}
|
||||
default:
|
||||
return "", errors.New("invalid or unsupported proxied protocol")
|
||||
}
|
||||
|
||||
// Return the IP address part.
|
||||
return parts[2], nil
|
||||
}
|
||||
|
||||
// parseVersion2 reads and parses a Proxy Protocol vesion 2 binary header and
|
||||
// returns the extracted source IP address.
|
||||
func parseVersion2(r *bufio.Reader) (string, error) {
|
||||
// Read the first 16 bytes into a buffer. The first 12 bytes is the Proxy
|
||||
// Protocol v2 signature. Byte 13 is the protocol version and command. Byte
|
||||
// 14 is the transport protocol and address family. Bytes 15-16 is the
|
||||
// length of the address data.
|
||||
header := make([]byte, 16)
|
||||
if _, err := io.ReadFull(r, header); err != nil {
|
||||
return "", fmt.Errorf("can't read header: %w", err)
|
||||
}
|
||||
|
||||
// Byte 13 must be 0x21. The upper four bits represent the proxy protocol
|
||||
// version, which must be 0x2. The lower four bits specify the command -
|
||||
// 0x1 (PROXY) is the only supported command in this implementation.
|
||||
if header[12] != 0x21 {
|
||||
return "", errors.New("unsupported proxy command or version data")
|
||||
}
|
||||
|
||||
// Read bytes 15-16, which specify the length (in bytes) of the address
|
||||
// data in big-endian format. The address data includes source/destination
|
||||
// IPs and ports. Read the specified number of bytes into a buffer. The
|
||||
// length may indicate that additional bytes are part of the header beyond
|
||||
// the address data. These are Type-Length-Value (TLV) vectors, which are
|
||||
// read, but ignored by this implementation.
|
||||
addresses := make([]byte, binary.BigEndian.Uint16(header[14:16]))
|
||||
if _, err := io.ReadFull(r, addresses); err != nil {
|
||||
return "", fmt.Errorf("can't read address information: %w", err)
|
||||
}
|
||||
|
||||
// Byte 14 is the transport protocol and address family. Only TCP/UDP
|
||||
// over IPv4 and IPv6 are supported in this implementation.
|
||||
addrType := header[13]
|
||||
|
||||
// Extract, parse, validate, and return the source IP address.
|
||||
// TCP over IPv4 = 0x11, UDP over IPv4 = 0x12.
|
||||
if (addrType == 0x11 || addrType == 0x12) && len(addresses) >= 12 {
|
||||
ip, ok := netip.AddrFromSlice(addresses[0:4])
|
||||
if !ok || !ip.IsValid() {
|
||||
return "", errors.New("invalid ipv4 source address")
|
||||
}
|
||||
return ip.String(), nil
|
||||
}
|
||||
// TCP over IPv6 = 0x21, UDP over IPv6 = 0x22.
|
||||
if (addrType == 0x21 || addrType == 0x22) && len(addresses) >= 36 {
|
||||
ip, ok := netip.AddrFromSlice(addresses[0:16])
|
||||
if !ok || !ip.IsValid() {
|
||||
return "", errors.New("invalid ipv6 source address")
|
||||
}
|
||||
return ip.String(), nil
|
||||
}
|
||||
|
||||
return "", errors.New("unsupported transport protocol or address family")
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func Start(cfg *config.Server) {
|
||||
|
||||
// Update the threat feed with the source IP address from the request.
|
||||
if cfg.SendToThreatFeed {
|
||||
threatfeed.Update(src_ip, cfg.ThreatScore)
|
||||
threatfeed.Update(src_ip)
|
||||
}
|
||||
|
||||
// Insert fixed delay to mimic PAM.
|
||||
@@ -139,7 +139,7 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
|
||||
// Load the specified file and return the parsed private key.
|
||||
privateKey, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read private key from '%s': %w", path, err)
|
||||
return nil, fmt.Errorf("failed to read private key '%s': %w", path, err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(privateKey)
|
||||
if err != nil {
|
||||
@@ -150,20 +150,19 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
|
||||
// Generate and return a new private key.
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
|
||||
return nil, fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
|
||||
// Save the private key to disk.
|
||||
if len(path) > 0 {
|
||||
// Silently ignore any potential errors and continue.
|
||||
_ = writePrivateKey(path, privateKey)
|
||||
// If saving fails, ignore the errors and use the in-memory private
|
||||
// key.
|
||||
}
|
||||
|
||||
// Convert the key to ssh.Signer.
|
||||
signer, err := ssh.NewSignerFromKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert RSA key to SSH signer: %w", err)
|
||||
return nil, fmt.Errorf("failed to convert key to SSH signer: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
} else {
|
||||
@@ -171,8 +170,7 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// writePrivateKey saves a private key in PEM format to the specified file
|
||||
// path.
|
||||
// writePrivateKey saves a private key in PEM format to the specified path.
|
||||
func writePrivateKey(path string, privateKey *rsa.PrivateKey) error {
|
||||
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
privPem := &pem.Block{
|
||||
|
||||
@@ -116,7 +116,7 @@ func handleConnection(conn net.Conn, cfg *config.Server) {
|
||||
|
||||
// Update the threat feed with the source IP address from the interaction.
|
||||
if cfg.SendToThreatFeed {
|
||||
threatfeed.Update(src_ip, cfg.ThreatScore)
|
||||
threatfeed.Update(src_ip)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -23,18 +23,18 @@ type IOC struct {
|
||||
// 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
|
||||
// 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
|
||||
|
||||
// maxScore is the maximum allowed threat score.
|
||||
maxScore = 999_999_999
|
||||
// maxObservations is the maximum number of interactions the threat feed
|
||||
// will record for each IP.
|
||||
maxObservations = 999_999_999
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -54,23 +54,19 @@ var (
|
||||
dataChanged = false
|
||||
|
||||
// csvHeader defines the header row for saved threat feed data.
|
||||
csvHeader = []string{"ip", "added", "last_seen", "threat_score"}
|
||||
csvHeader = []string{"ip", "added", "last_seen", "observations"}
|
||||
)
|
||||
|
||||
// Update updates the threat feed with the provided source IP address and
|
||||
// threat score. 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 threat score is
|
||||
// incremented. Otherwise, the IP address is added as a new entry in the threat
|
||||
// feed.
|
||||
func Update(ip string, threatScore int) {
|
||||
// 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() {
|
||||
parsedIP, err := netip.ParseAddr(ip)
|
||||
if err != nil || parsedIP.IsLoopback() || (!cfg.ThreatFeed.IsPrivateIncluded && parsedIP.IsPrivate()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,19 +75,15 @@ func Update(ip string, threatScore int) {
|
||||
if ioc, exists := iocData[ip]; exists {
|
||||
// Update existing entry.
|
||||
ioc.lastSeen = now
|
||||
if threatScore > 0 {
|
||||
if ioc.threatScore > maxScore-threatScore {
|
||||
ioc.threatScore = maxScore
|
||||
} else {
|
||||
ioc.threatScore += threatScore
|
||||
}
|
||||
if ioc.observations < maxObservations {
|
||||
ioc.observations++
|
||||
}
|
||||
} else {
|
||||
// Create a new entry.
|
||||
iocData[ip] = &IOC{
|
||||
added: now,
|
||||
lastSeen: now,
|
||||
threatScore: threatScore,
|
||||
added: now,
|
||||
lastSeen: now,
|
||||
observations: 1,
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
@@ -148,7 +140,7 @@ func loadCSV() error {
|
||||
|
||||
var added time.Time
|
||||
var lastSeen time.Time
|
||||
var threatScore int
|
||||
var count int
|
||||
for _, record := range records[1:] {
|
||||
ip := record[0]
|
||||
|
||||
@@ -164,15 +156,15 @@ func loadCSV() error {
|
||||
lastSeen, _ = time.Parse(dateFormat, record[2])
|
||||
}
|
||||
|
||||
// Parse threat score, defaulting to 1.
|
||||
threatScore = 1
|
||||
// Parse observation count, defaulting to 1.
|
||||
count = 1
|
||||
if len(record) > 3 && record[3] != "" {
|
||||
if parsedLevel, err := strconv.Atoi(record[3]); err == nil {
|
||||
threatScore = parsedLevel
|
||||
if parsedCount, err := strconv.Atoi(record[3]); err == nil {
|
||||
count = parsedCount
|
||||
}
|
||||
}
|
||||
|
||||
iocData[ip] = &IOC{added: added, lastSeen: lastSeen, threatScore: threatScore}
|
||||
iocData[ip] = &IOC{added: added, lastSeen: lastSeen, observations: count}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -201,7 +193,7 @@ func saveCSV() error {
|
||||
ip,
|
||||
ioc.added.Format(dateFormat),
|
||||
ioc.lastSeen.Format(dateFormat),
|
||||
ioc.threatScore,
|
||||
ioc.observations,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,10 +2,9 @@ package threatfeed
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -16,11 +15,11 @@ import (
|
||||
|
||||
// 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"`
|
||||
ThreatScore int `json:"threat_score"`
|
||||
IP string `json:"ip"`
|
||||
IPBytes netip.Addr `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
|
||||
@@ -37,7 +36,7 @@ const (
|
||||
byIP sortMethod = iota
|
||||
byAdded
|
||||
byLastSeen
|
||||
byThreatScore
|
||||
byObservations
|
||||
)
|
||||
|
||||
// sortDirection represents the direction of sorting (ascending or descending).
|
||||
@@ -82,17 +81,17 @@ func prepareFeed(options ...feedOptions) feedEntries {
|
||||
threats := make(feedEntries, 0, len(iocData))
|
||||
loop:
|
||||
for ip, ioc := range iocData {
|
||||
if ioc.expired() || ioc.threatScore < cfg.ThreatFeed.MinimumThreatScore || !ioc.lastSeen.After(opt.seenAfter) {
|
||||
if ioc.expired() || !ioc.lastSeen.After(opt.seenAfter) {
|
||||
continue
|
||||
}
|
||||
|
||||
parsedIP := net.ParseIP(ip)
|
||||
if parsedIP == nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
|
||||
parsedIP, err := netip.ParseAddr(ip)
|
||||
if err != nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ipnet := range excludedCIDR {
|
||||
if ipnet.Contains(parsedIP) {
|
||||
for _, prefix := range excludedCIDR {
|
||||
if prefix.Contains(parsedIP) {
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
@@ -102,11 +101,11 @@ loop:
|
||||
}
|
||||
|
||||
threats = append(threats, feedEntry{
|
||||
IP: ip,
|
||||
IPBytes: parsedIP,
|
||||
Added: ioc.added,
|
||||
LastSeen: ioc.lastSeen,
|
||||
ThreatScore: ioc.threatScore,
|
||||
IP: ip,
|
||||
IPBytes: parsedIP,
|
||||
Added: ioc.added,
|
||||
LastSeen: ioc.lastSeen,
|
||||
Observations: ioc.observations,
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
@@ -118,10 +117,11 @@ loop:
|
||||
|
||||
// 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.
|
||||
func parseExcludeList(filepath string) (map[string]struct{}, []*net.IPNet, error) {
|
||||
// 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{}, []netip.Prefix, error) {
|
||||
if len(filepath) == 0 {
|
||||
return map[string]struct{}{}, []*net.IPNet{}, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(filepath)
|
||||
@@ -133,16 +133,24 @@ func parseExcludeList(filepath string) (map[string]struct{}, []*net.IPNet, error
|
||||
// `ips` stores individual IPs to exclude, and `cidr` stores CIDR networks
|
||||
// to exclude.
|
||||
ips := make(map[string]struct{})
|
||||
cidr := []*net.IPNet{}
|
||||
cidr := []netip.Prefix{}
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) > 0 {
|
||||
if _, ipnet, err := net.ParseCIDR(line); err == nil {
|
||||
cidr = append(cidr, ipnet)
|
||||
} else {
|
||||
ips[line] = struct{}{}
|
||||
}
|
||||
line := scanner.Text()
|
||||
|
||||
// Remove comments and trim.
|
||||
if i := strings.IndexByte(line, '#'); i != -1 {
|
||||
line = line[:i]
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if prefix, err := netip.ParsePrefix(line); err == nil {
|
||||
cidr = append(cidr, prefix)
|
||||
} else {
|
||||
ips[line] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
@@ -157,13 +165,13 @@ 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)
|
||||
return a.IPBytes.Compare(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 a.IPBytes.Compare(b.IPBytes)
|
||||
}
|
||||
return t
|
||||
})
|
||||
@@ -171,15 +179,15 @@ func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
|
||||
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 a.IPBytes.Compare(b.IPBytes)
|
||||
}
|
||||
return t
|
||||
})
|
||||
case byThreatScore:
|
||||
case byObservations:
|
||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||
t := cmp.Compare(a.ThreatScore, b.ThreatScore)
|
||||
t := cmp.Compare(a.Observations, b.Observations)
|
||||
if t == 0 {
|
||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
||||
return a.IPBytes.Compare(b.IPBytes)
|
||||
}
|
||||
return t
|
||||
})
|
||||
@@ -267,10 +275,7 @@ func (f feedEntries) convertToSightings() []stix.Object {
|
||||
}
|
||||
pattern = pattern + entry.IP + "']"
|
||||
|
||||
count := entry.ThreatScore
|
||||
if count > maxCount {
|
||||
count = maxCount
|
||||
}
|
||||
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:
|
||||
|
||||
485
internal/threatfeed/handler-logs.go
Normal file
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()
|
||||
}
|
||||
}
|
||||
95
internal/threatfeed/handler-websocket.go
Normal file
95
internal/threatfeed/handler-websocket.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"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 parsedIP, err := netip.ParseAddr(ip); err != nil || (!parsedIP.IsPrivate() && !parsedIP.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
"github.com/r-smith/deceptifeed/internal/stix"
|
||||
"github.com/r-smith/deceptifeed/internal/taxii"
|
||||
)
|
||||
@@ -23,6 +22,11 @@ import (
|
||||
//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) {
|
||||
@@ -40,21 +44,6 @@ func handlePlain(w http.ResponseWriter, r *http.Request) {
|
||||
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(cfg.ThreatFeed.CustomThreatsPath) > 0 {
|
||||
data, err := os.ReadFile(cfg.ThreatFeed.CustomThreatsPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to read custom threats file:", err)
|
||||
return
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to serve threat feed:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleJSON handles HTTP requests to serve the full threat feed in JSON
|
||||
@@ -100,7 +89,7 @@ func handleCSV(w http.ResponseWriter, r *http.Request) {
|
||||
entry.IP,
|
||||
entry.Added.Format(dateFormat),
|
||||
entry.LastSeen.Format(dateFormat),
|
||||
strconv.Itoa(entry.ThreatScore),
|
||||
strconv.Itoa(entry.Observations),
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
|
||||
return
|
||||
@@ -297,23 +286,35 @@ func handleTAXIIObjects(w http.ResponseWriter, r *http.Request) {
|
||||
// delivers a static HTML document with information on accessing the threat
|
||||
// feed.
|
||||
func handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/home.html"))
|
||||
err := tmpl.Execute(w, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to parse template 'home.html':", err)
|
||||
return
|
||||
}
|
||||
_ = 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) {
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/docs.html"))
|
||||
err := tmpl.Execute(w, nil)
|
||||
_ = 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.Fprintln(os.Stderr, "Failed to parse template 'docs.html':", err)
|
||||
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.
|
||||
@@ -344,16 +345,15 @@ func handleHTML(w http.ResponseWriter, r *http.Request) {
|
||||
m = "added"
|
||||
case byLastSeen:
|
||||
m = "last_seen"
|
||||
case byThreatScore:
|
||||
m = "threat_score"
|
||||
case byObservations:
|
||||
m = "observations"
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/webfeed.html"))
|
||||
err = tmpl.Execute(w, map[string]any{"Data": prepareFeed(opt), "SortDirection": d, "SortMethod": m})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to HTML:", err)
|
||||
return
|
||||
}
|
||||
_ = 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
|
||||
@@ -426,8 +426,8 @@ func parseParams(r *http.Request) (feedOptions, error) {
|
||||
opt.sortMethod = byLastSeen
|
||||
case "added":
|
||||
opt.sortMethod = byAdded
|
||||
case "threat_score":
|
||||
opt.sortMethod = byThreatScore
|
||||
case "observations":
|
||||
opt.sortMethod = byObservations
|
||||
case "":
|
||||
// No sort option specified.
|
||||
default:
|
||||
@@ -460,156 +460,5 @@ func parseParams(r *http.Request) (feedOptions, error) {
|
||||
// response when a request is made to an undefined path.
|
||||
func handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/404.html"))
|
||||
_ = tmpl.Execute(w, nil)
|
||||
}
|
||||
|
||||
// handleLogsMain serves a static page listing honeypot logs available for
|
||||
// viewing.
|
||||
func handleLogsMain(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/logs.html"))
|
||||
_ = tmpl.Execute(w, nil)
|
||||
}
|
||||
|
||||
// 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":
|
||||
handleLogHTTP(w)
|
||||
case "ssh":
|
||||
handleLogSSH(w)
|
||||
default:
|
||||
handleNotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/logs-error.html"))
|
||||
_ = tmpl.Execute(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
|
||||
if err := d.Decode(&entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.EventType == "ssh" {
|
||||
data = append(data, entry)
|
||||
if len(data) > maxResults {
|
||||
data = data[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Reverse(data)
|
||||
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/logs-ssh.html"))
|
||||
_ = tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/logs-error.html"))
|
||||
_ = tmpl.Execute(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
|
||||
if err := d.Decode(&entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.EventType == "http" {
|
||||
data = append(data, entry)
|
||||
if len(data) > maxResults {
|
||||
data = data[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Reverse(data)
|
||||
|
||||
tmpl := template.Must(template.ParseFS(templates, "templates/logs-http.html"))
|
||||
_ = tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
// 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 := make(map[string]struct{}, len(cfg.Servers)*2)
|
||||
for _, s := range cfg.Servers {
|
||||
paths[s.LogPath] = struct{}{}
|
||||
paths[s.LogPath+".1"] = struct{}{}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
_ = parsedTemplates.ExecuteTemplate(w, "404.html", nil)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package threatfeed
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// enforcePrivateIP is a middleware that restricts access to the HTTP server
|
||||
@@ -12,11 +13,11 @@ 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)
|
||||
http.Error(w, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
|
||||
if parsedIP, err := netip.ParseAddr(ip); err != nil || (!parsedIP.IsPrivate() && !parsedIP.IsLoopback()) {
|
||||
http.Error(w, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package threatfeed
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/r-smith/deceptifeed/internal/certutil"
|
||||
"github.com/r-smith/deceptifeed/internal/config"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -50,11 +55,18 @@ func Start(c *config.Config) {
|
||||
}
|
||||
}()
|
||||
|
||||
// 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)))
|
||||
@@ -71,8 +83,9 @@ func Start(c *config.Config) {
|
||||
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", handleLogsMain)
|
||||
mux.HandleFunc("GET /logs/{logtype}", handleLogs)
|
||||
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,
|
||||
@@ -82,9 +95,32 @@ func Start(c *config.Config) {
|
||||
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)
|
||||
// Start the threat feed (HTTP) server if TLS is not enabled.
|
||||
if !c.ThreatFeed.EnableTLS {
|
||||
fmt.Printf("Starting threat feed (HTTP) on port: %s\n", c.ThreatFeed.Port)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The threat feed server has stopped:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a self-signed cert if the provided key and cert aren't found.
|
||||
if _, err := os.Stat(c.ThreatFeed.CertPath); errors.Is(err, fs.ErrNotExist) {
|
||||
if _, err := os.Stat(c.ThreatFeed.KeyPath); errors.Is(err, fs.ErrNotExist) {
|
||||
cert, err := certutil.GenerateSelfSigned(c.ThreatFeed.CertPath, c.ThreatFeed.KeyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to generate threat feed TLS certificate:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Add cert to server config.
|
||||
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the threat feed (HTTPS) server.
|
||||
fmt.Printf("Starting threat feed (HTTPS) on port: %s\n", c.ThreatFeed.Port)
|
||||
if err := srv.ListenAndServeTLS(c.ThreatFeed.CertPath, c.ThreatFeed.KeyPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "The threat feed server has stopped:", err)
|
||||
}
|
||||
}
|
||||
|
||||
69
internal/threatfeed/templates/config.html
Normal file
69
internal/threatfeed/templates/config.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!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>TLS</th><td>{{if .C.ThreatFeed.EnableTLS}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
|
||||
{{if .C.ThreatFeed.EnableTLS}}<tr><th>Certificate</th><td class="blue">{{if .C.ThreatFeed.CertPath}}{{.C.ThreatFeed.CertPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
|
||||
{{if .C.ThreatFeed.EnableTLS}}<tr><th>Private Key</th><td class="blue">{{if .C.ThreatFeed.KeyPath}}{{.C.ThreatFeed.KeyPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
|
||||
<tr><th>Threat 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
1013
internal/threatfeed/templates/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
198
internal/threatfeed/templates/live.html
Normal file
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>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
47
internal/threatfeed/templates/logs-stats.html
Normal file
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>
|
||||
File diff suppressed because one or more lines are too long
49
internal/threatfeed/templates/nav.html
Normal file
49
internal/threatfeed/templates/nav.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user