mirror of
				https://github.com/r-smith/deceptifeed.git
				synced 2025-11-03 21:53:39 +00:00 
			
		
		
		
	Compare commits
	
		
			60 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					6d98f7c4d2 | ||
| 
						 | 
					f009206dbf | ||
| 
						 | 
					324064ff11 | ||
| 
						 | 
					2d3bc23a50 | ||
| 
						 | 
					dd0b273601 | ||
| 
						 | 
					eca338336c | ||
| 
						 | 
					0f1af8704d | ||
| 
						 | 
					b68ad408ce | ||
| 
						 | 
					e442edf0aa | ||
| 
						 | 
					c9d1b06680 | ||
| 
						 | 
					51a0447e7d | ||
| 
						 | 
					0ffbfae468 | ||
| 
						 | 
					dead75f037 | ||
| 
						 | 
					b7a9eaced2 | ||
| 
						 | 
					55366a2cb3 | ||
| 
						 | 
					8b49b6f042 | ||
| 
						 | 
					f5a2ec3f97 | ||
| 
						 | 
					74ba8c648b | ||
| 
						 | 
					c8aa491b5b | ||
| 
						 | 
					4e596a9d20 | ||
| 
						 | 
					774eb787f9 | ||
| 
						 | 
					0cb24c3b19 | ||
| 
						 | 
					1c50279b29 | ||
| 
						 | 
					01de73c9f0 | ||
| 
						 | 
					4fdee88948 | ||
| 
						 | 
					df83ee2c87 | ||
| 
						 | 
					8820970e33 | ||
| 
						 | 
					090868a5dd | ||
| 
						 | 
					e2b3dc51c5 | ||
| 
						 | 
					744717886b | ||
| 
						 | 
					927903e47a | ||
| 
						 | 
					a8ee70ae3e | ||
| 
						 | 
					c12f0d7746 | ||
| 
						 | 
					c920d9a4a8 | ||
| 
						 | 
					6b2088b5bf | ||
| 
						 | 
					fc43f99af7 | ||
| 
						 | 
					7cd36a5018 | ||
| 
						 | 
					12ada38faa | ||
| 
						 | 
					a99f03768b | ||
| 
						 | 
					70db9094cd | ||
| 
						 | 
					cfc9650085 | ||
| 
						 | 
					2274ebbc29 | ||
| 
						 | 
					50dfbe2d6b | ||
| 
						 | 
					57dc10edb0 | ||
| 
						 | 
					c84a1e60a5 | ||
| 
						 | 
					324dd67ff0 | ||
| 
						 | 
					368914b566 | ||
| 
						 | 
					496b211243 | ||
| 
						 | 
					aa61a99c8a | ||
| 
						 | 
					59414fd00e | ||
| 
						 | 
					6485bbf3ff | ||
| 
						 | 
					d230c6721d | ||
| 
						 | 
					9e3e3303f5 | ||
| 
						 | 
					16971d7c0d | ||
| 
						 | 
					855d913ade | ||
| 
						 | 
					0bf7cbf694 | ||
| 
						 | 
					cbb18f4189 | ||
| 
						 | 
					5a1095b7e8 | ||
| 
						 | 
					a2c97a5900 | ||
| 
						 | 
					6bf0ba6331 | 
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
									
									
									
									
								
							@@ -11,7 +11,7 @@ GO111MODULE := on
 | 
			
		||||
.PHONY: build
 | 
			
		||||
build:
 | 
			
		||||
	@echo "Building to: ./out/"
 | 
			
		||||
	@mkdir --parents ./out/
 | 
			
		||||
	@mkdir -p ./out/
 | 
			
		||||
	GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -o $(TARGET_BINARY) $(SOURCE)
 | 
			
		||||
	@echo "Build complete."
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										315
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										315
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,315 @@
 | 
			
		||||
<p>
 | 
			
		||||
  <picture>
 | 
			
		||||
    <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
 | 
			
		||||
    <source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
 | 
			
		||||
    <img alt="Deceptifeed logo" src="assets/logo-light.svg">
 | 
			
		||||
  </picture>
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
`Deceptifeed` is a network defense tool that creates fake network services, or **honeypots**, to detect potential threats. It also provides a real-time **threat feed** that integrates with most enterprise firewalls. This feed lists the IP addresses that accessed your honeypots, allowing firewalls to automatically block them from reaching your legitimate services.
 | 
			
		||||
 | 
			
		||||
Unlike conventional honeypots that provide attackers with rich simulated environments, Deceptifeed is intentionally minimal. Simply the act of interacting with a fake service on your network is reason to trigger a defensive response. The integrated threat feed enables immediate action without needing a SIEM or additional tools.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Installation
 | 
			
		||||
 | 
			
		||||
### Option 1: Download the binary
 | 
			
		||||
 | 
			
		||||
1. Download the latest binary from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
 | 
			
		||||
2. Extract the downloaded file.
 | 
			
		||||
3. Run `install.sh` to install the application (optional).
 | 
			
		||||
   - Note: `install.sh` is intended for Linux distributions that use systemd (Ubuntu, Debian, Red Hat, Arch, SUSE, etc.).
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
# Extract.
 | 
			
		||||
tar xvzf <release>.tar.gz
 | 
			
		||||
cd deceptifeed
 | 
			
		||||
 | 
			
		||||
# Install (optional).
 | 
			
		||||
sudo ./install.sh
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
<img alt="" src="assets/install.gif" width="600" />
 | 
			
		||||
 | 
			
		||||
### Option 2: Build from source
 | 
			
		||||
 | 
			
		||||
**Go** version **1.22+** is required to build from source.
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
# Clone the repository.
 | 
			
		||||
git clone https://github.com/r-smith/deceptifeed.git
 | 
			
		||||
cd deceptifeed
 | 
			
		||||
 | 
			
		||||
# Compile and build the binary to `./out/deceptifeed`.
 | 
			
		||||
make
 | 
			
		||||
 | 
			
		||||
# The installation script is intended for Linux distributions that use systemd.
 | 
			
		||||
# For other systems, simply run the binary in `./out/` to launch Deceptifeed.
 | 
			
		||||
sudo make install
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Usage
 | 
			
		||||
 | 
			
		||||
### Option 1: Use the installation script
 | 
			
		||||
 | 
			
		||||
If you're on a supported system, run `install.sh` or `make install`, as described in the previous section.
 | 
			
		||||
 | 
			
		||||
- Deceptifeed runs as a background service. Use `sudo systemctl status deceptifeed` to check its status.
 | 
			
		||||
- To modify the configuration, edit `/opt/deceptifeed/etc/config.xml`, then restart the service with `sudo systemctl restart deceptifeed`.
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
/opt/deceptifeed/
 | 
			
		||||
├── bin/
 | 
			
		||||
│   └── deceptifeed
 | 
			
		||||
├── certs/
 | 
			
		||||
│   ├── https-cert.pem
 | 
			
		||||
│   ├── https-key.pem
 | 
			
		||||
│   └── ssh-key.pem
 | 
			
		||||
├── etc/
 | 
			
		||||
│   └── config.xml
 | 
			
		||||
└── logs/
 | 
			
		||||
    ├── honeypot.log
 | 
			
		||||
    └── threatfeed.csv
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Option 2: Run directly
 | 
			
		||||
 | 
			
		||||
You can run Deceptifeed directly without installation.
 | 
			
		||||
 | 
			
		||||
- Use `deceptifeed -help` to view the command-line options.
 | 
			
		||||
- By default, Deceptifeed starts the following network services:
 | 
			
		||||
  - SSH honeypot server on port 2222
 | 
			
		||||
  - HTTP honeypot server on port 8080
 | 
			
		||||
  - HTTPS honeypot server on port 8443
 | 
			
		||||
  - Threat feed server on port 9000
 | 
			
		||||
- Logs are saved to `deceptifeed-log.txt`.
 | 
			
		||||
- The threat feed database is saved to `deceptifeed-database.csv`.
 | 
			
		||||
- Certificates and keys are generated and saved as `deceptifeed-*.crt` and `deceptifeed-*.key`.
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
$ ./deceptifeed
 | 
			
		||||
Starting SSH server on port: 2222
 | 
			
		||||
Starting HTTP server on port: 8080
 | 
			
		||||
Starting HTTPS server on port: 8443
 | 
			
		||||
Starting Threat Feed server on port: 9000
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## 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 for firewall integration.
 | 
			
		||||
- **Rich Structured Logging:** Capture detailed logs of everything in JSON format for easy parsing.
 | 
			
		||||
- **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.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## 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.
 | 
			
		||||
 | 
			
		||||
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 several formats, including plain text, CSV, JSON, STIX, and TAXII 2.1.
 | 
			
		||||
 | 
			
		||||
**_Sample threat feed in plain text_**
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
$ curl http://threatfeed.example.com:9000
 | 
			
		||||
```
 | 
			
		||||
```
 | 
			
		||||
10.30.16.110
 | 
			
		||||
10.30.21.79
 | 
			
		||||
10.99.17.38
 | 
			
		||||
10.99.17.54
 | 
			
		||||
172.16.1.9
 | 
			
		||||
172.16.2.30
 | 
			
		||||
172.16.3.2
 | 
			
		||||
172.18.0.208
 | 
			
		||||
172.18.5.7
 | 
			
		||||
172.18.5.15
 | 
			
		||||
192.168.0.4
 | 
			
		||||
192.168.1.17
 | 
			
		||||
192.168.1.113
 | 
			
		||||
192.168.2.21
 | 
			
		||||
192.168.3.8
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**_Sample threat feed in JSON format_**
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
$ curl http://threatfeed.example.com:9000/json
 | 
			
		||||
```
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "threat_feed": [
 | 
			
		||||
    {
 | 
			
		||||
      "ip": "10.32.16.110",
 | 
			
		||||
      "added": "2024-11-12T16:18:36-08:00",
 | 
			
		||||
      "last_seen": "2024-11-15T04:27:59-08:00",
 | 
			
		||||
      "threat_score": 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
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Honeypots
 | 
			
		||||
 | 
			
		||||
### SSH
 | 
			
		||||
 | 
			
		||||
The SSH honeypot server responds to SSH authentication requests. Each attempt is automatically rejected, while the submitted credentials are logged. There is no actual shell for attackers to access.
 | 
			
		||||
 | 
			
		||||
**_Sample log from SSH honeypot_**
 | 
			
		||||
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "time": "2024-10-23T23:08:29.423821763-07:00",
 | 
			
		||||
  "event_type": "ssh",
 | 
			
		||||
  "source_ip": "172.16.44.209",
 | 
			
		||||
  "server_ip": "192.168.0.15",
 | 
			
		||||
  "server_port": "22",
 | 
			
		||||
  "server_name": "honeypot01",
 | 
			
		||||
  "event_details": {
 | 
			
		||||
    "username": "root",
 | 
			
		||||
    "password": "Password1",
 | 
			
		||||
    "ssh_client": "SSH-2.0-libssh2_1.10.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### HTTP/HTTPS
 | 
			
		||||
 | 
			
		||||
The HTTP honeypot server responds to all HTTP requests. Requests to the *root* or `/index.html` return a customizable HTML page. Requests outside of that return a 404 error.
 | 
			
		||||
 | 
			
		||||
**_Sample log from HTTP honeypot_**
 | 
			
		||||
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "time": "2024-10-23T23:01:38.989334656-07:00",
 | 
			
		||||
  "event_type": "http",
 | 
			
		||||
  "source_ip": "10.20.89.2",
 | 
			
		||||
  "server_ip": "192.168.0.15",
 | 
			
		||||
  "server_port": "443",
 | 
			
		||||
  "server_name": "honeypot01",
 | 
			
		||||
  "event_details": {
 | 
			
		||||
    "method": "GET",
 | 
			
		||||
    "path": "/",
 | 
			
		||||
    "query": "",
 | 
			
		||||
    "user_agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0) like Gecko",
 | 
			
		||||
    "protocol": "HTTP/1.1",
 | 
			
		||||
    "host": "www.example.com",
 | 
			
		||||
    "headers": {
 | 
			
		||||
      "accept-encoding": "gzip, br",
 | 
			
		||||
      "x-forwarded-for":"10.254.33.179",
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### TCP
 | 
			
		||||
 | 
			
		||||
The TCP honeypot server lets you create customizable honeypot services that log data from connecting clients. You can define prompts that wait for and record input. For example, you can mimic a Telnet server by showing a welcome banner and then prompting for a username. When data is received, it's logged, and you can follow up with a password prompt. You can include any number of prompts to resemble FTP, SMTP, or other services. The client is disconnected after responding to all the prompts.
 | 
			
		||||
 | 
			
		||||
**_Sample log from TCP honeypot_**
 | 
			
		||||
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "time": "2024-10-23T23:41:43.3235296-07:00",
 | 
			
		||||
  "event_type": "tcp",
 | 
			
		||||
  "source_ip": "172.18.206.66",
 | 
			
		||||
  "server_ip": "192.168.0.15",
 | 
			
		||||
  "server_port": "25",
 | 
			
		||||
  "server_name": "honeypot01",
 | 
			
		||||
  "event_details": {
 | 
			
		||||
    "helo": "HELO example.com",
 | 
			
		||||
    "mail_from": "MAIL FROM:<spammer@example.com>",
 | 
			
		||||
    "rcpt_to": "RCPT TO:<recipient@example.com>",
 | 
			
		||||
    "line1": "Subject: Congratualtions! You've won!",
 | 
			
		||||
    "line2": "From: Customer Support <spammer@example.com>",
 | 
			
		||||
    "line3": "To: recipient@example.com",
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### UDP
 | 
			
		||||
 | 
			
		||||
The UDP honeypot server records incoming data on the listening port. It does not respond to clients.
 | 
			
		||||
 | 
			
		||||
Due to the connectionless nature of UDP and the possibility of spoofed source information, UDP honeypots do not integrate with the threat feed. Data is logged, but no further action is taken.
 | 
			
		||||
 | 
			
		||||
**_Sample log from UDP honeypot_**
 | 
			
		||||
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "time": "2024-10-23T21:28:58.223738796-07:00",
 | 
			
		||||
  "event_type": "udp",
 | 
			
		||||
  "source_ip": "127.217.96.21 [unreliable]",
 | 
			
		||||
  "source_reliability": "unreliable",
 | 
			
		||||
  "server_ip": "192.168.0.15",
 | 
			
		||||
  "server_port": "5060",
 | 
			
		||||
  "server_name": "honeypot01",
 | 
			
		||||
  "event_details": {
 | 
			
		||||
    "data": "OPTIONS sip:nm SIP/2.0\r\nVia: SIP/2.0/UDP nm;branch=foo;rport\r\nMax-Forwards: 70\r\nTo: <sip:nm@nm>\r\nFrom: <sip:nm@nm>;tag=root\r\nCall-ID: 50000\r\nCSeq: 63104 OPTIONS\r\nContact: <sip:nm@nm>\r\nAccept: application/sdp\r\nContent-Length: 0\r\n\r\n"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Upgrading
 | 
			
		||||
 | 
			
		||||
To upgrade Deceptifeed, follow the same steps you used for installation:
 | 
			
		||||
 | 
			
		||||
#### If you installed from the binary:
 | 
			
		||||
 | 
			
		||||
1. Download the latest package from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
 | 
			
		||||
2. If you originally installed using the installation script, extract the latest package and re-run `install.sh`.
 | 
			
		||||
3. If you did not use the installation script, simply replace the existing `deceptifeed` binary with the new version.
 | 
			
		||||
 | 
			
		||||
#### If you installed from source:
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
# Navigate to the directory where you cloned the `deceptifeed` repository:
 | 
			
		||||
cd #/path/to/deceptifeed/repository
 | 
			
		||||
 | 
			
		||||
# Update your local repository:
 | 
			
		||||
git pull origin main
 | 
			
		||||
 | 
			
		||||
# Compile the code:
 | 
			
		||||
make
 | 
			
		||||
 | 
			
		||||
# Install the updated version:
 | 
			
		||||
sudo make install
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Uninstalling
 | 
			
		||||
 | 
			
		||||
#### If you installed from the binary:
 | 
			
		||||
 | 
			
		||||
- If you used the installation script, re-run it with the `--uninstall` option.
 | 
			
		||||
```shell
 | 
			
		||||
sudo ./install.sh --uninstall
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
- If you did not use the installation script, simply delete the `deceptifeed` binary and any generated files. When running the binary directly, any generated files will be named `deceptifeed-*` in the same directory where you ran the `deceptifeed` binary.
 | 
			
		||||
 | 
			
		||||
#### If you installed from source:
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
# Navigate to the directory where you cloned the `deceptifeed` repository:
 | 
			
		||||
cd #/path/to/deceptifeed/repository
 | 
			
		||||
 | 
			
		||||
# Uninstall Deceptifeed:
 | 
			
		||||
sudo make uninstall
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								assets/install.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/install.gif
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 246 KiB  | 
							
								
								
									
										65
									
								
								assets/install.tape
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								assets/install.tape
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
			
		||||
# ==============================================================================
 | 
			
		||||
# install.tape:
 | 
			
		||||
# Generate a GIF of the Deceptifeed installation process using Charm's VHS
 | 
			
		||||
#
 | 
			
		||||
# - VHS is available here: https://github.com/charmbracelet/vhs
 | 
			
		||||
#
 | 
			
		||||
# Notes:
 | 
			
		||||
# - Build using: `vhs install.tape`
 | 
			
		||||
# - Built with VHS version 0.8.0.
 | 
			
		||||
# - Since this tape file performs the installation process, Deceptifeed must not
 | 
			
		||||
#   be installed.
 | 
			
		||||
# - To avoid password prompts for `sudo` commands, temporarily disable `sudo`
 | 
			
		||||
#   password requirements for your user:
 | 
			
		||||
#   - Run `visudo`
 | 
			
		||||
#   - Add the following line, replacing `username` with the name of the user
 | 
			
		||||
#     that runs VHS: `username ALL=(ALL) NOPASSWD:ALL`
 | 
			
		||||
# ==============================================================================
 | 
			
		||||
 | 
			
		||||
# =====
 | 
			
		||||
# Setup
 | 
			
		||||
# =====
 | 
			
		||||
Output install.gif
 | 
			
		||||
 | 
			
		||||
Set FontSize 16
 | 
			
		||||
Set Width 735
 | 
			
		||||
Set Height 509
 | 
			
		||||
Set Padding 50
 | 
			
		||||
Set PlaybackSpeed 1.1
 | 
			
		||||
Set LoopOffset 80%
 | 
			
		||||
Set Theme { "name": "CGA Custom", "black": "#000000", "red": "#aa0000", "green": "#00aa00", "yellow": "#aa5500", "blue": "#0000aa", "magenta": "#aa00aa", "cyan": "#00aaaa", "white": "#aaaaaa", "brightBlack": "#555555", "brightRed": "#EF2929", "brightGreen": "#55ff55", "brightYellow": "#FCE94F", "brightBlue": "#5555ff", "brightMagenta": "#ff55ff", "brightCyan": "#55ffff", "brightWhite": "#ffffff", "background": "#000000", "foreground": "#aaaaaa", "cursor": "#b8b8b8", "selection": "#c1deff" }
 | 
			
		||||
 | 
			
		||||
# ====
 | 
			
		||||
# Hide
 | 
			
		||||
# ====
 | 
			
		||||
# Download the Deceptifeed package.
 | 
			
		||||
Hide
 | 
			
		||||
  Type    "wget https://github.com/r-smith/deceptifeed/releases/download/v0.9.0/deceptifeed_0.9.0_linux_amd64.tar.gz"
 | 
			
		||||
  Enter   Sleep 10s 
 | 
			
		||||
  Ctrl+L  Sleep 0.5s
 | 
			
		||||
Show
 | 
			
		||||
 | 
			
		||||
# ====
 | 
			
		||||
# Show
 | 
			
		||||
# ====
 | 
			
		||||
# Extract the files.
 | 
			
		||||
Type   "tar xvzf deceptifeed_0.9.0_linux_amd64.tar.gz"  Sleep 1s
 | 
			
		||||
Enter  Sleep 1s
 | 
			
		||||
 | 
			
		||||
# Enter the extracted directory.
 | 
			
		||||
Type   "cd deceptifeed"  Sleep 1s
 | 
			
		||||
Enter  Sleep 1s
 | 
			
		||||
 | 
			
		||||
# Run the installation script, then an extended pause to showcase the output.
 | 
			
		||||
Type   "sudo ./install.sh"  Sleep 1s
 | 
			
		||||
Enter  Sleep 18s
 | 
			
		||||
 | 
			
		||||
# ====
 | 
			
		||||
# Hide
 | 
			
		||||
# ====
 | 
			
		||||
# Uninstall and cleanup.
 | 
			
		||||
Hide
 | 
			
		||||
  Type  "sudo ./install.sh --uninstall"  Enter Sleep 5s
 | 
			
		||||
  Type  "yes"  Enter  Sleep 3s
 | 
			
		||||
  Type  "cd .."  Enter
 | 
			
		||||
  Type  "rm -rf deceptifeed deceptifeed_0.9.0_linux_amd64.tar.gz"  Enter
 | 
			
		||||
							
								
								
									
										6
									
								
								assets/logo-dark.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								assets/logo-dark.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 39 KiB  | 
							
								
								
									
										6
									
								
								assets/logo-light.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								assets/logo-light.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 39 KiB  | 
