60 Commits

Author SHA1 Message Date
Ryan Smith
6d98f7c4d2 Remove JSON formatting for TAXII output
This changes removes JSON formatting (beautifier) for TAXII output to minimize the response size.
2024-11-17 18:13:51 -08:00
Ryan Smith
f009206dbf Fix example UDP config should default to disabled 2024-11-17 18:01:18 -08:00
Ryan Smith
324064ff11 Apply fallback random number generator if primary fails
This change adds error handling for the `crypto/rand.Read` call when generating a UUIDv4 value. If an error occurs, it falls back to Go's PRNG from the `math/rand/v2` package. The PRNG fallback is acceptable because UUIDv4 values are not intended to be cryptographically secure.
2024-11-17 17:52:24 -08:00
Ryan Smith
2d3bc23a50 Set execute bit on install script 2024-11-17 15:55:20 -08:00
Ryan Smith
dd0b273601 Update README.md 2024-11-17 14:58:45 -08:00
Ryan Smith
eca338336c Update default configuration
- Add a default ruleset for HTTP and HTTPS honeypots.
- Restructure XML and add comments for each section.
- Change threat feed default port from 8081 to 9000.
- Change SSH honeypot default port from 2022 to 2222.
- Add example TCP and UDP honeypots.
2024-11-17 14:39:34 -08:00
Ryan Smith
0f1af8704d Change default listen ports
- Change the SSH honeypot server default port from 2022 to 2222
- Change the threat feed server default port from 8081 to 9000
2024-11-17 13:46:31 -08:00
Ryan Smith
b68ad408ce Add filtering and pagination to TAXII server
This change adds filtering and pagination when requesting objects from collections.

- Add `more` and `next` properties to the TAXII `envelope` resource.
- Add support for the `limit` URL query parameter to limit the maximum number of objects returned.
- Add full support for pagination. This includes:
  - Sett the `X-TAXII-Date-Added-First` and `X-TAXII-Date-Added-Last` headers with the timestamps of the first and last objects from the results.
  - Set the `more` property in the response to indicate whether there are more items remaining.
  - Set the `next` property in the response with the next page number if there are more items remaining.

Other changes:
- Modiy the threat feed `last seen` sort to sort by IP when the dates are equal.
2024-11-17 12:40:22 -08:00
Ryan Smith
e442edf0aa Add support for serving the threat feed over TAXII 2.1
This change adds support for serving the threat feed over TAXII 2.1. The TAXII API is not yet fully implemented, but should work with most threat intelligence platforms.

Missing TAXII features:
- Mising `limit` query parameter.
- Missing `match` query parameter.
- Pagination is not supported.
- Missing `X-TAXII` headers.
- {api-root}/collections/{id}/manifest/
- {api-root}/collections/{id}/objects/{object-id}/
- {api-root}/collections/{id}/objects/{object-id}/versions/

- Add `internal\taxi\taxi.go` with struct definitions and helpers for TAXII resources.
- Move threat feed parsing functions from `handler.go` to `feed.go`.
- Add option functions for `prepareFeed` to allow sorting by last seen date and to filter by last seen date.
- Default timestamps to time.Now when initially loading feed data.
2024-11-16 21:51:43 -08:00
Ryan Smith
c9d1b06680 Optimize IP sort function
Switch to `slices.SortFunc` instead of `sort.Slice` when sorting IP addresses in the feed.
2024-11-16 15:23:36 -08:00
Ryan Smith
51a0447e7d Refactor STIX functions
- Add new `stix` package for STIX-specific types and functions.
- Use `stix` package to decalre STIX types, rather than using local function declarations.
- Add function to return the Deceptifeed application represented as a STIX Identity object. Indicators and observables now reference this identity as the creator.
- Move `threatfeed\util.go` to `stix\uuid.go`.
- Move STIX conversions to dedicated functions to simplify HTTP handlers.
2024-11-16 11:37:44 -08:00
Ryan Smith
0ffbfae468 Switch to STIX namespace for deterministic IDs
This change switches to using the STIX namespace for generating deterministic identifiers for STIX Domain Objects (SDOs). While the STIX specification states SDOs must not use the STIX namespace, some applications (OpenCTI) do so. This update improves compatibility with OpenCTI by adopting the same behavior.
2024-11-15 15:19:22 -08:00
Ryan Smith
dead75f037 Add custom error page option for HTTP honeypots
- Added a new `<errorPagePath>` configuration option. This lets you specify a custom error page for HTTP and HTTPS honeypot servers. Only a single static HTML file may be specified.
- Renamed `<htmlPath>` to `<homePagePath>` in the configuration.
- Changed the default threat expiry hours from 168 (one week) to 336 (two weeks).
- Changed minimum threat score from `1` to `0` for honeypot servers.
2024-11-14 16:54:54 -08:00
Ryan Smith
b7a9eaced2 Add support for serving the threat feed in STIX 2.1 format
This change adds support for serving the threat feed in a STIX 2.1 format using the `/stix2` and `/stix2/ips` routes.