@@ -28,9 +28,9 @@ func main() {
 | 
			
		||||
	flag.BoolVar(&cfg.ThreatFeed.Enabled, "enable-threatfeed", config.DefaultEnableThreatFeed, "Enable threat feed server")
 | 
			
		||||
	flag.StringVar(&cfg.LogPath, "log", config.DefaultLogPath, "Path to log file")
 | 
			
		||||
	flag.StringVar(&cfg.ThreatFeed.DatabasePath, "threat-database", config.DefaultThreatDatabasePath, "Path to threat feed database file")
 | 
			
		||||
	flag.UintVar(&cfg.ThreatFeed.ExpiryHours, "threat-expiry-hours", config.DefaultThreatExpiryHours, "Remove inactive IPs from threat feed after specified hours")
 | 
			
		||||
	flag.IntVar(&cfg.ThreatFeed.ExpiryHours, "threat-expiry-hours", config.DefaultThreatExpiryHours, "Remove inactive IPs from threat feed after specified hours")
 | 
			
		||||
	flag.BoolVar(&cfg.ThreatFeed.IsPrivateIncluded, "threat-include-private", config.DefaultThreatIncludePrivate, "Include private IPs in threat feed")
 | 
			
		||||
	flag.StringVar(&http.HtmlPath, "html", config.DefaultHtmlPath, "Path to optional HTML file to serve")
 | 
			
		||||
	flag.StringVar(&http.HomePagePath, "html", config.DefaultHomePagePath, "Path to optional HTML file to serve")
 | 
			
		||||
	flag.StringVar(&http.Port, "port-http", config.DefaultPortHTTP, "Port number to listen on for HTTP server")
 | 
			
		||||
	flag.StringVar(&https.Port, "port-https", config.DefaultPortHTTPS, "Port number to listen on for HTTPS server")
 | 
			
		||||
	flag.StringVar(&ssh.Port, "port-ssh", config.DefaultPortSSH, "Port number to listen on for SSH server")
 | 
			
		||||
@@ -53,7 +53,7 @@ func main() {
 | 
			
		||||
		cfg = *cfgFromFile
 | 
			
		||||
	} else {
 | 
			
		||||
		// No config file specified. Use command line args.
 | 
			
		||||
		https.HtmlPath = http.HtmlPath
 | 
			
		||||
		https.HomePagePath = http.HomePagePath
 | 
			
		||||
		cfg.Servers = append(cfg.Servers, http, https, ssh)
 | 
			
		||||
		// Set defaults.
 | 
			
		||||
		for i := range cfg.Servers {
 | 
			
		||||
@@ -84,7 +84,7 @@ func main() {
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		threatfeed.StartThreatFeed(&cfg.ThreatFeed)
 | 
			
		||||
		threatfeed.Start(&cfg.ThreatFeed)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Start the honeypot servers.
 | 
			
		||||
@@ -97,16 +97,14 @@ func main() {
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			switch server.Type {
 | 
			
		||||
			case config.HTTP:
 | 
			
		||||
				httpserver.StartHTTP(&server)
 | 
			
		||||
			case config.HTTPS:
 | 
			
		||||
				httpserver.StartHTTPS(&server)
 | 
			
		||||
			case config.HTTP, config.HTTPS:
 | 
			
		||||
				httpserver.Start(&server)
 | 
			
		||||
			case config.SSH:
 | 
			
		||||
				sshserver.StartSSH(&server)
 | 
			
		||||
				sshserver.Start(&server)
 | 
			
		||||
			case config.TCP:
 | 
			
		||||
				tcpserver.StartTCP(&server)
 | 
			
		||||
				tcpserver.Start(&server)
 | 
			
		||||
			case config.UDP:
 | 
			
		||||
				udpserver.StartUDP(&server)
 | 
			
		||||
				udpserver.Start(&server)
 | 
			
		||||
			}
 | 
			
		||||
		}()
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,92 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<!-- =========== -->
 | 
			
		||||
<!-- Deceptifeed -->
 | 
			
		||||
<!-- =========== -->
 | 
			
		||||
<config>
 | 
			
		||||
 | 
			
		||||
  <!-- The default log path for honeypot servers. -->
 | 
			
		||||
  <defaultLogPath>/opt/deceptifeed/logs/honeypot.log</defaultLogPath>
 | 
			
		||||
 | 
			
		||||
  <!--
 | 
			
		||||
  ===========================================================================
 | 
			
		||||
  Threat Feed Configuration
 | 
			
		||||
  ===========================================================================
 | 
			
		||||
  This section controls the settings for the threat feed server, which
 | 
			
		||||
  provides a list of IP addresses observed interacting with your honeypot
 | 
			
		||||
  servers.
 | 
			
		||||
  -->
 | 
			
		||||
  <threatFeed>
 | 
			
		||||
    <enabled>true</enabled>
 | 
			
		||||
    <port>9000</port>
 | 
			
		||||
    <databasePath>/opt/deceptifeed/logs/threatfeed.csv</databasePath>
 | 
			
		||||
    <threatExpiryHours>336</threatExpiryHours>
 | 
			
		||||
    <minimumThreatScore>0</minimumThreatScore>
 | 
			
		||||
    <isPrivateIncluded>false</isPrivateIncluded>
 | 
			
		||||
    <customThreatsPath></customThreatsPath>
 | 
			
		||||
    <excludeListPath></excludeListPath>
 | 
			
		||||
  </threatFeed>
 | 
			
		||||
 | 
			
		||||
  <!--
 | 
			
		||||
  =============================================================================
 | 
			
		||||
  Honeypot Server Configuration
 | 
			
		||||
  =============================================================================
 | 
			
		||||
  This section allows you to define any number of honeypot servers. Each server
 | 
			
		||||
  accepts network connections, logs interactions from clients, and updates the
 | 
			
		||||
  threat feed with the connecting client's IP address.
 | 
			
		||||
  
 | 
			
		||||
  Use the `<server>` element to define a honeypot, and the `type` attribute to
 | 
			
		||||
  specify the server's role (for example, <server type="http"> ... </server>).
 | 
			
		||||
  
 | 
			
		||||
  Available server types:
 | 
			
		||||
  - "ssh"   SSH server. Records, but rejects every login attempt.
 | 
			
		||||
  - "http"  Web server. Returns error codes for requests outside the homepage.
 | 
			
		||||
  - "https" Web server. Returns error codes for requests outside the homepage.
 | 
			
		||||
  - "tcp"   Simulates a generic TCP-based service.
 | 
			
		||||
  - "udp"   Records incoming data. Does not send responses.
 | 
			
		||||
  -->
 | 
			
		||||
  <honeypotServers>
 | 
			
		||||
 | 
			
		||||
    <!-- SSH honeypot server on port 2222 -->
 | 
			
		||||
    <server type="ssh">
 | 
			
		||||
      <enabled>true</enabled>
 | 
			
		||||
      <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.3 FreeBSD-20230316</banner>
 | 
			
		||||
    </server>
 | 
			
		||||
 | 
			
		||||
    <!-- HTTP honeypot server on port 8080 -->
 | 
			
		||||
    <server type="http">
 | 
			
		||||
      <enabled>true</enabled>
 | 
			
		||||
      <port>8080</port>
 | 
			
		||||
      <logEnabled>true</logEnabled>
 | 
			
		||||
      <sendToThreatFeed>true</sendToThreatFeed>
 | 
			
		||||
      <threatScore>1</threatScore>
 | 
			
		||||
      <rules>
 | 
			
		||||
        <!-- Update the threat feed if any of the following conditions match: -->
 | 
			
		||||
        <!-- File extensions, dot files, and paths -->
 | 
			
		||||
        <include target="path">(?i)\.(asp|bak|cfm|cgi|dll|ds_store|env|esp|git|htaccess|ini|jhtml|js|key|log|pem|php|pl|sh|ssh|ssl|yml)</include>
 | 
			
		||||
        <include target="path">(?i)(api|admin|aws|cfide|cgi-bin|config|cscoe|dashboard|data|env|login|manage|owa|panel|portal|query|readme|remote|sdk|server|setup|status|store|user|vpn|wp-)</include>
 | 
			
		||||
        <!-- Query values -->
 | 
			
		||||
        <include target="query">(?i)(action|conf|dns|file|form|json|login|php|q=|url|user)</include>
 | 
			
		||||
        <!-- Directory traversal attempts -->
 | 
			
		||||
        <include target="path">\.\.</include>
 | 
			
		||||
        <include target="query">\.\.</include>
 | 
			
		||||
        <include target="lang">\.\.</include>
 | 
			
		||||
        <!-- Authorization header is set -->
 | 
			
		||||
        <include target="authorization">.*</include>
 | 
			
		||||
        <!-- An HTTP method that is not GET, HEAD or OPTIONS -->
 | 
			
		||||
        <include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
 | 
			
		||||
        <!-- User agents -->
 | 
			
		||||
        <include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
 | 
			
		||||
        <include target="user-agent">^$</include>
 | 
			
		||||
      </rules>
 | 
			
		||||
    </server>
 | 
			
		||||
 | 
			
		||||
    <!-- HTTPS honeypot server on port 8443 -->
 | 
			
		||||
    <server type="https">
 | 
			
		||||
      <enabled>true</enabled>
 | 
			
		||||
      <port>8443</port>
 | 
			
		||||
@@ -20,26 +95,49 @@
 | 
			
		||||
      <threatScore>1</threatScore>
 | 
			
		||||
      <certPath>/opt/deceptifeed/certs/https-cert.pem</certPath>
 | 
			
		||||
      <keyPath>/opt/deceptifeed/certs/https-key.pem</keyPath>
 | 
			
		||||
      <rules>
 | 
			
		||||
        <!-- Update the threat feed if any of the following conditions match: -->
 | 
			
		||||
        <!-- File extensions, dot files, and paths -->
 | 
			
		||||
        <include target="path">(?i)\.(asp|bak|cfm|cgi|dll|ds_store|env|esp|git|htaccess|ini|jhtml|js|key|log|pem|php|pl|sh|ssh|ssl|yml)</include>
 | 
			
		||||
        <include target="path">(?i)(api|admin|aws|cfide|cgi-bin|config|cscoe|dashboard|data|env|login|manage|owa|panel|portal|query|readme|remote|sdk|server|setup|status|store|user|vpn|wp-)</include>
 | 
			
		||||
        <!-- Query values -->
 | 
			
		||||
        <include target="query">(?i)(action|conf|dns|file|form|json|login|php|q=|url|user)</include>
 | 
			
		||||
        <!-- Directory traversal attempts -->
 | 
			
		||||
        <include target="path">\.\.</include>
 | 
			
		||||
        <include target="query">\.\.</include>
 | 
			
		||||
        <include target="lang">\.\.</include>
 | 
			
		||||
        <!-- Authorization header is set -->
 | 
			
		||||
        <include target="authorization">.*</include>
 | 
			
		||||
        <!-- An HTTP method that is not GET, HEAD or OPTIONS -->
 | 
			
		||||
        <include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
 | 
			
		||||
        <!-- User agents -->
 | 
			
		||||
        <include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
 | 
			
		||||
        <include target="user-agent">^$</include>
 | 
			
		||||
      </rules>
 | 
			
		||||
    </server>
 | 
			
		||||
 | 
			
		||||
    <server type="ssh">
 | 
			
		||||
      <enabled>true</enabled>
 | 
			
		||||
      <port>2022</port>
 | 
			
		||||
    <!-- DISABLED: -->
 | 
			
		||||
    <!-- Example TCP honeypot server to simulate a Cisco router -->
 | 
			
		||||
    <server type="tcp">
 | 
			
		||||
      <enabled>false</enabled>
 | 
			
		||||
      <port>2323</port>
 | 
			
		||||
      <logEnabled>true</logEnabled>
 | 
			
		||||
      <sendToThreatFeed>true</sendToThreatFeed>
 | 
			
		||||
      <threatScore>1</threatScore>
 | 
			
		||||
      <keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
 | 
			
		||||
      <banner>SSH-2.0-OpenSSH_9.3 FreeBSD-20230316</banner>
 | 
			
		||||
      <banner>\nUser Access Verification\n\n</banner>
 | 
			
		||||
      <prompts>
 | 
			
		||||
        <prompt log="username">Username: </prompt>
 | 
			
		||||
        <prompt log="password">Password: </prompt>
 | 
			
		||||
      </prompts>
 | 
			
		||||
    </server>
 | 
			
		||||
 | 
			
		||||
    <!-- DISABLED: -->
 | 
			
		||||
    <!-- Example UDP honeypot server to capture SIP scans -->
 | 
			
		||||
    <server type="udp">
 | 
			
		||||
      <enabled>false</enabled>
 | 
			
		||||
      <port>5060</port>
 | 
			
		||||
      <logEnabled>true</logEnabled>
 | 
			
		||||
    </server>
 | 
			
		||||
  </honeypotServers>
 | 
			
		||||
 | 
			
		||||
  <threatFeed>
 | 
			
		||||
    <enabled>true</enabled>
 | 
			
		||||
    <port>8081</port>
 | 
			
		||||
    <databasePath>/opt/deceptifeed/logs/threatfeed.csv</databasePath>
 | 
			
		||||
    <threatExpiryHours>168</threatExpiryHours>
 | 
			
		||||
    <minimumThreatScore>0</minimumThreatScore>
 | 
			
		||||
    <isPrivateIncluded>false</isPrivateIncluded>
 | 
			
		||||
  </threatFeed>
 | 
			
		||||
 | 
			
		||||
</config>
 | 
			
		||||
							
								
								
									
										4
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.mod
									
									
									
									
									
								
							@@ -2,6 +2,6 @@ module github.com/r-smith/deceptifeed
 | 
			
		||||
 | 
			
		||||
go 1.22
 | 
			
		||||
 | 
			
		||||
require golang.org/x/crypto v0.28.0
 | 
			
		||||
require golang.org/x/crypto v0.29.0
 | 
			
		||||
 | 
			
		||||
require golang.org/x/sys v0.26.0 // indirect
 | 
			
		||||
require golang.org/x/sys v0.27.0 // indirect
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								go.sum
									
									
									
									
									
								
							@@ -1,6 +1,11 @@
 | 
			
		||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
 | 
			
		||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
 | 
			
		||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
 | 
			
		||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
 | 
			
		||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
 | 
			
		||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 | 
			
		||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
 | 
			
		||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 | 
			
		||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
 | 
			
		||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
 | 
			
		||||
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
 | 
			
		||||
 
 | 
			
		||||
@@ -6,9 +6,11 @@ import (
 | 
			
		||||
	"io"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"regexp"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// This block of constants defines the application default settings.
 | 
			
		||||
// This block of constants defines the default application settings when no
 | 
			
		||||
// configuration file is provided.
 | 
			
		||||
const (
 | 
			
		||||
	DefaultEnableHTTP           = true
 | 
			
		||||
	DefaultEnableHTTPS          = true
 | 
			
		||||
@@ -16,13 +18,13 @@ const (
 | 
			
		||||
	DefaultEnableThreatFeed     = true
 | 
			
		||||
	DefaultPortHTTP             = "8080"
 | 
			
		||||
	DefaultPortHTTPS            = "8443"
 | 
			
		||||
	DefaultPortSSH              = "2022"
 | 
			
		||||
	DefaultPortThreatFeed       = "8081"
 | 
			
		||||
	DefaultThreatExpiryHours    = 168
 | 
			
		||||
	DefaultPortSSH              = "2222"
 | 
			
		||||
	DefaultPortThreatFeed       = "9000"
 | 
			
		||||
	DefaultThreatExpiryHours    = 336
 | 
			
		||||
	DefaultThreatDatabasePath   = "deceptifeed-database.csv"
 | 
			
		||||
	DefaultThreatIncludePrivate = true
 | 
			
		||||
	DefaultLogPath              = "deceptifeed-log.txt"
 | 
			
		||||
	DefaultHtmlPath             = ""
 | 
			
		||||
	DefaultHomePagePath         = ""
 | 
			
		||||
	DefaultCertPathHTTPS        = "deceptifeed-https.crt"
 | 
			
		||||
	DefaultKeyPathHTTPS         = "deceptifeed-https.key"
 | 
			
		||||
	DefaultKeyPathSSH           = "deceptifeed-ssh.key"
 | 
			
		||||
@@ -85,17 +87,32 @@ type Server struct {
 | 
			
		||||
	Port             string     `xml:"port"`
 | 
			
		||||
	CertPath         string     `xml:"certPath"`
 | 
			
		||||
	KeyPath          string     `xml:"keyPath"`
 | 
			
		||||
	HtmlPath         string     `xml:"htmlPath"`
 | 
			
		||||
	HomePagePath     string     `xml:"homePagePath"`
 | 
			
		||||
	ErrorPagePath    string     `xml:"errorPagePath"`
 | 
			
		||||
	Banner           string     `xml:"banner"`
 | 
			
		||||
	Prompts          []Prompt   `xml:"prompt"`
 | 
			
		||||
	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"`
 | 
			
		||||
	LogEnabled       bool       `xml:"logEnabled"`
 | 
			
		||||
	LogFile          *os.File
 | 
			
		||||
	Logger           *slog.Logger
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Rules struct {
 | 
			
		||||
	Include []Rule `xml:"include"`
 | 
			
		||||
	Exclude []Rule `xml:"exclude"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Rule struct {
 | 
			
		||||
	Target  string `xml:"target,attr"`
 | 
			
		||||
	Pattern string `xml:",chardata"`
 | 
			
		||||
	Negate  bool   `xml:"negate,attr"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Prompt represents a text prompt that can be displayed to connecting clients
 | 
			
		||||
// when using the TCP-type honeypot server. Each prompt waits for input and
 | 
			
		||||
// logs the response. A Server can include multiple prompts which are displayed
 | 
			
		||||
@@ -115,7 +132,7 @@ type ThreatFeed struct {
 | 
			
		||||
	Enabled            bool   `xml:"enabled"`
 | 
			
		||||
	Port               string `xml:"port"`
 | 
			
		||||
	DatabasePath       string `xml:"databasePath"`
 | 
			
		||||
	ExpiryHours        uint   `xml:"threatExpiryHours"`
 | 
			
		||||
	ExpiryHours        int    `xml:"threatExpiryHours"`
 | 
			
		||||
	IsPrivateIncluded  bool   `xml:"isPrivateIncluded"`
 | 
			
		||||
	MinimumThreatScore int    `xml:"minimumThreatScore"`
 | 
			
		||||
	CustomThreatsPath  string `xml:"customThreatsPath"`
 | 
			
		||||
@@ -140,16 +157,36 @@ func Load(filename string) (*Config, error) {
 | 
			
		||||
		return nil, fmt.Errorf("failed to decode XML file: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Ensure a minimum threat score of 1.
 | 
			
		||||
	for i := range config.Servers {
 | 
			
		||||
		if config.Servers[i].ThreatScore < 1 {
 | 
			
		||||
			config.Servers[i].ThreatScore = 1
 | 
			
		||||
		// 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
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &config, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateRegexRules checks the validity of regex patterns in the rules.
 | 
			
		||||
func validateRegexRules(rules Rules) error {
 | 
			
		||||
	for _, rule := range rules.Include {
 | 
			
		||||
		if _, err := regexp.Compile(rule.Pattern); err != nil {
 | 
			
		||||
			return fmt.Errorf("invalid regex pattern: %s", rule.Pattern)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	for _, rule := range rules.Exclude {
 | 
			
		||||
		if _, err := regexp.Compile(rule.Pattern); err != nil {
 | 
			
		||||
			return fmt.Errorf("invalid regex pattern: %s", rule.Pattern)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// InitializeLoggers creates structured loggers for each server. It opens log
 | 
			
		||||
// files using the server's specified log path, defaulting to the global log
 | 
			
		||||
// path if none is provided.
 | 
			
		||||
@@ -199,7 +236,7 @@ func (c *Config) InitializeLoggers() error {
 | 
			
		||||
func (c *Config) CloseLogFiles() {
 | 
			
		||||
	for i := range c.Servers {
 | 
			
		||||
		if c.Servers[i].LogFile != nil {
 | 
			
		||||
			c.Servers[i].LogFile.Close()
 | 
			
		||||
			_ = c.Servers[i].LogFile.Close()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,7 @@ import (
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
@@ -23,57 +24,72 @@ import (
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/threatfeed"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// StartHTTP initializes and starts an HTTP honeypot server. This is a fully
 | 
			
		||||
// functional HTTP server designed to log all incoming requests for analysis.
 | 
			
		||||
func StartHTTP(srv *config.Server) {
 | 
			
		||||
	// Get any custom headers, if provided.
 | 
			
		||||
	headers := parseCustomHeaders(srv.Banner)
 | 
			
		||||
 | 
			
		||||
	// Setup handler.
 | 
			
		||||
	mux := http.NewServeMux()
 | 
			
		||||
	mux.HandleFunc("/", handleConnection(srv, headers))
 | 
			
		||||
 | 
			
		||||
	// Start the HTTP server.
 | 
			
		||||
	fmt.Printf("Starting HTTP server on port: %s\n", srv.Port)
 | 
			
		||||
	if err := http.ListenAndServe(":"+srv.Port, mux); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The HTTP server has terminated:", err)
 | 
			
		||||
// Start initializes and starts an HTTP or HTTPS honeypot server. The server
 | 
			
		||||
// is a simple HTTP server designed to log all details from incoming requests.
 | 
			
		||||
// Optionally, a single static HTML file can be served as the homepage,
 | 
			
		||||
// otherwise, the server will return only HTTP status codes to clients.
 | 
			
		||||
// Interactions with the HTTP server are sent to the threat feed.
 | 
			
		||||
func Start(cfg *config.Server) {
 | 
			
		||||
	switch cfg.Type {
 | 
			
		||||
	case config.HTTP:
 | 
			
		||||
		listenHTTP(cfg)
 | 
			
		||||
	case config.HTTPS:
 | 
			
		||||
		listenHTTPS(cfg)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// StartHTTPS initializes and starts an HTTPS honeypot server. This  is a fully
 | 
			
		||||
// functional HTTPS server designed to log all incoming requests for analysis.
 | 
			
		||||
func StartHTTPS(srv *config.Server) {
 | 
			
		||||
	// Get any custom headers, if provided.
 | 
			
		||||
	headers := parseCustomHeaders(srv.Banner)
 | 
			
		||||
 | 
			
		||||
	// Setup handler and initialize HTTPS config.
 | 
			
		||||
// listenHTTP initializes and starts an HTTP (plaintext) honeypot server.
 | 
			
		||||
func listenHTTP(cfg *config.Server) {
 | 
			
		||||
	mux := http.NewServeMux()
 | 
			
		||||
	mux.HandleFunc("/", handleConnection(srv, headers))
 | 
			
		||||
	server := &http.Server{
 | 
			
		||||
		Addr:     ":" + srv.Port,
 | 
			
		||||
		Handler:  mux,
 | 
			
		||||
		ErrorLog: log.New(io.Discard, "", log.LstdFlags),
 | 
			
		||||
	mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
 | 
			
		||||
	srv := &http.Server{
 | 
			
		||||
		Addr:         ":" + cfg.Port,
 | 
			
		||||
		Handler:      mux,
 | 
			
		||||
		ErrorLog:     log.New(io.Discard, "", log.LstdFlags),
 | 
			
		||||
		ReadTimeout:  5 * time.Second,
 | 
			
		||||
		WriteTimeout: 10 * time.Second,
 | 
			
		||||
		IdleTimeout:  0,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start the HTTP server.
 | 
			
		||||
	fmt.Printf("Starting HTTP server on port: %s\n", cfg.Port)
 | 
			
		||||
	if err := srv.ListenAndServe(); err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "The HTTP server on port %s has stopped: %v\n", cfg.Port, err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// listenHTTP initializes and starts an HTTPS (encrypted) honeypot server.
 | 
			
		||||
func listenHTTPS(cfg *config.Server) {
 | 
			
		||||
	mux := http.NewServeMux()
 | 
			
		||||
	mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
 | 
			
		||||
	srv := &http.Server{
 | 
			
		||||
		Addr:         ":" + cfg.Port,
 | 
			
		||||
		Handler:      mux,
 | 
			
		||||
		ErrorLog:     log.New(io.Discard, "", log.LstdFlags),
 | 
			
		||||
		ReadTimeout:  5 * time.Second,
 | 
			
		||||
		WriteTimeout: 10 * time.Second,
 | 
			
		||||
		IdleTimeout:  0,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If the cert and key aren't found, generate a self-signed certificate.
 | 
			
		||||
	if _, err := os.Stat(srv.CertPath); os.IsNotExist(err) {
 | 
			
		||||
		if _, err := os.Stat(srv.KeyPath); os.IsNotExist(err) {
 | 
			
		||||
	if _, err := os.Stat(cfg.CertPath); os.IsNotExist(err) {
 | 
			
		||||
		if _, err := os.Stat(cfg.KeyPath); os.IsNotExist(err) {
 | 
			
		||||
			// Generate a self-signed certificate.
 | 
			
		||||
			cert, err := generateSelfSignedCert(srv.CertPath, srv.KeyPath)
 | 
			
		||||
			cert, err := generateSelfSignedCert(cfg.CertPath, cfg.KeyPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Add cert to server config.
 | 
			
		||||
			server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
 | 
			
		||||
			srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start the HTTPS server.
 | 
			
		||||
	fmt.Printf("Starting HTTPS server on port: %s\n", srv.Port)
 | 
			
		||||
	if err := server.ListenAndServeTLS(srv.CertPath, srv.KeyPath); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The HTTPS server has terminated:", err)
 | 
			
		||||
	fmt.Printf("Starting HTTPS server on port: %s\n", cfg.Port)
 | 
			
		||||
	if err := srv.ListenAndServeTLS(cfg.CertPath, cfg.KeyPath); err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "The HTTPS server on port %s has stopped: %v\n", cfg.Port, err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -83,17 +99,16 @@ func StartHTTPS(srv *config.Server) {
 | 
			
		||||
// HTML file specified in the configuration or a default page prompting for
 | 
			
		||||
// basic HTTP authentication. Requests for any other URLs will return a 404
 | 
			
		||||
// error to the client.
 | 
			
		||||
func handleConnection(srv *config.Server, customHeaders map[string]string) http.HandlerFunc {
 | 
			
		||||
func handleConnection(cfg *config.Server, customHeaders map[string]string) http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Log details of the incoming HTTP request.
 | 
			
		||||
		dst_ip, dst_port := getLocalAddr(r)
 | 
			
		||||
		src_ip, src_port, _ := net.SplitHostPort(r.RemoteAddr)
 | 
			
		||||
		src_ip, _, _ := net.SplitHostPort(r.RemoteAddr)
 | 
			
		||||
		username, password, isAuth := r.BasicAuth()
 | 
			
		||||
		if isAuth {
 | 
			
		||||
			srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
			cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
				slog.String("event_type", "http"),
 | 
			
		||||
				slog.String("source_ip", src_ip),
 | 
			
		||||
				slog.String("source_port", src_port),
 | 
			
		||||
				slog.String("server_ip", dst_ip),
 | 
			
		||||
				slog.String("server_port", dst_port),
 | 
			
		||||
				slog.String("server_name", config.GetHostname()),
 | 
			
		||||
@@ -108,14 +123,13 @@ func handleConnection(srv *config.Server, customHeaders map[string]string) http.
 | 
			
		||||
						slog.String("username", username),
 | 
			
		||||
						slog.String("password", password),
 | 
			
		||||
					),
 | 
			
		||||
					slog.Any("request_headers", flattenHeaders(r.Header)),
 | 
			
		||||
					slog.Any("headers", flattenHeaders(r.Header)),
 | 
			
		||||
				),
 | 
			
		||||
			)
 | 
			
		||||
		} else {
 | 
			
		||||
			srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
			cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
				slog.String("event_type", "http"),
 | 
			
		||||
				slog.String("source_ip", src_ip),
 | 
			
		||||
				slog.String("source_port", src_port),
 | 
			
		||||
				slog.String("server_ip", dst_ip),
 | 
			
		||||
				slog.String("server_port", dst_port),
 | 
			
		||||
				slog.String("server_name", config.GetHostname()),
 | 
			
		||||
@@ -126,7 +140,7 @@ func handleConnection(srv *config.Server, customHeaders map[string]string) http.
 | 
			
		||||
					slog.String("user_agent", r.UserAgent()),
 | 
			
		||||
					slog.String("protocol", r.Proto),
 | 
			
		||||
					slog.String("host", r.Host),
 | 
			
		||||
					slog.Any("request_headers", flattenHeaders(r.Header)),
 | 
			
		||||
					slog.Any("headers", flattenHeaders(r.Header)),
 | 
			
		||||
				),
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
@@ -135,55 +149,114 @@ func handleConnection(srv *config.Server, customHeaders map[string]string) http.
 | 
			
		||||
		fmt.Printf("[HTTP] %s %s %s %s\n", src_ip, r.Method, r.URL.Path, r.URL.RawQuery)
 | 
			
		||||
 | 
			
		||||
		// Update the threat feed with the source IP address from the request.
 | 
			
		||||
		if srv.SendToThreatFeed {
 | 
			
		||||
			threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
 | 
			
		||||
		// If the configuration specifies an HTTP header to be used for the
 | 
			
		||||
		// source IP, retrieve the header value and use it instead of the
 | 
			
		||||
		// connecting IP.
 | 
			
		||||
		if shouldUpdateThreatFeed(cfg, r) {
 | 
			
		||||
			src := src_ip
 | 
			
		||||
			if len(cfg.SourceIPHeader) > 0 {
 | 
			
		||||
				if header := r.Header.Get(cfg.SourceIPHeader); len(header) > 0 {
 | 
			
		||||
					src = header
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			threatfeed.Update(src, cfg.ThreatScore)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// If custom headers are provided, add each header and its value to the
 | 
			
		||||
		// HTTP response.
 | 
			
		||||
		for key, value := range customHeaders {
 | 
			
		||||
			w.Header().Set(key, value)
 | 
			
		||||
		// Apply any custom HTTP response headers.
 | 
			
		||||
		for header, value := range customHeaders {
 | 
			
		||||
			w.Header().Set(header, value)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Serve the web content to the client based on the requested URL. If
 | 
			
		||||
		// the root or /index.html is requested, serve the specified content.
 | 
			
		||||
		// For any other requests, return a '404 Not Found' response.
 | 
			
		||||
		// Serve a response based on the requested URL. If the root URL or
 | 
			
		||||
		// /index.html is requested, serve the homepage. For all other
 | 
			
		||||
		// requests, serve the error page with a 404 Not Found response.
 | 
			
		||||
		// Optionally, a single static HTML file may be specified for both the
 | 
			
		||||
		// homepage and the error page. If no custom files are provided,
 | 
			
		||||
		// default minimal responses will be served.
 | 
			
		||||
		if r.URL.Path == "/" || r.URL.Path == "/index.html" {
 | 
			
		||||
			// The request is for the root or /index.html.
 | 
			
		||||
			if len(srv.HtmlPath) > 0 {
 | 
			
		||||
				// Serve the custom HTML file specified in the configuration.
 | 
			
		||||
				http.ServeFile(w, r, srv.HtmlPath)
 | 
			
		||||
			// Serve the homepage response.
 | 
			
		||||
			if len(cfg.HomePagePath) > 0 {
 | 
			
		||||
				http.ServeFile(w, r, cfg.HomePagePath)
 | 
			
		||||
			} else {
 | 
			
		||||
				// Serve the default page that prompts the client for basic
 | 
			
		||||
				// authentication.
 | 
			
		||||
				w.Header().Set("WWW-Authenticate", "Basic")
 | 
			
		||||
				w.Header()["WWW-Authenticate"] = []string{"Basic"}
 | 
			
		||||
				w.WriteHeader(http.StatusUnauthorized)
 | 
			
		||||
				fmt.Fprintln(w, http.StatusText(http.StatusUnauthorized))
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			// The request is outside the root or /index.html. Respond with a
 | 
			
		||||
			// 404 error.
 | 
			
		||||
			// Serve the error page response.
 | 
			
		||||
			w.WriteHeader(http.StatusNotFound)
 | 
			
		||||
			fmt.Fprintln(w, http.StatusText(http.StatusNotFound))
 | 
			
		||||
			if len(cfg.ErrorPagePath) > 0 {
 | 
			
		||||
				http.ServeFile(w, r, cfg.ErrorPagePath)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// parseCustomHeaders parses a string of custom headers, if provided in the
 | 
			
		||||
// configuration, into a map[string]string. The keys in the map are the custom
 | 
			
		||||
// header names. For example, given the input:
 | 
			
		||||
// "Server: Microsoft-IIS/8.5, X-Powered-By: ASP.NET", the function would
 | 
			
		||||
// return a map with "Server" and "X-Powered-By" as keys, each linked to their
 | 
			
		||||
// corresponding values.
 | 
			
		||||
func parseCustomHeaders(headers string) map[string]string {
 | 
			
		||||
	if len(headers) == 0 {
 | 
			
		||||
		return nil
 | 
			
		||||
// shouldUpdateThreatFeed determines if the threat feed should be updated based
 | 
			
		||||
// on the server's configured rules.
 | 
			
		||||
func shouldUpdateThreatFeed(cfg *config.Server, r *http.Request) bool {
 | 
			
		||||
	// Return false if `sendToThreatFeed`` is disabled, or if the request
 | 
			
		||||
	// matches an `exclude` rule.
 | 
			
		||||
	if !cfg.SendToThreatFeed || checkRuleMatches(cfg.Rules.Exclude, r) {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Return true if no `include` rules are defined. Otherwise, return whether
 | 
			
		||||
	// the request matches any of the `include` rules.
 | 
			
		||||
	return len(cfg.Rules.Include) == 0 || checkRuleMatches(cfg.Rules.Include, r)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// checkRuleMatches checks if a request matches any of the specified rules.
 | 
			
		||||
func checkRuleMatches(rules []config.Rule, r *http.Request) bool {
 | 
			
		||||
	match := false
 | 
			
		||||
	for _, rule := range rules {
 | 
			
		||||
		// Ignore errors from regexp.Compile. Regular expression patterns are
 | 
			
		||||
		// validated at application startup.
 | 
			
		||||
		rx, _ := regexp.Compile(rule.Pattern)
 | 
			
		||||
 | 
			
		||||
		switch strings.ToLower(rule.Target) {
 | 
			
		||||
		case "path":
 | 
			
		||||
			match = rx.MatchString(r.URL.Path)
 | 
			
		||||
		case "query":
 | 
			
		||||
			match = rx.MatchString(r.URL.RawQuery)
 | 
			
		||||
		case "method":
 | 
			
		||||
			match = rx.MatchString(r.Method)
 | 
			
		||||
		case "host":
 | 
			
		||||
			match = rx.MatchString(r.Host)
 | 
			
		||||
		case "user-agent":
 | 
			
		||||
			match = rx.MatchString(r.UserAgent())
 | 
			
		||||
		default:
 | 
			
		||||
			header, ok := r.Header[http.CanonicalHeaderKey(rule.Target)]
 | 
			
		||||
			if ok {
 | 
			
		||||
				for _, v := range header {
 | 
			
		||||
					if rx.MatchString(v) {
 | 
			
		||||
						match = true
 | 
			
		||||
						break
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if rule.Negate {
 | 
			
		||||
			match = !match
 | 
			
		||||
		}
 | 
			
		||||
		if match {
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// parseCustomHeaders takes a slice of header strings in the format of
 | 
			
		||||
// "Name: Value", and returns a map of the Name-Value pairs. For example, given
 | 
			
		||||
// the input:
 | 
			
		||||
// `[]{"Server: Microsoft-IIS/8.5", "X-Powered-By: ASP.NET"}`, the function
 | 
			
		||||
// would return a map with "Server" and "X-Powered-By" as keys, each linked to
 | 
			
		||||
// their corresponding values.
 | 
			
		||||
func parseCustomHeaders(headers []string) map[string]string {
 | 
			
		||||
	result := make(map[string]string)
 | 
			
		||||
	kvPairs := strings.Split(headers, ",")
 | 
			
		||||
	for _, pair := range kvPairs {
 | 
			
		||||
		kv := strings.Split(strings.TrimSpace(pair), ":")
 | 
			
		||||
 | 
			
		||||
	for _, header := range headers {
 | 
			
		||||
		kv := strings.SplitN(header, ":", 2)
 | 
			
		||||
		if len(kv) == 2 {
 | 
			
		||||
			result[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
 | 
			
		||||
		}
 | 
			
		||||
@@ -193,16 +266,17 @@ func parseCustomHeaders(headers string) map[string]string {
 | 
			
		||||
 | 
			
		||||
// flattenHeaders converts HTTP headers from an http.Request from the format of
 | 
			
		||||
// map[string][]string to map[string]string. This results in a cleaner format
 | 
			
		||||
// for logging, where each headers values are represented as a single string
 | 
			
		||||
// for logging, where each header's values are represented as a single string
 | 
			
		||||
// instead of a slice. When a header contains multiple values, they are
 | 
			
		||||
// combined into a single string, separated by commas.
 | 
			
		||||
// combined into a single string, separated by commas. Additionally, header
 | 
			
		||||
// names are converted to lowercase.
 | 
			
		||||
func flattenHeaders(headers map[string][]string) map[string]string {
 | 
			
		||||
	newHeaders := make(map[string]string, len(headers))
 | 
			
		||||
	for header, values := range headers {
 | 
			
		||||
		if len(values) == 1 {
 | 
			
		||||
			newHeaders[header] = values[0]
 | 
			
		||||
			newHeaders[strings.ToLower(header)] = values[0]
 | 
			
		||||
		} else {
 | 
			
		||||
			newHeaders[header] = "[" + strings.Join(values, ", ") + "]"
 | 
			
		||||
			newHeaders[strings.ToLower(header)] = "[" + strings.Join(values, ", ") + "]"
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// Delete the User-Agent header, as it is managed separately.
 | 
			
		||||
@@ -301,7 +375,7 @@ func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath s
 | 
			
		||||
	defer keyFile.Close()
 | 
			
		||||
 | 
			
		||||
	// Limit key access to the owner only.
 | 
			
		||||
	keyFile.Chmod(0600)
 | 
			
		||||
	_ = keyFile.Chmod(0600)
 | 
			
		||||
 | 
			
		||||
	if err := pem.Encode(keyFile, key); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
 
 | 
			
		||||
@@ -10,36 +10,32 @@ import (
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"net"
 | 
			
		||||
	"os"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/config"
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/threatfeed"
 | 
			
		||||
	"golang.org/x/crypto/ssh"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// StartSSH serves as a wrapper to initialize and start an SSH honeypot server.
 | 
			
		||||
// The SSH server is designed to log the usernames and passwords submitted in
 | 
			
		||||
// authentication requests. It is not possible for clients to log in to the
 | 
			
		||||
// honeypot server, as authentication is the only function handled by the
 | 
			
		||||
// server. Clients receive authentication failure responses for every login
 | 
			
		||||
// attempt. This function calls the underlying startSSH function to perform the
 | 
			
		||||
// actual server startup.
 | 
			
		||||
func StartSSH(srv *config.Server) {
 | 
			
		||||
	fmt.Printf("Starting SSH server on port: %s\n", srv.Port)
 | 
			
		||||
	if err := startSSH(srv); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The SSH server has terminated:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
// serverTimeout defines the duration after which connected clients are
 | 
			
		||||
// automatically disconnected, set to 30 seconds.
 | 
			
		||||
const serverTimeout = 30 * time.Second
 | 
			
		||||
 | 
			
		||||
// startSSH starts the SSH honeypot server. It handles the server's main loop,
 | 
			
		||||
// authentication callback, and logging.
 | 
			
		||||
func startSSH(srv *config.Server) error {
 | 
			
		||||
	// Create a new SSH server configuration.
 | 
			
		||||
// Start initializes and starts an SSH honeypot server. The SSH server is
 | 
			
		||||
// designed to log the usernames and passwords submitted in authentication
 | 
			
		||||
// requests. It is not possible for clients to log in to the honeypot server,
 | 
			
		||||
// as authentication is the only function handled by the server. Clients
 | 
			
		||||
// receive authentication failure responses for every login attempt.
 | 
			
		||||
// Interactions with the SSH server are sent to the threat feed.
 | 
			
		||||
func Start(cfg *config.Server) {
 | 
			
		||||
	fmt.Printf("Starting SSH server on port: %s\n", cfg.Port)
 | 
			
		||||
	sshConfig := &ssh.ServerConfig{}
 | 
			
		||||
 | 
			
		||||
	// Load or generate a private key and add it to the SSH configuration.
 | 
			
		||||
	privateKey, err := loadOrGeneratePrivateKey(srv.KeyPath)
 | 
			
		||||
	privateKey, err := loadOrGeneratePrivateKey(cfg.KeyPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "The SSH server on port %s has stopped: %v\n", cfg.Port, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	sshConfig.AddHostKey(privateKey)
 | 
			
		||||
 | 
			
		||||
@@ -47,21 +43,33 @@ func startSSH(srv *config.Server) error {
 | 
			
		||||
	// server version string advertised to connecting clients. This allows
 | 
			
		||||
	// the honeypot server to mimic the appearance of other common SSH servers,
 | 
			
		||||
	// such as OpenSSH on Debian, Ubuntu, FreeBSD, or Raspberry Pi.
 | 
			
		||||
	if len(srv.Banner) > 0 {
 | 
			
		||||
		sshConfig.ServerVersion = srv.Banner
 | 
			
		||||
	if len(cfg.Banner) > 0 {
 | 
			
		||||
		sshConfig.ServerVersion = cfg.Banner
 | 
			
		||||
	} else {
 | 
			
		||||
		sshConfig.ServerVersion = config.DefaultBannerSSH
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Define the password callback function for the SSH server.
 | 
			
		||||
	// Define the public key authentication callback function.
 | 
			
		||||
	sshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
 | 
			
		||||
		// This public key authentication function rejects all requests.
 | 
			
		||||
		// Currently, no data is logged. Useful information may include:
 | 
			
		||||
		// `key.Type()` and `ssh.FingerprintSHA256(key)`.
 | 
			
		||||
 | 
			
		||||
		// Short, intentional delay.
 | 
			
		||||
		time.Sleep(200 * time.Millisecond)
 | 
			
		||||
 | 
			
		||||
		// Reject the authentication request.
 | 
			
		||||
		return nil, fmt.Errorf("permission denied")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Define the password authentication callback function.
 | 
			
		||||
	sshConfig.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
 | 
			
		||||
		// Log the the username and password submitted by the client.
 | 
			
		||||
		dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
 | 
			
		||||
		src_ip, src_port, _ := net.SplitHostPort(conn.RemoteAddr().String())
 | 
			
		||||
		srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
		src_ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
 | 
			
		||||
		cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
			slog.String("event_type", "ssh"),
 | 
			
		||||
			slog.String("source_ip", src_ip),
 | 
			
		||||
			slog.String("source_port", src_port),
 | 
			
		||||
			slog.String("server_ip", dst_ip),
 | 
			
		||||
			slog.String("server_port", dst_port),
 | 
			
		||||
			slog.String("server_name", config.GetHostname()),
 | 
			
		||||
@@ -76,18 +84,22 @@ func startSSH(srv *config.Server) error {
 | 
			
		||||
		fmt.Printf("[SSH] %s Username: %s Password: %s\n", src_ip, conn.User(), string(password))
 | 
			
		||||
 | 
			
		||||
		// Update the threat feed with the source IP address from the request.
 | 
			
		||||
		if srv.SendToThreatFeed {
 | 
			
		||||
			threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
 | 
			
		||||
		if cfg.SendToThreatFeed {
 | 
			
		||||
			threatfeed.Update(src_ip, cfg.ThreatScore)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Return an invalid username or password error to the client.
 | 
			
		||||
		// Insert fixed delay to mimic PAM.
 | 
			
		||||
		time.Sleep(2 * time.Second)
 | 
			
		||||
 | 
			
		||||
		// Reject the authentication request.
 | 
			
		||||
		return nil, fmt.Errorf("invalid username or password")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start the SSH server.
 | 
			
		||||
	listener, err := net.Listen("tcp", ":"+srv.Port)
 | 
			
		||||
	listener, err := net.Listen("tcp", ":"+cfg.Port)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to listen on port '%s': %w", srv.Port, err)
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "The SSH server on port %s has stopped: %v\n", cfg.Port, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	defer listener.Close()
 | 
			
		||||
 | 
			
		||||
@@ -103,28 +115,20 @@ func startSSH(srv *config.Server) error {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleConnection manages incoming SSH client connections. It performs the
 | 
			
		||||
// handshake and establishes communication channels.
 | 
			
		||||
// handshake and handles authentication callbacks.
 | 
			
		||||
func handleConnection(conn net.Conn, config *ssh.ServerConfig) {
 | 
			
		||||
	defer conn.Close()
 | 
			
		||||
	_ = conn.SetDeadline(time.Now().Add(serverTimeout))
 | 
			
		||||
 | 
			
		||||
	// Perform handshake on incoming connection.
 | 
			
		||||
	sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
 | 
			
		||||
	// Perform handshake and authentication. Authentication callbacks are
 | 
			
		||||
	// defined in the SSH server configuration. Since authentication requests
 | 
			
		||||
	// are always rejected, this function will consistently return an error,
 | 
			
		||||
	// and no further connection handling is necessary.
 | 
			
		||||
	sshConn, _, _, err := ssh.NewServerConn(conn, config)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	defer sshConn.Close()
 | 
			
		||||
 | 
			
		||||
	// Handle SSH requests and channels.
 | 
			
		||||
	go ssh.DiscardRequests(reqs)
 | 
			
		||||
	go handleChannels(chans)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleChannels processes SSH channels for the connected client.
 | 
			
		||||
func handleChannels(chans <-chan ssh.NewChannel) {
 | 
			
		||||
	for newChannel := range chans {
 | 
			
		||||
		newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
 | 
			
		||||
		continue
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// loadOrGeneratePrivateKey attempts to load a private key from the specified
 | 
			
		||||
@@ -183,7 +187,7 @@ func writePrivateKey(path string, privateKey *rsa.PrivateKey) error {
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
 | 
			
		||||
	// Limit key access to the owner only.
 | 
			
		||||
	file.Chmod(0600)
 | 
			
		||||
	_ = file.Chmod(0600)
 | 
			
		||||
 | 
			
		||||
	if err := pem.Encode(file, privPem); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										106
									
								
								internal/stix/stix.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								internal/stix/stix.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
package stix
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// DeceptifeedID is a deterministic identifier for the Deceptifeed Identity
 | 
			
		||||
	// object. STIX objects should reference this ID using the `created_by_ref`
 | 
			
		||||
	// property to show the object was created by Deceptifeed. This constant is
 | 
			
		||||
	// the result of:
 | 
			
		||||
	// DeterministicID("identity", "{"identity_class":"system","name":"deceptifeed"}")
 | 
			
		||||
	DeceptifeedID = "identity--370c0cfb-3203-5ca4-b8a9-b1aeef9d6fb3"
 | 
			
		||||
 | 
			
		||||
	// SpecVersion is the version of the STIX specification being implemented.
 | 
			
		||||
	SpecVersion = "2.1"
 | 
			
		||||
 | 
			
		||||
	// ContentType is the `Content-Type` HTTP response header used when
 | 
			
		||||
	// returning STIX objects.
 | 
			
		||||
	ContentType = "application/stix+json;version=2.1"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Object represents a STIX Object, a general term for a STIX Domain Object
 | 
			
		||||
// (SDO), STIX Cyber-observable Object (SCO), STIX Relationship Object (SRO),
 | 
			
		||||
// or STIX Meta Object.
 | 
			
		||||
type Object interface{}
 | 
			
		||||
 | 
			
		||||
// Bundle represents a STIX Bundle Object. A Bundle is a collection of
 | 
			
		||||
// arbitrary STIX Objects grouped together in a single container.
 | 
			
		||||
type Bundle struct {
 | 
			
		||||
	Type    string   `json:"type"`              // Required
 | 
			
		||||
	ID      string   `json:"id"`                // Required
 | 
			
		||||
	Objects []Object `json:"objects,omitempty"` // Optional
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Indicator represents a STIX Indicator SDO.
 | 
			
		||||
type Indicator struct {
 | 
			
		||||
	Type           string      `json:"type"`                        // Required
 | 
			
		||||
	SpecVersion    string      `json:"spec_version"`                // Required
 | 
			
		||||
	ID             string      `json:"id"`                          // Required
 | 
			
		||||
	IndicatorTypes []string    `json:"indicator_types"`             // Required
 | 
			
		||||
	Pattern        string      `json:"pattern"`                     // Required
 | 
			
		||||
	PatternType    string      `json:"pattern_type"`                // Required
 | 
			
		||||
	Created        time.Time   `json:"created"`                     // Required
 | 
			
		||||
	Modified       time.Time   `json:"modified"`                    // Required
 | 
			
		||||
	ValidFrom      time.Time   `json:"valid_from"`                  // Required
 | 
			
		||||
	ValidUntil     *time.Time  `json:"valid_until,omitempty"`       // Optional
 | 
			
		||||
	Name           string      `json:"name,omitempty"`              // Optional
 | 
			
		||||
	Description    string      `json:"description,omitempty"`       // Optional
 | 
			
		||||
	KillChains     []KillChain `json:"kill_chain_phases,omitempty"` // Optional
 | 
			
		||||
	Labels         []string    `json:"labels,omitempty"`            // Optional
 | 
			
		||||
	Lang           string      `json:"lang,omitempty"`              // Optional
 | 
			
		||||
	CreatedByRef   string      `json:"created_by_ref,omitempty"`    // Optional
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// KillChain represents a STIX `kill-chain-phase` type, which represents a
 | 
			
		||||
// phase in a kill chain.
 | 
			
		||||
type KillChain struct {
 | 
			
		||||
	KillChain string `json:"kill_chain_name"` // Required
 | 
			
		||||
	Phase     string `json:"phase_name"`      // Required
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ObservableIP represents a STIX IP Address SCO.
 | 
			
		||||
type ObservableIP struct {
 | 
			
		||||
	Type         string `json:"type"`                     // Required
 | 
			
		||||
	SpecVersion  string `json:"spec_version,omitempty"`   // Optional
 | 
			
		||||
	ID           string `json:"id"`                       // Required
 | 
			
		||||
	Value        string `json:"value"`                    // Required
 | 
			
		||||
	CreatedByRef string `json:"created_by_ref,omitempty"` // Optional
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Identity represents a STIX Identity SDO, used to represent individuals,
 | 
			
		||||
// organizations, groups, or systems.
 | 
			
		||||
type Identity struct {
 | 
			
		||||
	Type        string    `json:"type"`                          // Required
 | 
			
		||||
	SpecVersion string    `json:"spec_version"`                  // Required
 | 
			
		||||
	ID          string    `json:"id"`                            // Required
 | 
			
		||||
	Class       string    `json:"identity_class"`                // Required
 | 
			
		||||
	Name        string    `json:"name"`                          // Required
 | 
			
		||||
	Description string    `json:"description,omitempty"`         // Optional
 | 
			
		||||
	Contact     string    `json:"contact_information,omitempty"` // Optional
 | 
			
		||||
	Created     time.Time `json:"created"`                       // Required
 | 
			
		||||
	Modified    time.Time `json:"modified"`                      // Required
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeceptifeedIdentity returns a STIX Identity object representing the
 | 
			
		||||
// Deceptifeed application.
 | 
			
		||||
func DeceptifeedIdentity() Identity {
 | 
			
		||||
	const initialCommitTime = "2024-10-16T18:48:00.000Z"
 | 
			
		||||
	created, err := time.Parse(time.RFC3339, initialCommitTime)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		created = time.Now()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return Identity{
 | 
			
		||||
		Type:        "identity",
 | 
			
		||||
		SpecVersion: SpecVersion,
 | 
			
		||||
		ID:          DeceptifeedID,
 | 
			
		||||
		Class:       "system",
 | 
			
		||||
		Name:        "Deceptifeed",
 | 
			
		||||
		Description: "Deceptifeed is a defense system that combines honeypot servers with an integrated threat feed.",
 | 
			
		||||
		Contact:     "deceptifeed.com",
 | 
			
		||||
		Created:     created,
 | 
			
		||||
		Modified:    created,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										102
									
								
								internal/stix/uuid.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								internal/stix/uuid.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,102 @@
 | 
			
		||||
package stix
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"crypto/rand"
 | 
			
		||||
	"crypto/sha1"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	prng "math/rand/v2"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	// nsSTIX is the byte representation of the STIX UUIDv5 namespace:
 | 
			
		||||
	// {00abedb4-aa42-466c-9c01-fed23315a9b7}
 | 
			
		||||
	nsSTIX = [16]byte{
 | 
			
		||||
		0x00, 0xab, 0xed, 0xb4,
 | 
			
		||||
		0xaa, 0x42,
 | 
			
		||||
		0x46, 0x6c,
 | 
			
		||||
		0x9c, 0x01,
 | 
			
		||||
		0xfe, 0xd2, 0x33, 0x15, 0xa9, 0xb7,
 | 
			
		||||
	}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// NewID returns a new random unique identifier for a STIX Object. Identifiers
 | 
			
		||||
// follow the form `objectType--UUID` where `objectType` is the exact value
 | 
			
		||||
// from the `type` property of the object and where `UUID` is an RFC
 | 
			
		||||
// 4122-compliant UUID. Random identifiers use UUIDv4.
 | 
			
		||||
func NewID(objectType string) string {
 | 
			
		||||
	return objectType + "--" + newUUIDv4()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeterministicID returns a deterministic unique identifier for a STIX Object.
 | 
			
		||||
// Identifiers follow the form `objectType--UUID` where `objectType` is the
 | 
			
		||||
// exact value from the `type` property of the object and where `UUID` is an RFC
 | 
			
		||||
// 4122-compliant UUID. Deterministic identifiers use UUIDv5 with the STIX
 | 
			
		||||
// namespace and select properties represented in JSON.
 | 
			
		||||
func DeterministicID(objectType string, jsonValues string) string {
 | 
			
		||||
	return objectType + "--" + newUUIDv5(nsSTIX, jsonValues)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// newUUIDv5 returns a string representation of a Universally Unique Identifier
 | 
			
		||||
// (UUID) RFC 4122 version 5 value. Version 5 UUIDs are a SHA-1 hash of a
 | 
			
		||||
// namespace identifier and a name.
 | 
			
		||||
func newUUIDv5(ns [16]byte, name string) string {
 | 
			
		||||
	// A version 5 UUID is generated by hashing a namespace identifier (itself,
 | 
			
		||||
	// a UUID) and a name (value) using SHA-1. Then specific bits are
 | 
			
		||||
	// overwritten to indicate version 5 and the variant (the format of the
 | 
			
		||||
	// UUID).
 | 
			
		||||
 | 
			
		||||
	// As per STIX 2.1, STIX Cyber-observable Objects using deterministic
 | 
			
		||||
	// identifiers should use UUIDv5 and the STIX 2.1 namespace:
 | 
			
		||||
	// {00abedb4-aa42-466c-9c01-fed23315a9b7}. The value of the name portion
 | 
			
		||||
	// should be the list of "ID Contributing Properties" defined for the SCO
 | 
			
		||||
	// and their values and represented as a JSON object. Example for IPv4
 | 
			
		||||
	// Address Object: {"value":"127.0.0.1"}
 | 
			
		||||
 | 
			
		||||
	// STIX Domain Objects may use UUIDv5 for the UUID portion of the
 | 
			
		||||
	// identifier, but must not use the STIX namespace.
 | 
			
		||||
 | 
			
		||||
	// Get the SHA-1 hash of `namespace` + `name`.
 | 
			
		||||
	h := sha1.New()
 | 
			
		||||
	_, _ = h.Write(ns[:])
 | 
			
		||||
	_, _ = h.Write([]byte(name))
 | 
			
		||||
 | 
			
		||||
	// Use only the first 16-bytes of the hash.
 | 
			
		||||
	b := h.Sum(nil)[:16]
 | 
			
		||||
 | 
			
		||||
	// Overwrite the version bits with 0b0101 (UUID version 5).
 | 
			
		||||
	b[6] = (b[6] & 0x0f) | 0x50
 | 
			
		||||
 | 
			
		||||
	// Overwrite the variant bits with 0b10.
 | 
			
		||||
	b[8] = (b[8] & 0x3f) | 0x80
 | 
			
		||||
 | 
			
		||||
	// Return as UUID string representation.
 | 
			
		||||
	return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// newUUIDv4 returns a string representation of a Universally Unique Identifier
 | 
			
		||||
// (UUID) RFC 4122 version 4 value. Version 4 UUIDs are random values.
 | 
			
		||||
func newUUIDv4() string {
 | 
			
		||||
	// A version 4 UUID is randomly generated. Then specific bits are
 | 
			
		||||
	// overwritten to indicate version 4 and the variant (the format of the
 | 
			
		||||
	// UUID).
 | 
			
		||||
 | 
			
		||||
	// Get 16 random bytes.
 | 
			
		||||
	var b = [16]byte{}
 | 
			
		||||
	_, err := rand.Read(b[:])
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// Fall back to PRNG if the OS random number generator call fails.
 | 
			
		||||
		for i := range b {
 | 
			
		||||
			// Go's math/rand/v2 package is imported as `prng`.
 | 
			
		||||
			b[i] = byte(prng.Int())
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Overwrite the version bits with 0b0100 (UUID version 4).
 | 
			
		||||
	b[6] = (b[6] & 0x0f) | 0x40
 | 
			
		||||
 | 
			
		||||
	// Overwrite the variant bits with 0b10.
 | 
			
		||||
	b[8] = (b[8] & 0x3f) | 0x80
 | 
			
		||||
 | 
			
		||||
	// Return as UUID string representation.
 | 
			
		||||
	return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										87
									
								
								internal/taxii/taxii.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								internal/taxii/taxii.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
package taxii
 | 
			
		||||
 | 
			
		||||
import "github.com/r-smith/deceptifeed/internal/stix"
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// APIRoot is the part of the URL that makes up the TAXII API root.
 | 
			
		||||
	APIRoot = "/taxii2/api/"
 | 
			
		||||
 | 
			
		||||
	// ContentType is the `Content-Type` HTTP response header used when
 | 
			
		||||
	// returning TAXII responses.
 | 
			
		||||
	ContentType = "application/taxii+json;version=2.1"
 | 
			
		||||
 | 
			
		||||
	// IndicatorsID is a fixed (random) identifier for the indicators
 | 
			
		||||
	// collection.
 | 
			
		||||
	IndicatorsID = "2cc72f88-8d92-4745-9c00-ea0deac18163"
 | 
			
		||||
 | 
			
		||||
	// IndicatorsAlias is the friendly alias for the indicators collection.
 | 
			
		||||
	IndicatorsAlias = "deceptifeed-indicators"
 | 
			
		||||
 | 
			
		||||
	// ObservablesID is a fixed (random) identifier for the observables
 | 
			
		||||
	// collection.
 | 
			
		||||
	ObservablesID = "8aaff655-40de-41e2-9064-3dc1620d6420"
 | 
			
		||||
 | 
			
		||||
	// ObservablesAlias is the friendly alias for the observables collection.
 | 
			
		||||
	ObservablesAlias = "deceptifeed-observables"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ImplementedCollections returns the collections that are available for use.
 | 
			
		||||
func ImplementedCollections() []Collection {
 | 
			
		||||
	return []Collection{
 | 
			
		||||
		{
 | 
			
		||||
			ID:          IndicatorsID,
 | 
			
		||||
			Title:       "Deceptifeed Indicators",
 | 
			
		||||
			Description: "This collection contains IP addresses represented as STIX Indicators",
 | 
			
		||||
			Alias:       IndicatorsAlias,
 | 
			
		||||
			CanRead:     true,
 | 
			
		||||
			CanWrite:    false,
 | 
			
		||||
			MediaTypes:  []string{ContentType},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			ID:          ObservablesID,
 | 
			
		||||
			Title:       "Deceptifeed Observables",
 | 
			
		||||
			Description: "This collection contains IP addresses represented as STIX Observables",
 | 
			
		||||
			Alias:       ObservablesAlias,
 | 
			
		||||
			CanRead:     true,
 | 
			
		||||
			CanWrite:    false,
 | 
			
		||||
			MediaTypes:  []string{ContentType},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Envelope represents a TAXII envelope resource, which is a simple wrapper for
 | 
			
		||||
// STIX 2 content.
 | 
			
		||||
type Envelope struct {
 | 
			
		||||
	More    bool          `json:"more"`           // Optional
 | 
			
		||||
	Next    string        `json:"next,omitempty"` // Optional
 | 
			
		||||
	Objects []stix.Object `json:"objects"`        // Optional
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Collection represents a TAXII collection resource, which contains general
 | 
			
		||||
// information about a collection.
 | 
			
		||||
type Collection struct {
 | 
			
		||||
	ID          string   `json:"id"`                    // Required
 | 
			
		||||
	Title       string   `json:"title"`                 // Required
 | 
			
		||||
	Description string   `json:"description,omitempty"` // Optional
 | 
			
		||||
	Alias       string   `json:"alias,omitempty"`       // Optional
 | 
			
		||||
	CanRead     bool     `json:"can_read"`              // Required
 | 
			
		||||
	CanWrite    bool     `json:"can_write"`             // Required
 | 
			
		||||
	MediaTypes  []string `json:"media_types,omitempty"` // Optional
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DiscoveryResource represents a TAXII discovery resource, which contains
 | 
			
		||||
// information about a TAXII server.
 | 
			
		||||
type DiscoveryResource struct {
 | 
			
		||||
	Title       string   `json:"title"`                 // Required
 | 
			
		||||
	Description string   `json:"description,omitempty"` // Optional
 | 
			
		||||
	Default     string   `json:"default,omitempty"`     // Optional
 | 
			
		||||
	APIRoots    []string `json:"api_roots,omitempty"`   // Optional
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// APIRootResource represents a TAXII api-root resource, which contains general
 | 
			
		||||
// information about the API root.
 | 
			
		||||
type APIRootResource struct {
 | 
			
		||||
	Title            string   `json:"title"`              // Required
 | 
			
		||||
	Versions         []string `json:"versions"`           // Required
 | 
			
		||||
	MaxContentLength int      `json:"max_content_length"` // Required
 | 
			
		||||
}
 | 
			
		||||
@@ -19,23 +19,15 @@ import (
 | 
			
		||||
// automatically disconnected, set to 30 seconds.
 | 
			
		||||
const serverTimeout = 30 * time.Second
 | 
			
		||||
 | 
			
		||||
// StartTCP serves as a wrapper to initialize and start a generic TCP honeypot
 | 
			
		||||
// server. It presents custom prompts to connected clients and logs their
 | 
			
		||||
// responses. This function calls the underlying startTCP function to
 | 
			
		||||
// perform the actual server startup.
 | 
			
		||||
func StartTCP(srv *config.Server) {
 | 
			
		||||
	fmt.Printf("Starting TCP server on port: %s\n", srv.Port)
 | 
			
		||||
	if err := startTCP(srv); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The TCP server has terminated:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// startTCP starts the TCP honeypot server. It handles the server's main loop.
 | 
			
		||||
func startTCP(srv *config.Server) error {
 | 
			
		||||
	// Start the TCP server.
 | 
			
		||||
	listener, err := net.Listen("tcp", ":"+srv.Port)
 | 
			
		||||
// Start initializes and starts a generic TCP honeypot server. It presents
 | 
			
		||||
// custom prompts to connected clients and logs their responses. Interactions
 | 
			
		||||
// with the TCP server are sent to the threat feed.
 | 
			
		||||
func Start(cfg *config.Server) {
 | 
			
		||||
	fmt.Printf("Starting TCP server on port: %s\n", cfg.Port)
 | 
			
		||||
	listener, err := net.Listen("tcp", ":"+cfg.Port)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to listen on port '%s': %w", srv.Port, err)
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "The TCP server on port %s has stopped: %v\n", cfg.Port, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	defer listener.Close()
 | 
			
		||||
 | 
			
		||||
@@ -46,7 +38,7 @@ func startTCP(srv *config.Server) error {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		go handleConnection(conn, srv)
 | 
			
		||||
		go handleConnection(conn, cfg)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -54,23 +46,23 @@ func startTCP(srv *config.Server) error {
 | 
			
		||||
// server. It presents custom prompts to the client, records and logs their
 | 
			
		||||
// responses, and then disconnects the client. This function manages the entire
 | 
			
		||||
// client interaction.
 | 
			
		||||
func handleConnection(conn net.Conn, srv *config.Server) {
 | 
			
		||||
func handleConnection(conn net.Conn, cfg *config.Server) {
 | 
			
		||||
	defer conn.Close()
 | 
			
		||||
	conn.SetDeadline(time.Now().Add(serverTimeout))
 | 
			
		||||
	_ = conn.SetDeadline(time.Now().Add(serverTimeout))
 | 
			
		||||
 | 
			
		||||
	// Print an optional banner. Replace any occurrences of the newline escape
 | 
			
		||||
	// sequence "\\n" with "\r\n" (carriage return, line feed), used by
 | 
			
		||||
	// protocols such as Telnet and SMTP.
 | 
			
		||||
	if len(srv.Banner) > 0 {
 | 
			
		||||
		conn.Write([]byte(strings.ReplaceAll(srv.Banner, "\\n", "\r\n")))
 | 
			
		||||
	if len(cfg.Banner) > 0 {
 | 
			
		||||
		_, _ = conn.Write([]byte(strings.ReplaceAll(cfg.Banner, "\\n", "\r\n")))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Present the prompts from the server configuration to the connected
 | 
			
		||||
	// client and record their responses.
 | 
			
		||||
	scanner := bufio.NewScanner(conn)
 | 
			
		||||
	responses := make(map[string]string)
 | 
			
		||||
	for i, prompt := range srv.Prompts {
 | 
			
		||||
		conn.Write([]byte(strings.ReplaceAll(prompt.Text, "\\n", "\r\n")))
 | 
			
		||||
	for i, prompt := range cfg.Prompts {
 | 
			
		||||
		_, _ = conn.Write([]byte(strings.ReplaceAll(prompt.Text, "\\n", "\r\n")))
 | 
			
		||||
		scanner.Scan()
 | 
			
		||||
		var key string
 | 
			
		||||
		// Each prompt includes an optional Log field that serves as the key
 | 
			
		||||
@@ -90,7 +82,7 @@ func handleConnection(conn net.Conn, srv *config.Server) {
 | 
			
		||||
 | 
			
		||||
	// If no prompts are provided in the configuration, wait for the client to
 | 
			
		||||
	// send data then record the received input.
 | 
			
		||||
	if len(srv.Prompts) == 0 {
 | 
			
		||||
	if len(cfg.Prompts) == 0 {
 | 
			
		||||
		scanner.Scan()
 | 
			
		||||
		responses["data"] = scanner.Text()
 | 
			
		||||
	}
 | 
			
		||||
@@ -109,11 +101,10 @@ func handleConnection(conn net.Conn, srv *config.Server) {
 | 
			
		||||
 | 
			
		||||
	// Log the connection along with all responses received from the client.
 | 
			
		||||
	dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
 | 
			
		||||
	src_ip, src_port, _ := net.SplitHostPort(conn.RemoteAddr().String())
 | 
			
		||||
	srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
	src_ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
 | 
			
		||||
	cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
		slog.String("event_type", "tcp"),
 | 
			
		||||
		slog.String("source_ip", src_ip),
 | 
			
		||||
		slog.String("source_port", src_port),
 | 
			
		||||
		slog.String("server_ip", dst_ip),
 | 
			
		||||
		slog.String("server_port", dst_port),
 | 
			
		||||
		slog.String("server_name", config.GetHostname()),
 | 
			
		||||
@@ -124,8 +115,8 @@ func handleConnection(conn net.Conn, srv *config.Server) {
 | 
			
		||||
	fmt.Printf("[TCP] %s %v\n", src_ip, responsesToString(responses))
 | 
			
		||||
 | 
			
		||||
	// Update the threat feed with the source IP address from the interaction.
 | 
			
		||||
	if srv.SendToThreatFeed {
 | 
			
		||||
		threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
 | 
			
		||||
	if cfg.SendToThreatFeed {
 | 
			
		||||
		threatfeed.Update(src_ip, cfg.ThreatScore)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										202
									
								
								internal/threatfeed/data.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								internal/threatfeed/data.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,202 @@
 | 
			
		||||
package threatfeed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"encoding/csv"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"math"
 | 
			
		||||
	"net"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// IoC represents an Indicator of Compromise (IoC) entry that makes up the
 | 
			
		||||
// structure of the threat feed.
 | 
			
		||||
type IoC struct {
 | 
			
		||||
	// Added records the time when an IP address is added to the threat feed.
 | 
			
		||||
	Added time.Time
 | 
			
		||||
 | 
			
		||||
	// LastSeen records the last time an IP was observed interacting with a
 | 
			
		||||
	// honeypot server.
 | 
			
		||||
	LastSeen time.Time
 | 
			
		||||
 | 
			
		||||
	// ThreatScore represents a score for a given IP address. It is incremented
 | 
			
		||||
	// based on the configured threat score of the honeypot server that the IP
 | 
			
		||||
	// interacted with.
 | 
			
		||||
	ThreatScore int
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// dateFormat specifies the timestamp format used for CSV data.
 | 
			
		||||
	dateFormat = time.RFC3339
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	// iocData stores the Indicator of Compromise (IoC) entries which make up
 | 
			
		||||
	// the active threat feed. It is initially populated by loadCSV if an
 | 
			
		||||
	// existing CSV file is provided. The map is subsequently updated by
 | 
			
		||||
	// `Update` whenever a client interacts with a honeypot server. This
 | 
			
		||||
	// map is served by the threat feed HTTP server for clients to consume.
 | 
			
		||||
	iocData = make(map[string]*IoC)
 | 
			
		||||
 | 
			
		||||
	// mutex is to ensure thread-safe access to iocData.
 | 
			
		||||
	mutex sync.Mutex
 | 
			
		||||
 | 
			
		||||
	// dataChanged indicates whether the IoC map has been modified since the
 | 
			
		||||
	// last time it was saved to disk.
 | 
			
		||||
	dataChanged = false
 | 
			
		||||
 | 
			
		||||
	// csvHeader defines the header row for saved threat feed data.
 | 
			
		||||
	csvHeader = []string{"ip", "added", "last_seen", "threat_score"}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// 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) {
 | 
			
		||||
	// 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 !configuration.IsPrivateIncluded && netIP.IsPrivate() {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	now := time.Now()
 | 
			
		||||
	mutex.Lock()
 | 
			
		||||
	if ioc, exists := iocData[ip]; exists {
 | 
			
		||||
		// Update existing entry.
 | 
			
		||||
		ioc.LastSeen = now
 | 
			
		||||
		if threatScore > 0 {
 | 
			
		||||
			if ioc.ThreatScore > math.MaxInt-threatScore {
 | 
			
		||||
				ioc.ThreatScore = math.MaxInt
 | 
			
		||||
			} else {
 | 
			
		||||
				ioc.ThreatScore += threatScore
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		// Create a new entry.
 | 
			
		||||
		iocData[ip] = &IoC{
 | 
			
		||||
			Added:       now,
 | 
			
		||||
			LastSeen:    now,
 | 
			
		||||
			ThreatScore: threatScore,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	mutex.Unlock()
 | 
			
		||||
 | 
			
		||||
	dataChanged = true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// deleteExpired deletes expired threat feed entries from the IoC map.
 | 
			
		||||
func deleteExpired() {
 | 
			
		||||
	mutex.Lock()
 | 
			
		||||
	defer mutex.Unlock()
 | 
			
		||||
 | 
			
		||||
	for key, value := range iocData {
 | 
			
		||||
		if value.expired() {
 | 
			
		||||
			delete(iocData, key)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// expired returns whether an IoC is considered expired based on the last
 | 
			
		||||
// seen date and the configured expiry hours.
 | 
			
		||||
func (ioc *IoC) expired() bool {
 | 
			
		||||
	if configuration.ExpiryHours <= 0 {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
	return ioc.LastSeen.Before(time.Now().Add(-time.Hour * time.Duration(configuration.ExpiryHours)))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// loadCSV loads existing threat feed data from a CSV file. If found, it
 | 
			
		||||
// populates iocData which represents the active threat feed. This function is
 | 
			
		||||
// called once during the initialization of the threat feed server.
 | 
			
		||||
func loadCSV() error {
 | 
			
		||||
	file, err := os.Open(configuration.DatabasePath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, os.ErrNotExist) {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
 | 
			
		||||
	reader := csv.NewReader(file)
 | 
			
		||||
	reader.FieldsPerRecord = -1
 | 
			
		||||
	records, err := reader.ReadAll()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if len(records) < 2 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var added time.Time
 | 
			
		||||
	var lastSeen time.Time
 | 
			
		||||
	var threatScore int
 | 
			
		||||
	for _, record := range records[1:] {
 | 
			
		||||
		ip := record[0]
 | 
			
		||||
 | 
			
		||||
		// Parse added, defaulting to current time.
 | 
			
		||||
		added = time.Now()
 | 
			
		||||
		if len(record) > 1 && record[1] != "" {
 | 
			
		||||
			added, _ = time.Parse(dateFormat, record[1])
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Parse lastSeen, defaulting to current time.
 | 
			
		||||
		lastSeen = time.Now()
 | 
			
		||||
		if len(record) > 2 && record[2] != "" {
 | 
			
		||||
			lastSeen, _ = time.Parse(dateFormat, record[2])
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Parse threat score, defaulting to 1.
 | 
			
		||||
		threatScore = 1
 | 
			
		||||
		if len(record) > 3 && record[3] != "" {
 | 
			
		||||
			if parsedLevel, err := strconv.Atoi(record[3]); err == nil {
 | 
			
		||||
				threatScore = parsedLevel
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		iocData[ip] = &IoC{Added: added, LastSeen: lastSeen, ThreatScore: threatScore}
 | 
			
		||||
	}
 | 
			
		||||
	deleteExpired()
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// saveCSV writes the current threat feed to a CSV file. This CSV file ensures
 | 
			
		||||
// the threat feed data persists across application restarts. It is not the
 | 
			
		||||
// active threat feed.
 | 
			
		||||
func saveCSV() error {
 | 
			
		||||
	buf := new(bytes.Buffer)
 | 
			
		||||
	writer := csv.NewWriter(buf)
 | 
			
		||||
	err := writer.Write(csvHeader)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mutex.Lock()
 | 
			
		||||
	for ip, ioc := range iocData {
 | 
			
		||||
		if err := writer.Write([]string{
 | 
			
		||||
			ip,
 | 
			
		||||
			ioc.Added.Format(dateFormat),
 | 
			
		||||
			ioc.LastSeen.Format(dateFormat),
 | 
			
		||||
			strconv.Itoa(ioc.ThreatScore),
 | 
			
		||||
		}); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	mutex.Unlock()
 | 
			
		||||
	writer.Flush()
 | 
			
		||||
 | 
			
		||||
	if err := os.WriteFile(configuration.DatabasePath, buf.Bytes(), 0644); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
@@ -1,162 +0,0 @@
 | 
			
		||||
package threatfeed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"encoding/csv"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"math"
 | 
			
		||||
	"net"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// IoC represents an Indicator of Compromise (IoC) entry in the threat feed
 | 
			
		||||
// database. The database is in CSV format, with each row containing an IP
 | 
			
		||||
// address and its associated IoC data.
 | 
			
		||||
type IoC struct {
 | 
			
		||||
	// LastSeen records the last time an IP was observed interacting with a
 | 
			
		||||
	// honeypot server.
 | 
			
		||||
	LastSeen time.Time
 | 
			
		||||
 | 
			
		||||
	// ThreatScore represents a score for a given IP address. It is incremented
 | 
			
		||||
	// based on the configured threat score of the honeypot server that the IP
 | 
			
		||||
	// interacted with.
 | 
			
		||||
	ThreatScore int
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// csvHeader defines the header row for the threat feed database.
 | 
			
		||||
	csvHeader = "ip,last_seen,threat_score"
 | 
			
		||||
 | 
			
		||||
	// dateFormat specifies the timestamp format used for CSV data.
 | 
			
		||||
	dateFormat = time.RFC3339
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// loadIoC reads IoC data from an existing CSV database. If found, it
 | 
			
		||||
// populates iocMap. This function is called once during the initialization of
 | 
			
		||||
// the threat feed server.
 | 
			
		||||
func loadIoC() error {
 | 
			
		||||
	file, err := os.Open(configuration.DatabasePath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, os.ErrNotExist) {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
 | 
			
		||||
	reader := csv.NewReader(file)
 | 
			
		||||
	reader.FieldsPerRecord = -1
 | 
			
		||||
	records, err := reader.ReadAll()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if len(records) < 2 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var lastSeen time.Time
 | 
			
		||||
	var threatScore int
 | 
			
		||||
	for _, record := range records[1:] {
 | 
			
		||||
		ip := record[0]
 | 
			
		||||
 | 
			
		||||
		// Parse lastSeen, if available.
 | 
			
		||||
		if len(record) > 1 && record[1] != "" {
 | 
			
		||||
			lastSeen, _ = time.Parse(dateFormat, record[1])
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Parse threat score, defaulting to 1.
 | 
			
		||||
		threatScore = 1
 | 
			
		||||
		if len(record) > 2 && record[2] != "" {
 | 
			
		||||
			if parsedLevel, err := strconv.Atoi(record[2]); err == nil {
 | 
			
		||||
				threatScore = parsedLevel
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		iocMap[ip] = &IoC{LastSeen: lastSeen, ThreatScore: threatScore}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateIoC updates the IoC map. This function is called by honeypot servers
 | 
			
		||||
// each time a client interacts with the honeypot.
 | 
			
		||||
func UpdateIoC(ip string, threatScore int) {
 | 
			
		||||
	mutex.Lock()
 | 
			
		||||
	defer mutex.Unlock()
 | 
			
		||||
 | 
			
		||||
	// Check if the given IP string is a private address. The threat feed may
 | 
			
		||||
	// be configured to include or exclude private IPs.
 | 
			
		||||
	netIP := net.ParseIP(ip)
 | 
			
		||||
	if netIP == nil {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if !configuration.IsPrivateIncluded && netIP.IsPrivate() {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	now := time.Now()
 | 
			
		||||
	hasMapChanged = true
 | 
			
		||||
	if ioc, exists := iocMap[ip]; exists {
 | 
			
		||||
		// Update existing entry.
 | 
			
		||||
		ioc.LastSeen = now
 | 
			
		||||
		if ioc.ThreatScore+threatScore <= math.MaxInt {
 | 
			
		||||
			ioc.ThreatScore += threatScore
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		// Create a new entry.
 | 
			
		||||
		iocMap[ip] = &IoC{
 | 
			
		||||
			LastSeen:    now,
 | 
			
		||||
			ThreatScore: threatScore,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Remove expired entries from iocMap.
 | 
			
		||||
	removeExpired()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// removeExpired checks the IoC map for entries that have expired based on
 | 
			
		||||
// their last seen date and the configured expiry hours. It deletes any expired
 | 
			
		||||
// entries from the map. This function should be called exclusively by
 | 
			
		||||
// UpdateIoC, which manages the mutex lock.
 | 
			
		||||
func removeExpired() {
 | 
			
		||||
	// If expiryHours is set to 0, entries never expire and will remain
 | 
			
		||||
	// indefinitely.
 | 
			
		||||
	if configuration.ExpiryHours <= 0 {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var iocToRemove []string
 | 
			
		||||
	expirtyTime := time.Now().Add(-time.Hour * time.Duration(configuration.ExpiryHours))
 | 
			
		||||
 | 
			
		||||
	for key, value := range iocMap {
 | 
			
		||||
		if value.LastSeen.Before(expirtyTime) {
 | 
			
		||||
			iocToRemove = append(iocToRemove, key)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, key := range iocToRemove {
 | 
			
		||||
		delete(iocMap, key)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// saveIoC writes the current IoC map to a CSV file, ensuring the threat feed
 | 
			
		||||
// database persists across application restarts.
 | 
			
		||||
func saveIoC() error {
 | 
			
		||||
	mutex.Lock()
 | 
			
		||||
	defer mutex.Unlock()
 | 
			
		||||
 | 
			
		||||
	buf := new(bytes.Buffer)
 | 
			
		||||
	writer := csv.NewWriter(buf)
 | 
			
		||||
	writer.Write(strings.Split(csvHeader, ","))
 | 
			
		||||
	for ip, ioc := range iocMap {
 | 
			
		||||
		writer.Write([]string{ip, ioc.LastSeen.Format(dateFormat), strconv.Itoa(ioc.ThreatScore)})
 | 
			
		||||
	}
 | 
			
		||||
	writer.Flush()
 | 
			
		||||
 | 
			
		||||
	if err := os.WriteFile(configuration.DatabasePath, buf.Bytes(), 0644); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										265
									
								
								internal/threatfeed/feed.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								internal/threatfeed/feed.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,265 @@
 | 
			
		||||
package threatfeed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net"
 | 
			
		||||
	"os"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/stix"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// sortMethod is a type representing threat feed sorting methods.
 | 
			
		||||
type sortMethod int
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	byIP sortMethod = iota
 | 
			
		||||
	byLastSeen
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// feedOptions define configurable options for serving the threat feed.
 | 
			
		||||
type feedOptions struct {
 | 
			
		||||
	sortMethod sortMethod
 | 
			
		||||
	seenAfter  time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// option defines a function type for configuring `feedOptions`.
 | 
			
		||||
type option func(*feedOptions)
 | 
			
		||||
 | 
			
		||||
// sortByLastSeen returns an option that sets the sort method in `feedOptions`
 | 
			
		||||
// to sort the threat feed by the last seen time.
 | 
			
		||||
func sortByLastSeen() option {
 | 
			
		||||
	return func(o *feedOptions) {
 | 
			
		||||
		o.sortMethod = byLastSeen
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// seenAfter returns an option that sets the the `seenAfter` time in
 | 
			
		||||
// `feedOptions`. This filters the feed to include only entries seen after the
 | 
			
		||||
// specified timestamp.
 | 
			
		||||
func seenAfter(after time.Time) option {
 | 
			
		||||
	return func(o *feedOptions) {
 | 
			
		||||
		o.seenAfter = after
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// prepareFeed filters, processes, and sorts IP addresses from the threat feed.
 | 
			
		||||
// The resulting slice of `net.IP` represents the current threat feed to be
 | 
			
		||||
// served to clients.
 | 
			
		||||
func prepareFeed(options ...option) []net.IP {
 | 
			
		||||
	opt := feedOptions{
 | 
			
		||||
		sortMethod: byIP,
 | 
			
		||||
		seenAfter:  time.Time{},
 | 
			
		||||
	}
 | 
			
		||||
	for _, o := range options {
 | 
			
		||||
		o(&opt)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse IPs from iocData to net.IP. Skip IPs that are expired, below the
 | 
			
		||||
	// minimum threat score, or are private, based on the configuration.
 | 
			
		||||
	mutex.Lock()
 | 
			
		||||
	netIPs := make([]net.IP, 0, len(iocData))
 | 
			
		||||
	for ip, ioc := range iocData {
 | 
			
		||||
		if ioc.expired() || ioc.ThreatScore < configuration.MinimumThreatScore || !ioc.LastSeen.After(opt.seenAfter) {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ipParsed := net.ParseIP(ip)
 | 
			
		||||
		if ipParsed == nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		if !configuration.IsPrivateIncluded && ipParsed.IsPrivate() {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		netIPs = append(netIPs, ipParsed)
 | 
			
		||||
	}
 | 
			
		||||
	mutex.Unlock()
 | 
			
		||||
 | 
			
		||||
	// If an exclude list is provided, filter the IP list.
 | 
			
		||||
	if len(configuration.ExcludeListPath) > 0 {
 | 
			
		||||
		ipsToRemove, err := parseExcludeList(configuration.ExcludeListPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Fprintln(os.Stderr, "Failed to read threat feed exclude list:", err)
 | 
			
		||||
		} else {
 | 
			
		||||
			netIPs = filterIPs(netIPs, ipsToRemove)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Apply sorting.
 | 
			
		||||
	switch opt.sortMethod {
 | 
			
		||||
	case byIP:
 | 
			
		||||
		slices.SortFunc(netIPs, func(a, b net.IP) int {
 | 
			
		||||
			return bytes.Compare(a, b)
 | 
			
		||||
		})
 | 
			
		||||
	case byLastSeen:
 | 
			
		||||
		mutex.Lock()
 | 
			
		||||
		slices.SortFunc(netIPs, func(a, b net.IP) int {
 | 
			
		||||
			// Sort by LastSeen date, and if equal, sort by IP.
 | 
			
		||||
			dateCompare := iocData[a.String()].LastSeen.Compare(iocData[b.String()].LastSeen)
 | 
			
		||||
			if dateCompare != 0 {
 | 
			
		||||
				return dateCompare
 | 
			
		||||
			}
 | 
			
		||||
			return bytes.Compare(a, b)
 | 
			
		||||
		})
 | 
			
		||||
		mutex.Unlock()
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return netIPs
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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
 | 
			
		||||
// CIDR ranges found in the file.
 | 
			
		||||
func parseExcludeList(filepath string) (map[string]struct{}, error) {
 | 
			
		||||
	ips := make(map[string]struct{})
 | 
			
		||||
 | 
			
		||||
	file, err := os.Open(filepath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
 | 
			
		||||
	scanner := bufio.NewScanner(file)
 | 
			
		||||
	for scanner.Scan() {
 | 
			
		||||
		line := strings.TrimSpace(scanner.Text())
 | 
			
		||||
		if len(line) > 0 {
 | 
			
		||||
			ips[line] = struct{}{}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if err := scanner.Err(); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return ips, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// filterIPs removes IPs from ipList that are found in the ipsToRemove map. The
 | 
			
		||||
// keys in ipsToRemove may be single IP addresses or CIDR ranges. If a key is a
 | 
			
		||||
// CIDR range, an IP will be removed if it falls within that range.
 | 
			
		||||
func filterIPs(ipList []net.IP, ipsToRemove map[string]struct{}) []net.IP {
 | 
			
		||||
	if len(ipsToRemove) == 0 {
 | 
			
		||||
		return ipList
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cidrNetworks := []*net.IPNet{}
 | 
			
		||||
	for cidr := range ipsToRemove {
 | 
			
		||||
		if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
 | 
			
		||||
			cidrNetworks = append(cidrNetworks, ipnet)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	i := 0
 | 
			
		||||
	for _, ip := range ipList {
 | 
			
		||||
		if _, found := ipsToRemove[ip.String()]; found {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		contains := false
 | 
			
		||||
		for _, ipnet := range cidrNetworks {
 | 
			
		||||
			if ipnet.Contains(ip) {
 | 
			
		||||
				contains = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if !contains {
 | 
			
		||||
			ipList[i] = ip
 | 
			
		||||
			i++
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return ipList[:i]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// convertToIndicators converts IP addresses from the threat feed into a
 | 
			
		||||
// collection of STIX Indicator objects.
 | 
			
		||||
func convertToIndicators(ips []net.IP) []stix.Object {
 | 
			
		||||
	if len(ips) == 0 {
 | 
			
		||||
		return []stix.Object{}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const indicator = "indicator"
 | 
			
		||||
	result := make([]stix.Object, 0, len(ips)+1)
 | 
			
		||||
 | 
			
		||||
	// Add the Deceptifeed `Identity` as the first object in the collection.
 | 
			
		||||
	// All IP addresses in the collection will reference this identity as
 | 
			
		||||
	// the creator.
 | 
			
		||||
	result = append(result, stix.DeceptifeedIdentity())
 | 
			
		||||
 | 
			
		||||
	for _, ip := range ips {
 | 
			
		||||
		if ioc, found := iocData[ip.String()]; found {
 | 
			
		||||
			pattern := "[ipv4-addr:value = '"
 | 
			
		||||
			if strings.Contains(ip.String(), ":") {
 | 
			
		||||
				pattern = "[ipv6-addr:value = '"
 | 
			
		||||
			}
 | 
			
		||||
			pattern = pattern + ip.String() + "']"
 | 
			
		||||
 | 
			
		||||
			// Fixed expiration: 2 months since last seen.
 | 
			
		||||
			validUntil := new(time.Time)
 | 
			
		||||
			*validUntil = ioc.LastSeen.AddDate(0, 2, 0).UTC()
 | 
			
		||||
 | 
			
		||||
			// Generate a deterministic identifier for each IP address in the
 | 
			
		||||
			// threat feed using the STIX IP pattern represented as a JSON
 | 
			
		||||
			// string. For example: {"pattern":"[ipv4-addr:value='127.0.0.1']"}
 | 
			
		||||
			patternJSON := fmt.Sprintf("{\"pattern\":\"%s\"}", pattern)
 | 
			
		||||
 | 
			
		||||
			result = append(result, stix.Indicator{
 | 
			
		||||
				Type:           indicator,
 | 
			
		||||
				SpecVersion:    stix.SpecVersion,
 | 
			
		||||
				ID:             stix.DeterministicID(indicator, patternJSON),
 | 
			
		||||
				IndicatorTypes: []string{"malicious-activity"},
 | 
			
		||||
				Pattern:        pattern,
 | 
			
		||||
				PatternType:    "stix",
 | 
			
		||||
				Created:        ioc.Added.UTC(),
 | 
			
		||||
				Modified:       ioc.LastSeen.UTC(),
 | 
			
		||||
				ValidFrom:      ioc.Added.UTC(),
 | 
			
		||||
				ValidUntil:     validUntil,
 | 
			
		||||
				Name:           ip.String() + " : honeypot interaction",
 | 
			
		||||
				Description:    "This IP was observed interacting with a honeypot server.",
 | 
			
		||||
				KillChains:     []stix.KillChain{{KillChain: "mitre-attack", Phase: "reconnaissance"}},
 | 
			
		||||
				Lang:           "en",
 | 
			
		||||
				Labels:         []string{"honeypot"},
 | 
			
		||||
				CreatedByRef:   stix.DeceptifeedID,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// convertToObservables converts IP addresses from the threat feed into a
 | 
			
		||||
// collection of STIX Cyber-observable Objects.
 | 
			
		||||
func convertToObservables(ips []net.IP) []stix.Object {
 | 
			
		||||
	if len(ips) == 0 {
 | 
			
		||||
		return []stix.Object{}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := make([]stix.Object, 0, len(ips)+1)
 | 
			
		||||
 | 
			
		||||
	// Add the Deceptifeed `Identity` as the first object in the collection.
 | 
			
		||||
	// All IP addresses in the collection will reference this identity as
 | 
			
		||||
	// the creator.
 | 
			
		||||
	result = append(result, stix.DeceptifeedIdentity())
 | 
			
		||||
 | 
			
		||||
	for _, ip := range ips {
 | 
			
		||||
		if _, found := iocData[ip.String()]; found {
 | 
			
		||||
			t := "ipv4-addr"
 | 
			
		||||
			if strings.Contains(ip.String(), ":") {
 | 
			
		||||
				t = "ipv6-addr"
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Generate a deterministic identifier for each IP address in the
 | 
			
		||||
			// threat feed using the IP value represented as a JSON string. For
 | 
			
		||||
			// example: {"value":"127.0.0.1"}
 | 
			
		||||
			result = append(result, stix.ObservableIP{
 | 
			
		||||
				Type:         t,
 | 
			
		||||
				SpecVersion:  stix.SpecVersion,
 | 
			
		||||
				ID:           stix.DeterministicID(t, "{\"value\":\""+ip.String()+"\"}"),
 | 
			
		||||
				Value:        ip.String(),
 | 
			
		||||
				CreatedByRef: stix.DeceptifeedID,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										402
									
								
								internal/threatfeed/handler.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										402
									
								
								internal/threatfeed/handler.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,402 @@
 | 
			
		||||
package threatfeed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/csv"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/stix"
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/taxii"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// 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.
 | 
			
		||||
// This is the default catch-all route handler.
 | 
			
		||||
func handlePlain(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Set("Content-Type", "text/plain")
 | 
			
		||||
	for _, ip := range prepareFeed() {
 | 
			
		||||
		_, err := w.Write([]byte(ip.String() + "\n"))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Fprintln(os.Stderr, "Failed to serve threat feed:", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If a custom threat file is supplied in the configuration, append the
 | 
			
		||||
	// contents of the file to the HTTP response. To allow for flexibility, the
 | 
			
		||||
	// contents of the file are not parsed or validated.
 | 
			
		||||
	if len(configuration.CustomThreatsPath) > 0 {
 | 
			
		||||
		data, err := os.ReadFile(configuration.CustomThreatsPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Fprintln(os.Stderr, "Failed to read custom threats file:", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		_, err = w.Write(data)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Fprintln(os.Stderr, "Failed to serve threat feed:", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleJSON handles HTTP requests to serve the full threat feed in JSON
 | 
			
		||||
// format. It returns a JSON array containing all IoC data (IP addresses and
 | 
			
		||||
// their associated data).
 | 
			
		||||
func handleJSON(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	type iocDetailed struct {
 | 
			
		||||
		IP          string    `json:"ip"`
 | 
			
		||||
		Added       time.Time `json:"added"`
 | 
			
		||||
		LastSeen    time.Time `json:"last_seen"`
 | 
			
		||||
		ThreatScore int       `json:"threat_score"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ipData := prepareFeed()
 | 
			
		||||
	result := make([]iocDetailed, 0, len(ipData))
 | 
			
		||||
	for _, ip := range ipData {
 | 
			
		||||
		if ioc, found := iocData[ip.String()]; found {
 | 
			
		||||
			result = append(result, iocDetailed{
 | 
			
		||||
				IP:          ip.String(),
 | 
			
		||||
				Added:       ioc.Added,
 | 
			
		||||
				LastSeen:    ioc.LastSeen,
 | 
			
		||||
				ThreatScore: ioc.ThreatScore,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	e := json.NewEncoder(w)
 | 
			
		||||
	e.SetIndent("", "  ")
 | 
			
		||||
	if err := e.Encode(map[string]interface{}{"threat_feed": result}); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to JSON:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleJSONSimple handles HTTP requests to serve a simplified version of the
 | 
			
		||||
// threat feed in JSON format. It returns a JSON array containing only the IP
 | 
			
		||||
// addresses from the threat feed.
 | 
			
		||||
func handleJSONSimple(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	e := json.NewEncoder(w)
 | 
			
		||||
	e.SetIndent("", "  ")
 | 
			
		||||
	if err := e.Encode(map[string]interface{}{"threat_feed": prepareFeed()}); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to JSON:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleCSV handles HTTP requests to serve the full threat feed in CSV format.
 | 
			
		||||
// It returns a CSV file containing all IoC data (IP addresses and their
 | 
			
		||||
// associated data).
 | 
			
		||||
func handleCSV(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Set("Content-Type", "text/csv")
 | 
			
		||||
	w.Header().Set("Content-Disposition", "attachment; filename=\"threat-feed-"+time.Now().Format("20060102-150405")+".csv\"")
 | 
			
		||||
 | 
			
		||||
	c := csv.NewWriter(w)
 | 
			
		||||
	if err := c.Write(csvHeader); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, ip := range prepareFeed() {
 | 
			
		||||
		if ioc, found := iocData[ip.String()]; found {
 | 
			
		||||
			if err := c.Write([]string{
 | 
			
		||||
				ip.String(),
 | 
			
		||||
				ioc.Added.Format(dateFormat),
 | 
			
		||||
				ioc.LastSeen.Format(dateFormat),
 | 
			
		||||
				strconv.Itoa(ioc.ThreatScore),
 | 
			
		||||
			}); err != nil {
 | 
			
		||||
				fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Flush()
 | 
			
		||||
	if err := c.Error(); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleCSVSimple handles HTTP requests to serve a simplified version of the
 | 
			
		||||
// threat feed in CSV format. It returns a CSV file containing only the IP
 | 
			
		||||
// addresses of the threat feed.
 | 
			
		||||
func handleCSVSimple(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Set("Content-Type", "text/csv")
 | 
			
		||||
	w.Header().Set("Content-Disposition", "attachment; filename=\"threat-feed-ips-"+time.Now().Format("20060102-150405")+".csv\"")
 | 
			
		||||
 | 
			
		||||
	c := csv.NewWriter(w)
 | 
			
		||||
	if err := c.Write([]string{"ip"}); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, ip := range prepareFeed() {
 | 
			
		||||
		if err := c.Write([]string{ip.String()}); err != nil {
 | 
			
		||||
			fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Flush()
 | 
			
		||||
	if err := c.Error(); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleSTIX2 handles HTTP requests to serve the full threat feed in STIX 2
 | 
			
		||||
// format. The response includes all IoC data (IP addresses and their
 | 
			
		||||
// associated data). The response is structured as a STIX Bundle containing
 | 
			
		||||
// `Indicators` (STIX Domain Objects) for each IP address in the threat feed.
 | 
			
		||||
func handleSTIX2(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	const bundle = "bundle"
 | 
			
		||||
	result := stix.Bundle{
 | 
			
		||||
		Type:    bundle,
 | 
			
		||||
		ID:      stix.NewID(bundle),
 | 
			
		||||
		Objects: convertToIndicators(prepareFeed()),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", stix.ContentType)
 | 
			
		||||
	if err := json.NewEncoder(w).Encode(result); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to STIX:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleSTIX2Simple handles HTTP requests to serve a simplified version of the
 | 
			
		||||
// threat feed in STIX 2 format. The response is structured as a STIX Bundle,
 | 
			
		||||
// with each IP address in the threat feed included as a STIX Cyber-observable
 | 
			
		||||
// Object.
 | 
			
		||||
func handleSTIX2Simple(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	const bundle = "bundle"
 | 
			
		||||
	result := stix.Bundle{
 | 
			
		||||
		Type:    bundle,
 | 
			
		||||
		ID:      stix.NewID(bundle),
 | 
			
		||||
		Objects: convertToObservables(prepareFeed()),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", stix.ContentType)
 | 
			
		||||
	e := json.NewEncoder(w)
 | 
			
		||||
	e.SetIndent("", "  ")
 | 
			
		||||
	if err := e.Encode(result); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "Failed to encode threat feed to STIX:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleTAXIINotFound returns a 404 Not Found response. This is the default
 | 
			
		||||
// response for the /taxii2/... endpoint when a request is made outside the
 | 
			
		||||
// defined API.
 | 
			
		||||
func handleTAXIINotFound(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleTAXIIDiscovery handles the TAXII server discovery endpoint, defined as
 | 
			
		||||
// `/taxii2/`. It returns a list of API root URLs available on the TAXII server.
 | 
			
		||||
// Deceptifeed has a single API root at `/taxii2/api/`
 | 
			
		||||
func handleTAXIIDiscovery(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	result := taxii.DiscoveryResource{
 | 
			
		||||
		Title:       "Deceptifeed TAXII Server",
 | 
			
		||||
		Description: "This TAXII server contains IP addresses observed interacting with honeypots",
 | 
			
		||||
		Default:     taxii.APIRoot,
 | 
			
		||||
		APIRoots:    []string{taxii.APIRoot},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", taxii.ContentType)
 | 
			
		||||
	e := json.NewEncoder(w)
 | 
			
		||||
	e.SetIndent("", "  ")
 | 
			
		||||
	if err := e.Encode(result); err != nil {
 | 
			
		||||
		http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleTAXIIRoot returns general information about the requested API root.
 | 
			
		||||
func handleTAXIIRoot(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	result := taxii.APIRootResource{
 | 
			
		||||
		Title:            "Deceptifeed TAXII Server",
 | 
			
		||||
		Versions:         []string{taxii.ContentType},
 | 
			
		||||
		MaxContentLength: 1,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", taxii.ContentType)
 | 
			
		||||
	e := json.NewEncoder(w)
 | 
			
		||||
	e.SetIndent("", "  ")
 | 
			
		||||
	if err := e.Encode(result); err != nil {
 | 
			
		||||
		http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleTAXIICollections returns details about available TAXII collections
 | 
			
		||||
// hosted under the API root. Requests for `{api-root}/collections/` return a
 | 
			
		||||
// list of all available collections. Requests for
 | 
			
		||||
// `{api-root}/collections/{id}/` return information about the requested
 | 
			
		||||
// collection ID.
 | 
			
		||||
func handleTAXIICollections(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	// Depending on the request, the result may be a single Collection or a
 | 
			
		||||
	// slice of Collections.
 | 
			
		||||
	var result any
 | 
			
		||||
	collections := taxii.ImplementedCollections()
 | 
			
		||||
 | 
			
		||||
	if id := r.PathValue("id"); len(id) > 0 {
 | 
			
		||||
		found := false
 | 
			
		||||
		for i, c := range collections {
 | 
			
		||||
			if id == c.ID || id == c.Alias {
 | 
			
		||||
				found = true
 | 
			
		||||
				result = collections[i]
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if !found {
 | 
			
		||||
			handleTAXIINotFound(w, r)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		result = map[string]interface{}{"collections": collections}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", taxii.ContentType)
 | 
			
		||||
	e := json.NewEncoder(w)
 | 
			
		||||
	e.SetIndent("", "  ")
 | 
			
		||||
	if err := e.Encode(result); err != nil {
 | 
			
		||||
		http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleTAXIIObjects returns the threat feed as STIX objects. The objects are
 | 
			
		||||
// structured according to the requested TAXII collection and wrapped in a
 | 
			
		||||
// TAXII Envelope. Request URL format: `{api-root}/collections/{id}/objects/`.
 | 
			
		||||
func handleTAXIIObjects(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	// Set default values.
 | 
			
		||||
	after := time.Time{}
 | 
			
		||||
	limit := 0
 | 
			
		||||
	page := 0
 | 
			
		||||
	var err error
 | 
			
		||||
 | 
			
		||||
	// Parse the URL query parameters.
 | 
			
		||||
	if len(r.URL.Query().Get("added_after")) > 0 {
 | 
			
		||||
		after, err = time.Parse(time.RFC3339, r.URL.Query().Get("added_after"))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if len(r.URL.Query().Get("limit")) > 0 {
 | 
			
		||||
		limit, err = strconv.Atoi(r.URL.Query().Get("limit"))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if len(r.URL.Query().Get("next")) > 0 {
 | 
			
		||||
		page, err = strconv.Atoi(r.URL.Query().Get("next"))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Ensure a minimum page number of 1.
 | 
			
		||||
	if page < 1 {
 | 
			
		||||
		page = 1
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Build the requested collection.
 | 
			
		||||
	result := taxii.Envelope{}
 | 
			
		||||
	switch r.PathValue("id") {
 | 
			
		||||
	case taxii.IndicatorsID, taxii.IndicatorsAlias:
 | 
			
		||||
		result.Objects = convertToIndicators(prepareFeed(sortByLastSeen(), seenAfter(after)))
 | 
			
		||||
	case taxii.ObservablesID, taxii.ObservablesAlias:
 | 
			
		||||
		result.Objects = convertToObservables(prepareFeed(sortByLastSeen(), seenAfter(after)))
 | 
			
		||||
	default:
 | 
			
		||||
		handleTAXIINotFound(w, r)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Paginate. result.Objects may be resliced depending on the requested
 | 
			
		||||
	// limit and page number.
 | 
			
		||||
	result.Objects, result.More = paginate(result.Objects, limit, page)
 | 
			
		||||
 | 
			
		||||
	// If more results are available, include the `next` property in the
 | 
			
		||||
	// response with the next page number.
 | 
			
		||||
	if result.More {
 | 
			
		||||
		if page+1 > 0 {
 | 
			
		||||
			result.Next = strconv.Itoa(page + 1)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the `last seen` timestamps of the first and last objects in the
 | 
			
		||||
	// results for setting `X-TAXII-Date-Added-` headers.
 | 
			
		||||
	first := time.Time{}
 | 
			
		||||
	last := time.Time{}
 | 
			
		||||
	objectCount := len(result.Objects)
 | 
			
		||||
	if objectCount > 0 {
 | 
			
		||||
		// Loop twice: the first iteration accesses the first element of the
 | 
			
		||||
		// Objects slice, and the second iteration accesses the last element.
 | 
			
		||||
		for i := 0; i < 2; i++ {
 | 
			
		||||
			element := 0
 | 
			
		||||
			if i == 1 {
 | 
			
		||||
				element = len(result.Objects) - 1
 | 
			
		||||
			}
 | 
			
		||||
			timestamp := time.Time{}
 | 
			
		||||
			switch v := result.Objects[element].(type) {
 | 
			
		||||
			case stix.Indicator:
 | 
			
		||||
				timestamp = v.Modified
 | 
			
		||||
			case stix.ObservableIP:
 | 
			
		||||
				if ioc, found := iocData[v.Value]; found {
 | 
			
		||||
					timestamp = ioc.LastSeen
 | 
			
		||||
				}
 | 
			
		||||
			case stix.Identity:
 | 
			
		||||
				timestamp = v.Created
 | 
			
		||||
			}
 | 
			
		||||
			if i == 0 {
 | 
			
		||||
				first = timestamp
 | 
			
		||||
			} else {
 | 
			
		||||
				last = timestamp
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", taxii.ContentType)
 | 
			
		||||
	if objectCount > 0 {
 | 
			
		||||
		w.Header()["X-TAXII-Date-Added-First"] = []string{first.UTC().Format(time.RFC3339)}
 | 
			
		||||
		w.Header()["X-TAXII-Date-Added-Last"] = []string{last.UTC().Format(time.RFC3339)}
 | 
			
		||||
	}
 | 
			
		||||
	if err := json.NewEncoder(w).Encode(result); err != nil {
 | 
			
		||||
		http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// paginate returns a slice of stix.Objects for the requested page, based on
 | 
			
		||||
// the provided limit and page numbers. It also returns whether more items are
 | 
			
		||||
// available.
 | 
			
		||||
func paginate(items []stix.Object, limit int, page int) ([]stix.Object, bool) {
 | 
			
		||||
	if limit <= 0 {
 | 
			
		||||
		return items, false
 | 
			
		||||
	}
 | 
			
		||||
	if page < 1 {
 | 
			
		||||
		page = 1
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Determine the start index. Return an empty collection if out of bounds
 | 
			
		||||
	// or if the calculation overflows.
 | 
			
		||||
	start := (page - 1) * limit
 | 
			
		||||
	if start >= len(items) || start < 0 {
 | 
			
		||||
		return []stix.Object{}, false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Determine the end index and whether more items are remaining.
 | 
			
		||||
	end := start + limit
 | 
			
		||||
	more := end < len(items)
 | 
			
		||||
	if end > len(items) {
 | 
			
		||||
		end = len(items)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return items[start:end], more
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleEmpty handles HTTP requests to /empty. It returns an empty body with
 | 
			
		||||
// status code 200. This endpoint is useful for temporarily clearing the threat
 | 
			
		||||
// feed data in firewalls.
 | 
			
		||||
func handleEmpty(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	w.Header().Set("Content-Type", "text/plain")
 | 
			
		||||
	w.WriteHeader(http.StatusOK)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										38
									
								
								internal/threatfeed/middleware.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								internal/threatfeed/middleware.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
package threatfeed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// enforcePrivateIP is a middleware that restricts access to the HTTP server
 | 
			
		||||
// based on the client's IP address. It allows only requests from private IP
 | 
			
		||||
// addresses. Any other requests are denied with a 403 Forbidden error.
 | 
			
		||||
func enforcePrivateIP(next http.HandlerFunc) http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, "Could not get IP", http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
 | 
			
		||||
			http.Error(w, "", http.StatusForbidden)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		next.ServeHTTP(w, r)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// disableCache is a middleware that sets HTTP response headers to prevent
 | 
			
		||||
// clients from caching the threat feed.
 | 
			
		||||
func disableCache(next http.HandlerFunc) http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		w.Header().Set("Cache-Control", "no-store, must-revalidate")
 | 
			
		||||
		w.Header().Set("Pragma", "no-cache")
 | 
			
		||||
		w.Header().Set("Expires", "0")
 | 
			
		||||
 | 
			
		||||
		next.ServeHTTP(w, r)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										87
									
								
								internal/threatfeed/server.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								internal/threatfeed/server.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
package threatfeed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/config"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// saveInterval represents how frequently the threat feed is saved to disk.
 | 
			
		||||
	// The saved file ensures threat feed data persists across application
 | 
			
		||||
	// restarts. It is not the active threat feed.
 | 
			
		||||
	saveInterval = 20 * time.Second
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	// configuration holds the configuration for the threat feed server. It is
 | 
			
		||||
	// assigned when the server is initializing and the configuration values
 | 
			
		||||
	// should not change.
 | 
			
		||||
	configuration config.ThreatFeed
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Start initializes and starts the threat feed server. The server provides a
 | 
			
		||||
// list of IP addresses observed interacting with the honeypot servers in
 | 
			
		||||
// various formats.
 | 
			
		||||
func Start(cfg *config.ThreatFeed) {
 | 
			
		||||
	configuration = *cfg
 | 
			
		||||
 | 
			
		||||
	// Check for and open an existing threat feed CSV file, if available.
 | 
			
		||||
	err := loadCSV()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped: Failed to open Threat Feed data:", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Periodically delete expired entries and save the current threat feed to
 | 
			
		||||
	// disk.
 | 
			
		||||
	ticker := time.NewTicker(saveInterval)
 | 
			
		||||
	go func() {
 | 
			
		||||
		for range ticker.C {
 | 
			
		||||
			if dataChanged {
 | 
			
		||||
				deleteExpired()
 | 
			
		||||
				if err := saveCSV(); err != nil {
 | 
			
		||||
					fmt.Fprintln(os.Stderr, "Error saving Threat Feed data:", err)
 | 
			
		||||
				}
 | 
			
		||||
				dataChanged = false
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Setup handlers and server configuration.
 | 
			
		||||
	mux := http.NewServeMux()
 | 
			
		||||
	mux.HandleFunc("GET /", enforcePrivateIP(disableCache(handlePlain)))
 | 
			
		||||
	mux.HandleFunc("GET /empty", enforcePrivateIP(handleEmpty))
 | 
			
		||||
	mux.HandleFunc("GET /json", enforcePrivateIP(disableCache(handleJSON)))
 | 
			
		||||
	mux.HandleFunc("GET /json/ips", enforcePrivateIP(disableCache(handleJSONSimple)))
 | 
			
		||||
	mux.HandleFunc("GET /csv", enforcePrivateIP(disableCache(handleCSV)))
 | 
			
		||||
	mux.HandleFunc("GET /csv/ips", enforcePrivateIP(disableCache(handleCSVSimple)))
 | 
			
		||||
	mux.HandleFunc("GET /stix2", enforcePrivateIP(disableCache(handleSTIX2)))
 | 
			
		||||
	mux.HandleFunc("GET /stix2/ips", enforcePrivateIP(disableCache(handleSTIX2Simple)))
 | 
			
		||||
	// TAXII 2.1 handlers.
 | 
			
		||||
	mux.HandleFunc("GET    /taxii2/", enforcePrivateIP(disableCache(handleTAXIINotFound)))
 | 
			
		||||
	mux.HandleFunc("POST   /taxii2/", enforcePrivateIP(disableCache(handleTAXIINotFound)))
 | 
			
		||||
	mux.HandleFunc("DELETE /taxii2/", enforcePrivateIP(disableCache(handleTAXIINotFound)))
 | 
			
		||||
	mux.HandleFunc("GET    /taxii2/{$}", enforcePrivateIP(disableCache(handleTAXIIDiscovery)))
 | 
			
		||||
	mux.HandleFunc("GET    /taxii2/api/{$}", enforcePrivateIP(disableCache(handleTAXIIRoot)))
 | 
			
		||||
	mux.HandleFunc("GET    /taxii2/api/collections/{$}", enforcePrivateIP(disableCache(handleTAXIICollections)))
 | 
			
		||||
	mux.HandleFunc("GET    /taxii2/api/collections/{id}/{$}", enforcePrivateIP(disableCache(handleTAXIICollections)))
 | 
			
		||||
	mux.HandleFunc("GET    /taxii2/api/collections/{id}/objects/{$}", enforcePrivateIP(disableCache(handleTAXIIObjects)))
 | 
			
		||||
 | 
			
		||||
	srv := &http.Server{
 | 
			
		||||
		Addr:         ":" + cfg.Port,
 | 
			
		||||
		Handler:      mux,
 | 
			
		||||
		ReadTimeout:  5 * time.Second,
 | 
			
		||||
		WriteTimeout: 30 * time.Second,
 | 
			
		||||
		IdleTimeout:  0,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start the threat feed HTTP server.
 | 
			
		||||
	fmt.Printf("Starting Threat Feed server on port: %s\n", cfg.Port)
 | 
			
		||||
	if err := srv.ListenAndServe(); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,235 +0,0 @@
 | 
			
		||||
package threatfeed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/config"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	// configuration holds the global configuration for the threat feed server.
 | 
			
		||||
	// This variable is assigned the config.ThreatFeed value that's passed in
 | 
			
		||||
	// during the server's startup.
 | 
			
		||||
	configuration config.ThreatFeed
 | 
			
		||||
 | 
			
		||||
	// iocMap stores the Indicator of Compromise (IoC) entries which makes up
 | 
			
		||||
	// the threat feed database. It is initially populated by loadIoC if an
 | 
			
		||||
	// existing CSV database file is provided. The map is subsequently updated
 | 
			
		||||
	// by UpdateIoC whenever a client interacts with a honeypot server. This
 | 
			
		||||
	// map is accessed and served by the threat feed HTTP server.
 | 
			
		||||
	iocMap = make(map[string]*IoC)
 | 
			
		||||
 | 
			
		||||
	// mutex is to ensure thread-safe access to iocMap.
 | 
			
		||||
	mutex sync.Mutex
 | 
			
		||||
 | 
			
		||||
	// ticker creates a new ticker for periodically writing the IoC map to
 | 
			
		||||
	// disk.
 | 
			
		||||
	ticker = time.NewTicker(10 * time.Second)
 | 
			
		||||
 | 
			
		||||
	// hasMapChanged indicates whether the IoC map has been modified since the
 | 
			
		||||
	// last time it was saved to disk.
 | 
			
		||||
	hasMapChanged = false
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// StartThreatFeed initializes and starts the threat feed server. The server
 | 
			
		||||
// provides a list of IP addresses observed interacting with the honeypot
 | 
			
		||||
// servers. The data is served in a format compatible with most enterprise
 | 
			
		||||
// firewalls.
 | 
			
		||||
func StartThreatFeed(cfg *config.ThreatFeed) {
 | 
			
		||||
	// Assign the passed-in config.ThreatFeed to the global configuration
 | 
			
		||||
	// variable.
 | 
			
		||||
	configuration = *cfg
 | 
			
		||||
 | 
			
		||||
	// Check for and open an existing threat feed CSV database, if available.
 | 
			
		||||
	err := loadIoC()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The Threat Feed server has terminated: Failed to open Threat Feed database:", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Periodically save the current iocMap to disk.
 | 
			
		||||
	go func() {
 | 
			
		||||
		for range ticker.C {
 | 
			
		||||
			if hasMapChanged {
 | 
			
		||||
				if err := saveIoC(); err != nil {
 | 
			
		||||
					fmt.Fprintln(os.Stderr, "Error saving Threat Feed database:", err)
 | 
			
		||||
				}
 | 
			
		||||
				hasMapChanged = false
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Setup handlers.
 | 
			
		||||
	mux := http.NewServeMux()
 | 
			
		||||
	mux.HandleFunc("/", enforcePrivateIP(handleConnection))
 | 
			
		||||
	mux.HandleFunc("/empty/", enforcePrivateIP(serveEmpty))
 | 
			
		||||
 | 
			
		||||
	// Start the threat feed HTTP server.
 | 
			
		||||
	fmt.Printf("Starting Threat Feed server on port: %s\n", cfg.Port)
 | 
			
		||||
	if err := http.ListenAndServe(":"+cfg.Port, mux); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The Threat Feed server has terminated:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleConnection processes incoming HTTP requests for the threat feed
 | 
			
		||||
// server. It serves the sorted list of IP addresses observed interacting with
 | 
			
		||||
// the honeypot servers.
 | 
			
		||||
func handleConnection(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	mutex.Lock()
 | 
			
		||||
	defer mutex.Unlock()
 | 
			
		||||
 | 
			
		||||
	// Calculate expiry time.
 | 
			
		||||
	now := time.Now()
 | 
			
		||||
	expiryTime := now.Add(-time.Hour * time.Duration(configuration.ExpiryHours))
 | 
			
		||||
 | 
			
		||||
	// Parse IPs from the iocMap to net.IP for filtering and sorting. Skip any
 | 
			
		||||
	// IPs that have expired or don't meet the minimum threat score.
 | 
			
		||||
	var netIPs []net.IP
 | 
			
		||||
	for ip, ioc := range iocMap {
 | 
			
		||||
		if ioc.LastSeen.After(expiryTime) && ioc.ThreatScore >= configuration.MinimumThreatScore {
 | 
			
		||||
			netIPs = append(netIPs, net.ParseIP(ip))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If an exclude list is provided, filter the original IP list.
 | 
			
		||||
	var filteredIPList []net.IP
 | 
			
		||||
	if len(configuration.ExcludeListPath) > 0 {
 | 
			
		||||
		ipsToRemove, err := readIPsFromFile(configuration.ExcludeListPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Fprintln(os.Stderr, "Failed to read threat feed exclude list:", err)
 | 
			
		||||
			filteredIPList = netIPs
 | 
			
		||||
		} else {
 | 
			
		||||
			filteredIPList = filterIPs(netIPs, ipsToRemove)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		filteredIPList = netIPs
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Sort the IP addresses.
 | 
			
		||||
	sort.Slice(filteredIPList, func(i, j int) bool {
 | 
			
		||||
		return bytes.Compare(filteredIPList[i], filteredIPList[j]) < 0
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Serve the sorted list of IP addresses.
 | 
			
		||||
	w.Header().Set("Content-Type", "text/plain")
 | 
			
		||||
	for _, ip := range filteredIPList {
 | 
			
		||||
		if ip == nil || (!configuration.IsPrivateIncluded && ip.IsPrivate()) {
 | 
			
		||||
			// Skip IP addresses that failed parsing or are private, based on
 | 
			
		||||
			// the configuration.
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		_, err := w.Write([]byte(ip.String() + "\n"))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, "Falled to write response", http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If a custom threat file is supplied in the configuration, append the
 | 
			
		||||
	// contents of the file to the HTTP response. To allow for flexibility, the
 | 
			
		||||
	// contents of the file are not parsed or validated.
 | 
			
		||||
	if len(configuration.CustomThreatsPath) > 0 {
 | 
			
		||||
		data, err := os.ReadFile(configuration.CustomThreatsPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Fprintln(os.Stderr, "Failed to read custom threats file:", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		_, err = w.Write(data)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, "Falled to write response", http.StatusInternalServerError)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// enforcePrivateIP is a middleware that restricts access to the HTTP server
 | 
			
		||||
// based on the client's IP address. It allows only requests from private IP
 | 
			
		||||
// addresses. Any other requests are denied with a 403 Forbidden error.
 | 
			
		||||
func enforcePrivateIP(next http.HandlerFunc) http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, "Could not get IP", http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !net.ParseIP(ip).IsPrivate() {
 | 
			
		||||
			http.Error(w, "", http.StatusForbidden)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		next.ServeHTTP(w, r)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// readIPsFromFile reads IP addresses and CIDR ranges from a file. Each line
 | 
			
		||||
// should contain an IP address or CIDR. It returns a map of the unique IPs and
 | 
			
		||||
// CIDR ranges found in the file.
 | 
			
		||||
func readIPsFromFile(filepath string) (map[string]struct{}, error) {
 | 
			
		||||
	ips := make(map[string]struct{})
 | 
			
		||||
 | 
			
		||||
	file, err := os.Open(filepath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
 | 
			
		||||
	scanner := bufio.NewScanner(file)
 | 
			
		||||
	for scanner.Scan() {
 | 
			
		||||
		line := strings.TrimSpace(scanner.Text())
 | 
			
		||||
		if len(line) > 0 {
 | 
			
		||||
			ips[line] = struct{}{}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if err := scanner.Err(); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return ips, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// filterIPs removes IPs from ipList that are found in the ipsToRemove map. The
 | 
			
		||||
// keys in ipsToRemove may be single IP addresses or CIDR ranges. If a key is a
 | 
			
		||||
// CIDR range, an IP will be removed if it falls within that range.
 | 
			
		||||
func filterIPs(ipList []net.IP, ipsToRemove map[string]struct{}) []net.IP {
 | 
			
		||||
	filtered := []net.IP{}
 | 
			
		||||
 | 
			
		||||
	// If there's nothing to filter, return the original list.
 | 
			
		||||
	if len(ipsToRemove) == 0 {
 | 
			
		||||
		return ipList
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, ip := range ipList {
 | 
			
		||||
		if _, found := ipsToRemove[ip.String()]; found {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check for CIDR matches.
 | 
			
		||||
		for cidr := range ipsToRemove {
 | 
			
		||||
			_, netCIDR, err := net.ParseCIDR(cidr)
 | 
			
		||||
			if err == nil && netCIDR.Contains(ip) {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
			filtered = append(filtered, ip)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return filtered
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// serveEmpty handles HTTP requests to /empty/. It returns an empty body with
 | 
			
		||||
// status code 200. This endpoint is useful for clearing the threat feed in
 | 
			
		||||
// firewalls, as many firewalls retain the last ingested feed. Firewalls can be
 | 
			
		||||
// configured to point to this endpoint, effectively clearing all previous
 | 
			
		||||
// threat feed data.
 | 
			
		||||
func serveEmpty(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	// Serve an empty body with status code 200.
 | 
			
		||||
	w.Header().Set("Content-Type", "text/plain")
 | 
			
		||||
	w.WriteHeader(http.StatusOK)
 | 
			
		||||
}
 | 
			
		||||
@@ -12,35 +12,26 @@ import (
 | 
			
		||||
	"github.com/r-smith/deceptifeed/internal/config"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// StartUDP serves as a wrapper to initialize and start a generic UDP honeypot
 | 
			
		||||
// server. It listens on the specified port, logging any received data without
 | 
			
		||||
// responding back to the client. Since UDP is connectionless, clients are
 | 
			
		||||
// unaware of the server's existence and that it is actively listening and
 | 
			
		||||
// recording data sent to the port. Note that source IP addresses for UDP
 | 
			
		||||
// packets are unreliable due to potential spoofing. As a result, interactions
 | 
			
		||||
// logged from the UDP server will not be added to the threat feed. This
 | 
			
		||||
// function calls the underlying startUDP function to perform the actual server
 | 
			
		||||
// startup.
 | 
			
		||||
func StartUDP(srv *config.Server) {
 | 
			
		||||
	fmt.Printf("Starting UDP server on port: %s\n", srv.Port)
 | 
			
		||||
	if err := startUDP(srv); err != nil {
 | 
			
		||||
		fmt.Fprintln(os.Stderr, "The UDP server has terminated:", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// startUDP starts the UDP honeypot server. It handles the server's main loop
 | 
			
		||||
// and logging.
 | 
			
		||||
func startUDP(srv *config.Server) error {
 | 
			
		||||
	// Convert the specified port number to an integer.
 | 
			
		||||
	port, err := strconv.Atoi(srv.Port)
 | 
			
		||||
// Start initializes and starts a generic UDP honeypot server. It listens on
 | 
			
		||||
// the specified port, logging any received data without responding back to the
 | 
			
		||||
// client. Since UDP is connectionless, clients are unaware of the server's
 | 
			
		||||
// existence and that it is actively listening and recording data sent to the
 | 
			
		||||
// port. Note that source IP addresses for UDP packets are unreliable due to
 | 
			
		||||
// potential spoofing. As a result, interactions with the UDP server are not
 | 
			
		||||
// added to the threat feed.
 | 
			
		||||
func Start(cfg *config.Server) {
 | 
			
		||||
	fmt.Printf("Starting UDP server on port: %s\n", cfg.Port)
 | 
			
		||||
	port, err := strconv.Atoi(cfg.Port)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("invalid port '%s': %w", srv.Port, err)
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "The UDP server on port %s has stopped: %v\n", cfg.Port, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start the UDP server.
 | 
			
		||||
	conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: port})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failure to listen on port '%s': %w", srv.Port, err)
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "The UDP server on port %s has stopped: %v\n", cfg.Port, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	defer conn.Close()
 | 
			
		||||
 | 
			
		||||
@@ -67,11 +58,10 @@ func startUDP(srv *config.Server) error {
 | 
			
		||||
			// received the UDP data. However, this limitation is acceptable as
 | 
			
		||||
			// the primary goal is to log the received data.
 | 
			
		||||
			_, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
 | 
			
		||||
			src_ip, src_port, _ := net.SplitHostPort(remoteAddr.String())
 | 
			
		||||
			srv.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
			src_ip, _, _ := net.SplitHostPort(remoteAddr.String())
 | 
			
		||||
			cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
 | 
			
		||||
				slog.String("event_type", "udp"),
 | 
			
		||||
				slog.String("source_ip", src_ip+" [unreliable]"),
 | 
			
		||||
				slog.String("source_port", src_port+" [unreliable]"),
 | 
			
		||||
				slog.String("source_reliability", "unreliable"),
 | 
			
		||||
				slog.String("server_ip", config.GetHostIP()),
 | 
			
		||||
				slog.String("server_port", dst_port),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										190
									
								
								scripts/install.sh
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										190
									
								
								scripts/install.sh
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							@@ -14,6 +14,7 @@ systemd_check_dir="/run/systemd/system"
 | 
			
		||||
systemd_dir="/etc/systemd/system"
 | 
			
		||||
service_short_name="deceptifeed"
 | 
			
		||||
systemd_unit="${service_short_name}.service"
 | 
			
		||||
auto_confirm_prompts=false
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
# startup_checks:
 | 
			
		||||
@@ -24,16 +25,13 @@ systemd_unit="${service_short_name}.service"
 | 
			
		||||
startup_checks() {
 | 
			
		||||
    # If supported, enable colored output.
 | 
			
		||||
    if [[ -t 1 ]]; then
 | 
			
		||||
        # Detect color support.
 | 
			
		||||
        n_colors=$(tput colors 2>/dev/null)
 | 
			
		||||
        if [[ -n "${n_colors}" ]] && [[ "${n_colors}" -ge 8 ]]; then
 | 
			
		||||
            # Color support detected. Enable colored output.
 | 
			
		||||
            red='\033[1;31m'
 | 
			
		||||
            green='\033[1;32m'
 | 
			
		||||
            yellow='\033[1;33m'
 | 
			
		||||
            blue='\033[1;34m'
 | 
			
		||||
            magenta='\033[1;35m'
 | 
			
		||||
            dmagenta='\033[0;35m'
 | 
			
		||||
            cyan='\033[1;36m'
 | 
			
		||||
            white='\033[1;37m'
 | 
			
		||||
            gray='\033[0;37m'
 | 
			
		||||
@@ -42,9 +40,9 @@ startup_checks() {
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Output aids.
 | 
			
		||||
    # Output helper messages.
 | 
			
		||||
    msg_error="${dgray}[${red}Error${dgray}]${clear}"
 | 
			
		||||
    msg_info="${dgray}${magenta}‣${dgray}${clear}"
 | 
			
		||||
    msg_info="${dgray}${magenta}•${dgray}${clear}"
 | 
			
		||||
 | 
			
		||||
    # Require systemd.
 | 
			
		||||
    if [[ ! -d "${systemd_check_dir}" || ! -d "${systemd_dir}" ]] || ! command -v systemctl &>/dev/null; then
 | 
			
		||||
@@ -52,26 +50,25 @@ startup_checks() {
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Ensure the script is running as root.
 | 
			
		||||
    if [[ "$(id --user)" -ne 0 ]]; then
 | 
			
		||||
    # Require root privileges.
 | 
			
		||||
    if [[ "$(id -u)" -ne 0 ]]; then
 | 
			
		||||
        echo -e "\n${msg_error} ${white}This script must be run as root.${clear}\n" >&2
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
# print_banner:
 | 
			
		||||
# Prints the application's banner.
 | 
			
		||||
# print_logo:
 | 
			
		||||
# Prints the application's logo.
 | 
			
		||||
# =============================================================================
 | 
			
		||||
print_banner() {
 | 
			
		||||
    echo -e "${yellow}         __                     __  _ ${green}______              __"
 | 
			
		||||
    echo -e "${yellow}    ____/ /__  ________  ____  / /_(_)${green} ____/__  ___  ____/ /"
 | 
			
		||||
    echo -e "${yellow}   / __  / _ \/ ___/ _ \/ __ \/ __/ /${green} /_  / _ \/ _ \/ __  / "
 | 
			
		||||
    echo -e "${yellow}  / /_/ /  __/ /__/  __/ /_/ / /_/ /${green} __/ /  __/  __/ /_/ /  "
 | 
			
		||||
    echo -e "${yellow}  \____/\___/\___/\___/ .___/\__/_/${green}_/    \___/\___/\____/   "
 | 
			
		||||
    echo -e "${dmagenta} ::::::::::::::::::::${yellow}/_/${dmagenta}::::::::::::::::::::::::::::::::::"
 | 
			
		||||
    echo -e "${clear}"
 | 
			
		||||
    echo
 | 
			
		||||
print_logo() {
 | 
			
		||||
    echo -e "${red}         __                     __  _ ${yellow}______              __"
 | 
			
		||||
    echo -e "${red}    ____/ /__  ________  ____  / /_(_)${yellow} ____/__  ___  ____/ /"
 | 
			
		||||
    echo -e "${red}   / __  / _ \/ ___/ _ \/ __ \/ __/ /${yellow} /_  / _ \/ _ \/ __  /"
 | 
			
		||||
    echo -e "${red}  / /_/ /  __/ /__/  __/ /_/ / /_/ /${yellow} __/ /  __/  __/ /_/ /"
 | 
			
		||||
    echo -e "${red}  \____/\___/\___/\___/ .___/\__/_/${yellow}_/    \___/\___/\____/"
 | 
			
		||||
    echo -e "${blue}  ═══════════════════${red}/_/${blue}══════════════════════════════════"
 | 
			
		||||
    echo -e "${clear}\n"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
@@ -90,28 +87,36 @@ upgrade_app() {
 | 
			
		||||
    echo -e " ${red}Deceptifeed is already installed to${gray}: ${blue}${install_dir}/${clear}"
 | 
			
		||||
    echo -e " ${red}Would you like to upgrade?${clear}"
 | 
			
		||||
    echo -en " ${gray}(${white}yes${gray}/${white}no${gray}) ${gray}[${yellow}no${gray}]${white}: ${green}"
 | 
			
		||||
    read -r response
 | 
			
		||||
    if [[ "${auto_confirm_prompts}" = true ]]; then
 | 
			
		||||
        echo "yes"
 | 
			
		||||
        response="yes"
 | 
			
		||||
    else
 | 
			
		||||
        read -r response
 | 
			
		||||
    fi
 | 
			
		||||
    echo -en "${clear}"
 | 
			
		||||
    if [[ ! "${response}" =~ ^[yY][eE][sS]$ && ! "${response}" =~ ^[yY]$ ]]; then
 | 
			
		||||
        echo
 | 
			
		||||
        echo -e " ${white}Upgrade canceled${clear}"
 | 
			
		||||
        echo
 | 
			
		||||
        echo
 | 
			
		||||
        echo -e "\n ${white}Upgrade canceled${clear}\n\n"
 | 
			
		||||
        exit 0
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Print upgrade banner.
 | 
			
		||||
    print_banner
 | 
			
		||||
    # Print the application logo.
 | 
			
		||||
    print_logo
 | 
			
		||||
 | 
			
		||||
    # Stop the service.
 | 
			
		||||
    echo -e " ${msg_info}  ${gray}Stopping service: ${cyan}${systemd_unit}${clear}"
 | 
			
		||||
    systemctl stop "${systemd_unit}"
 | 
			
		||||
 | 
			
		||||
    # Backup (rename) the original binary.
 | 
			
		||||
    echo -e " ${msg_info}  ${gray}Moving old binary to: ${cyan}${target_bin}.bak${clear}"
 | 
			
		||||
    if ! mv -f "${target_bin}" "${target_bin}.bak"; then
 | 
			
		||||
        echo -e " ${msg_error} ${white}Failed to move file: ${yellow}'${target_bin}' ${white}to: ${yellow}'${target_bin}.bak'${clear}\n" >&2
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Copy the binary.
 | 
			
		||||
    echo -e " ${msg_info}  ${gray}Replacing binary: ${cyan}${target_bin}${clear}"
 | 
			
		||||
    if ! cp --force "${source_bin}" "${target_bin}"; then
 | 
			
		||||
        echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}" >&2
 | 
			
		||||
        echo
 | 
			
		||||
    echo -e " ${msg_info}  ${gray}Copying new binary to: ${cyan}${target_bin}${clear}"
 | 
			
		||||
    if ! cp -f "${source_bin}" "${target_bin}"; then
 | 
			
		||||
        echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}\n" >&2
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
@@ -128,13 +133,10 @@ upgrade_app() {
 | 
			
		||||
 | 
			
		||||
    # Upgrade complete.
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e " ${green}✓  ${white}Upgrade complete${clear}"
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e " ${green}✓  ${white}Upgrade complete${clear}\n"
 | 
			
		||||
    echo -e "${yellow} Check service status: ${cyan}systemctl status ${service_short_name}${clear}"
 | 
			
		||||
    echo -e "${yellow}         Log location: ${cyan}${install_dir}/logs/${clear}"
 | 
			
		||||
    echo -e "${yellow}   Configuration file: ${cyan}${target_cfg}${clear}"
 | 
			
		||||
    echo
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e "${yellow}   Configuration file: ${cyan}${target_cfg}${clear}\n\n"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
@@ -161,8 +163,7 @@ install_app() {
 | 
			
		||||
        source_bin="${script_dir}/../out/${source_bin}"
 | 
			
		||||
    else
 | 
			
		||||
        # Could not locate.
 | 
			
		||||
        echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_bin}'${clear}" >&2
 | 
			
		||||
        echo
 | 
			
		||||
        echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_bin}'${clear}\n" >&2
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
@@ -175,29 +176,26 @@ install_app() {
 | 
			
		||||
        source_cfg="${script_dir}/../configs/${source_cfg}"
 | 
			
		||||
    else
 | 
			
		||||
        # Could not locate.
 | 
			
		||||
        echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_cfg}'${clear}" >&2
 | 
			
		||||
        echo
 | 
			
		||||
        echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_cfg}'${clear}\n" >&2
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Upgrade check.
 | 
			
		||||
    if [[ -f "${target_bin}" && -f "${systemd_dir}/${systemd_unit}" ]]; then
 | 
			
		||||
        # Call the upgrade function.
 | 
			
		||||
        upgrade_app
 | 
			
		||||
        exit 0
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Print install banner.
 | 
			
		||||
    print_banner
 | 
			
		||||
    # Print the application logo.
 | 
			
		||||
    print_logo
 | 
			
		||||
    echo -e " ${msg_info}  ${gray}Installing to: ${cyan}${install_dir}/"
 | 
			
		||||
 | 
			
		||||
    # Create the directory structure.
 | 
			
		||||
    mkdir --parents "${install_dir}/bin/" "${install_dir}/certs/" "${install_dir}/etc/" "${install_dir}/logs/"
 | 
			
		||||
    mkdir -p "${install_dir}/bin/" "${install_dir}/certs/" "${install_dir}/etc/" "${install_dir}/logs/"
 | 
			
		||||
 | 
			
		||||
    # Copy the binary.
 | 
			
		||||
    if ! cp --force "${source_bin}" "${target_bin}"; then
 | 
			
		||||
        echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}" >&2
 | 
			
		||||
        echo
 | 
			
		||||
    if ! cp -f "${source_bin}" "${target_bin}"; then
 | 
			
		||||
        echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}\n" >&2
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
@@ -206,9 +204,8 @@ install_app() {
 | 
			
		||||
        # Don't copy anything. An existing configuration file already exists.
 | 
			
		||||
        echo -e " ${msg_info}  ${gray}Keeping existing configuration found at: ${cyan}${target_cfg}"
 | 
			
		||||
    else
 | 
			
		||||
        if ! cp --force "${source_cfg}" "${target_cfg}"; then
 | 
			
		||||
            echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_cfg}' ${white}to: ${yellow}'${target_cfg}'${clear}" >&2
 | 
			
		||||
            echo
 | 
			
		||||
        if ! cp -f "${source_cfg}" "${target_cfg}"; then
 | 
			
		||||
            echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_cfg}' ${white}to: ${yellow}'${target_cfg}'${clear}\n" >&2
 | 
			
		||||
            exit 1
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
@@ -225,15 +222,14 @@ install_app() {
 | 
			
		||||
                     --system \
 | 
			
		||||
                     --shell /usr/sbin/nologin \
 | 
			
		||||
                     --user-group "${username}"; then
 | 
			
		||||
            echo -e " ${msg_error} ${white}Failed to create user: ${yellow}${username}${clear}" >&2
 | 
			
		||||
            echo
 | 
			
		||||
            echo -e " ${msg_error} ${white}Failed to create user: ${yellow}${username}${clear}\n" >&2
 | 
			
		||||
            exit 1
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Set file and directory permissions.
 | 
			
		||||
    echo -e " ${msg_info}  ${gray}Setting file and directory permissions.${clear}"
 | 
			
		||||
    chown --recursive "${username}":"${username}" "${install_dir}"
 | 
			
		||||
    chown -R "${username}":"${username}" "${install_dir}"
 | 
			
		||||
    chmod 755 "${target_bin}"
 | 
			
		||||
    chmod 644 "${target_cfg}"
 | 
			
		||||
 | 
			
		||||
@@ -242,8 +238,7 @@ install_app() {
 | 
			
		||||
 | 
			
		||||
    # Create a systemd unit file.
 | 
			
		||||
    echo -e " ${msg_info}  ${gray}Creating service: ${cyan}${systemd_dir}/${systemd_unit}${clear}"
 | 
			
		||||
    if [[ ! -f "${systemd_dir}/${systemd_unit}" ]]; then
 | 
			
		||||
        cat > "${systemd_dir}/${systemd_unit}" << EOF
 | 
			
		||||
    cat > "${systemd_dir}/${systemd_unit}" << EOF
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=Deceptifeed
 | 
			
		||||
ConditionPathExists=${target_bin}
 | 
			
		||||
@@ -261,23 +256,17 @@ ExecStart=${target_bin} -config ${target_cfg}
 | 
			
		||||
WantedBy=multi-user.target
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
        # Reload systemd, enable, and start the service.
 | 
			
		||||
        systemctl daemon-reload
 | 
			
		||||
        systemctl enable "${systemd_unit}" &>/dev/null
 | 
			
		||||
        systemctl start "${systemd_unit}"
 | 
			
		||||
    else
 | 
			
		||||
        # Service already exists. Restart it.
 | 
			
		||||
        echo -e " ${msg_info}  ${gray}Restarting the service.${clear}"
 | 
			
		||||
        systemctl restart "${systemd_unit}"
 | 
			
		||||
    fi
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e " ${green}✓  ${white}Installation complete${clear}"
 | 
			
		||||
    # Reload systemd, enable, and start the service.
 | 
			
		||||
    systemctl daemon-reload
 | 
			
		||||
    systemctl enable "${systemd_unit}" &>/dev/null
 | 
			
		||||
    systemctl start "${systemd_unit}"
 | 
			
		||||
 | 
			
		||||
    # Installation complete.
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e " ${green}✓  ${white}Installation complete${clear}\n"
 | 
			
		||||
    echo -e "${yellow} Check service status: ${cyan}systemctl status ${service_short_name}${clear}"
 | 
			
		||||
    echo -e "${yellow}         Log location: ${cyan}${install_dir}/logs/${clear}"
 | 
			
		||||
    echo -e "${yellow}   Configuration file: ${cyan}${target_cfg}${clear}"
 | 
			
		||||
    echo
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e "${yellow}   Configuration file: ${cyan}${target_cfg}${clear}\n\n"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
@@ -289,10 +278,8 @@ EOF
 | 
			
		||||
# =============================================================================
 | 
			
		||||
uninstall_app() {
 | 
			
		||||
    # Print uninstall banner.
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e " ${white}Uninstalling Deceptifeed${clear}"
 | 
			
		||||
    echo -e " ${dgray}========================${clear}"
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e "\n ${white}Uninstalling Deceptifeed${clear}"
 | 
			
		||||
    echo -e " ${dgray}========================${clear}\n"
 | 
			
		||||
 | 
			
		||||
    # If the service exists: stop, disable, delete the service, and run daemon-reload.
 | 
			
		||||
    if [[ -f "${systemd_dir}/${systemd_unit}" ]]; then
 | 
			
		||||
@@ -301,7 +288,7 @@ uninstall_app() {
 | 
			
		||||
        echo -e " ${msg_info}  ${gray}Disabling service: ${cyan}${systemd_unit}${clear}"
 | 
			
		||||
        systemctl disable "${systemd_unit}" &>/dev/null
 | 
			
		||||
        echo -e " ${msg_info}  ${gray}Deleting: ${cyan}${systemd_dir}/${systemd_unit}${clear}"
 | 
			
		||||
        rm --force "${systemd_dir}/${systemd_unit}"
 | 
			
		||||
        rm -f "${systemd_dir}/${systemd_unit}"
 | 
			
		||||
        echo -e " ${msg_info}  ${gray}Reloading the systemd configuration.${clear}"
 | 
			
		||||
        systemctl daemon-reload
 | 
			
		||||
    else
 | 
			
		||||
@@ -324,13 +311,18 @@ uninstall_app() {
 | 
			
		||||
        echo -e " ${red}The installation directory may contain logs and configuration files."
 | 
			
		||||
        echo -e " ${red}Are you ready to delete ${blue}'${install_dir}'${red}?${clear}"
 | 
			
		||||
        echo -en " ${gray}(${white}yes${gray}/${white}no${gray}) ${gray}[${yellow}no${gray}]${white}: ${green}"
 | 
			
		||||
        read -r response
 | 
			
		||||
        if [[ "${auto_confirm_prompts}" = true ]]; then
 | 
			
		||||
            echo "yes"
 | 
			
		||||
            response="yes"
 | 
			
		||||
        else
 | 
			
		||||
            read -r response
 | 
			
		||||
        fi
 | 
			
		||||
        echo -en "${clear}"
 | 
			
		||||
        if [[ "${response}" =~ ^[yY][eE][sS]$ || "${response}" =~ ^[yY]$ ]]; then
 | 
			
		||||
            # Confirmed. Delete directory.
 | 
			
		||||
            echo
 | 
			
		||||
            echo -e " ${msg_info}  ${gray}Deleting installation directory: ${cyan}${install_dir}/${clear}"
 | 
			
		||||
            rm --recursive --force "${install_dir}"
 | 
			
		||||
            rm -rf "${install_dir}"
 | 
			
		||||
        else
 | 
			
		||||
            # Skip deleteion.
 | 
			
		||||
            echo
 | 
			
		||||
@@ -341,26 +333,66 @@ uninstall_app() {
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Uninstall complete.
 | 
			
		||||
    echo -e "\n ${green}✓  ${white}Uninstallation complete${clear}\n\n"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
# print_usage:
 | 
			
		||||
# Show usage information.
 | 
			
		||||
# =============================================================================
 | 
			
		||||
print_usage() {
 | 
			
		||||
    echo "Usage: install.sh [options]"
 | 
			
		||||
    echo "Install, upgrade, or uninstall Deceptifeed"
 | 
			
		||||
    echo
 | 
			
		||||
    echo -e " ${green}✓  ${white}Uninstallation complete${clear}"
 | 
			
		||||
    echo "Options:"
 | 
			
		||||
    echo "  -h, --help          Display this help and exit"
 | 
			
		||||
    echo "  -y, --yes           Automatically confirm actions without prompting"
 | 
			
		||||
    echo "      --uninstall     Uninstall Deceptifeed"
 | 
			
		||||
    echo
 | 
			
		||||
    echo "Description:"
 | 
			
		||||
    echo "Run the script without options to install Deceptifeed or upgrade if it's"
 | 
			
		||||
    echo "already installed."
 | 
			
		||||
    echo
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
# main:
 | 
			
		||||
# The primary entry point of the script. This function:
 | 
			
		||||
#   1. Calls the startup_checks function to perform initial setup and checks.
 | 
			
		||||
#   2. Checks command-line arguments to determine whether to install (default)
 | 
			
		||||
#   1. Checks command-line arguments to determine whether to install (default)
 | 
			
		||||
#      or uninstall the application.
 | 
			
		||||
#   2. Calls the startup_checks function to perform initial setup and checks.
 | 
			
		||||
# =============================================================================
 | 
			
		||||
main() {
 | 
			
		||||
    startup_checks
 | 
			
		||||
    if [[ "$#" -gt 2 ]]; then
 | 
			
		||||
        print_usage
 | 
			
		||||
        exit 0
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    if [[ "$1" == "--uninstall" ]]; then
 | 
			
		||||
    local uninstall_flag=false
 | 
			
		||||
    
 | 
			
		||||
    while [[ -n "$1" ]]; do
 | 
			
		||||
        case "$1" in
 | 
			
		||||
            -y | --yes)
 | 
			
		||||
                auto_confirm_prompts=true
 | 
			
		||||
                shift
 | 
			
		||||
                ;;
 | 
			
		||||
            --uninstall)
 | 
			
		||||
                uninstall_flag=true
 | 
			
		||||
                shift
 | 
			
		||||
                ;;
 | 
			
		||||
            *)
 | 
			
		||||
                print_usage
 | 
			
		||||
                exit 0
 | 
			
		||||
                ;;
 | 
			
		||||
        esac
 | 
			
		||||
    done
 | 
			
		||||
 | 
			
		||||
    if [ "${uninstall_flag}" == true ]; then
 | 
			
		||||
        startup_checks
 | 
			
		||||
        uninstall_app
 | 
			
		||||
        exit 0
 | 
			
		||||
    else
 | 
			
		||||
        startup_checks
 | 
			
		||||
        install_app
 | 
			
		||||
        exit 0
 | 
			
		||||
    fi
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user