- Add `handleSTIX2` function for the `/stix2` route. This returns the detailed threat feed structured as a STIX bundle containing *Indicator* objects for each IP address in the feed.
- Add `handleSTIX2Simple` handler for the `/stix2/ips` route. This returns the simplified threat feed structured as a STIX bundle with each IP address included as a STIX Cyber-observable Object (SCO).
- Add `util.go` with helper functions for creating STIX 2.1 identifiers. This includes a `newUUIDv5` function, a `newUUIDv4` function, and a `namespace` type.
2024-11-13 14:53:18 -08:00
Ryan Smith
55366a2cb3 Move ticker variable from global scope to local
This change moves the threat feed's time.Ticker to a local variable. Previously, the variable was mistakenly declared as global. The time interval for the ticker is now declared as a const.
2024-11-13 11:43:55 -08:00
Ryan Smith
8b49b6f042 Split threat feed code into separate files
- Rename database.go to data.go.
- Move data-related global vars from threatfeed.go to data.go.
- Split out functions from threatfeed.go into seperate files:
  - Move HTTP server functions to server.go.
  - Move HTTP handler functions to handler.go.
  - Move HTTP middleware functions to middleware.go.
- Rename hasMapChanged to dataChanged.
2024-11-13 11:28:36 -08:00
Ryan Smith
f5a2ec3f97 Rename UpdateIoC to Update
- Rename the threat feed `UpdateIoC` function to `Update`.
- Rename `iocMap` to `iocData`.
- Rename `loadIoC` and `saveIoC` to `loadCSV` and `saveCSV`.
- Edit most comments mentioning *database* to simply *threat feed* or *data*.
2024-11-13 11:01:26 -08:00
Ryan Smith
74ba8c648b Rename and simplify Start functions
- Renamed functions from `Start<server_type>` to `Start`.
- Removed unnecessary wrappers for Start functions.
- The HTTP and HTTPS servers now share a single Start function which starts the appropriate listener depending on the passed in configuration.
2024-11-13 10:27:57 -08:00
Ryan Smith
c8aa491b5b Add field to record when IP is added to threat feed
This change adds the `Added` field to the `IoC` struct to record the time when an IP address is added to the threat feed. Functions that read or write IoC data have been updated to include the new field.
2024-11-12 16:35:56 -08:00
Ryan Smith
4e596a9d20 Add /csv and /csv/ips handlers
This changes adds `/csv` and `/csv/ips` routes for serving the threat feed in a CSV format.
2024-11-12 11:52:10 -08:00
Ryan Smith
774eb787f9 Add minor threat feed optimizations
Changed `csvHeader` from a single string constant to a slice of strings. This eliminates the need to call `strings.Split` during threat feed updates.

Fixed integer overflow checking when adding the threat score to an existing IoC.
2024-11-12 11:49:19 -08:00
Ryan Smith
0cb24c3b19 Rename JSON threat feed routes
- Renamed the full detailed JSON format route from `/json/detailed` to `/json`.
- Renamed the simple JSON format route from `/json` to `/json/ips`.
2024-11-12 09:56:26 -08:00
Ryan Smith
1c50279b29 Optimize performance in threat feed processing
- Repositioned mutex locks and unlocks to minimize contention around `iocMap` access.
- Add `expired()` method to `IoC` struct for better expiration logic.
- Renamed `removeExpired()` to `deleteExpired()` and simplified its use.
- Skip threat feed updates for loopback IP addresses.
- Fixed integer overflow detection when updating threat scores.
- Increased ticker interval for writing threat feed to disk from 10 seconds to 20 seconds.
- `deleteExpired()` is now only triggered by the ticker.
2024-11-12 09:37:42 -08:00
Ryan Smith
01de73c9f0 Add /json and /json/detailed handlers
This change adds a `/json` and `/json/detailed` handlers for serving the threat feed in a JSON format
2024-11-11 22:15:27 -08:00
Ryan Smith
4fdee88948 Disable cache when serving the threat feed
Set HTTP response headers to prevent clients from caching the threat feed.
2024-11-11 22:12:51 -08:00
Ryan Smith
df83ee2c87 Refactor http handler
This change moves the code for preparing the threat feed IP list into a separate function.
2024-11-11 22:08:12 -08:00
Ryan Smith
8820970e33 Optimize filterIPs function
Parse CIDRs once before iterating over `ipList`. This improves performance by avoiding redundant parsing.

Modify `ipList` in place.
2024-11-11 21:38:14 -08:00
Ryan Smith
090868a5dd Allow loopback address access to threat feed
This change allows access to the threat feed locally using a loopback address (http://127.0.0.1:8081)
2024-11-08 13:55:43 -08:00
Ryan Smith
e2b3dc51c5 Switch long arguments to short for better compatibility
Changed all commands that use long arguments to short arguments in the installation script and Makefile. This improves compatibility, as short arguments are more universally supported.
2024-11-08 13:32:13 -08:00
Ryan Smith
744717886b Backup original binary when upgrading 2024-11-08 13:26:08 -08:00
Ryan Smith
927903e47a Rename request_headers log field to headers 2024-11-08 10:10:50 -08:00
Ryan Smith
a8ee70ae3e Upgrade modules 2024-11-08 09:53:58 -08:00
Ryan Smith
c12f0d7746 Revise casing to match new log format
Header names are now logged in all lowercase. The HTTP honeypot log sample now reflects that.
2024-11-08 09:35:18 -08:00
Ryan Smith
c920d9a4a8 Change custom headers element to <headers>
This change modifies the configuration for HTTP honeypot servers. Previously, custom headers were defined using the `<banner>` element, which was shared with SSH and TCP honeypot servers. Now there is a dedicated `<headers>` element allowing any number of `<header>` elements to be defined for custom HTTP response headers.

Before (old configuration):
```xml
<server type="http">
  <banner>Server: Microsoft-IIS/8.5, X-Powered-By: ASP.NET</banner>
</server>
```

After (new configuration):
```xml
<server type="http">
  <headers>
    <header>Server: Microsoft-IIS/8.5</header>
    <header>X-Powered-By: ASP.NET</header>
  </headers>
</server>
```
2024-11-08 09:26:12 -08:00
Ryan Smith
6b2088b5bf Change XML structure for <prompt> elements
This change revises the configuration for custom prompts in the TCP honeypot server. Previously, `<prompt>` elements were defined directly within the `<server>` element. In the new configuration, a `<prompts>` element is used to enclose any number of `<prompt>` elements.

Before (old configuration):
```xml
<server type="tcp">
  <prompt>Username:</prompt>
  <prompt>Password:</prompt>
</server>
```

After (new configuration):
```xml
<server type="tcp">
  <prompts>
    <prompt>Username:</prompt>
    <prompt>Password:</prompt>
  </prompts>
</server>
```
2024-11-08 08:37:34 -08:00
Ryan Smith
fc43f99af7 Force HTTP response header casing to WWW-Authenticate
This change forces the `WWW-Authenticate` casing for the default HTTP response header on the HTTP/HTTPS honeypot servers. Previously, Go automatically converted it to `Www-Authenticate`. This update matches the casing used by most other web servers. The change is intended to reduce the risk of fingerprinting the honeypot server by making it behave more like a typical web server.
2024-11-07 17:13:22 -08:00
Ryan Smith
7cd36a5018 Change HTTP honeypot to return empty body by default
This changes modifies the HTTP/HTTPS honeypot servers to return an empty response body by default. While status codes remain intact, the body content is now empty. This change is intended to minimize the risk of fingerprinting the honeypot server.
2024-11-07 17:06:50 -08:00
Ryan Smith
12ada38faa Change http header name logging to lowercase
This change converts HTTP header names to lower case when writing to JSON logs. This ensures consistent casing across all log fields.
2024-11-07 16:17:10 -08:00
Ryan Smith
a99f03768b Adjust ASCII logo colors to match SVG logo 2024-11-07 15:34:00 -08:00
Ryan Smith
70db9094cd Add ability to specify header to use for source IP
This change adds a new `<sourceIpHeader>` element to the HTTP/HTTPS honeypot server configuration. It allows you to specify an HTTP header to use as the source IP address when updating the threat feed.

You would set this option if the HTTP honeypot server is behind a proxy server. Typically a proxy would set an HTTP header, such as `X-Forwarded-For`, that records the source IP of the originating client.

Example configuration:
```xml
<sourceIpHeader>X-Forwarded-For</sourceIpHeader>
```
2024-11-05 17:07:48 -08:00
Ryan Smith
cfc9650085 Add negate attribute to regex rules
This change introduces a `negate` attribute to `<include>` and `<exclude>` rules in the configuration. When `negate` is set to `true`, the rule applies when the regex pattern does not match.

For example, the following _include_ rule matches when the HTTP request does not equal "GET", "HEAD", or "OPTIONS":

```xml
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
```
2024-11-05 16:06:27 -08:00
Ryan Smith
2274ebbc29 Rename match to include
This change renames the `<match>` XML element in the configuration to `<include>`.
2024-11-05 15:58:51 -08:00
Ryan Smith
50dfbe2d6b Add project logo 2024-11-04 16:40:17 -08:00
Ryan Smith
57dc10edb0 Add logos 2024-11-04 16:16:40 -08:00
Ryan Smith
c84a1e60a5 Remove source port from logging
This change removes the logging of source ports for connecting clients in the honeypot servers. The source port does not provide value for this type of honeypot and only clutters the logs.
2024-11-02 09:41:15 -07:00
Ryan Smith
324dd67ff0 Add deadline and delay to SSH honeypot
This change adds a 30-second deadline to SSH connections. Client connections are forced closed after the deadline.

Additionally, a 2-second delay is added prior to rejecting authentication requests. This mimics the `pam_faildelay` PAM module found on modern Linux systems.
2024-11-02 09:22:50 -07:00
Ryan Smith
368914b566 Add public key callback
This change adds a public key authentication callback function to the SSH honeypot server. All requests are rejected, and currently, no data is logged.
2024-11-02 08:31:38 -07:00
Ryan Smith
496b211243 Remove unnecessary channel handling
This change removes unnecessary channel handling code from the SSH honeypot server. Since authentication requests are always rejected, `ssh.NewServerConn` will consistently return an error, making the channel handling redundant.
2024-11-02 08:04:40 -07:00
Ryan Smith
aa61a99c8a Add ability to define rules for when a request updates the threat feed
This change adds Rule and Rules structs for HTTP honeypot configurations. The rules are regex patterns that define when an HTTP request should trigger an update the threat feed.

The Rule struct allows you specify a target that defines which part of the HTTP request should match your pattern.

Example configuration:
```
<server type="http">
  <rules>
    <match target="path">\.php$</match>
  </rules>
</server>
```

This example triggers an update to the threat feed only if the HTTP request path ends with `.php`. Any other request will not trigger an update.
2024-11-01 20:39:14 -07:00
Ryan Smith
59414fd00e Rename srv variables to cfg 2024-11-01 11:38:42 -07:00
Ryan Smith
6485bbf3ff Change expiryHours from uint to int 2024-11-01 11:32:55 -07:00
Ryan Smith
d230c6721d Fix expiry logic when serving the threat feed
Previously, setting `expiryHours` to `0` did not prevent IP addresses from expiring in the threat feed. This has been corrected. Now, when `expiryHours` is set to `0`, IP addresses never expire from the threat feed.
2024-11-01 11:31:46 -07:00
Ryan Smith
9e3e3303f5 Add explicit discard for non-essential errors
This change adds explicit discard statments on function calls that return errors when the error is irrelevant.
2024-11-01 10:58:25 -07:00
Ryan Smith
16971d7c0d Add timeouts to http servers 2024-11-01 10:31:30 -07:00
Ryan Smith
855d913ade Revise readme 2024-10-30 13:31:28 -07:00
Ryan Smith
0bf7cbf694 Add auto-confirmation option for upgrades and uninstalls
* Add `-y` and `--yes` command-line arguments to the installation script. Setting this argument sets the `auto_confirm_prompts` variable to `true`. When a confirmation is needed, the variable is checked to determine whether to show a prompt or to auto-confirm.

* Add `print_usage` function to print basic help and usage information. It is shown if an unsupported argument is supplied (including `-h` and --`help`).
2024-10-30 13:26:37 -07:00
Ryan Smith
cbb18f4189 Add vhs tape file used to generate install.gif 2024-10-30 10:31:10 -07:00
Ryan Smith
5a1095b7e8 Reduce padding for install.gif 2024-10-30 10:30:23 -07:00
Ryan Smith
a2c97a5900 Create README.md 2024-10-29 09:32:59 -07:00
Ryan Smith
6bf0ba6331 Add install.gif 2024-10-29 09:19:35 -07:00
26 changed files with 2208 additions and 695 deletions

View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

65
assets/install.tape Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

6
assets/logo-light.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -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)
}
}()
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
}

View File

@@ -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
View 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
}

View File

@@ -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
View 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
}

View 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)
}

View 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)
}
}

View 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)
}
}

View File

@@ -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)
}

View File

@@ -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
View 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