159 Commits

Author SHA1 Message Date
Ryan Smith
9e14d3886a Merge pull request #1 from eltociear/patch-1
docs: update README.md
2025-04-10 07:10:47 -07:00
Ikko Eltociear Ashimine
abaa098099 docs: update README.md
honeyot  -> honeypot
2025-04-10 14:51:00 +09:00
Ryan Smith
62b166c62a Update README.md
Update threat feed example output to show 'observations' count rather than 'threat_score'. The threat score feature was replaced with observation count in a previous change.
2025-04-08 11:10:18 -07:00
Ryan Smith
53fd03cd46 Revise screenshot 2025-04-08 10:37:21 -07:00
Ryan Smith
a1dfb7f648 threatfeed: Pre-parse and cache html templates
This change pre-parses all html templates from the `templates` directory and stores the results globally. With this change, http handlers no longer need to re-parse templates on every request.
2025-04-07 16:57:09 -07:00
Ryan Smith
540b0b940c threatfeed: Add honeypot log data statistics
This change adds the ability to view various statistics for honeypot log data. This includes views such as unique SSH usernames, unique HTTP paths, unique HTTP host headers, etc.

A new `/logs/{logtype}/{subtype}` route is added for rendering stats.
2025-04-07 16:40:18 -07:00
Ryan Smith
7bc73f6695 threatfeed: move nav bar to dedicated template
This change moves the nav bar for the threat feed web interface to a dedicated template defined in `nav.html`. HTTP handlers and existing HTML templates are updated to utilize the new template.
2025-04-06 22:44:27 -07:00
Ryan Smith
d0f046593e theatfeed: tooltips in live feed + style updates
This change adds tooltips to the live logs for HTTP log data. Hovering over an HTTP even reveals the full HTTP request details.

Updated style.css to support the new tooltips.

Other changes:
- Minor color changes applied to the web feed.
- Minor text revisions in `home.html` and `docs.html`.
2025-04-06 20:15:54 -07:00
Ryan Smith
444a446b0f webfeed: format dates and numbers via javascript
This change adjusts the webfeed.html template to return timestamps in ISO 8601 format in UTC and instead uses JavaScript to format and display using the user's local time.

JavaScript is also used to add a thousands seprator to values in the 'Observations' column.

When formatting the 'Added' column, the time is dropped and now displays as YYYY-MM-DD.
2025-04-06 14:55:18 -07:00
Ryan Smith
0462ed7b4c Explicitly ignore errors in WS handlers 2025-04-05 14:27:00 -07:00
Ryan Smith
ecbe1d4972 Append -dirty or -broken tag to version if local changes found 2025-04-05 14:26:10 -07:00
Ryan Smith
4eebe8029f Revise screenshots 2025-04-04 14:51:40 -07:00
Ryan Smith
0e66c52a16 Update README.md 2025-04-04 09:32:54 -07:00
Ryan Smith
7334aac745 Update screenshots 2025-04-04 08:30:35 -07:00
Ryan Smith
fd60dc89eb Add ability to monitor honeypot logs in realtime via WebSockets
This change adds support for WebSockets using Google's WebSocket package.

When the threat feed server is starting, a Go function is created to monitor honeypot log data via a channel. When log data is received on the channel, it is broadcast to all connected WebSocket clients.

A /live endpoint and handler is added for serving the live.html template. This page displays the log data in real time by utilizing WebSockets.

Updated the nav bar on all html pages to include the new 'Live' icon for accessing the realtime log.
2025-04-03 14:07:50 -07:00
Ryan Smith
35c0eb06f8 Move log-related handlers to separate file 2025-04-03 10:52:05 -07:00
Ryan Smith
d3f7cb4e86 Add logmonitor to configuration 2025-03-31 08:59:49 -07:00
Ryan Smith
c3ca87c7af Add logmonitor package for monitoring writes to log files
The Monitor type is an io.Writer that sends data to a channel. It is meant for use with the honeypot logging system. This will allow the threat feed to provide real-time monitoring of the logging system while simultaneously logging the data to disk.
2025-03-29 12:18:05 -07:00
Ryan Smith
6ba9f0acf5 Add ability to view the running configuration
This change adds a /config handler for displaying the Deceptifeed configuration. An icon is added to the nav bar for accessing the page.

- Add config.html template for displaying the Deceptifeed configuration.
- Add config.html supporting styles to style.css.
- Add /config http handler to render the config.html template.
- Add icon to nav bar for accessing /config page.
2025-03-27 13:26:52 -07:00
Ryan Smith
94dce2c13a Revise styles 2025-03-27 10:03:47 -07:00
Ryan Smith
4fd048c287 Remove feature for custom threat file
This change removes the `CustomThreatsPath` setting from the threat feed configuration. The default configuration files are updated with this setting removed.
2025-03-26 18:23:37 -07:00
Ryan Smith
7bad11a4a7 Allow comments in exclude list
This change allows for comments in the exclude list using the `#` symbol. The `#` symbol on a line and everything after it is ignored when parsing the exclude list.
2025-03-26 18:17:31 -07:00
Ryan Smith
30c3095541 Remove v prefix from version tag 2025-03-26 18:13:18 -07:00
Ryan Smith
920759db70 Explicitly disable threat feed for UDP honeypots
This change explicitly disables the threat feed for UDP honeypots in the configuration. The UDP honeypot server does not implement the threat feed.
2025-03-24 11:17:05 -07:00
Ryan Smith
7dc7b1ee83 Add setting and getting version information
- Add `Version` string var to config package for storing version information.
- Update Makefile to set the `Version` variable at build time using the latest Git tag.
- Add `-version` flag to main package to print version information and exit.
- Remove setting the GO111MODULE environment variable from Makefile when building. It's not needed.
2025-03-22 08:56:10 -07:00
Ryan Smith
f6cd4c783e Change nav bar names and icons
This change renames `Web Feed` to `Threats` and `Honeypot Logs` to `Logs` in the navigation bar.

Apply visual tweaks to the nav bar.
2025-03-20 16:34:48 -07:00
Ryan Smith
4cf8d15402 Add FilePath field to Config struct
The FilePath field stores the absolute path to the running configuration file and is set while the configuration file is initially loading.
2025-03-20 09:44:40 -07:00
Ryan Smith
f5d6f9f78b Sort server configuration by port number
This change sorts the honeypot server configuration by port number during application initialization.

This is purely cosmetic and intended for a planned config viewer feature.
2025-03-20 09:39:07 -07:00
Ryan Smith
60ab753c42 Remove threat score feature, replace with observation count
This change removes the 'threat score' feature which allowed users to configure each honeypot server with a variable 'score' when updating the threat feed.

It is replaced with a fixed observation count that is incremented by 1 for each honeypot interaction.

The field `threat_score` has been replaced with `observations` in all API call parameters and threat feed data.

The `threat_score` field in the CSV file has been renamed to `observations`. Existing threat feed CSV files will be automatically updated on the next threat feed save.
2025-03-20 09:20:15 -07:00
Ryan Smith
b23e9b4a9e Remove minimum threat score feature and settings 2025-03-19 20:50:21 -07:00
Ryan Smith
f72cf4ddba Use centralized stylesheet
This change removes the CSS sections from HTML templates and switches to using a single stylesheet at /css/style.css.
2025-03-19 19:55:14 -07:00
Ryan Smith
d50bce3fbf Explicitly set default SSH banner
This change explicitly sets the default SSH banner in the running configuration when the application starts.

If starting without a configuration file, the global default banner is always used.

If starting with a configuration file, the global default banner is used when no banner is specified.
2025-03-19 19:36:59 -07:00
Ryan Smith
5b7618ad5e Fix log path not set when using CLI flags
This resolves a bug introduced in commit bc7fcef4b5 which resulted in log paths not being set when the app was launched without a configuration file.

This change corrects the problem by explicitly setting the log path for each honeypot server when no configuration file is provided.
2025-03-18 11:39:24 -07:00
Ryan Smith
764188cf2b Require private IP when accessing log data 2025-03-18 09:40:58 -07:00
Ryan Smith
00b747341b Display message when feed/logs are empty
Adjust the html templates for web feed and log viewers to check if there's any data to display. If no data, a message is shown informing the user that there's no data.
2025-03-18 07:36:01 -07:00
Ryan Smith
97cddb8cfe Ensure log files are read in a consistent order
Use a slice instead of a map to track unique paths to ensure log files are read in the correct order.
2025-03-18 07:26:27 -07:00
Ryan Smith
9384834da1 Remove click effect on logo in nav bar 2025-03-17 19:56:10 -07:00
Ryan Smith
0e0a18e1f1 Adjust Makefile to strip debug info and add architectures
- Strip debug information to reduce binary size, using `-ldflags="-s -w"`.
- Remove file system paths from the build environment in the resulting binary, using `-trimpath`.
- Add targets to build for Linux, FreeBSD, and Windows.
2025-03-17 17:43:36 -07:00
Ryan Smith
115efa5b69 Change output directory to ./bin when compiling
Previously, binaries were compiled to ./out.
2025-03-17 16:14:33 -07:00
Ryan Smith
5dc1a6d91c Change output directory to ./bin when compiling
Previously, binaries were compiled to ./out.
2025-03-17 15:54:14 -07:00
Ryan Smith
0d09a59d3c Explicitly ignore errors from rand.Read
The error is ignored because rand.Read is guranteed to never return an error.
2025-03-17 14:55:06 -07:00
Ryan Smith
86eb9b773a Update dependencies 2025-03-17 14:26:04 -07:00
Ryan Smith
182262d474 Add ability to view honeypot logs from the threat feed server 2025-03-17 14:08:13 -07:00
Ryan Smith
bc7fcef4b5 Refactor log path initialization
Previously:
When no log path was specified for a server, it would fall back to the global/default log path during logger initialization. However, the `LogPath` field didn't update and would not reflect the actual path used by the logger.

Now:
Log path determination is handled while the configuration is loading. If a server falls back to the default log path, the `LogPath` field is updated to reflect the actual path used by the logger.
2025-03-17 13:25:09 -07:00
Ryan Smith
857966808c Revise CSS styling for threat feed server 2025-03-14 10:42:17 -07:00
Ryan Smith
70e8180b2b Add custom 404 page 2025-03-09 12:35:10 -07:00
Ryan Smith
1bde74187f Add nav bar and visual tweaks to management pages
- Add a horizontal navigation bar to the top of all management pages.
- Tweaks to colors and styling of management pages.
- Rename the endpoint '/threatfeed' to '/webfeed'.
- Rename the file 'htmlfeed.html' to 'webfeed.html'.
2025-03-09 10:08:05 -07:00
Ryan Smith
96b5be5758 Minor visual and structural revisions
- Remove the `/feed` endpoint, as this is already handled by `/plain`.
- Rename `/html` endpoint to `/threatfeed`.
- Add a `/docs` endpoint for displaying information on accessing the threat feed.
- Make the Deceptifeed logo smaller on HTML pages.
- Revise the layout of the initial homepage. Endpoint docs are now moved and linked to /docs.
- Remove `/html` from the list of threat feed endpoints in the docs, as it doesn't belong here (there is a link to the html threat feed on the home page).
2025-03-07 13:34:41 -08:00
Ryan Smith
3e72919170 Use slog.DiscardHandler when logging is disabled
This change switches to using slog.DiscardHandler, added to Go 1.24, when logging is disabled.
2025-03-03 22:26:41 -08:00
Ryan Smith
122e1ca83d Update rand.Read error handling
As of Go 1.24, rand.Read is guaranteed to never return an error. This change removes the error check and the associated fallback function when calling rand.Read.
2025-03-03 22:07:45 -08:00
Ryan Smith
c96313242a Update dependencies 2025-03-03 21:59:24 -08:00
Ryan Smith
bfc121ce06 Increment minimum Go version to v1.24 2025-03-03 21:49:30 -08:00
Ryan Smith
99b9760830 Update threat feed server to receive full app config
This change updates the threat feed server so that the entire application config is passed in to the threat feed server. This allows the threat feed server to access its own configuration as well as the settings for each honeypot server and any other application-wide settings.
2024-12-30 10:39:01 -08:00
Ryan Smith
126577d842 Revise saveCSV routine
Refactor saveCSV to use a bufio.NewWriter and manually format CSV data rather than using the built-in CSV writer. This results in slightly better performance and memory usage when the threat feed is large (>500k entries).

Various miscellaneous changes:
- Rename `mutex` variable to `mu`.
- Lock the mutex in `loadCSV` because it accesses the iocData map.
2024-12-28 23:35:14 -08:00
Ryan Smith
6f4d1d9921 Revise error messages 2024-12-27 22:04:13 -08:00
Ryan Smith
849709ae01 Explicitly ignore errors from file.Close()
In logrotate.OpenFile(), explicitly ignore errors from file.Close().
2024-12-27 21:43:48 -08:00
Ryan Smith
ef2bc057f4 Update dependencies 2024-12-27 21:17:36 -08:00
Ryan Smith
ae596b82e8 Automatically rotate honeypot logs
This change configures the honeypot loggers to use the internal `logrotate` package. This enables automatic log file rotation. The maximum log size is set to 50 MB.
2024-12-27 21:06:59 -08:00
Ryan Smith
646c09a4fa Add logrotate package to manage log file rotation
The logrotate package implements an io.WriteCloser with automatic file rotation. When the file size exceeds a specified limit, the file is renamed with a `.1` suffix. Subsequent rotations overwrite the file with the `.1` suffix, meaning only one backup is retained. This approach keeps the rotation simple.
2024-12-27 17:31:21 -08:00
Ryan Smith
0269fe34d2 Ensure only 1 file handle/logger is created per log file
This change adjusts logger initialization to ensure only 1 file handle and 1 logger is created per unique log path.

Each honeypot server may have its own log path specified, which may be unique or may be shared with other honeypot servers.

Previously, each honeypot server would open a separate file handle and logger, even if the file was already opened by another server.
2024-12-27 09:01:02 -08:00
Ryan Smith
563c76696b Change default SSH banner to appear as OpenSSH 9.6
This change adjusts the default SSH server version string to `SSH-2.0-OpenSSH_9.6`. This makes SSH honeypots appear as OpenSSH 9.6.

The change is applied both to default configuration files and as an application default when no configuration is provided.
2024-12-27 08:48:43 -08:00
Ryan Smith
1a631e7e14 Use quoted strings when printing certain fields
This change adjusts the SSH, TCP, and UDP honeypots to print quoted (escaped) strings to the terminal for certain log fields rather than raw strings. The adjusted fields are SSH username, SSH password, TCP responses, and UDP received data.
2024-12-26 11:29:06 -08:00
Ryan Smith
079becbd82 Update threat feed html templates
Changes to HTML feed template:
- Drop milliseconds from added and last seen dates.
- Make the logo a link to return to the threat feed homepage.
- Fix logo sizing on small screens.
- Reduce logo margins.

Changes to home page template:
- Update /stix API endpoint documentation to change references from /stix/indicators and /stix/observables to just /stix.
- Update TAXII documentation to include the new sightings collection.
- Update TAXII documentation with shortened collection names.
- Set `break-word` wrapping on code snippets to fix rendering on small screens.
- Collapse API endpoint table on small screens.
- Fix logo sizing on small screens.
- Reduce logo margins.
2024-12-26 10:27:46 -08:00
Ryan Smith
b47d5278f4 Consilidate STIX endpoints to /stix
This change removes the /stix/indicators and /stix/observables endpoints and replaces them with a single /stix endpoint. The /stix endpoint returns the threat feed as STIX indicators.
2024-12-26 10:16:04 -08:00
Ryan Smith
505e1fa2e0 Add sightings TAXII collection
- Add `sightings` TAXII collection to represent IP addresses observed interacting with Deceptifeed as STIX sightings
- Rename collection aliases `deceptifeed-indicators` and `deceptifeed-observables` to `indicators` and `observables`.
- Add `convertToSightings` method for converting the threat feed to STIX sightings.
- Set a confidence score of 100 to STIX indicators.
- Rename label `honeypot` to `honeypot-interaction` on STIX indicators.
2024-12-26 10:05:55 -08:00
Ryan Smith
45bb7e48b9 Define a maximum threat score of 999,999,999
This change sets a maximum threat score of 999,999,999. Previously, the maximum threat score was the max signed int value (9,223,372,036,854,775,807 on 64-bit systems).
2024-12-26 09:42:54 -08:00
Ryan Smith
73e2dd1c4b Add Sighting STIX Relationship Object
This change adds a `Sighting` struct to the stix package for representing a `Sighting` STIX Relationship Object (SRO).
2024-12-26 08:49:17 -08:00
Ryan Smith
1df1a045d0 Fix: Don't log "User-Agent" in headers key
This commit ensures the `User-Agent` value is removed from the HTTP request headers when logging the request. `User-Agent` is logged in the `event_details` array, so it should not duplicate the value in the `headers` array when logging. This commit fixes the issue introduced in commit 12ada38faa which normalised header names to lowercase.
2024-12-19 16:34:57 -08:00
Ryan Smith
41eab266fa Fix missing end tag in /html template 2024-12-17 10:36:56 -08:00
Ryan Smith
c35c8ebda9 Revise HTML styles 2024-12-17 10:20:26 -08:00
Ryan Smith
c120b2633f Update README.md 2024-12-09 17:20:17 -08:00
Ryan Smith
3d727ff0cf Change references from /feed to /plain 2024-12-05 22:12:02 -08:00
Ryan Smith
183e078671 Add tzdata package step to Dockerfile
This change adjusts the Dockerfile to add the `tzdata` package to the Alpine Linux image. This allows users to set a timezone through the `TZ` environment variable when running Deceptifeed as a Docker container.
2024-12-05 18:01:08 -08:00
Ryan Smith
f5561776a7 Add error check in handleHome
This change adds a missing error check to the template .Execute call in `handleHome`.
2024-12-05 16:48:00 -08:00
Ryan Smith
d728a9a500 Update dependencies 2024-12-05 16:30:21 -08:00
Ryan Smith
1b76ac5251 Add default configuration file for Docker usage 2024-12-05 15:45:35 -08:00
Ryan Smith
e3261088f4 Update README.md 2024-12-05 14:44:20 -08:00
Ryan Smith
ae670554e8 Add threat feed web screenshot 2024-12-05 14:40:28 -08:00
Ryan Smith
060027d2ee Update README.md 2024-12-05 13:52:55 -08:00
Ryan Smith
e269e8289e Update README.md 2024-12-04 17:27:20 -08:00
Ryan Smith
1bfcd140e7 Merge branch 'main' of https://github.com/r-smith/deceptifeed 2024-12-04 17:26:45 -08:00
Ryan Smith
e3a4a1ade6 Update .SVG diagrams 2024-12-04 17:26:39 -08:00
Ryan Smith
3af87f4a1d Update README.md 2024-12-04 17:19:55 -08:00
Ryan Smith
f90ff62af2 Update README.md 2024-12-04 17:18:21 -08:00
Ryan Smith
9c19418d1f Revise diagrams 2024-12-04 17:06:22 -08:00
Ryan Smith
bd7212fa92 Revise diagrams 2024-11-28 20:37:19 -08:00
Ryan Smith
9b6524be40 Add diagrams 2024-11-27 14:06:29 -08:00
Ryan Smith
f6a4f1ff5f Add default landing page and revise routes
This change adds a default landing page that is served when accessing the threat feed root URL. The landing page provides information about accessing the threat feed.

Move the template from handleHTML to the templates directory and embed using embed.FS.

Several changes to HTTP routes:
- Delete `/csv/ips` route and handler.
- Delete `/json/ips` route and handler.
- Rename `/stix2` route to `/stix/indicators`.
- Rename `/stix2/ips` route to `/stix/observables`.
- Change default route `/` to return 404 Not Found.
- Move plain text feed to `/feed` and `/plain` routes.
2024-11-26 08:59:29 -08:00
Ryan Smith
0c46913497 Add Dockerfile 2024-11-25 13:04:06 -08:00
Ryan Smith
865d06dd21 Update default config
This change updates the default configuration file by removing most comments. Configuration options will be documented separately.

The regex patterns in the default ruleset for HTTP honeypots is simplified.
2024-11-25 13:03:49 -08:00
Ryan Smith
16f7b6a86d Rename XML element isPrivateIncluded to includePrivateIPs 2024-11-25 12:57:40 -08:00
Ryan Smith
19b61b90e7 Set default sort method for html route 2024-11-25 12:56:35 -08:00
Ryan Smith
94bf060035 Set default config file to 'config.xml' if found
If the `-config` flag is not provided, the app will automatically use "config.xml" from the current directory, if it exists.
2024-11-22 12:21:09 -08:00
Ryan Smith
c952356879 Add support for serving the threat feed as a web page
- Add new route `/html` and associated handler for serving the threat feed as a web page for viewing in a browser.
- Revise sorting methods to apply a secondary sort by IP adddress when the values for the requested sort method are equal.
2024-11-21 17:48:55 -08:00
Ryan Smith
0db1b81617 Refactor threat feed data structure
- Add a `feedEntry` struct to represent threat feed data as it is being served to clients. The threat feed is now served as a slice of feedEntries. Previously, the threat feed was built using a slice of net.IPs. The new struct avoids additional map lookups when formatting the feed and faster sorting operations.
- Rename `IoC` struct to `IOC` and make fields non-exportable.
- Revise HTTP handlers to format/encode the feed results using the new structs.
2024-11-21 14:17:57 -08:00
Ryan Smith
0421fd66ba Add sort direction option
- Add `sortDirection` type to represent sort directions.
- Add sortDirection argument to applySort function.
- Add `direction` HTTP query parameter for setting the sort direction option.
2024-11-21 08:38:17 -08:00
Ryan Smith
64cd270d9b Add sort and filter query parameters to all endpoints
- Add `sort` query parameter to sort the threat feed by IP (default), last seen date, added date, or by threat score.
- Add `last_seen_hours` query parameter to have the threat feed only return IPs that have been seen within the specified number of hours.

Examples:

`http://127.0.0.1:9000/json?sort=added`

`http://127.0.0.1:9000/?sort=ip&last_seen_hours=24`
2024-11-20 13:12:19 -08:00
Ryan Smith
b7ed763661 Refactor feed operations
- Add parseParams function to parse HTTP query parameters.
- Switch from functional options to an options struct for prepareFeed.
- Define a `feed` type for []net.IP.
- Add `applySort` method for sorting the feed.
- Change `convertToObservables` and `convertToIndicators` from functions to methods.
- Optimize exclude list processing.
  - parseExcludeList now determines and tracks whether each entry is a single IP or a CIDR block and now returns both a map of single IPs and a slice of CIDR blocks.
  - prepareFeed now calls parseExcludeList prior to iterating over iocData.
  - Delete the filterIPs function. All filtering logic is applied while iterating over iocData rather than afterwards.
2024-11-20 08:31:20 -08:00
Ryan Smith
716988a546 Adjust feed output
- Change the date/time format to include fractional seconds when saving the threat feed to CSV. This ensures saved threat feed timestamps match that of the in-memory threat feed.

- Change STIX indicator name field from `ip : honeypot interaction` to `Honeypot interaction: ip`.
2024-11-18 12:58:33 -08:00
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
54 changed files with 5545 additions and 780 deletions

2
.gitignore vendored
View File

@@ -27,4 +27,4 @@ deceptifeed.*
deceptifeed-*.*
# Ignore build output directory used by Makefile.
out/
bin/

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
# syntax=docker/dockerfile:1
FROM golang:latest AS build-stage
WORKDIR /build
COPY . .
RUN make
FROM alpine:latest
RUN apk add --no-cache tzdata
WORKDIR /data
COPY --from=build-stage /build/bin /
ENTRYPOINT ["/deceptifeed"]

View File

@@ -1,22 +1,55 @@
# Makefile for Deceptifeed
TARGET_BINARY := ./out/deceptifeed
SOURCE := ./cmd/deceptifeed/
BIN_DIRECTORY := ./bin/
BIN_DEFAULT := deceptifeed
BIN_LINUX := $(BIN_DEFAULT)_linux_amd64
BIN_FREEBSD := $(BIN_DEFAULT)_freebsd_amd64
BIN_WINDOWS := $(BIN_DEFAULT)_windows_amd64.exe
INSTALL_SCRIPT := ./scripts/install.sh
UNINSTALL_SCRIPT := ./scripts/install.sh --uninstall
VERSION := $(shell git describe --tags --dirty --broken)
BUILD_OPTIONS := -trimpath -ldflags="-s -w -X 'github.com/r-smith/deceptifeed/internal/config.Version=$(VERSION:v%=%)'"
GO := go
CGO_ENABLED := 0
GO111MODULE := on
.PHONY: build
build:
@echo "Building to: ./out/"
@mkdir --parents ./out/
GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -o $(TARGET_BINARY) $(SOURCE)
@echo "Build complete."
@echo "Building for current operating system..."
@mkdir -p $(BIN_DIRECTORY)
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_DEFAULT) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_DEFAULT)"
@echo
.PHONY: all
all: build build-linux build-freebsd build-windows
.PHONY: build-linux
build-linux:
@echo "Building for Linux..."
@mkdir -p $(BIN_DIRECTORY)
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_LINUX) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_LINUX)"
@echo
.PHONY: build-freebsd
build-freebsd:
@echo "Building for FreeBSD..."
@mkdir -p $(BIN_DIRECTORY)
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_FREEBSD) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_FREEBSD)"
@echo
.PHONY: build-windows
build-windows:
@echo "Building for Windows..."
@mkdir -p $(BIN_DIRECTORY)
GOOS=windows GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_WINDOWS) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_WINDOWS)"
@echo
.PHONY: install
install: $(TARGET_BINARY)
install: $(BIN_DIRECTORY)$(BIN_DEFAULT)
@bash $(INSTALL_SCRIPT)
.PHONY: uninstall
@@ -27,5 +60,5 @@ uninstall:
clean:
@echo "Cleaning started."
-@$(GO) clean
@rm --recursive --force ./out/
@rm --recursive --force $(BIN_DIRECTORY)
@echo "Cleaning complete."

418
README.md Normal file
View File

@@ -0,0 +1,418 @@
<p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
<img alt="Deceptifeed logo" src="assets/logo-light.svg">
</picture>
</p>
`Deceptifeed` is a honeypot and threat feed server. It runs multiple deceptive network services (honeypots), while the threat feed lists IP addresses that have interacted with the honeypots. Additionally, `Deceptifeed` provides real-time visibility into honeypot activity, allowing you to monitor logs and interactions as they occur.
When an IP address interacts with a fake server on your network, why should it be allowed to access your real servers? `Deceptifeed` helps you build an automated defense system to reduce such risks. In a typical deployment, it runs alongside your real servers. The honeypots are exposed to the internet, while the threat feed remains private for use with your internal tools.
Most enterprise firewalls support ingesting threat feeds. By pointing to `Deceptifeed`, your firewall can automatically block IP addresses that interact with the honeypots. For other security tools, the threat feed is available in several formats, including plain text, CSV, JSON, and TAXII.
## Visuals
*Deployment diagram*
<a href="assets/diagram-light.svg?raw=true">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/diagram-dark.png" width="884">
<source media="(prefers-color-scheme: light)" srcset="assets/diagram-light.png" width="884">
<img alt="Deceptifeed deployment diagram" src="assets/diagram-light.png" width="884">
</picture>
</a>
<br>
<br>
<img alt="Example of the threat feed web interface" src="assets/screenshot-webfeed.png" width="860" />
<br>
<img alt="Example showing real-time honeypot log monitoring" src="assets/screenshot-live.png" width="860" />
## Quick Start
This section guides you through trying Deceptifeed as quickly as possible. There are no dependencies, configuration, or installation required. Refer to the [Installation section](#installation) when you're ready to set up a production environment.
### Option 1: Download the binary
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
2. Extract and run the `deceptifeed` binary.
```shell
# Extract:
tar xvzf <release>.tar.gz
# Change into the extracted directory:
cd deceptifeed
# Run:
./deceptifeed
```
A `default-config.xml` file is included with the release but is not used by default. Instead, Deceptifeed starts with sensible defaults, and you can customize options using command-line flags. Run `./deceptifeed --help` to view the available options. If you want to use a configuration file, rename `default-config.xml` to `config.xml`, and Deceptifeed will automatically use it. You'll need to update several paths in the configuration. Search for occurrences of `/opt/deceptifeed/` in the file and adjust the paths as needed.
### Option 2: Docker
```shell
# Pull and run the latest Deceptifeed Docker image:
docker run -d --name deceptifeed -p 2222:2222 -p 8080:8080 -p 9000:9000 deceptifeed/server:latest
# (Optional) Delete the container when you're finished testing:
docker rm -f deceptifeed
```
### Try it out
```shell
# Trigger login attempts on the SSH honeypot:
ssh -p 2222 root@<your-ip-address>
# Trigger requests to the HTTP honeypot:
curl -v http://<your-ip-address>:8080
# Retrieve the threat feed in JSON format:
curl http://<your-ip-address>:9000/json
# View the threat feed web interface:
# From a web browser, navigate to `http://<your-ip-address>:9000`
```
## Installation
### Option 1: Install on a Linux system
An installation script is available to quickly configure a production setup on a Linux system. The script supports only Linux distributions that use **systemd** (Debian, Ubuntu, Red Hat, Arch, SUSE, etc.).
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
2. Extract and run the `install.sh` script.
```shell
# Extract:
tar xvzf <release>.tar.gz
# Change into the extracted directory:
cd deceptifeed
# Install:
sudo ./install.sh
```
<img alt="" src="assets/install.gif" width="600" />
The installation script performs the following tasks:
- Creates a low-privilege `deceptifeed` user and group to run Deceptifeed.
- Sets up a directory structure under `/opt/deceptifeed/` to organize everything.
- Registers Deceptifeed as a background service and configures it to start automatically at boot.
Once installed:
- Run `systemctl status deceptifeed` to check the status of the background service.
- To modify the configuration, edit `/opt/deceptifeed/etc/config.xml`, then restart the service with `sudo systemctl restart deceptifeed`.
**_Directory structure_**
```
/opt/deceptifeed/
├── bin/
│   └── deceptifeed
├── certs/
│   ├── https-cert.pem
│   ├── https-key.pem
│   └── ssh-key.pem
├── etc/
│   └── config.xml
└── logs/
├── honeypot.log
└── threatfeed.csv
```
### Option 2: Docker
1. Create a directory on your host system (for example, `/opt/deceptifeed/`) to store your configuration file and persistent data.
```shell
mkdir /opt/deceptifeed/
```
2. Download the default configuration file to the directory you created in step 1. The configuration file must be named `config.xml`.
```
curl https://raw.githubusercontent.com/r-smith/deceptifeed/main/configs/docker-config.xml -o /opt/deceptifeed/config.xml
```
3. Edit the configuration file to suit your needs. The default configuration file is production-ready.
4. Run the Deceptifeed Docker container.
```shell
docker run --detach --name deceptifeed \
--env "TZ=America/Los_Angeles" \
--publish 2222:2222 \
--publish 8080:8080 \
--publish 8443:8443 \
--publish 9000:9000 \
--restart unless-stopped \
--volume /opt/deceptifeed/:/data/ \
deceptifeed/server:latest
```
Here is a breakdown of the arguments:
- `--detach` instructs Docker to run the Deceptifeed container in the background.
- `--env "TZ=xxx/yyy"` sets the time zone. Replace `xxx/yyy` with the TZ identifier for your local time zone. If you prefer UTC time, don't include this argument. Refer to this [list of time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for valid TZ identifiers.
- `--publish ####:####` opens a network port on your host machine and maps it to Deceptifeed's Docker container. The first number specifies the port your host system listens on. You can set it to any open port. The second number specifies the port used by Deceptifeed inside the Docker container, which should match the ports configured in `config.xml`. There are multiple `--publish` arguments because Deceptifeed runs multiple network services. The default configuration includes an SSH honeypot on port 2222, an HTTP honeypot on port 8080, an HTTPS honeypot on port 8443, and the threat feed on port 9000. If you want your host machine to listen on port 443 for the HTTPS honeypot, for example, you would use the following line `--publish 443:8443 \`. This makes your host system listen on port 443 and maps it to the HTTPS honeypot defined for port 8443 in `config.xml`.
- `--restart unless-stopped` ensures Deceptifeed starts automatically when the host boots.
- `--volume /opt/deceptifeed/:/data/` specifies the directory on your host machine where persistent data is stored. If you used a different directory, adjust the path accordingly, but keep `:/data/` unchanged. For example: `--volume /path/to/deceptifeed/directory/:/data/ \`.
- `deceptifeed/server:latest` is the latest Docker image for Deceptifeed, hosted on *Docker Hub*. The image is updated with each official release and can be viewed on [Docker Hub](https://hub.docker.com/r/deceptifeed/server).
## Features
- **Honeypot Servers:**
- Run any number of honeypot services simultaneously.
- Honeypots are low interaction (no simulated environments for attackers to access).
- **SSH honeypot:** Record and reject login attempts to a fake SSH service.
- **HTTP/HTTPS honeypot:** Record requested URLs and HTTP headers.
- **Generic TCP/UDP services:** Record data sent by connecting clients.
- **Threat Feed Server:**
- A feed of IP addresses that have accessed your honeypots, delivered over HTTP.
- Available in plain text, CSV, JSON, STIX, and TAXII.
- Includes a friendly web interface for browsing feed and honeypot data.
- **Rich Structured Logging:**
- Honeypot interactions are logged in a structured JSON format.
- Logs can be optionally forwarded to the SIEM of your choice.
- Automatic log file rollover ensures the system is self-managing.
- **Security:**
- The honeypot services never process or respond to client input.
- Attackers are not given simulated or virtual environments.
- Deceptifeed is self-contained and does **not** use any external libraries, frameworks, plugins, third-party modules, or GitHub actions.
- **Cross-platform:**
- Supports Linux, macOS, Windows, and *BSD.
- Available as a Docker container.
## Threat Feed
The threat feed provides a list of IP addresses that have interacted with your honeypot services. It is delivered over HTTP for easy integration with firewalls. Most enterprise firewalls support ingesting custom threat feeds, allowing them to automatically block communication with the listed IP addresses.
Configure your firewall to use Deceptifeed as a custom threat feed and set your blocking rules accordingly. Ideally, exclude your honeypot services from any automatic blocking rules.
The threat feed is available in plain text, CSV, JSON, STIX, and TAXII.
**_Sample threat feed in plain text_**
```shell
$ curl http://threatfeed.example.com:9000/plain
```
```
10.30.16.110
10.30.21.79
10.99.17.38
10.99.17.54
172.16.1.9
172.16.2.30
172.16.3.2
172.18.0.208
172.18.5.7
172.18.5.15
192.168.0.4
192.168.1.17
192.168.1.113
192.168.2.21
192.168.3.8
```
**_Sample threat feed in JSON format_**
```shell
$ curl http://threatfeed.example.com:9000/json
```
```json
{
"threat_feed": [
{
"ip": "10.32.16.110",
"added": "2025-02-12T16:18:36-08:00",
"last_seen": "2025-03-15T04:27:59-08:00",
"observations": 27
},
{
"ip": "192.168.2.21",
"added": "2025-04-02T23:09:11-08:00",
"last_seen": "2025-04-08T00:40:51-08:00",
"observations": 51
}
]
}
```
## Honeypots
### SSH
The SSH honeypot server responds to SSH authentication requests. Each attempt is automatically rejected, while the submitted credentials are logged. There is no actual shell for attackers to access.
**_Sample log from SSH honeypot_**
```json
{
"time": "2024-10-23T23:08:29.423821763-07:00",
"event_type": "ssh",
"source_ip": "172.16.44.209",
"server_ip": "192.168.0.15",
"server_port": "22",
"server_name": "honeypot01",
"event_details": {
"username": "root",
"password": "Password1",
"ssh_client": "SSH-2.0-libssh2_1.10.0"
}
}
```
### HTTP/HTTPS
The HTTP honeypot server responds to all HTTP requests. Requests to the *root* or `/index.html` return a customizable HTML page. Requests outside of that return a 404 error.
**_Sample log from HTTP honeypot_**
```json
{
"time": "2024-10-23T23:01:38.989334656-07:00",
"event_type": "http",
"source_ip": "10.20.89.2",
"server_ip": "192.168.0.15",
"server_port": "443",
"server_name": "honeypot01",
"event_details": {
"method": "GET",
"path": "/",
"query": "",
"user_agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0) like Gecko",
"protocol": "HTTP/1.1",
"host": "www.example.com",
"headers": {
"accept-encoding": "gzip, br",
"x-forwarded-for":"10.254.33.179",
}
}
}
```
### TCP
The TCP honeypot server lets you create customizable honeypot services that log data from connecting clients. You can define prompts that wait for and record input. For example, you can mimic a Telnet server by showing a welcome banner and then prompting for a username. When data is received, it's logged, and you can follow up with a password prompt. You can include any number of prompts to resemble FTP, SMTP, or other services. The client is disconnected after responding to all the prompts.
**_Sample log from TCP honeypot_**
```json
{
"time": "2024-10-23T23:41:43.3235296-07:00",
"event_type": "tcp",
"source_ip": "172.18.206.66",
"server_ip": "192.168.0.15",
"server_port": "25",
"server_name": "honeypot01",
"event_details": {
"helo": "HELO example.com",
"mail_from": "MAIL FROM:<spammer@example.com>",
"rcpt_to": "RCPT TO:<recipient@example.com>",
"line1": "Subject: Congratualtions! You've won!",
"line2": "From: Customer Support <spammer@example.com>",
"line3": "To: recipient@example.com",
}
}
```
### UDP
The UDP honeypot server records incoming data on the listening port. It does not respond to clients.
Due to the connectionless nature of UDP and the possibility of spoofed source information, UDP honeypots do not integrate with the threat feed. Data is logged, but no further action is taken.
**_Sample log from UDP honeypot_**
```json
{
"time": "2024-10-23T21:28:58.223738796-07:00",
"event_type": "udp",
"source_ip": "127.217.96.21 [unreliable]",
"source_reliability": "unreliable",
"server_ip": "192.168.0.15",
"server_port": "5060",
"server_name": "honeypot01",
"event_details": {
"data": "OPTIONS sip:nm SIP/2.0\r\nVia: SIP/2.0/UDP nm;branch=foo;rport\r\nMax-Forwards: 70\r\nTo: <sip:nm@nm>\r\nFrom: <sip:nm@nm>;tag=root\r\nCall-ID: 50000\r\nCSeq: 63104 OPTIONS\r\nContact: <sip:nm@nm>\r\nAccept: application/sdp\r\nContent-Length: 0\r\n\r\n"
}
}
```
## Upgrading
### Binary
**If you originally installed using the installation script:**
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
2. Extract the files.
3. Run `install.sh`. The script will detect the existing installation and prompt you to upgrade.
- Optionally, you can add `--yes` to automatically confirm the upgrade prompt.
```shell
# Extract:
tar xvzf <release>.tar.gz
# Change into the extracted directory:
cd deceptifeed
# Install (add `--yes` to auto-confirm the upgrade):
sudo ./install.sh
```
**If you did not use the installation script:**
1. Download the latest release from the [Releases page](https://github.com/r-smith/deceptifeed/releases).
2. Extract the files.
3. Replace the existing `deceptifeed` binary with the new version.
### Docker
1. Pull the latest version of the Deceptifeed image:
```shell
docker pull deceptifeed/server:latest
```
2. Stop and remove the existing container:
```shell
docker stop deceptifeed
docker rm deceptifeed
```
3. Recreate the container with the new image:
```shell
# The `docker run` command will vary depending on how you originally ran the container.
# If you used the example from this documentation, it will look like this:
docker run --detach --name deceptifeed \
--env "TZ=America/Los_Angeles" \
--publish 2222:2222 \
--publish 8080:8080 \
--publish 8443:8443 \
--publish 9000:9000 \
--restart unless-stopped \
--volume /opt/deceptifeed/:/data/ \
deceptifeed/server:latest
```
## Uninstalling
### Binary
**If you originally installed using the installation script:**
1. Re-run `install.sh` with the `--uninstall` option.
```shell
sudo ./install.sh --uninstall
```
**If you did not use the installation script:**
1. Delete the `deceptifeed` binary and any generated files.
### Docker
```shell
docker stop deceptifeed
docker rm deceptifeed
```

112
assets/diagram-dark.drawio Normal file
View File

@@ -0,0 +1,112 @@
<mxfile>
<diagram name="Page-1" id="2Eu4RQjrI3PlWwBZhT0F">
<mxGraphModel dx="1434" dy="774" grid="0" gridSize="4" guides="1" tooltips="1" connect="1" arrows="0" fold="1" page="1" pageScale="1" pageWidth="926" pageHeight="496" background="#000000" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="em4zL2hBkfHOsg3rVNTH-55" value="" style="rounded=1;whiteSpace=wrap;labelBackgroundColor=none;fillStyle=auto;glass=0;shadow=0;fillColor=#1B1B1B;fontColor=#333333;strokeColor=none;perimeterSpacing=0;textShadow=0;html=1;horizontal=1;arcSize=0;fontSize=1;" parent="1" vertex="1">
<mxGeometry x="184" y="20" width="520" height="428" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-57" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#001933;strokeColor=#0066CC;arcSize=13;" parent="1" vertex="1">
<mxGeometry x="196" y="32" width="496" height="153" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-7" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="244" y="248" as="targetPoint" />
<mxPoint x="244" y="283" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-4" value="&lt;div&gt;&lt;font face=&quot;Nunito&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; style=&quot;font-size: 14px;&quot;&gt;Web Server&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;www.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;198.51.100.38&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#3C91FF;shape=mxgraph.mscae.enterprise.server_generic;horizontal=1;labelBorderColor=none;fontSize=15;strokeWidth=1;perimeterSpacing=0;spacingLeft=-5;spacingBottom=0;spacingTop=2;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="232" y="283" width="24" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-5" value="&lt;div&gt;&lt;font face=&quot;Nunito&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; style=&quot;font-size: 14px;&quot;&gt;VPN Server&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;vpn.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;198.51.100.2&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#3C91FF;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="412" y="283" width="24" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-67" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#CC0000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="123" y="225.25" width="50" height="44" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-110" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="284" y="246" as="targetPoint" />
<mxPoint x="283" y="205" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-96" value="DMZ" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=24;fontColor=#999999;fontStyle=0;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;" parent="1" vertex="1">
<mxGeometry x="414" y="415" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-105" value="&lt;div&gt;&lt;font face=&quot;Nunito&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; style=&quot;font-size: 14px;&quot;&gt;File Server&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;ftp.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;198.51.100.17&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#3C91FF;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="595" y="283" width="24" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-123" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.733;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#999999;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-67" target="em4zL2hBkfHOsg3rVNTH-68" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-68" value="&lt;div&gt;Internet&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;fontFamily=Nunito;strokeWidth=3;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="8" y="224" width="75" height="46.5" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-124" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.507;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#999999;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-3" target="em4zL2hBkfHOsg3rVNTH-8" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-8" value="&lt;div&gt;Internal&lt;/div&gt;&lt;div&gt;Network&lt;br&gt;&lt;/div&gt;" style="pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;aspect=fixed;strokeWidth=3;perimeterSpacing=0;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="806" y="224" width="75" height="46.5" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-1" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="274" y="175" width="20" height="20" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-2" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-3" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="444" y="246" as="targetPoint" />
<mxPoint x="443" y="205" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-3" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="434" y="175" width="20" height="20" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-4" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-5" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="595" y="246" as="targetPoint" />
<mxPoint x="594" y="205" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-5" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="585" y="175" width="20" height="20" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="424" y="248" as="targetPoint" />
<mxPoint x="424" y="283" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="607" y="248" as="targetPoint" />
<mxPoint x="607" y="283" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-23" value="Deceptifeed" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=32;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;fontStyle=1;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="412" y="57" width="92" height="30" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;endArrow=none;endFill=0;strokeColor=#999999;strokeWidth=6;" parent="1" target="em4zL2hBkfHOsg3rVNTH-3" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="174" y="247.2500000000001" as="sourcePoint" />
<mxPoint x="731" y="247.2500000000001" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-1" value="&lt;div&gt;&lt;font face=&quot;Nunito&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; style=&quot;font-size: 14px;&quot;&gt;&lt;b&gt;Honeypot&lt;/b&gt; (SSH)&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;dev.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;198.51.100.24&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="228" y="121" width="132" height="50" as="geometry" />
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-2" value="&lt;div&gt;&lt;font face=&quot;Nunito&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; style=&quot;font-size: 14px;&quot;&gt;&lt;b&gt;Threat Feed&lt;/b&gt;&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Nunito&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; style=&quot;font-size: 14px;&quot;&gt;- Private -&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;10.15.80.5&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="398" y="121" width="100" height="50" as="geometry" />
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-3" value="&lt;div&gt;&lt;font face=&quot;Nunito&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; style=&quot;font-size: 14px;&quot;&gt;&lt;b&gt;Honeypot&lt;/b&gt; (HTTP)&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;api.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font face=&quot;Roboto Mono&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; style=&quot;font-size: 13px;&quot;&gt;198.51.100.29&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="537" y="121" width="132" height="50" as="geometry" />
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-5" value="" style="sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;fillColor=#7967E8;aspect=fixed;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;shape=mxgraph.vvd.cpu;" vertex="1" parent="1">
<mxGeometry x="282" y="48" width="50" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-3" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#CC0000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;fontColor=#FFFFFF;" parent="1" vertex="1">
<mxGeometry x="716" y="225.25" width="50" height="44" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

BIN
assets/diagram-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

3
assets/diagram-dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

112
assets/diagram-light.drawio Normal file
View File

@@ -0,0 +1,112 @@
<mxfile>
<diagram name="Page-1" id="2Eu4RQjrI3PlWwBZhT0F">
<mxGraphModel dx="1434" dy="774" grid="0" gridSize="4" guides="1" tooltips="1" connect="1" arrows="0" fold="1" page="1" pageScale="1" pageWidth="926" pageHeight="496" background="#ffffff" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="em4zL2hBkfHOsg3rVNTH-55" value="" style="rounded=1;whiteSpace=wrap;labelBackgroundColor=none;fillStyle=auto;glass=0;shadow=0;fillColor=#E6E6E6;fontColor=#333333;strokeColor=none;perimeterSpacing=0;textShadow=0;html=1;horizontal=1;arcSize=0;fontSize=1;" parent="1" vertex="1">
<mxGeometry x="184" y="20" width="520" height="428" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-57" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#cce5ff;strokeColor=#66B2FF;arcSize=13;" parent="1" vertex="1">
<mxGeometry x="196" y="32" width="496" height="153" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-7" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="244" y="248" as="targetPoint" />
<mxPoint x="244" y="283" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-4" value="&lt;div&gt;&lt;font style=&quot;font-size: 14px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; face=&quot;Nunito&quot;&gt;Web Server&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;www.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;198.51.100.38&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#004C99;shape=mxgraph.mscae.enterprise.server_generic;horizontal=1;labelBorderColor=none;fontSize=15;strokeWidth=1;perimeterSpacing=0;spacingLeft=-5;spacingBottom=0;spacingTop=2;" parent="1" vertex="1">
<mxGeometry x="232" y="283" width="24" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-5" value="&lt;div&gt;&lt;font style=&quot;font-size: 14px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; face=&quot;Nunito&quot;&gt;VPN Server&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;vpn.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;198.51.100.2&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#004C99;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;" parent="1" vertex="1">
<mxGeometry x="412" y="283" width="24" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-67" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#990000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
<mxGeometry x="123" y="225.25" width="50" height="44" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-110" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="284" y="246" as="targetPoint" />
<mxPoint x="283" y="205" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-96" value="DMZ" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=24;fontColor=#666666;fontStyle=0;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;" parent="1" vertex="1">
<mxGeometry x="414" y="415" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-105" value="&lt;div&gt;&lt;font style=&quot;font-size: 14px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; face=&quot;Nunito&quot;&gt;File Server&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;ftp.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;198.51.100.17&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=left;verticalLabelPosition=bottom;verticalAlign=top;align=left;fillColor=#004C99;shape=mxgraph.mscae.enterprise.server_generic;fontSize=15;spacingLeft=-5;spacingTop=2;" parent="1" vertex="1">
<mxGeometry x="595" y="283" width="24" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-123" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.733;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#333333;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-67" target="em4zL2hBkfHOsg3rVNTH-68" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-68" value="&lt;div&gt;Internet&lt;/div&gt;" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;fontFamily=Nunito;strokeWidth=3;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
<mxGeometry x="8" y="224" width="75" height="46.5" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-124" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.507;entryY=0.501;entryDx=0;entryDy=0;entryPerimeter=0;strokeWidth=6;strokeColor=#333333;endArrow=none;endFill=0;" parent="1" source="em4zL2hBkfHOsg3rVNTH-3" target="em4zL2hBkfHOsg3rVNTH-8" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-8" value="&lt;div&gt;Internal&lt;/div&gt;&lt;div&gt;Network&lt;br&gt;&lt;/div&gt;" style="pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=default;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#E6E6E6;shape=mxgraph.mscae.enterprise.internet;fontSize=14;aspect=fixed;strokeWidth=3;perimeterSpacing=0;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
<mxGeometry x="806" y="224" width="75" height="46.5" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-1" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="274" y="175" width="20" height="20" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-2" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-3" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="444" y="246" as="targetPoint" />
<mxPoint x="443" y="205" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-3" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="434" y="175" width="20" height="20" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-4" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" source="N2PlaIGEcuWRkpGyRWMP-5" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="595" y="246" as="targetPoint" />
<mxPoint x="594" y="205" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-5" value="" style="shape=waypoint;sketch=0;fillStyle=solid;size=6;pointerEvents=1;points=[];fillColor=none;resizable=0;rotatable=0;perimeter=centerPerimeter;snapToPoint=1;strokeWidth=6;strokeColor=#4D9900;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="585" y="175" width="20" height="20" as="geometry" />
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="424" y="248" as="targetPoint" />
<mxPoint x="424" y="283" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="607" y="248" as="targetPoint" />
<mxPoint x="607" y="283" as="sourcePoint" />
</mxGeometry>
</mxCell>
<mxCell id="N2PlaIGEcuWRkpGyRWMP-23" value="Deceptifeed" style="text;strokeColor=none;align=center;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=32;fontFamily=Roboto Mono;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DRoboto%2BMono;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="412" y="57" width="92" height="30" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;endArrow=none;endFill=0;strokeColor=#333333;strokeWidth=6;" parent="1" target="em4zL2hBkfHOsg3rVNTH-3" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="174" y="247.2500000000001" as="sourcePoint" />
<mxPoint x="731" y="247.2500000000001" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-1" value="&lt;div&gt;&lt;font style=&quot;font-size: 14px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; face=&quot;Nunito&quot;&gt;&lt;b&gt;Honeypot&lt;/b&gt; (SSH)&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;dev.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;198.51.100.24&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="228" y="121" width="132" height="50" as="geometry" />
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-2" value="&lt;div&gt;&lt;font style=&quot;font-size: 14px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; face=&quot;Nunito&quot;&gt;&lt;b&gt;Threat Feed&lt;/b&gt;&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 14px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; face=&quot;Nunito&quot;&gt;- Private -&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;10.15.80.5&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="398" y="121" width="100" height="50" as="geometry" />
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-3" value="&lt;div&gt;&lt;font style=&quot;font-size: 14px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Nunito&quot; face=&quot;Nunito&quot;&gt;&lt;b&gt;Honeypot&lt;/b&gt; (HTTP)&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;api.example.com&lt;/font&gt;&lt;/div&gt;&lt;div&gt;&lt;font style=&quot;font-size: 13px;&quot; data-font-src=&quot;https://fonts.googleapis.com/css?family=Roboto+Mono&quot; face=&quot;Roboto Mono&quot;&gt;198.51.100.29&lt;/font&gt;&lt;br&gt;&lt;/div&gt;" style="text;strokeColor=none;align=left;fillColor=none;html=1;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="537" y="121" width="132" height="50" as="geometry" />
</mxCell>
<mxCell id="ir_BUrcSTQge2nd-7u8N-5" value="" style="sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;fillColor=#660066;aspect=fixed;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;shape=mxgraph.vvd.cpu;" vertex="1" parent="1">
<mxGeometry x="282" y="48" width="50" height="50" as="geometry" />
</mxCell>
<mxCell id="em4zL2hBkfHOsg3rVNTH-3" value="Firewall" style="aspect=fixed;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#990000;shape=mxgraph.mscae.enterprise.firewall;fontSize=14;fontFamily=Nunito;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DNunito;" parent="1" vertex="1">
<mxGeometry x="716" y="225.25" width="50" height="44" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

BIN
assets/diagram-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

3
assets/diagram-light.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

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

BIN
assets/screenshot-live.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,8 +1,13 @@
package main
import (
"cmp"
"flag"
"fmt"
"log"
"os"
"slices"
"strconv"
"sync"
"github.com/r-smith/deceptifeed/internal/config"
@@ -21,16 +26,16 @@ func main() {
ssh := config.Server{Type: config.SSH}
// Parse command line flags.
configFile := flag.String("config", "", "Path to optional XML configuration file")
configPath := flag.String("config", "", "Path to optional XML configuration file")
flag.BoolVar(&http.Enabled, "enable-http", config.DefaultEnableHTTP, "Enable HTTP server")
flag.BoolVar(&https.Enabled, "enable-https", config.DefaultEnableHTTPS, "Enable HTTPS server")
flag.BoolVar(&ssh.Enabled, "enable-ssh", config.DefaultEnableSSH, "Enable SSH server")
flag.BoolVar(&cfg.ThreatFeed.Enabled, "enable-threatfeed", config.DefaultEnableThreatFeed, "Enable threat feed server")
flag.StringVar(&cfg.LogPath, "log", config.DefaultLogPath, "Path to log file")
flag.StringVar(&cfg.ThreatFeed.DatabasePath, "threat-database", config.DefaultThreatDatabasePath, "Path to threat feed database file")
flag.UintVar(&cfg.ThreatFeed.ExpiryHours, "threat-expiry-hours", config.DefaultThreatExpiryHours, "Remove inactive IPs from threat feed after specified hours")
flag.IntVar(&cfg.ThreatFeed.ExpiryHours, "threat-expiry-hours", config.DefaultThreatExpiryHours, "Remove inactive IPs from threat feed after specified hours")
flag.BoolVar(&cfg.ThreatFeed.IsPrivateIncluded, "threat-include-private", config.DefaultThreatIncludePrivate, "Include private IPs in threat feed")
flag.StringVar(&http.HtmlPath, "html", config.DefaultHtmlPath, "Path to optional HTML file to serve")
flag.StringVar(&http.HomePagePath, "html", config.DefaultHomePagePath, "Path to optional HTML file to serve")
flag.StringVar(&http.Port, "port-http", config.DefaultPortHTTP, "Port number to listen on for HTTP server")
flag.StringVar(&https.Port, "port-https", config.DefaultPortHTTPS, "Port number to listen on for HTTPS server")
flag.StringVar(&ssh.Port, "port-ssh", config.DefaultPortSSH, "Port number to listen on for SSH server")
@@ -38,35 +43,66 @@ func main() {
flag.StringVar(&https.CertPath, "https-cert", config.DefaultCertPathHTTPS, "Path to optional TLS public certificate")
flag.StringVar(&https.KeyPath, "https-key", config.DefaultKeyPathHTTPS, "Path to optional TLS private key")
flag.StringVar(&ssh.KeyPath, "ssh-key", config.DefaultKeyPathSSH, "Path to optional SSH private key")
ver := flag.Bool("version", false, "Output the version number and exit")
flag.Parse()
// If the '-config' flag is provided, the specified configuration file is
// loaded. When a config file is used, all other command-line flags are
// ignored. The 'cfg' variable will contain all settings parsed from the
// configuration file.
if *configFile != "" {
// Load the specified config file.
cfgFromFile, err := config.Load(*configFile)
// If the `-version` flag is provided, output the version number and exit.
if *ver {
fmt.Println(config.Version)
return
}
// If the `-config` flag is not provided, use "config.xml" from the current
// directory if the file exists.
if len(*configPath) == 0 {
if _, err := os.Stat("config.xml"); err == nil {
*configPath = "config.xml"
fmt.Printf("Using configuration file: '%v'\n", *configPath)
}
}
// If a config file is specified (via the `-config` flag or "config.xml"),
// load it. Otherwise, configure the app using the command line flags and
// default settings.
if len(*configPath) > 0 {
cfgFromFile, err := config.Load(*configPath)
if err != nil {
log.Fatalln("Failed to load config:", err)
log.Fatalln("Shutting down. Failed to load configuration file:", err)
}
cfg = *cfgFromFile
} else {
// No config file specified. Use command line args.
https.HtmlPath = http.HtmlPath
https.HomePagePath = http.HomePagePath
cfg.Servers = append(cfg.Servers, http, https, ssh)
// Set defaults.
for i := range cfg.Servers {
cfg.Servers[i].LogPath = cfg.LogPath
cfg.Servers[i].LogEnabled = true
cfg.Servers[i].SendToThreatFeed = true
cfg.Servers[i].ThreatScore = 1
if cfg.Servers[i].Type == config.SSH {
cfg.Servers[i].Banner = config.DefaultBannerSSH
}
}
}
// Sort the servers by port number. This is for cosmetic reasons to format
// the output when querying / viewing the active configuration.
slices.SortFunc(cfg.Servers, func(a, b config.Server) int {
p1, err := strconv.Atoi(a.Port)
if err != nil {
return 0
}
p2, err := strconv.Atoi(b.Port)
if err != nil {
return 0
}
t := cmp.Compare(p1, p2)
return t
})
// Initialize structured loggers for each honeypot server.
err := cfg.InitializeLoggers()
if err != nil {
log.Fatal("Shutting down. Error: ", err)
log.Fatalln("Shutting down. Failed to initialize logging:", err)
}
defer cfg.CloseLogFiles()
@@ -84,7 +120,7 @@ func main() {
return
}
threatfeed.StartThreatFeed(&cfg.ThreatFeed)
threatfeed.Start(&cfg)
}()
// Start the honeypot servers.
@@ -97,16 +133,14 @@ func main() {
}
switch server.Type {
case config.HTTP:
httpserver.StartHTTP(&server)
case config.HTTPS:
httpserver.StartHTTPS(&server)
case config.HTTP, config.HTTPS:
httpserver.Start(&server)
case config.SSH:
sshserver.StartSSH(&server)
sshserver.Start(&server)
case config.TCP:
tcpserver.StartTCP(&server)
tcpserver.Start(&server)
case config.UDP:
udpserver.StartUDP(&server)
udpserver.Start(&server)
}
}()
}

View File

@@ -1,45 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Deceptifeed Configuration -->
<config>
<!-- The default log path for honeypot servers -->
<defaultLogPath>/opt/deceptifeed/logs/honeypot.log</defaultLogPath>
<!-- Threat Feed Configuration -->
<threatFeed>
<enabled>true</enabled>
<port>9000</port>
<databasePath>/opt/deceptifeed/logs/threatfeed.csv</databasePath>
<threatExpiryHours>336</threatExpiryHours>
<includePrivateIPs>false</includePrivateIPs>
<excludeListPath></excludeListPath>
</threatFeed>
<!-- Honeypot Server Configuration -->
<honeypotServers>
<!-- SSH honeypot server on port 2222 -->
<server type="ssh">
<enabled>true</enabled>
<port>2222</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
<banner>SSH-2.0-OpenSSH_9.6</banner>
</server>
<!-- HTTP honeypot server on port 8080 -->
<server type="http">
<enabled>true</enabled>
<port>8080</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<threatScore>1</threatScore>
<rules>
<!-- Update the threat feed if any of the following rules match: -->
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
<include target="authorization">.*</include>
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
<include target="user-agent">^$</include>
</rules>
</server>
<!-- HTTPS honeypot server on port 8443 -->
<server type="https">
<enabled>true</enabled>
<port>8443</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<threatScore>1</threatScore>
<certPath>/opt/deceptifeed/certs/https-cert.pem</certPath>
<keyPath>/opt/deceptifeed/certs/https-key.pem</keyPath>
<rules>
<!-- Update the threat feed if any of the following rules match: -->
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
<include target="authorization">.*</include>
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
<include target="user-agent">^$</include>
</rules>
</server>
<server type="ssh">
<enabled>true</enabled>
<port>2022</port>
<!-- DISABLED: -->
<!-- Example TCP honeypot server to simulate a Cisco router -->
<server type="tcp">
<enabled>false</enabled>
<port>2323</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<threatScore>1</threatScore>
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
<banner>SSH-2.0-OpenSSH_9.3 FreeBSD-20230316</banner>
<banner>\nUser Access Verification\n\n</banner>
<prompts>
<prompt log="username">Username: </prompt>
<prompt log="password">Password: </prompt>
</prompts>
</server>
<!-- DISABLED: -->
<!-- Example UDP honeypot server to capture SIP scans -->
<server type="udp">
<enabled>false</enabled>
<port>5060</port>
<logEnabled>true</logEnabled>
</server>
</honeypotServers>
<threatFeed>
<enabled>true</enabled>
<port>8081</port>
<databasePath>/opt/deceptifeed/logs/threatfeed.csv</databasePath>
<threatExpiryHours>168</threatExpiryHours>
<minimumThreatScore>0</minimumThreatScore>
<isPrivateIncluded>false</isPrivateIncluded>
</threatFeed>
</config>

91
configs/docker-config.xml Normal file
View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Deceptifeed Configuration -->
<config>
<!-- The default log path for honeypot servers -->
<defaultLogPath>honeypot.log</defaultLogPath>
<!-- Threat Feed Configuration -->
<threatFeed>
<enabled>true</enabled>
<port>9000</port>
<databasePath>threatfeed.csv</databasePath>
<threatExpiryHours>336</threatExpiryHours>
<includePrivateIPs>false</includePrivateIPs>
<excludeListPath></excludeListPath>
</threatFeed>
<!-- Honeypot Server Configuration -->
<honeypotServers>
<!-- SSH honeypot server on port 2222 -->
<server type="ssh">
<enabled>true</enabled>
<port>2222</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<keyPath>key-ssh-private.pem</keyPath>
<banner>SSH-2.0-OpenSSH_9.6</banner>
</server>
<!-- HTTP honeypot server on port 8080 -->
<server type="http">
<enabled>true</enabled>
<port>8080</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<rules>
<!-- Update the threat feed if any of the following rules match: -->
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
<include target="authorization">.*</include>
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
<include target="user-agent">^$</include>
</rules>
</server>
<!-- HTTPS honeypot server on port 8443 -->
<server type="https">
<enabled>true</enabled>
<port>8443</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<certPath>key-https-public.pem</certPath>
<keyPath>key-https-private.pem</keyPath>
<rules>
<!-- Update the threat feed if any of the following rules match: -->
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
<include target="query">(?i)(action|conf|dns|file|form|http|id=|json|login|php|q=|url|user|\.\.)</include>
<include target="authorization">.*</include>
<include target="method" negate="true">(?i)^(GET|HEAD|OPTIONS)$</include>
<include target="user-agent">(?i)(curl|go-http-client|httpclient|java|libwww|nikto|nmap|php|python|wget)</include>
<include target="user-agent">^$</include>
</rules>
</server>
<!-- DISABLED: -->
<!-- Example TCP honeypot server to simulate a Cisco router -->
<server type="tcp">
<enabled>false</enabled>
<port>2323</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<banner>\nUser Access Verification\n\n</banner>
<prompts>
<prompt log="username">Username: </prompt>
<prompt log="password">Password: </prompt>
</prompts>
</server>
<!-- DISABLED: -->
<!-- Example UDP honeypot server to capture SIP scans -->
<server type="udp">
<enabled>false</enabled>
<port>5060</port>
<logEnabled>true</logEnabled>
</server>
</honeypotServers>
</config>

9
go.mod
View File

@@ -1,7 +1,10 @@
module github.com/r-smith/deceptifeed
go 1.22
go 1.24
require golang.org/x/crypto v0.28.0
require (
golang.org/x/crypto v0.36.0
golang.org/x/net v0.38.0
)
require golang.org/x/sys v0.26.0 // indirect
require golang.org/x/sys v0.31.0 // indirect

14
go.sum
View File

@@ -1,6 +1,8 @@
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=

View File

@@ -6,9 +6,20 @@ import (
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"github.com/r-smith/deceptifeed/internal/logmonitor"
"github.com/r-smith/deceptifeed/internal/logrotate"
)
// This block of constants defines the application default settings.
// Version stores Deceptifeed's version number. This variable is set at build
// time using the `-X` option with `-ldflags` and is assigned the latest Git
// tag. Refer to the Makefile in the project root for details on how it's set.
var Version = "undefined"
// This block of constants defines the default application settings when no
// configuration file is provided.
const (
DefaultEnableHTTP = true
DefaultEnableHTTPS = true
@@ -16,17 +27,17 @@ const (
DefaultEnableThreatFeed = true
DefaultPortHTTP = "8080"
DefaultPortHTTPS = "8443"
DefaultPortSSH = "2022"
DefaultPortThreatFeed = "8081"
DefaultThreatExpiryHours = 168
DefaultPortSSH = "2222"
DefaultPortThreatFeed = "9000"
DefaultThreatExpiryHours = 336
DefaultThreatDatabasePath = "deceptifeed-database.csv"
DefaultThreatIncludePrivate = true
DefaultLogPath = "deceptifeed-log.txt"
DefaultHtmlPath = ""
DefaultHomePagePath = ""
DefaultCertPathHTTPS = "deceptifeed-https.crt"
DefaultKeyPathHTTPS = "deceptifeed-https.key"
DefaultKeyPathSSH = "deceptifeed-ssh.key"
DefaultBannerSSH = "SSH-2.0-OpenSSH_9.3 FreeBSD-20230316" // SSH banner for FreeBSD 13.2
DefaultBannerSSH = "SSH-2.0-OpenSSH_9.6"
)
// ServerType represents the different types of honeypot servers that can be
@@ -73,27 +84,43 @@ func (t *ServerType) UnmarshalXMLAttr(attr xml.Attr) error {
// logger, settings for managing a threat feed, and the collection of honeypot
// servers that are configured to run.
type Config struct {
LogPath string `xml:"defaultLogPath"`
Servers []Server `xml:"honeypotServers>server"`
ThreatFeed ThreatFeed `xml:"threatFeed"`
LogPath string `xml:"defaultLogPath"`
Servers []Server `xml:"honeypotServers>server"`
ThreatFeed ThreatFeed `xml:"threatFeed"`
FilePath string `xml:"-"`
Monitor *logmonitor.Monitor `xml:"-"`
}
// Server represents a honeypot server with its relevant settings.
type Server struct {
Type ServerType `xml:"type,attr"`
Enabled bool `xml:"enabled"`
Port string `xml:"port"`
CertPath string `xml:"certPath"`
KeyPath string `xml:"keyPath"`
HtmlPath string `xml:"htmlPath"`
Banner string `xml:"banner"`
Prompts []Prompt `xml:"prompt"`
SendToThreatFeed bool `xml:"sendToThreatFeed"`
ThreatScore int `xml:"threatScore"`
LogPath string `xml:"logPath"`
LogEnabled bool `xml:"logEnabled"`
LogFile *os.File
Logger *slog.Logger
Type ServerType `xml:"type,attr"`
Enabled bool `xml:"enabled"`
Port string `xml:"port"`
CertPath string `xml:"certPath"`
KeyPath string `xml:"keyPath"`
HomePagePath string `xml:"homePagePath"`
ErrorPagePath string `xml:"errorPagePath"`
Banner string `xml:"banner"`
Headers []string `xml:"headers>header"`
Prompts []Prompt `xml:"prompts>prompt"`
SendToThreatFeed bool `xml:"sendToThreatFeed"`
Rules Rules `xml:"rules"`
SourceIPHeader string `xml:"sourceIpHeader"`
LogPath string `xml:"logPath"`
LogEnabled bool `xml:"logEnabled"`
LogFile *logrotate.File `xml:"-"`
Logger *slog.Logger `xml:"-"`
}
type Rules struct {
Include []Rule `xml:"include"`
Exclude []Rule `xml:"exclude"`
}
type Rule struct {
Target string `xml:"target,attr"`
Pattern string `xml:",chardata"`
Negate bool `xml:"negate,attr"`
}
// Prompt represents a text prompt that can be displayed to connecting clients
@@ -112,14 +139,12 @@ type Prompt struct {
// can be configured to automatically block communication with IP addresses
// appearing in the threat feed.
type ThreatFeed struct {
Enabled bool `xml:"enabled"`
Port string `xml:"port"`
DatabasePath string `xml:"databasePath"`
ExpiryHours uint `xml:"threatExpiryHours"`
IsPrivateIncluded bool `xml:"isPrivateIncluded"`
MinimumThreatScore int `xml:"minimumThreatScore"`
CustomThreatsPath string `xml:"customThreatsPath"`
ExcludeListPath string `xml:"excludeListPath"`
Enabled bool `xml:"enabled"`
Port string `xml:"port"`
DatabasePath string `xml:"databasePath"`
ExpiryHours int `xml:"threatExpiryHours"`
IsPrivateIncluded bool `xml:"includePrivateIPs"`
ExcludeListPath string `xml:"excludeListPath"`
}
// Load reads an optional XML configuration file and unmarshals its contents
@@ -134,61 +159,116 @@ func Load(filename string) (*Config, error) {
defer file.Close()
var config Config
absPath, err := filepath.Abs(filename)
if err != nil {
config.FilePath = filename
} else {
config.FilePath = absPath
}
xmlBytes, _ := io.ReadAll(file)
err = xml.Unmarshal(xmlBytes, &config)
if err != nil {
return nil, fmt.Errorf("failed to decode XML file: %w", err)
return nil, err
}
// Ensure a minimum threat score of 1.
for i := range config.Servers {
if config.Servers[i].ThreatScore < 1 {
config.Servers[i].ThreatScore = 1
// Use the global log path if the server log path is not specified.
if len(config.Servers[i].LogPath) == 0 {
config.Servers[i].LogPath = config.LogPath
}
// Validate regex rules.
if err := validateRegexRules(config.Servers[i].Rules); err != nil {
return nil, err
}
// Use the default SSH banner if no banner is specified.
if config.Servers[i].Type == SSH && len(config.Servers[i].Banner) == 0 {
config.Servers[i].Banner = DefaultBannerSSH
}
// Explicitly disable threat feed for UDP honeypots.
if config.Servers[i].Type == UDP {
config.Servers[i].SendToThreatFeed = false
}
}
return &config, nil
}
// validateRegexRules checks the validity of regex patterns in the rules.
func validateRegexRules(rules Rules) error {
for _, rule := range rules.Include {
if _, err := regexp.Compile(rule.Pattern); err != nil {
return fmt.Errorf("invalid regex pattern: %s", rule.Pattern)
}
}
for _, rule := range rules.Exclude {
if _, err := regexp.Compile(rule.Pattern); err != nil {
return fmt.Errorf("invalid regex pattern: %s", rule.Pattern)
}
}
return nil
}
// InitializeLoggers creates structured loggers for each server. It opens log
// files using the server's specified log path, defaulting to the global log
// path if none is provided.
func (c *Config) InitializeLoggers() error {
const maxSize = 50
c.Monitor = logmonitor.New()
openedLogFiles := make(map[string]*slog.Logger)
for i := range c.Servers {
if !c.Servers[i].Enabled {
continue
}
// Use the global log path if the server log path is not specified.
var logPath string
if len(c.Servers[i].LogPath) > 0 {
logPath = c.Servers[i].LogPath
} else {
logPath = c.LogPath
}
logPath := c.Servers[i].LogPath
// If no log path is given or if logging is disabled, set up a dummy
// logger to discard output.
// If no log path is specified or if logging is disabled, discard logs.
if len(logPath) == 0 || !c.Servers[i].LogEnabled {
c.Servers[i].Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
c.Servers[i].Logger = slog.New(slog.DiscardHandler)
continue
}
// Open the specified log file and create a new logger.
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
// Check if this log path has already been opened. If so, reuse the
// logger.
if logger, exists := openedLogFiles[logPath]; exists {
c.Servers[i].Logger = logger
continue
}
c.Servers[i].LogFile = logFile
c.Servers[i].Logger = slog.New(slog.NewJSONHandler(c.Servers[i].LogFile, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Remove 'message' and 'log level' fields from output.
if a.Key == slog.MessageKey || a.Key == slog.LevelKey {
return slog.Attr{}
}
return a
},
}))
// Open the specified log file.
file, err := logrotate.OpenFile(logPath, maxSize)
if err != nil {
return err
}
// Create a JSON logger with two writers: one writes to disk using file
// rotation, the other writes to a channel for live monitoring.
logger := slog.New(
slog.NewJSONHandler(
io.MultiWriter(file, c.Monitor),
&slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Remove 'message' and 'log level' fields from output.
if a.Key == slog.MessageKey || a.Key == slog.LevelKey {
return slog.Attr{}
}
return a
},
},
),
)
c.Servers[i].Logger = logger
c.Servers[i].LogFile = file
// Store the logger for reuse.
openedLogFiles[logPath] = logger
}
return nil
@@ -199,7 +279,7 @@ func (c *Config) InitializeLoggers() error {
func (c *Config) CloseLogFiles() {
for i := range c.Servers {
if c.Servers[i].LogFile != nil {
c.Servers[i].LogFile.Close()
_ = c.Servers[i].LogFile.Close()
}
}
}

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)
}
// If custom headers are provided, add each header and its value to the
// HTTP response.
for key, value := range customHeaders {
w.Header().Set(key, value)
// Apply any custom HTTP response headers.
for header, value := range customHeaders {
w.Header().Set(header, value)
}
// Serve the web content to the client based on the requested URL. If
// the root or /index.html is requested, serve the specified content.
// For any other requests, return a '404 Not Found' response.
// Serve a response based on the requested URL. If the root URL or
// /index.html is requested, serve the homepage. For all other
// requests, serve the error page with a 404 Not Found response.
// Optionally, a single static HTML file may be specified for both the
// homepage and the error page. If no custom files are provided,
// default minimal responses will be served.
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
// The request is for the root or /index.html.
if len(srv.HtmlPath) > 0 {
// Serve the custom HTML file specified in the configuration.
http.ServeFile(w, r, srv.HtmlPath)
// Serve the homepage response.
if len(cfg.HomePagePath) > 0 {
http.ServeFile(w, r, cfg.HomePagePath)
} else {
// Serve the default page that prompts the client for basic
// authentication.
w.Header().Set("WWW-Authenticate", "Basic")
w.Header()["WWW-Authenticate"] = []string{"Basic"}
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, http.StatusText(http.StatusUnauthorized))
}
} else {
// The request is outside the root or /index.html. Respond with a
// 404 error.
// Serve the error page response.
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, http.StatusText(http.StatusNotFound))
if len(cfg.ErrorPagePath) > 0 {
http.ServeFile(w, r, cfg.ErrorPagePath)
}
}
}
}
// parseCustomHeaders parses a string of custom headers, if provided in the
// configuration, into a map[string]string. The keys in the map are the custom
// header names. For example, given the input:
// "Server: Microsoft-IIS/8.5, X-Powered-By: ASP.NET", the function would
// return a map with "Server" and "X-Powered-By" as keys, each linked to their
// corresponding values.
func parseCustomHeaders(headers string) map[string]string {
if len(headers) == 0 {
return nil
// shouldUpdateThreatFeed determines if the threat feed should be updated based
// on the server's configured rules.
func shouldUpdateThreatFeed(cfg *config.Server, r *http.Request) bool {
// Return false if `sendToThreatFeed`` is disabled, or if the request
// matches an `exclude` rule.
if !cfg.SendToThreatFeed || checkRuleMatches(cfg.Rules.Exclude, r) {
return false
}
// Return true if no `include` rules are defined. Otherwise, return whether
// the request matches any of the `include` rules.
return len(cfg.Rules.Include) == 0 || checkRuleMatches(cfg.Rules.Include, r)
}
// checkRuleMatches checks if a request matches any of the specified rules.
func checkRuleMatches(rules []config.Rule, r *http.Request) bool {
match := false
for _, rule := range rules {
// Ignore errors from regexp.Compile. Regular expression patterns are
// validated at application startup.
rx, _ := regexp.Compile(rule.Pattern)
switch strings.ToLower(rule.Target) {
case "path":
match = rx.MatchString(r.URL.Path)
case "query":
match = rx.MatchString(r.URL.RawQuery)
case "method":
match = rx.MatchString(r.Method)
case "host":
match = rx.MatchString(r.Host)
case "user-agent":
match = rx.MatchString(r.UserAgent())
default:
header, ok := r.Header[http.CanonicalHeaderKey(rule.Target)]
if ok {
for _, v := range header {
if rx.MatchString(v) {
match = true
break
}
}
}
}
if rule.Negate {
match = !match
}
if match {
return true
}
}
return false
}
// parseCustomHeaders takes a slice of header strings in the format of
// "Name: Value", and returns a map of the Name-Value pairs. For example, given
// the input:
// `[]{"Server: Microsoft-IIS/8.5", "X-Powered-By: ASP.NET"}`, the function
// would return a map with "Server" and "X-Powered-By" as keys, each linked to
// their corresponding values.
func parseCustomHeaders(headers []string) map[string]string {
result := make(map[string]string)
kvPairs := strings.Split(headers, ",")
for _, pair := range kvPairs {
kv := strings.Split(strings.TrimSpace(pair), ":")
for _, header := range headers {
kv := strings.SplitN(header, ":", 2)
if len(kv) == 2 {
result[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
@@ -193,20 +266,21 @@ func parseCustomHeaders(headers string) map[string]string {
// flattenHeaders converts HTTP headers from an http.Request from the format of
// map[string][]string to map[string]string. This results in a cleaner format
// for logging, where each headers values are represented as a single string
// for logging, where each header's values are represented as a single string
// instead of a slice. When a header contains multiple values, they are
// combined into a single string, separated by commas.
// combined into a single string, separated by commas. Additionally, header
// names are converted to lowercase.
func flattenHeaders(headers map[string][]string) map[string]string {
newHeaders := make(map[string]string, len(headers))
for header, values := range headers {
if len(values) == 1 {
newHeaders[header] = values[0]
newHeaders[strings.ToLower(header)] = values[0]
} else {
newHeaders[header] = "[" + strings.Join(values, ", ") + "]"
newHeaders[strings.ToLower(header)] = "[" + strings.Join(values, ", ") + "]"
}
}
// Delete the User-Agent header, as it is managed separately.
delete(newHeaders, "User-Agent")
delete(newHeaders, "user-agent")
return newHeaders
}
@@ -301,7 +375,7 @@ func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath s
defer keyFile.Close()
// Limit key access to the owner only.
keyFile.Chmod(0600)
_ = keyFile.Chmod(0600)
if err := pem.Encode(keyFile, key); err != nil {
return err

View File

@@ -0,0 +1,34 @@
package logmonitor
// Monitor is an io.Writer that sends bytes written to its Write method to an
// underlying byte channel. This allows other packages to receive the data from
// the channel. Writes are non-blocking. If there is no receiver, the data is
// silently discarded.
//
// Monitor does not implement io.Closer. Once initialized, it is meant to run
// for the duration of the program. If needed, manually close `Channel` when
// finished.
type Monitor struct {
Channel chan []byte
}
// New creates a new Monitor ready for I/O operations. The underlying `Channel`
// should have a receiver to capture and process the data.
func New() *Monitor {
channel := make(chan []byte, 2)
return &Monitor{
Channel: channel,
}
}
// Write sends the bytes from p to the underlying Monitor's channel. If there
// is no receiver for the channel, the data is silently discarded. Write always
// returns n = len(p) and err = nil.
func (m *Monitor) Write(p []byte) (n int, err error) {
select {
case m.Channel <- p:
return len(p), nil
default:
return len(p), nil
}
}

View File

@@ -0,0 +1,123 @@
package logrotate
import (
"fmt"
"os"
"sync"
)
// File is an io.WriteCloser that supports appending data to a file and file
// rotation.
//
// The file is automatically rotated once the file size exceeds the maximum
// size limit (specified in megabytes). `File` should be created using the
// `OpenFile` function.
type File struct {
name string
file *os.File
maxSize int64
size int64
mu sync.Mutex
}
// OpenFile opens the named file for appending. If successful, methods on the
// returned File can be used for I/O. When writing to the file, it will
// automatically rotate once the file size exceeds the maxSize (specified in
// megabytes).
func OpenFile(name string, maxSize int) (*File, error) {
if maxSize < 1 {
return nil, fmt.Errorf("maxSize must be greater than 0")
}
// Open the file for appending.
file, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return nil, err
}
// Get the current file size.
stat, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
return &File{
file: file,
name: name,
maxSize: int64(maxSize) * 1024 * 1024, // Convert to megabytes
size: stat.Size(),
}, nil
}
// rotate checks if the file size exceeds the maximum allowed size. If so, it
// renames the current file by appending ".1" to its name and opens a new file
// with the original name. If a file with the ".1" suffix already exists, it is
// replaced.
func (f *File) rotate() error {
if f.size > f.maxSize {
// Retrieve the file information for the current file to capture its
// permissions. Any errors encountered are handled later and do not
// affect the rotation process.
info, statErr := f.file.Stat()
// Close the current file.
if err := f.file.Close(); err != nil {
return fmt.Errorf("can't close file: %w", err)
}
// Rename the file with a ".1" suffix.
if err := os.Rename(f.name, f.name+".1"); err != nil {
return fmt.Errorf("can't rename file: %w", err)
}
// Open a new file with the original name.
file, err := os.OpenFile(f.name, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("can't create new file: %w", err)
}
// Apply the original permissions to the new file. This is a
// best-effort operation that only runs if the previous os.Stat call
// was successful. Any errors from chmod are ignored.
if statErr == nil {
_ = file.Chmod(info.Mode().Perm())
}
// Reassign file and reset the size.
f.file = file
f.size = 0
}
return nil
}
// Write writes len(b) bytes from b to the File. If the File's size exceeds its
// maxSize, the file is renamed, a new file is opened with the orginal name,
// and the write is applied to the new file. Write returns the number of bytes
// written and an error, if any. Write returns a non-nil error when n != len(b).
func (f *File) Write(b []byte) (n int, err error) {
f.mu.Lock()
defer f.mu.Unlock()
// Rotate the log file if needed.
err = f.rotate()
if err != nil {
return 0, fmt.Errorf("log rotate: %w", err)
}
// Write the data and update the size.
n, err = f.file.Write(b)
f.size += int64(n)
return n, err
}
// Close closes the File, rendering it unusable for I/O. Close will return an
// error if it has already been called.
func (f *File) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
err := f.file.Close()
f.file = nil
return err
}

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()),
@@ -73,21 +81,25 @@ func startSSH(srv *config.Server) error {
)
// Print a simplified version of the request to the console.
fmt.Printf("[SSH] %s Username: %s Password: %s\n", src_ip, conn.User(), string(password))
fmt.Printf("[SSH] %s Username: %q Password: %q\n", src_ip, conn.User(), string(password))
// Update the threat feed with the source IP address from the request.
if srv.SendToThreatFeed {
threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
if cfg.SendToThreatFeed {
threatfeed.Update(src_ip)
}
// Return an invalid username or password error to the client.
// Insert fixed delay to mimic PAM.
time.Sleep(2 * time.Second)
// Reject the authentication request.
return nil, fmt.Errorf("invalid username or password")
}
// Start the SSH server.
listener, err := net.Listen("tcp", ":"+srv.Port)
listener, err := net.Listen("tcp", ":"+cfg.Port)
if err != nil {
return fmt.Errorf("failed to listen on port '%s': %w", srv.Port, err)
fmt.Fprintf(os.Stderr, "The SSH server on port %s has stopped: %v\n", cfg.Port, err)
return
}
defer listener.Close()
@@ -103,28 +115,20 @@ func startSSH(srv *config.Server) error {
}
// handleConnection manages incoming SSH client connections. It performs the
// handshake and establishes communication channels.
// handshake and handles authentication callbacks.
func handleConnection(conn net.Conn, config *ssh.ServerConfig) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(serverTimeout))
// Perform handshake on incoming connection.
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
// Perform handshake and authentication. Authentication callbacks are
// defined in the SSH server configuration. Since authentication requests
// are always rejected, this function will consistently return an error,
// and no further connection handling is necessary.
sshConn, _, _, err := ssh.NewServerConn(conn, config)
if err != nil {
return
}
defer sshConn.Close()
// Handle SSH requests and channels.
go ssh.DiscardRequests(reqs)
go handleChannels(chans)
}
// handleChannels processes SSH channels for the connected client.
func handleChannels(chans <-chan ssh.NewChannel) {
for newChannel := range chans {
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
continue
}
}
// loadOrGeneratePrivateKey attempts to load a private key from the specified
@@ -183,7 +187,7 @@ func writePrivateKey(path string, privateKey *rsa.PrivateKey) error {
defer file.Close()
// Limit key access to the owner only.
file.Chmod(0600)
_ = file.Chmod(0600)
if err := pem.Encode(file, privPem); err != nil {
return err

125
internal/stix/stix.go Normal file
View File

@@ -0,0 +1,125 @@
package stix
import (
"time"
)
const (
// DeceptifeedID is a deterministic identifier for the Deceptifeed Identity
// object. STIX objects should reference this ID using the `created_by_ref`
// property to show the object was created by Deceptifeed. This constant is
// the result of:
// DeterministicID("identity", "{"identity_class":"system","name":"deceptifeed"}")
DeceptifeedID = "identity--370c0cfb-3203-5ca4-b8a9-b1aeef9d6fb3"
// SpecVersion is the version of the STIX specification being implemented.
SpecVersion = "2.1"
// ContentType is the `Content-Type` HTTP response header used when
// returning STIX objects.
ContentType = "application/stix+json;version=2.1"
)
// Object represents a STIX Object, a general term for a STIX Domain Object
// (SDO), STIX Cyber-observable Object (SCO), STIX Relationship Object (SRO),
// or STIX Meta Object.
type Object interface{}
// Bundle represents a STIX Bundle Object. A Bundle is a collection of
// arbitrary STIX Objects grouped together in a single container.
type Bundle struct {
Type string `json:"type"` // Required
ID string `json:"id"` // Required
Objects []Object `json:"objects,omitempty"` // Optional
}
// Indicator represents a STIX Indicator SDO.
type Indicator struct {
Type string `json:"type"` // Required
SpecVersion string `json:"spec_version"` // Required
ID string `json:"id"` // Required
IndicatorTypes []string `json:"indicator_types"` // Required
Pattern string `json:"pattern"` // Required
PatternType string `json:"pattern_type"` // Required
Created time.Time `json:"created"` // Required
Modified time.Time `json:"modified"` // Required
ValidFrom time.Time `json:"valid_from"` // Required
ValidUntil *time.Time `json:"valid_until,omitempty"` // Optional
Name string `json:"name,omitempty"` // Optional
Confidence int `json:"confidence,omitempty"` // Optional
Description string `json:"description,omitempty"` // Optional
KillChains []KillChain `json:"kill_chain_phases,omitempty"` // Optional
Labels []string `json:"labels,omitempty"` // Optional
Lang string `json:"lang,omitempty"` // Optional
CreatedByRef string `json:"created_by_ref,omitempty"` // Optional
}
// Sighting represents a STIX Sighting SRO.
type Sighting struct {
Type string `json:"type"` // Required
SpecVersion string `json:"spec_version"` // Required
ID string `json:"id"` // Required
Created time.Time `json:"created"` // Required
Modified time.Time `json:"modified"` // Required
FirstSeen time.Time `json:"first_seen"` // Optional
LastSeen time.Time `json:"last_seen"` // Optional
Count int `json:"count"` // Optional
Confidence int `json:"confidence,omitempty"` // Optional
Description string `json:"description,omitempty"` // Optional
Lang string `json:"lang,omitempty"` // Optional
SightingOfRef string `json:"sighting_of_ref"` // Required
WhereSightedRefs []string `json:"where_sighted_refs,omitempty"` // Optional
CreatedByRef string `json:"created_by_ref,omitempty"` // Optional
}
// KillChain represents a STIX `kill-chain-phase` type, which represents a
// phase in a kill chain.
type KillChain struct {
KillChain string `json:"kill_chain_name"` // Required
Phase string `json:"phase_name"` // Required
}
// ObservableIP represents a STIX IP Address SCO.
type ObservableIP struct {
Type string `json:"type"` // Required
SpecVersion string `json:"spec_version,omitempty"` // Optional
ID string `json:"id"` // Required
Value string `json:"value"` // Required
CreatedByRef string `json:"created_by_ref,omitempty"` // Optional
}
// Identity represents a STIX Identity SDO, used to represent individuals,
// organizations, groups, or systems.
type Identity struct {
Type string `json:"type"` // Required
SpecVersion string `json:"spec_version"` // Required
ID string `json:"id"` // Required
Class string `json:"identity_class"` // Required
Name string `json:"name"` // Required
Description string `json:"description,omitempty"` // Optional
Contact string `json:"contact_information,omitempty"` // Optional
Created time.Time `json:"created"` // Required
Modified time.Time `json:"modified"` // Required
}
// DeceptifeedIdentity returns a STIX Identity object representing the
// Deceptifeed application.
func DeceptifeedIdentity() Identity {
const initialCommitTime = "2024-10-16T18:48:00.000Z"
created, err := time.Parse(time.RFC3339, initialCommitTime)
if err != nil {
created = time.Now()
}
return Identity{
Type: "identity",
SpecVersion: SpecVersion,
ID: DeceptifeedID,
Class: "system",
Name: "Deceptifeed",
Description: "Deceptifeed is a defense system that combines honeypot servers with an integrated threat feed.",
Contact: "deceptifeed.com",
Created: created,
Modified: created,
}
}

95
internal/stix/uuid.go Normal file
View File

@@ -0,0 +1,95 @@
package stix
import (
"crypto/rand"
"crypto/sha1"
"fmt"
)
var (
// nsSTIX is the byte representation of the STIX UUIDv5 namespace:
// {00abedb4-aa42-466c-9c01-fed23315a9b7}
nsSTIX = [16]byte{
0x00, 0xab, 0xed, 0xb4,
0xaa, 0x42,
0x46, 0x6c,
0x9c, 0x01,
0xfe, 0xd2, 0x33, 0x15, 0xa9, 0xb7,
}
)
// NewID returns a new random unique identifier for a STIX Object. Identifiers
// follow the form `objectType--UUID` where `objectType` is the exact value
// from the `type` property of the object and where `UUID` is an RFC
// 4122-compliant UUID. Random identifiers use UUIDv4.
func NewID(objectType string) string {
return objectType + "--" + newUUIDv4()
}
// DeterministicID returns a deterministic unique identifier for a STIX Object.
// Identifiers follow the form `objectType--UUID` where `objectType` is the
// exact value from the `type` property of the object and where `UUID` is an RFC
// 4122-compliant UUID. Deterministic identifiers use UUIDv5 with the STIX
// namespace and select properties represented in JSON.
func DeterministicID(objectType string, jsonValues string) string {
return objectType + "--" + newUUIDv5(nsSTIX, jsonValues)
}
// newUUIDv5 returns a string representation of a Universally Unique Identifier
// (UUID) RFC 4122 version 5 value. Version 5 UUIDs are a SHA-1 hash of a
// namespace identifier and a name.
func newUUIDv5(ns [16]byte, name string) string {
// A version 5 UUID is generated by hashing a namespace identifier (itself,
// a UUID) and a name (value) using SHA-1. Then specific bits are
// overwritten to indicate version 5 and the variant (the format of the
// UUID).
// As per STIX 2.1, STIX Cyber-observable Objects using deterministic
// identifiers should use UUIDv5 and the STIX 2.1 namespace:
// {00abedb4-aa42-466c-9c01-fed23315a9b7}. The value of the name portion
// should be the list of "ID Contributing Properties" defined for the SCO
// and their values and represented as a JSON object. Example for IPv4
// Address Object: {"value":"127.0.0.1"}
// STIX Domain Objects may use UUIDv5 for the UUID portion of the
// identifier, but must not use the STIX namespace.
// Get the SHA-1 hash of `namespace` + `name`.
h := sha1.New()
_, _ = h.Write(ns[:])
_, _ = h.Write([]byte(name))
// Use only the first 16-bytes of the hash.
b := h.Sum(nil)[:16]
// Overwrite the version bits with 0b0101 (UUID version 5).
b[6] = (b[6] & 0x0f) | 0x50
// Overwrite the variant bits with 0b10.
b[8] = (b[8] & 0x3f) | 0x80
// Return as UUID string representation.
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
// newUUIDv4 returns a string representation of a Universally Unique Identifier
// (UUID) RFC 4122 version 4 value. Version 4 UUIDs are random values.
func newUUIDv4() string {
// A version 4 UUID is randomly generated. Then specific bits are
// overwritten to indicate version 4 and the variant (the format of the
// UUID).
// Get 16 random bytes. crypto/rand.Read is guaranteed to never return an
// error (as of Go 1.24).
var b = [16]byte{}
_, _ = rand.Read(b[:])
// Overwrite the version bits with 0b0100 (UUID version 4).
b[6] = (b[6] & 0x0f) | 0x40
// Overwrite the variant bits with 0b10.
b[8] = (b[8] & 0x3f) | 0x80
// Return as UUID string representation.
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

102
internal/taxii/taxii.go Normal file
View File

@@ -0,0 +1,102 @@
package taxii
import "github.com/r-smith/deceptifeed/internal/stix"
const (
// APIRoot is the part of the URL that makes up the TAXII API root.
APIRoot = "/taxii2/api/"
// ContentType is the `Content-Type` HTTP response header used when
// returning TAXII responses.
ContentType = "application/taxii+json;version=2.1"
// IndicatorsID is a fixed (random) identifier for the indicators
// collection.
IndicatorsID = "2cc72f88-8d92-4745-9c00-ea0deac18163"
// IndicatorsAlias is the friendly alias for the indicators collection.
IndicatorsAlias = "indicators"
// ObservablesID is a fixed (random) identifier for the observables
// collection.
ObservablesID = "8aaff655-40de-41e2-9064-3dc1620d6420"
// ObservablesAlias is the friendly alias for the observables collection.
ObservablesAlias = "observables"
// SightingsID is a fixed (random) identifier for the sightings collection.
SightingsID = "2b27973a-5891-4883-89aa-b7141e78e3e1"
// SightingsAlias is the friendly alias for the sightings collection.
SightingsAlias = "sightings"
)
// ImplementedCollections returns the collections that are available for use.
func ImplementedCollections() []Collection {
return []Collection{
{
ID: IndicatorsID,
Title: "Deceptifeed Indicators",
Description: "This collection contains IP addresses observed interacting with honeypots, represented as STIX Indicators",
Alias: IndicatorsAlias,
CanRead: true,
CanWrite: false,
MediaTypes: []string{ContentType},
},
{
ID: ObservablesID,
Title: "Deceptifeed Observables",
Description: "This collection contains IP addresses observed interacting with honeypots, represented as STIX Observables",
Alias: ObservablesAlias,
CanRead: true,
CanWrite: false,
MediaTypes: []string{ContentType},
},
{
ID: SightingsID,
Title: "Deceptifeed Sightings",
Description: "This collection contains Sightings of Indicators observed interacting with honeypots",
Alias: SightingsAlias,
CanRead: true,
CanWrite: false,
MediaTypes: []string{ContentType},
},
}
}
// Envelope represents a TAXII envelope resource, which is a simple wrapper for
// STIX 2 content.
type Envelope struct {
More bool `json:"more"` // Optional
Next string `json:"next,omitempty"` // Optional
Objects []stix.Object `json:"objects"` // Optional
}
// Collection represents a TAXII collection resource, which contains general
// information about a collection.
type Collection struct {
ID string `json:"id"` // Required
Title string `json:"title"` // Required
Description string `json:"description,omitempty"` // Optional
Alias string `json:"alias,omitempty"` // Optional
CanRead bool `json:"can_read"` // Required
CanWrite bool `json:"can_write"` // Required
MediaTypes []string `json:"media_types,omitempty"` // Optional
}
// DiscoveryResource represents a TAXII discovery resource, which contains
// information about a TAXII server.
type DiscoveryResource struct {
Title string `json:"title"` // Required
Description string `json:"description,omitempty"` // Optional
Default string `json:"default,omitempty"` // Optional
APIRoots []string `json:"api_roots,omitempty"` // Optional
}
// APIRootResource represents a TAXII api-root resource, which contains general
// information about the API root.
type APIRootResource struct {
Title string `json:"title"` // Required
Versions []string `json:"versions"` // Required
MaxContentLength int `json:"max_content_length"` // Required
}

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()),
@@ -121,11 +112,11 @@ func handleConnection(conn net.Conn, srv *config.Server) {
)
// Print a simplified version of the interaction to the console.
fmt.Printf("[TCP] %s %v\n", src_ip, responsesToString(responses))
fmt.Printf("[TCP] %s %q\n", src_ip, responsesToString(responses))
// Update the threat feed with the source IP address from the interaction.
if srv.SendToThreatFeed {
threatfeed.UpdateIoC(src_ip, srv.ThreatScore)
if cfg.SendToThreatFeed {
threatfeed.Update(src_ip)
}
}

209
internal/threatfeed/data.go Normal file
View File

@@ -0,0 +1,209 @@
package threatfeed
import (
"bufio"
"encoding/csv"
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
)
// IOC represents an Indicator of Compromise (IOC) entry that stores
// information about IP addresses that interact with the honeypot servers.
type IOC struct {
// added records the time when an IP address is added to the threat feed.
added time.Time
// lastSeen records the last time an IP was observed interacting with a
// honeypot server.
lastSeen time.Time
// observations tracks the total number of interactions an IP has had with
// the honeypot servers.
observations int
}
const (
// dateFormat specifies the timestamp format used for threat feed entries.
dateFormat = time.RFC3339Nano
// maxObservations is the maximum number of interactions the threat feed
// will record for each IP.
maxObservations = 999_999_999
)
var (
// iocData stores Indicator of Compromise (IOC) entries, keyed by IP
// address. This map represents the internal structure of the threat feed.
// It is populated with existing threat data when the server starts. The
// map is then updated by `Update` whenever a potential attacker interacts
// with a honeypot server. The threat feed served to clients is generated
// based on the data in this map.
iocData = make(map[string]*IOC)
// mu is to ensure thread-safe access to iocData.
mu sync.Mutex
// dataChanged indicates whether the IoC map has been modified since the
// last time it was saved to disk.
dataChanged = false
// csvHeader defines the header row for saved threat feed data.
csvHeader = []string{"ip", "added", "last_seen", "observations"}
)
// Update updates the threat feed with the provided source IP address. This
// function should be called by honeypot servers whenever a client interacts
// with the honeypot. If the source IP address is already in the threat feed,
// its last-seen timestamp is updated, and its observation count is
// incremented. Otherwise, the IP address is added as a new entry.
func Update(ip string) {
// Check if the given IP string is a private address. The threat feed may
// be configured to include or exclude private IPs.
netIP := net.ParseIP(ip)
if netIP == nil || netIP.IsLoopback() {
return
}
if !cfg.ThreatFeed.IsPrivateIncluded && netIP.IsPrivate() {
return
}
now := time.Now()
mu.Lock()
if ioc, exists := iocData[ip]; exists {
// Update existing entry.
ioc.lastSeen = now
if ioc.observations < maxObservations {
ioc.observations++
}
} else {
// Create a new entry.
iocData[ip] = &IOC{
added: now,
lastSeen: now,
observations: 1,
}
}
mu.Unlock()
dataChanged = true
}
// deleteExpired deletes expired threat feed entries from the IoC map.
func deleteExpired() {
mu.Lock()
defer mu.Unlock()
for key, value := range iocData {
if value.expired() {
delete(iocData, key)
}
}
}
// expired returns whether an IoC is considered expired based on the last
// seen date and the configured expiry hours.
func (ioc *IOC) expired() bool {
if cfg.ThreatFeed.ExpiryHours <= 0 {
return false
}
return ioc.lastSeen.Before(time.Now().Add(-time.Hour * time.Duration(cfg.ThreatFeed.ExpiryHours)))
}
// loadCSV loads existing threat feed data from a CSV file. If found, it
// populates iocData which represents the active threat feed. This function is
// called once during the initialization of the threat feed server.
func loadCSV() error {
mu.Lock()
defer mu.Unlock()
f, err := os.Open(cfg.ThreatFeed.DatabasePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
defer f.Close()
reader := csv.NewReader(f)
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
return err
}
if len(records) < 2 {
return nil
}
var added time.Time
var lastSeen time.Time
var count int
for _, record := range records[1:] {
ip := record[0]
// Parse added, defaulting to current time.
added = time.Now()
if len(record) > 1 && record[1] != "" {
added, _ = time.Parse(dateFormat, record[1])
}
// Parse lastSeen, defaulting to current time.
lastSeen = time.Now()
if len(record) > 2 && record[2] != "" {
lastSeen, _ = time.Parse(dateFormat, record[2])
}
// Parse observation count, defaulting to 1.
count = 1
if len(record) > 3 && record[3] != "" {
if parsedCount, err := strconv.Atoi(record[3]); err == nil {
count = parsedCount
}
}
iocData[ip] = &IOC{added: added, lastSeen: lastSeen, observations: count}
}
return nil
}
// saveCSV writes the current threat feed to a CSV file. This CSV file ensures
// the threat feed data persists across application restarts. It is not the
// active threat feed.
func saveCSV() error {
f, err := os.OpenFile(cfg.ThreatFeed.DatabasePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
w := bufio.NewWriterSize(f, 65536)
_, err = w.WriteString(strings.Join(csvHeader, ",") + "\n")
if err != nil {
return err
}
mu.Lock()
for ip, ioc := range iocData {
_, err = w.WriteString(
fmt.Sprintf(
"%s,%s,%s,%d\n",
ip,
ioc.added.Format(dateFormat),
ioc.lastSeen.Format(dateFormat),
ioc.observations,
),
)
if err != nil {
return err
}
}
mu.Unlock()
return w.Flush()
}

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
}

337
internal/threatfeed/feed.go Normal file
View File

@@ -0,0 +1,337 @@
package threatfeed
import (
"bufio"
"bytes"
"cmp"
"fmt"
"net"
"os"
"slices"
"strings"
"time"
"github.com/r-smith/deceptifeed/internal/stix"
)
// feedEntry represents an individual entry in the threat feed.
type feedEntry struct {
IP string `json:"ip"`
IPBytes net.IP `json:"-"`
Added time.Time `json:"added"`
LastSeen time.Time `json:"last_seen"`
Observations int `json:"observations"`
}
// feedEntries is a slice of feedEntry structs. It represents the threat feed
// served to clients. When clients request the feed, this structure is built
// from the `iocData` map. The data is then formatted and served to clients in
// the requested format.
type feedEntries []feedEntry
// sortMethod represents the method used for sorting the threat feed.
type sortMethod int
// Constants representing the possible values for sortMethod.
const (
byIP sortMethod = iota
byAdded
byLastSeen
byObservations
)
// sortDirection represents the direction of sorting (ascending or descending).
type sortDirection int
// Constants representing the possible values for sortDirection.
const (
ascending sortDirection = iota
descending
)
// feedOptions define configurable options for serving the threat feed.
type feedOptions struct {
sortMethod sortMethod
sortDirection sortDirection
seenAfter time.Time
limit int
page int
}
// prepareFeed filters, processes, and sorts IP addresses from the threat feed.
// The resulting slice of `net.IP` represents the current threat feed to be
// served to clients.
func prepareFeed(options ...feedOptions) feedEntries {
// Set default feed options.
opt := feedOptions{
sortMethod: byIP,
sortDirection: ascending,
}
// Override default options if provided.
if len(options) > 0 {
opt = options[0]
}
excludedIPs, excludedCIDR, err := parseExcludeList(cfg.ThreatFeed.ExcludeListPath)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to read threat feed exclude list:", err)
}
// Parse and filter IPs from iocData into the threat feed.
mu.Lock()
threats := make(feedEntries, 0, len(iocData))
loop:
for ip, ioc := range iocData {
if ioc.expired() || !ioc.lastSeen.After(opt.seenAfter) {
continue
}
parsedIP := net.ParseIP(ip)
if parsedIP == nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
continue
}
for _, ipnet := range excludedCIDR {
if ipnet.Contains(parsedIP) {
continue loop
}
}
if _, found := excludedIPs[ip]; found {
continue
}
threats = append(threats, feedEntry{
IP: ip,
IPBytes: parsedIP,
Added: ioc.added,
LastSeen: ioc.lastSeen,
Observations: ioc.observations,
})
}
mu.Unlock()
threats.applySort(opt.sortMethod, opt.sortDirection)
return threats
}
// parseExcludeList reads IP addresses and CIDR ranges from a file. Each line
// should contain an IP address or CIDR. It returns a map of the unique IPs and
// a slice of the CIDR ranges found in the file. The file may include comments
// using "#". The "#" symbol on a line and everything after is ignored.
func parseExcludeList(filepath string) (map[string]struct{}, []*net.IPNet, error) {
if len(filepath) == 0 {
return map[string]struct{}{}, []*net.IPNet{}, nil
}
f, err := os.Open(filepath)
if err != nil {
return nil, nil, err
}
defer f.Close()
// `ips` stores individual IPs to exclude, and `cidr` stores CIDR networks
// to exclude.
ips := make(map[string]struct{})
cidr := []*net.IPNet{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Remove comments from text.
if i := strings.Index(line, "#"); i != -1 {
line = strings.TrimSpace(line[:i])
}
if len(line) > 0 {
if _, ipnet, err := net.ParseCIDR(line); err == nil {
cidr = append(cidr, ipnet)
} else {
ips[line] = struct{}{}
}
}
}
if err := scanner.Err(); err != nil {
return nil, nil, err
}
return ips, cidr, nil
}
// applySort sorts the threat feed based on the specified sort method and
// direction.
func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
switch method {
case byIP:
slices.SortFunc(f, func(a, b feedEntry) int {
return bytes.Compare(a.IPBytes, b.IPBytes)
})
case byLastSeen:
slices.SortFunc(f, func(a, b feedEntry) int {
t := a.LastSeen.Compare(b.LastSeen)
if t == 0 {
return bytes.Compare(a.IPBytes, b.IPBytes)
}
return t
})
case byAdded:
slices.SortFunc(f, func(a, b feedEntry) int {
t := a.Added.Compare(b.Added)
if t == 0 {
return bytes.Compare(a.IPBytes, b.IPBytes)
}
return t
})
case byObservations:
slices.SortFunc(f, func(a, b feedEntry) int {
t := cmp.Compare(a.Observations, b.Observations)
if t == 0 {
return bytes.Compare(a.IPBytes, b.IPBytes)
}
return t
})
}
if direction == descending {
slices.Reverse(f)
}
}
// convertToIndicators converts IP addresses from the threat feed into a
// collection of STIX Indicator objects.
func (f feedEntries) convertToIndicators() []stix.Object {
if len(f) == 0 {
return []stix.Object{}
}
const indicator = "indicator"
result := make([]stix.Object, 0, len(f)+1)
// Add the Deceptifeed `Identity` as the first object in the collection.
// All objects in the collection will reference this identity as the
// creator.
result = append(result, stix.DeceptifeedIdentity())
for _, entry := range f {
pattern := "[ipv4-addr:value = '"
if strings.Contains(entry.IP, ":") {
pattern = "[ipv6-addr:value = '"
}
pattern = pattern + entry.IP + "']"
// Fixed expiration: 2 months since last seen.
validUntil := new(time.Time)
*validUntil = entry.LastSeen.AddDate(0, 2, 0).UTC()
// Generate a deterministic identifier using the IP address represented
// as a STIX IP pattern and structured as a JSON string. Example:
// {"pattern":"[ipv4-addr:value='127.0.0.1']"}
patternJSON := fmt.Sprintf("{\"pattern\":\"%s\"}", pattern)
result = append(result, stix.Indicator{
Type: indicator,
SpecVersion: stix.SpecVersion,
ID: stix.DeterministicID(indicator, patternJSON),
IndicatorTypes: []string{"malicious-activity"},
Pattern: pattern,
PatternType: "stix",
Created: entry.Added.UTC(),
Modified: entry.LastSeen.UTC(),
ValidFrom: entry.Added.UTC(),
ValidUntil: validUntil,
Name: "Honeypot interaction: " + entry.IP,
Description: "This IP was observed interacting with a honeypot server.",
KillChains: []stix.KillChain{{KillChain: "mitre-attack", Phase: "reconnaissance"}},
Confidence: 100,
Lang: "en",
Labels: []string{"honeypot-interaction"},
CreatedByRef: stix.DeceptifeedID,
})
}
return result
}
// convertToSightings converts IP addresses from the threat feed into a
// collection of STIX Sighting objects.
func (f feedEntries) convertToSightings() []stix.Object {
if len(f) == 0 {
return []stix.Object{}
}
const indicator = "indicator"
const sighting = "sighting"
const maxCount = 999_999_999 // Maximum count according to STIX 2.1 specification.
result := make([]stix.Object, 0, len(f)+1)
// Add the Deceptifeed `Identity` as the first object in the collection.
// All objects in the collection will reference this identity as the
// creator.
result = append(result, stix.DeceptifeedIdentity())
for _, entry := range f {
pattern := "[ipv4-addr:value = '"
if strings.Contains(entry.IP, ":") {
pattern = "[ipv6-addr:value = '"
}
pattern = pattern + entry.IP + "']"
count := min(entry.Observations, maxCount)
// Generate a deterministic identifier using the IP address represented
// as a STIX IP pattern and structured as a JSON string. Example:
// {"pattern":"[ipv4-addr:value='127.0.0.1']"}
indicatorJSON := fmt.Sprintf("{\"pattern\":\"%s\"}", pattern)
indicatorID := stix.DeterministicID(indicator, indicatorJSON)
result = append(result, stix.Sighting{
Type: sighting,
SpecVersion: stix.SpecVersion,
ID: stix.DeterministicID(sighting, "{\"sighting_of_ref\":\""+indicatorID+"\"}"),
Created: entry.Added.UTC(),
Modified: entry.LastSeen.UTC(),
FirstSeen: entry.Added.UTC(),
LastSeen: entry.LastSeen.UTC(),
Count: count,
Description: "This IP was observed interacting with a honeypot server.",
Confidence: 100,
Lang: "en",
SightingOfRef: indicatorID,
WhereSightedRefs: []string{stix.DeceptifeedID},
CreatedByRef: stix.DeceptifeedID,
})
}
return result
}
// convertToObservables converts IP addresses from the threat feed into a
// collection of STIX Cyber-observable Objects.
func (f feedEntries) convertToObservables() []stix.Object {
if len(f) == 0 {
return []stix.Object{}
}
result := make([]stix.Object, 0, len(f)+1)
// Add the Deceptifeed `Identity` as the first object in the collection.
// All objects in the collection will reference this identity as the
// creator.
result = append(result, stix.DeceptifeedIdentity())
for _, entry := range f {
t := "ipv4-addr"
if strings.Contains(entry.IP, ":") {
t = "ipv6-addr"
}
// Generate a deterministic identifier for each IP address in the
// threat feed using the IP value represented as a JSON string. For
// example: {"value":"127.0.0.1"}
result = append(result, stix.ObservableIP{
Type: t,
SpecVersion: stix.SpecVersion,
ID: stix.DeterministicID(t, "{\"value\":\""+entry.IP+"\"}"),
Value: entry.IP,
CreatedByRef: stix.DeceptifeedID,
})
}
return result
}

View File

@@ -0,0 +1,485 @@
package threatfeed
import (
"cmp"
"encoding/json"
"io"
"net/http"
"os"
"slices"
"time"
)
// handleLogsMain serves a static page listing honeypot logs available for
// viewing.
func handleLogsMain(w http.ResponseWriter, r *http.Request) {
_ = parsedTemplates.ExecuteTemplate(w, "logs.html", "logs")
}
// handleLogs directs the request to the appropriate log parser based on the
// request path.
func handleLogs(w http.ResponseWriter, r *http.Request) {
switch r.PathValue("logtype") {
case "http":
switch r.PathValue("subtype") {
case "":
handleLogHTTP(w)
case "ip":
displayStats(w, httpIPStats{})
case "useragent":
displayStats(w, httpUserAgentStats{})
case "path":
displayStats(w, httpPathStats{})
case "query":
displayStats(w, httpQueryStats{})
case "method":
displayStats(w, httpMethodStats{})
case "host":
displayStats(w, httpHostStats{})
default:
handleNotFound(w, r)
}
case "ssh":
switch r.PathValue("subtype") {
case "":
handleLogSSH(w)
case "ip":
displayStats(w, sshIPStats{})
case "client":
displayStats(w, sshClientStats{})
case "username":
displayStats(w, sshUsernameStats{})
case "password":
displayStats(w, sshPasswordStats{})
default:
handleNotFound(w, r)
}
default:
handleNotFound(w, r)
}
}
// displayLogErrorPage servers an error page when there is a problem parsing
// log files.
func displayLogErrorPage(w http.ResponseWriter, err error) {
w.WriteHeader(http.StatusInternalServerError)
_ = parsedTemplates.ExecuteTemplate(w, "logs-error.html", map[string]any{"Error": err, "NavData": "logs"})
}
// handleLogSSH serves the SSH honeypot logs as a web page. It opens the
// honeypot log files, parses the data to JSON, and passes the result to an
// HTML template for rendering.
func handleLogSSH(w http.ResponseWriter) {
l := logFiles{}
reader, err := l.open()
if err != nil {
displayLogErrorPage(w, err)
return
}
defer l.close()
type Log struct {
Time time.Time `json:"time"`
EventType string `json:"event_type"`
SourceIP string `json:"source_ip"`
Details struct {
Username string `json:"username"`
Password string `json:"password"`
} `json:"event_details"`
}
const maxResults = 25_000
d := json.NewDecoder(reader)
data := make([]Log, 0, maxResults+1)
for d.More() {
var entry Log
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
data = append(data, entry)
if len(data) > maxResults {
data = data[1:]
}
}
slices.Reverse(data)
_ = parsedTemplates.ExecuteTemplate(w, "logs-ssh.html", map[string]any{"Data": data, "NavData": "logs"})
}
// handleLogHTTP serves the HTTP honeypot logs as a web page. It opens the
// honeypot log files, parses the data to JSON, and passes the result to an
// HTML template for rendering.
func handleLogHTTP(w http.ResponseWriter) {
l := logFiles{}
reader, err := l.open()
if err != nil {
displayLogErrorPage(w, err)
return
}
defer l.close()
type Log struct {
Time time.Time `json:"time"`
EventType string `json:"event_type"`
SourceIP string `json:"source_ip"`
Details struct {
Method string `json:"method"`
Path string `json:"path"`
} `json:"event_details"`
}
const maxResults = 25_000
d := json.NewDecoder(reader)
data := make([]Log, 0, maxResults+1)
for d.More() {
var entry Log
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
data = append(data, entry)
if len(data) > maxResults {
data = data[1:]
}
}
slices.Reverse(data)
_ = parsedTemplates.ExecuteTemplate(w, "logs-http.html", map[string]any{"Data": data, "NavData": "logs"})
}
// displayStats handles the processing and rendering of statistics for a given
// field. It reads honeypot log data, counts the occurrences of `field` and
// displays the results.
func displayStats(w http.ResponseWriter, field fieldCounter) {
l := logFiles{}
reader, err := l.open()
if err != nil {
displayLogErrorPage(w, err)
return
}
defer l.close()
fieldCounts := field.count(reader)
results := []statsResult{}
for k, v := range fieldCounts {
results = append(results, statsResult{Field: k, Count: v})
}
slices.SortFunc(results, func(a, b statsResult) int {
return cmp.Or(
-cmp.Compare(a.Count, b.Count),
cmp.Compare(a.Field, b.Field),
)
})
_ = parsedTemplates.ExecuteTemplate(
w,
"logs-stats.html",
map[string]any{
"Data": results,
"Header": field.fieldName(),
"NavData": "logs",
},
)
}
// statsResult holds a specific value for field and its associated count.
type statsResult struct {
Field string
Count int
}
// fieldCounter is an interface that defines methods for counting occurrences
// of specific fields.
type fieldCounter interface {
count(io.Reader) map[string]int
fieldName() string
}
// sshIPStats is the log structure for extracting SSH IP data.
type sshIPStats struct {
EventType string `json:"event_type"`
SourceIP string `json:"source_ip"`
}
func (sshIPStats) fieldName() string { return "Source IP" }
func (sshIPStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshIPStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.SourceIP]++
}
return fieldCounts
}
// sshClientStats is the log structure for extracting SSH client data.
type sshClientStats struct {
EventType string `json:"event_type"`
Details struct {
Client string `json:"ssh_client"`
} `json:"event_details"`
}
func (sshClientStats) fieldName() string { return "SSH Client" }
func (sshClientStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshClientStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.Details.Client]++
}
return fieldCounts
}
// sshUsernameStats is the log structure for extracting SSH username data.
type sshUsernameStats struct {
EventType string `json:"event_type"`
Details struct {
Username string `json:"username"`
} `json:"event_details"`
}
func (sshUsernameStats) fieldName() string { return "Username" }
func (sshUsernameStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshUsernameStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.Details.Username]++
}
return fieldCounts
}
// sshPasswordStats is the log structure for extracting SSH password data.
type sshPasswordStats struct {
EventType string `json:"event_type"`
Details struct {
Password string `json:"password"`
} `json:"event_details"`
}
func (sshPasswordStats) fieldName() string { return "Password" }
func (sshPasswordStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshPasswordStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.Details.Password]++
}
return fieldCounts
}
// httpIPStats is the log structure for extracting HTTP IP data.
type httpIPStats struct {
EventType string `json:"event_type"`
SourceIP string `json:"source_ip"`
}
func (httpIPStats) fieldName() string { return "Source IP" }
func (httpIPStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpIPStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.SourceIP]++
}
return fieldCounts
}
// httpUserAgentStats is the log structure for extracting HTTP user-agent data.
type httpUserAgentStats struct {
EventType string `json:"event_type"`
Details struct {
UserAgent string `json:"user_agent"`
} `json:"event_details"`
}
func (httpUserAgentStats) fieldName() string { return "User-Agent" }
func (httpUserAgentStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpUserAgentStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.UserAgent]++
}
return fieldCounts
}
// httpPathStats is the log structure for extracting HTTP path data.
type httpPathStats struct {
EventType string `json:"event_type"`
Details struct {
Path string `json:"path"`
} `json:"event_details"`
}
func (httpPathStats) fieldName() string { return "Path" }
func (httpPathStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpPathStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Path]++
}
return fieldCounts
}
// httpQueryStats is the log structure for extracting HTTP query string data.
type httpQueryStats struct {
EventType string `json:"event_type"`
Details struct {
Query string `json:"query"`
} `json:"event_details"`
}
func (httpQueryStats) fieldName() string { return "Query String" }
func (httpQueryStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpQueryStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Query]++
}
return fieldCounts
}
// httpMethodStats is the log structure for extracting HTTP method data.
type httpMethodStats struct {
EventType string `json:"event_type"`
Details struct {
Method string `json:"method"`
} `json:"event_details"`
}
func (httpMethodStats) fieldName() string { return "HTTP Method" }
func (httpMethodStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpMethodStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Method]++
}
return fieldCounts
}
// httpHostStats is the log structure for extracting HTTP host header data.
type httpHostStats struct {
EventType string `json:"event_type"`
Details struct {
Host string `json:"host"`
} `json:"event_details"`
}
func (httpHostStats) fieldName() string { return "Host Header" }
func (httpHostStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpHostStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Host]++
}
return fieldCounts
}
// logFiles represents open honeypot log files and their associate io.Reader.
type logFiles struct {
files []*os.File
readers []io.Reader
}
// open opens all honeypot log files and returns an io.MultiReader that
// combines all of the logs.
func (l *logFiles) open() (io.Reader, error) {
paths := []string{}
seenPaths := make(map[string]bool)
// Helper function to ensure only unique paths are added to the slice.
add := func(p string) {
if seenPaths[p] {
return
}
// New path. Add both the path and the path with ".1" to the slice.
paths = append(paths, p+".1", p)
seenPaths[p] = true
}
for _, s := range cfg.Servers {
add(s.LogPath)
}
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
l.files = append(l.files, f)
}
for _, f := range l.files {
l.readers = append(l.readers, f)
}
return io.MultiReader(l.readers...), nil
}
// close closes all honeypot log files.
func (l *logFiles) close() {
for _, f := range l.files {
_ = f.Close()
}
}

View File

@@ -0,0 +1,94 @@
package threatfeed
import (
"fmt"
"net"
"net/http"
"sync"
"golang.org/x/net/websocket"
)
// maxRecentMessages is the maximum number of recent log messages to store.
const maxRecentMessages = 100
var (
// muWSClients is to ensure threat-safe access to wsClients.
muWSClients sync.Mutex
// wsClients holds the connected WebSocket clients and is used to broadcast
// messages to all clients.
wsClients = make(map[*websocket.Conn]bool)
// wsRecentMessages stores the most recent log messages. These messages
// are sent to clients when they first connect.
wsRecentMessages = make([]string, 0, maxRecentMessages*1.5)
)
// handleLiveIndex serves a web page that displays honeypot log data in
// real-time through a WebSocket connection.
func handleLiveIndex(w http.ResponseWriter, r *http.Request) {
_ = parsedTemplates.ExecuteTemplate(w, "live.html", "live")
}
// broadcastLogsToClients receives honeypot log data through a byte channel
// configured to monitor the logs. When log data is received, the data is
// sent to all connected WebSocket clients. It also stores recent log data in a
// cache for newly connected clients.
func broadcastLogsToClients() {
for msg := range cfg.Monitor.Channel {
wsRecentMessages = append(wsRecentMessages, string(msg))
if len(wsRecentMessages) > maxRecentMessages {
wsRecentMessages = wsRecentMessages[1:]
}
muWSClients.Lock()
for client := range wsClients {
_ = websocket.Message.Send(client, string(msg))
}
muWSClients.Unlock()
}
}
// handleWebSocket establishes and maintains WebSocket connections with clients
// and performs cleanup when clients disconnect.
func handleWebSocket(ws *websocket.Conn) {
defer func() {
muWSClients.Lock()
delete(wsClients, ws)
muWSClients.Unlock()
_ = ws.Close()
}()
// Enforce private IPs.
ip, _, err := net.SplitHostPort(ws.Request().RemoteAddr)
if err != nil {
return
}
if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
return
}
fmt.Println("[Threat Feed]", ip, "established WebSocket connection")
// Add newly connected client to map.
muWSClients.Lock()
wsClients[ws] = true
muWSClients.Unlock()
// Send the cache of recent log messages to the new client.
for _, msg := range wsRecentMessages {
_ = websocket.Message.Send(ws, msg)
}
// Send a message informing the client that we're done sending the initial
// cache of log messages.
_ = websocket.Message.Send(ws, "---end---")
// Keep WebSocket open.
var message string
for {
err := websocket.Message.Receive(ws, &message)
if err != nil {
break
}
}
}

View File

@@ -0,0 +1,464 @@
package threatfeed
import (
"embed"
"encoding/csv"
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/r-smith/deceptifeed/internal/config"
"github.com/r-smith/deceptifeed/internal/stix"
"github.com/r-smith/deceptifeed/internal/taxii"
)
// templates embeds .html and template files in the `./templates/` folder.
//
//go:embed templates
var templates embed.FS
// parsedTemplates pre-parses and caches all HTML templates when the threat
// feed server starts. This eliminates the need for HTTP handlers to re-parse
// templates on each request.
var parsedTemplates = template.Must(template.ParseFS(templates, "templates/*.html"))
// handlePlain handles HTTP requests to serve the threat feed in plain text. It
// returns a list of IP addresses that interacted with the honeypot servers.
func handlePlain(w http.ResponseWriter, r *http.Request) {
opt, err := parseParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/plain")
for _, entry := range prepareFeed(opt) {
_, err := w.Write([]byte(entry.IP + "\n"))
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to serve threat feed:", err)
return
}
}
}
// handleJSON handles HTTP requests to serve the full threat feed in JSON
// format. It returns a JSON array containing all IoC data (IP addresses and
// their associated data).
func handleJSON(w http.ResponseWriter, r *http.Request) {
opt, err := parseParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w)
e.SetIndent("", " ")
if err := e.Encode(map[string]any{"threat_feed": prepareFeed(opt)}); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to JSON:", err)
return
}
}
// handleCSV handles HTTP requests to serve the full threat feed in CSV format.
// It returns a CSV file containing all IoC data (IP addresses and their
// associated data).
func handleCSV(w http.ResponseWriter, r *http.Request) {
opt, err := parseParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment; filename=\"threat-feed-"+time.Now().Format("20060102-150405")+".csv\"")
c := csv.NewWriter(w)
if err := c.Write(csvHeader); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
return
}
for _, entry := range prepareFeed(opt) {
if err := c.Write([]string{
entry.IP,
entry.Added.Format(dateFormat),
entry.LastSeen.Format(dateFormat),
strconv.Itoa(entry.Observations),
}); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
return
}
}
c.Flush()
if err := c.Error(); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
}
}
// handleSTIX handles HTTP requests to serve the full threat feed in STIX 2.1
// format. The response includes all IoC data (IP addresses and their
// associated data). The response is structured as a STIX Bundle containing
// `Indicators` (STIX Domain Objects) for each IP address in the threat feed.
func handleSTIX(w http.ResponseWriter, r *http.Request) {
opt, err := parseParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
const bundle = "bundle"
result := stix.Bundle{
Type: bundle,
ID: stix.NewID(bundle),
Objects: prepareFeed(opt).convertToIndicators(),
}
w.Header().Set("Content-Type", stix.ContentType)
if err := json.NewEncoder(w).Encode(result); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to STIX:", err)
}
}
// handleTAXIIDiscovery handles the TAXII server discovery endpoint, defined as
// `/taxii2/`. It returns a list of API root URLs available on the TAXII server.
// Deceptifeed has a single API root at `/taxii2/api/`
func handleTAXIIDiscovery(w http.ResponseWriter, r *http.Request) {
result := taxii.DiscoveryResource{
Title: "Deceptifeed TAXII Server",
Description: "This TAXII server contains IP addresses observed interacting with honeypots",
Default: taxii.APIRoot,
APIRoots: []string{taxii.APIRoot},
}
w.Header().Set("Content-Type", taxii.ContentType)
e := json.NewEncoder(w)
e.SetIndent("", " ")
if err := e.Encode(result); err != nil {
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
}
}
// handleTAXIIRoot returns general information about the requested API root.
func handleTAXIIRoot(w http.ResponseWriter, r *http.Request) {
result := taxii.APIRootResource{
Title: "Deceptifeed TAXII Server",
Versions: []string{taxii.ContentType},
MaxContentLength: 1,
}
w.Header().Set("Content-Type", taxii.ContentType)
e := json.NewEncoder(w)
e.SetIndent("", " ")
if err := e.Encode(result); err != nil {
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
}
}
// handleTAXIICollections returns details about available TAXII collections
// hosted under the API root. Requests for `{api-root}/collections/` return a
// list of all available collections. Requests for
// `{api-root}/collections/{id}/` return information about the requested
// collection ID.
func handleTAXIICollections(w http.ResponseWriter, r *http.Request) {
// Depending on the request, the result may be a single Collection or a
// slice of Collections.
var result any
collections := taxii.ImplementedCollections()
if id := r.PathValue("id"); len(id) > 0 {
found := false
for i, c := range collections {
if id == c.ID || id == c.Alias {
found = true
result = collections[i]
break
}
}
if !found {
handleNotFound(w, r)
return
}
} else {
result = map[string]any{"collections": collections}
}
w.Header().Set("Content-Type", taxii.ContentType)
e := json.NewEncoder(w)
e.SetIndent("", " ")
if err := e.Encode(result); err != nil {
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
}
}
// handleTAXIIObjects returns the threat feed as STIX objects. The objects are
// structured according to the requested TAXII collection and wrapped in a
// TAXII Envelope. Request URL format: `{api-root}/collections/{id}/objects/`.
func handleTAXIIObjects(w http.ResponseWriter, r *http.Request) {
opt, err := parseParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Ensure a minimum page number of 1.
if opt.page < 1 {
opt.page = 1
}
// Build the requested collection.
result := taxii.Envelope{}
switch r.PathValue("id") {
case taxii.IndicatorsID, taxii.IndicatorsAlias:
result.Objects = prepareFeed(opt).convertToIndicators()
case taxii.ObservablesID, taxii.ObservablesAlias:
result.Objects = prepareFeed(opt).convertToObservables()
case taxii.SightingsID, taxii.SightingsAlias:
result.Objects = prepareFeed(opt).convertToSightings()
default:
handleNotFound(w, r)
return
}
// Paginate. result.Objects may be resliced depending on the requested
// limit and page number.
result.Objects, result.More = paginate(result.Objects, opt.limit, opt.page)
// If more results are available, include the `next` property in the
// response with the next page number.
if result.More {
if opt.page+1 > 0 {
result.Next = strconv.Itoa(opt.page + 1)
}
}
// Get the `last seen` timestamps of the first and last objects in the
// results for setting `X-TAXII-Date-Added-` headers.
first := time.Time{}
last := time.Time{}
objectCount := len(result.Objects)
if objectCount > 0 {
// Loop twice: the first iteration accesses the first element of the
// Objects slice, and the second iteration accesses the last element.
for i := 0; i < 2; i++ {
element := 0
if i == 1 {
element = len(result.Objects) - 1
}
timestamp := time.Time{}
switch v := result.Objects[element].(type) {
case stix.Indicator:
timestamp = v.Modified
case stix.Sighting:
timestamp = v.LastSeen
case stix.ObservableIP:
if ioc, found := iocData[v.Value]; found {
timestamp = ioc.lastSeen
}
case stix.Identity:
timestamp = v.Created
}
if i == 0 {
first = timestamp
} else {
last = timestamp
}
}
}
w.Header().Set("Content-Type", taxii.ContentType)
if objectCount > 0 {
w.Header()["X-TAXII-Date-Added-First"] = []string{first.UTC().Format(time.RFC3339)}
w.Header()["X-TAXII-Date-Added-Last"] = []string{last.UTC().Format(time.RFC3339)}
}
if err := json.NewEncoder(w).Encode(result); err != nil {
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
}
}
// handleHome serves as the default landing page for the threat feed. It
// delivers a static HTML document with information on accessing the threat
// feed.
func handleHome(w http.ResponseWriter, r *http.Request) {
_ = parsedTemplates.ExecuteTemplate(w, "home.html", "home")
}
// handleDocs serves a static page with documentation for accessing the threat
// feed.
func handleDocs(w http.ResponseWriter, r *http.Request) {
_ = parsedTemplates.ExecuteTemplate(w, "docs.html", "docs")
}
// handleCSS serves a CSS stylesheet for styling HTML templates.
func handleCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
data, err := templates.ReadFile("templates/css/style.css")
if err != nil {
fmt.Println(err)
return
}
_, _ = w.Write(data)
}
// handleConfig serves a page that displays the Deceptifeed configuration.
func handleConfig(w http.ResponseWriter, r *http.Request) {
type templateData struct {
C config.Config
Version string
NavData string
}
d := templateData{C: cfg, Version: config.Version, NavData: "config"}
_ = parsedTemplates.ExecuteTemplate(w, "config.html", d)
}
// handleHTML returns the threat feed as a web page for viewing in a browser.
func handleHTML(w http.ResponseWriter, r *http.Request) {
opt, err := parseParams(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Set default sort if no query parameters are provided.
if len(r.URL.Query()) == 0 {
opt.sortMethod = byLastSeen
opt.sortDirection = descending
}
var d string
switch opt.sortDirection {
case ascending:
d = "asc"
case descending:
d = "desc"
}
var m string
switch opt.sortMethod {
case byIP:
m = "ip"
case byAdded:
m = "added"
case byLastSeen:
m = "last_seen"
case byObservations:
m = "observations"
}
_ = parsedTemplates.ExecuteTemplate(
w,
"webfeed.html",
map[string]any{"Data": prepareFeed(opt), "SortDirection": d, "SortMethod": m, "NavData": "webfeed"},
)
}
// paginate returns a slice of stix.Objects for the requested page, based on
// the provided limit and page numbers. It also returns whether more items are
// available.
func paginate(items []stix.Object, limit int, page int) ([]stix.Object, bool) {
if limit <= 0 {
return items, false
}
if page < 1 {
page = 1
}
// Determine the start index. Return an empty collection if out of bounds
// or if the calculation overflows.
start := (page - 1) * limit
if start >= len(items) || start < 0 {
return []stix.Object{}, false
}
// Determine the end index and whether more items are remaining.
end := start + limit
more := end < len(items)
if end > len(items) {
end = len(items)
}
return items[start:end], more
}
// parseParams extracts HTTP query parameters and maps them to options for
// controlling the threat feed output.
func parseParams(r *http.Request) (feedOptions, error) {
opt := feedOptions{}
// Handle TAXII parameters.
if strings.HasPrefix(r.URL.Path, taxii.APIRoot) {
// While TAXII requires sorting by creation date, we sort by `LastSeen`
// instead. This is because the threat feed is dynamic and IPs may be
// updated. This ensures clients don't miss updates if they are only
// looking for new entries.
opt.sortMethod = byLastSeen
var err error
if len(r.URL.Query().Get("added_after")) > 0 {
opt.seenAfter, err = time.Parse(time.RFC3339, r.URL.Query().Get("added_after"))
if err != nil {
return feedOptions{}, fmt.Errorf("invalid 'added_after' value")
}
}
if len(r.URL.Query().Get("limit")) > 0 {
opt.limit, err = strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil {
return feedOptions{}, fmt.Errorf("invalid 'limit' value")
}
}
if len(r.URL.Query().Get("next")) > 0 {
opt.page, err = strconv.Atoi(r.URL.Query().Get("next"))
if err != nil {
return feedOptions{}, fmt.Errorf("invalid 'next' value")
}
}
return opt, nil
}
switch r.URL.Query().Get("sort") {
case "ip":
opt.sortMethod = byIP
case "last_seen":
opt.sortMethod = byLastSeen
case "added":
opt.sortMethod = byAdded
case "observations":
opt.sortMethod = byObservations
case "":
// No sort option specified.
default:
return feedOptions{}, fmt.Errorf("invalid 'sort' value")
}
switch r.URL.Query().Get("direction") {
case "asc":
opt.sortDirection = ascending
case "desc":
opt.sortDirection = descending
case "":
// No direction option specified.
default:
return feedOptions{}, fmt.Errorf("invalid 'direction' value")
}
if len(r.URL.Query().Get("last_seen_hours")) > 0 {
hours, err := strconv.Atoi(r.URL.Query().Get("last_seen_hours"))
if err != nil {
return feedOptions{}, fmt.Errorf("invalid 'last_seen_hours' value")
}
opt.seenAfter = time.Now().Add(-time.Hour * time.Duration(hours))
}
return opt, nil
}
// handleNotFound returns a 404 Not Found response. This is the default
// response when a request is made to an undefined path.
func handleNotFound(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_ = parsedTemplates.ExecuteTemplate(w, "404.html", nil)
}

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,99 @@
package threatfeed
import (
"fmt"
"net/http"
"os"
"time"
"github.com/r-smith/deceptifeed/internal/config"
"golang.org/x/net/websocket"
)
const (
// saveInterval represents how frequently the threat feed is saved to disk.
// The saved file ensures threat feed data persists across application
// restarts. It is not the active threat feed.
saveInterval = 20 * time.Second
)
var (
// cfg contains the application configuration. This includes settings for
// the threat feed server as well as for each individual honeypot server.
cfg config.Config
)
// Start initializes and starts the threat feed server. The server provides a
// list of IP addresses observed interacting with the honeypot servers in
// various formats.
func Start(c *config.Config) {
cfg = *c
// Check for and open an existing threat feed CSV file, if available.
if err := loadCSV(); err != nil {
fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped: Failed to open Threat Feed data:", err)
return
}
deleteExpired()
// Periodically delete expired entries and save the current threat feed to
// disk.
ticker := time.NewTicker(saveInterval)
go func() {
for range ticker.C {
if dataChanged {
dataChanged = false
deleteExpired()
if err := saveCSV(); err != nil {
fmt.Fprintln(os.Stderr, "Error saving Threat Feed data:", err)
}
}
}
}()
// Monitor honeypot log data and broadcast to connected WebSocket clients.
go broadcastLogsToClients()
// Setup handlers and server configuration.
mux := http.NewServeMux()
mux.HandleFunc("GET /", enforcePrivateIP(handleNotFound))
mux.HandleFunc("GET /{$}", enforcePrivateIP(handleHome))
mux.HandleFunc("GET /css/style.css", enforcePrivateIP(handleCSS))
mux.HandleFunc("GET /docs", enforcePrivateIP(handleDocs))
mux.HandleFunc("GET /config", enforcePrivateIP(handleConfig))
mux.HandleFunc("GET /live", enforcePrivateIP(handleLiveIndex))
mux.Handle("GET /live-ws", websocket.Handler(handleWebSocket))
// Threat feed handlers.
mux.HandleFunc("GET /webfeed", enforcePrivateIP(disableCache(handleHTML)))
mux.HandleFunc("GET /plain", enforcePrivateIP(disableCache(handlePlain)))
mux.HandleFunc("GET /csv", enforcePrivateIP(disableCache(handleCSV)))
mux.HandleFunc("GET /json", enforcePrivateIP(disableCache(handleJSON)))
mux.HandleFunc("GET /stix", enforcePrivateIP(disableCache(handleSTIX)))
// TAXII 2.1 handlers.
mux.HandleFunc("GET /taxii2/", enforcePrivateIP(handleNotFound))
mux.HandleFunc("POST /taxii2/", enforcePrivateIP(handleNotFound))
mux.HandleFunc("DELETE /taxii2/", enforcePrivateIP(handleNotFound))
mux.HandleFunc("GET /taxii2/{$}", enforcePrivateIP(handleTAXIIDiscovery))
mux.HandleFunc("GET /taxii2/api/{$}", enforcePrivateIP(handleTAXIIRoot))
mux.HandleFunc("GET /taxii2/api/collections/{$}", enforcePrivateIP(handleTAXIICollections))
mux.HandleFunc("GET /taxii2/api/collections/{id}/{$}", enforcePrivateIP(handleTAXIICollections))
mux.HandleFunc("GET /taxii2/api/collections/{id}/objects/{$}", enforcePrivateIP(disableCache(handleTAXIIObjects)))
// Honeypot log handlers.
mux.HandleFunc("GET /logs", enforcePrivateIP(handleLogsMain))
mux.HandleFunc("GET /logs/{logtype}", enforcePrivateIP(handleLogs))
mux.HandleFunc("GET /logs/{logtype}/{subtype}", enforcePrivateIP(handleLogs))
srv := &http.Server{
Addr: ":" + c.ThreatFeed.Port,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 0,
}
// Start the threat feed HTTP server.
fmt.Printf("Starting Threat Feed server on port: %s\n", c.ThreatFeed.Port)
if err := srv.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped:", err)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
{{template "nav" .NavData}}
</header>
<main>
<article>
<h2>Configuration</h2>
<table class="config-info server-info">
<tbody>
<tr><th>Deceptifeed Version</th></tr>
<tr><td class="yellow">{{.Version}}</td></tr>
<tr><th>Configuration File</th></tr>
<tr><td>{{if .C.FilePath}}<span class="gray">{{.C.FilePath}}{{else}}<span class="red">(not set){{end}}</span></td></tr>
</tbody>
</table>
<table class="server-info">
<thead>
<tr><th class="cyan" colspan="2">Threat Feed</th></tr>
<tr><th class="gray" colspan="2">Port: <span class="orange">{{.C.ThreatFeed.Port}}</span></th></tr>
</thead>
<tbody>
<tr><th>State</th><td>{{if .C.ThreatFeed.Enabled}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
<tr><th>Database</th><td class="blue">{{if .C.ThreatFeed.DatabasePath}}{{.C.ThreatFeed.DatabasePath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
<tr><th>Include Private IPs</th><td>{{if .C.ThreatFeed.IsPrivateIncluded}}<span class="red">Yes{{else}}<span class="green">No{{end}}</span></td></tr>
<tr><th>Expiry Hours</th><td class="orange">{{if eq .C.ThreatFeed.ExpiryHours 0}}<span class="gray">(never expire)</span>{{else}}{{.C.ThreatFeed.ExpiryHours}}{{end}}</td></tr>
<tr><th>Exclude List</th><td class="blue">{{if .C.ThreatFeed.ExcludeListPath}}{{.C.ThreatFeed.ExcludeListPath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
</tbody>
</table>
{{range .C.Servers}}
<table class="server-info">
<thead>
<tr><th class="cyan" colspan="2"><span style="text-transform: uppercase;">{{.Type}}</span> Honeypot</th></tr>
<tr><th class="gray" colspan="2">Port: <span class="orange">{{.Port}}</span></th></tr>
</thead>
<tbody>
<tr><th>State</th><td>{{if .Enabled}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
<tr><th>Send to Threat Feed</th><td>{{if .SendToThreatFeed}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
<tr><th>Log State</th><td>{{if .LogEnabled}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
<tr><th>Log Path</th><td class="blue">{{if .LogPath}}{{.LogPath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
{{if eq .Type.String "https"}}<tr><th>Certificate</th><td class="blue">{{if .CertPath}}{{.CertPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
{{if or (eq .Type.String "https") (eq .Type.String "ssh")}}<tr><th>Private Key</th><td class="blue">{{if .KeyPath}}{{.KeyPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
{{if .HomePagePath}}<tr><th>Home Page</th><td class="blue">{{.HomePagePath}}</td></tr>{{end}}
{{if .ErrorPagePath}}<tr><th>Error Page</th><td class="blue">{{.ErrorPagePath}}</td></tr>{{end}}
{{if .Banner}}<tr><th>Banner</th><td class="magenta">{{.Banner}}</td></tr>{{end}}
{{if .Headers}}<tr><th>Headers</th><td class="magenta">{{range .Headers}}{{.}}<br />{{end}}</td></tr>{{end}}
{{if .Prompts}}<tr><th>Prompts</th><td class="magenta">{{range .Prompts}}{{if .Text}}{{.Text}}<br />{{end}}{{end}}</td></tr>{{end}}
{{if .SourceIPHeader}}<tr><th>Source IP Header</th><td class="magenta">{{.SourceIPHeader}}</td></tr>{{end}}
{{if .Rules.Include}}{{range .Rules.Include}}<tr><th>Include Rule</th><td><span class="gray">Target:</span> <span class="white">{{.Target}}</span><br /><span class="gray">Negate:</span> <span class="white">{{.Negate}}</span><br /><span class="gray">Regex:</span> <span class="yellow">{{.Pattern}}</span></td></tr>{{end}}{{end}}
{{if .Rules.Exclude}}{{range .Rules.Exclude}}<tr><th>Exclude Rule</th><td><span class="gray">Target:</span> <span class="white">{{.Target}}</span><br /><span class="gray">Negate:</span> <span class="white">{{.Negate}}</span><br /><span class="gray">Regex:</span> <span class="yellow">{{.Pattern}}</span></td></tr>{{end}}{{end}}
</tbody>
</table>
{{end}}
</article>
</main>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
{{template "nav" .}}
</header>
<main>
<article>
<h2>Threat Feed API</h2>
<table class="api-table">
<thead>
<tr>
<th>Endpoint</th>
<th>Format</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td row-header="Endpoint"><a class="endpoint" href="/plain">/plain</a></td>
<td row-header="Format"><span class="badge">Plain</span></td>
<td>One IP address per line. Suitable for firewall integration.</td>
</tr>
<tr>
<td row-header="Endpoint"><a class="endpoint" href="/csv">/csv</a></td>
<td row-header="Format"><span class="badge">CSV</span></td>
<td>CSV format containing full threat feed details.</td>
</tr>
<tr>
<td row-header="Endpoint"><a class="endpoint" href="/json">/json</a></td>
<td row-header="Format"><span class="badge">JSON</span></td>
<td>JSON format containing full threat feed details.</td>
</tr>
<tr>
<td row-header="Endpoint"><a class="endpoint" href="/stix">/stix</a></td>
<td row-header="Format"><span class="badge">STIX</span></td>
<td>STIX <em>Indicators</em> containing full threat feed details.</td>
</tr>
<tr>
<td row-header="Endpoint"><a class="endpoint" href="/taxii2">/taxii2</a></td>
<td row-header="Format"><span class="badge">TAXII</span></td>
<td>TAXII server. See the <em>TAXII</em> section for usage instructions.</td>
</tr>
</tbody>
</table>
<figure>
<figcaption><b>Example:</b> Retrieve the threat feed formatted as plain text:</figcaption>
<pre>curl "http://threatfeed.example.com:9000/plain"</pre>
</figure>
<figure>
<figcaption><b>Example:</b> Retrieve the threat feed formatted as JSON:</figcaption>
<pre>curl "http://threatfeed.example.com:9000/json"</pre>
</figure>
</article>
<article>
<h2>Query Parameters</h2>
<p>All endpoints support optional query parameters to customize how the threat feed is formatted.
The following query parameters are supported:</p>
<table class="docs-table">
<thead>
<tr>
<th>Parameter</th>
<th>Description</th>
</tr>
</thead>
<tbody class="align-top">
<tr>
<td><code>sort</code></td>
<td>Sort the results by a specific field. Valid values are:
<ul class="no-bullets">
<li><code>added</code></li>
<li><code>ip</code></li>
<li><code>last_seen</code></li>
<li><code>observations</code></li>
</ul>
</td>
</tr>
<tr>
<td><code>direction</code></td>
<td>Specify the sorting direction. Valid values are:
<ul class="no-bullets">
<li><code>asc</code> - Ascending order</li>
<li><code>desc</code> - Descending order</li>
</ul>
</td>
</tr>
<tr>
<td><code>last_seen_hours</code></td>
<td>Filter results to only include entries seen within the last specified number of hours.</td>
</tr>
</tbody>
</table>
<figure>
<figcaption><b>Example:</b> Retrieve the JSON feed, sorted by the last seen date in descending order:</figcaption>
<pre>curl "http://threatfeed.example.com:9000/json?sort=last_seen&direction=desc"</pre>
</figure>
<figure>
<figcaption><b>Example:</b> Retrieve the plain text feed, filtered to include only IP addresses seen within the last 24 hours:</figcaption>
<pre>curl "http://threatfeed.example.com:9000/plain?last_seen_hours=24"</pre>
</figure>
</article>
<article>
<h2>TAXII</h2>
<p>The threat feed is accessible via TAXII 2.1.
This allows for integration with Threat Intelligence Platforms (TIPs) like <i>OpenCTI</i> and <i>Microsoft Sentinel</i>.</p>
<p>To access via TAXII, clients require the API root URL and a collection ID.
The API root URL is available at the path <b>/taxii2/api/</b>.
Three collections are available: <b>indicators</b>, <b>sightings</b>, and <b>observables</b>.</p>
<h3>Key Details</h3>
<ul class="no-bullets">
<li>API root URL: <code>http://threatfeed.example.com:9000/taxii2/api/</code></li>
<li>Collection ID: <code>indicators</code></li>
<li>Collection ID: <code>observables</code></li>
<li>Collection ID: <code>sightings</code></li>
</ul>
</article>
</main>
</body>
</html>

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
{{template "nav" .}}
</header>
<main>
<article>
<h2>Threat Feed</h2>
<p>The threat feed lists IP addresses that have interacted with Deceptifeed's honeypots. Its goal is to
help you build an automated defense system.
</p>
<p>
<a href="/webfeed">View the threat feed</a> from your browser, or
<a href="/docs">see the documentation</a> for other ways to access the data.
</p>
<p>
Supported formats include:
</p>
<ul>
<li><a href="/plain">Plain text</a> is the most commonly used format for integration with firewalls.</li>
<li><a href="/json">JSON</a> and <a href="/csv">CSV</a> formats offer flexibility for integrating with various tools and workflows.</li>
<li><a href="/taxii2/">TAXII</a> is designed for integration with Threat Intelligence Platforms (TIPs).</li>
</ul>
</article>
</main>
<script>
window.addEventListener('DOMContentLoaded', function () {
// This script ensures the hover effect on the Deceptifeed logo
// doesn't restart if you navigate home from a different page.
// Initially, the logo is in the 'no-hover' class which disables
// the hover effect. After a short delay, this script removes the
// class which re-enables the effect.
const logoLink = document.getElementById('logo-link');
setTimeout(function() {
logoLink.classList.remove('no-hover');
}, 100);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="full-width">
<header>
{{template "nav" .}}
</header>
<main>
<div id="ws-status"></div>
<table id="logs" class="live-logs"></table>
</main>
<script>
const maxLogs = 200;
const logs = document.getElementById('logs');
const wsStatus = document.getElementById('ws-status');
const wsURL = '/live-ws';
const initialReconnectDelay = 1000;
const maxReconnectDelay = 15000;
const maxReconnectAttempts = 100;
const timeFormat = new Intl.DateTimeFormat([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
let ws;
let reconnectAttempts = 0;
let isInitialBatchProcessed = false;
function handleWS() {
ws = new WebSocket(wsURL);
ws.onopen = () => {
reconnectAttempts = 0;
wsStatus.textContent = '';
wsStatus.className = '';
};
ws.onmessage = (event) => {
if (event.data === '---end---') {
isInitialBatchProcessed = true;
return;
}
handleMessage(event.data, isInitialBatchProcessed);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
reconnectWS();
};
}
function reconnectWS() {
if (reconnectAttempts > maxReconnectAttempts) {
wsStatus.textContent = 'Failed connecting to Deceptifeed';
wsStatus.className = 'red';
return;
}
const delay = Math.min(
maxReconnectDelay,
initialReconnectDelay * (2 ** reconnectAttempts),
);
const finalDelay = delay + (Math.random() * delay * 0.2);
setTimeout(() => {
reconnectAttempts++;
wsStatus.textContent = 'Connecting... ';
wsStatus.classList.add('gray', 'connecting');
handleWS();
}, finalDelay);
}
function handleMessage(data, shouldAnimate) {
try {
const d = JSON.parse(data);
const timeElement = document.createElement('td');
const initialTime = new Date(d.time);
timeElement.textContent = timeFormat.format(initialTime);
timeElement.className = 'timestamp';
const srcIPElement = document.createElement('td');
srcIPElement.textContent = d.source_ip;
srcIPElement.className = 'source-ip';
const eventDetails = document.createElement('td');
eventDetails.className = 'event-details';
switch (d.event_type) {
case 'http': {
const httpMethod = document.createElement('span');
httpMethod.textContent = `${d.event_details.method} `;
httpMethod.className = 'magenta';
const httpPath = document.createTextNode(d.event_details.path);
const tooltipContent = document.createElement('pre');
tooltipContent.className = 'tooltip-content';
let jsonDetails = JSON.stringify(d.event_details, null, 2);
// Remove outer braces.
jsonDetails = jsonDetails.slice(2, -1);
// Remove initial 2-space indent.
jsonDetails = jsonDetails.replace(/^ {2}/gm, '');
jsonDetails = jsonDetails.replace(/"([^"]+)":/g, '$1:');
tooltipContent.textContent = jsonDetails
eventDetails.classList.add('tooltip');
eventDetails.appendChild(httpMethod);
eventDetails.appendChild(httpPath);
eventDetails.appendChild(tooltipContent);
break;
}
case 'ssh': {
const usernameLabel = document.createElement('span');
usernameLabel.textContent = 'User: ';
usernameLabel.className = 'magenta';
const username = document.createTextNode(d.event_details.username);
const br = document.createElement('br');
const passwordLabel = document.createElement('span');
passwordLabel.textContent = 'Pass: ';
passwordLabel.className = 'magenta';
const password = document.createTextNode(d.event_details.password);
eventDetails.appendChild(usernameLabel);
eventDetails.appendChild(username);
eventDetails.appendChild(br);
eventDetails.appendChild(passwordLabel);
eventDetails.appendChild(password);
break;
}
case 'udp': {
// Remove '[unreliable]' string from IP found in UDP logs.
const spaceIndex = d.source_ip.indexOf(' ');
if (spaceIndex >= 0) {
srcIPElement.textContent = d.source_ip.slice(0, spaceIndex);
}
const udpLabel = document.createElement('span');
udpLabel.textContent = `[UDP:${d.server_port}] `;
udpLabel.className = 'magenta';
const udpData = document.createTextNode(d.event_details.data);
eventDetails.appendChild(udpLabel);
eventDetails.appendChild(udpData);
break;
}
default: {
const protoLabel = document.createElement('span');
protoLabel.textContent = `[${d.event_type.toUpperCase()}:${d.server_port}] `;
protoLabel.className = 'magenta';
const protoData = document.createTextNode(JSON.stringify(d.event_details, null, 1));
eventDetails.appendChild(protoLabel);
eventDetails.appendChild(protoData);
}
}
// Add log entry to table.
const logEntry = document.createElement('tr');
logEntry.appendChild(timeElement);
logEntry.appendChild(srcIPElement);
logEntry.appendChild(eventDetails);
if (shouldAnimate) {
logEntry.className = 'fade-in';
}
logs.insertBefore(logEntry, logs.firstChild);
if (logs.children.length > maxLogs) {
logs.removeChild(logs.lastChild);
}
}
catch (error) {
console.error('Failed to parse log data:', error);
}
}
handleWS();
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
{{template "nav" .NavData}}
</header>
<main>
<article>
<h1 class="error">Error opening log files</h1>
{{if .Error}}
<p class="error">{{.Error}}</p>
{{end}}
</article>
</main>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="full-width">
<header>
{{template "nav" .NavData}}
</header>
<main class="full-width">
{{if .Data}}
<table class="logs logs-http">
<thead>
<tr><th>Time<th>Source IP<th>Method<th>Path</tr>
</thead>
<tbody>
{{range .Data}}<tr><td>{{.Time.Format "2006-01-02 15:04:05"}}<td>{{.SourceIP}}<td>{{.Details.Method}}<td>{{.Details.Path}}</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="no-results">No log data found</p>
{{end}}
</main>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="full-width">
<header>
{{template "nav" .NavData}}
</header>
<main class="full-width">
{{if .Data}}
<table class="logs logs-ssh">
<thead>
<tr><th>Time<th>Source IP<th>Username<th>Password</tr>
</thead>
<tbody>
{{range .Data}}<tr><td class="time">{{.Time.Format "2006-01-02 15:04:05"}}<td>{{.SourceIP}}<td>{{.Details.Username}}<td>{{.Details.Password}}</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="no-results">No log data found</p>
{{end}}
</main>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="full-width">
<header>
{{template "nav" .NavData}}
</header>
<main class="full-width">
{{if .Data}}
<table id="stats" class="logs logs-stats">
<thead>
<tr>
<th onclick="sortTable(0)">Count
<th onclick="sortTable(1)">{{.Header}}
</tr>
</thead>
<tbody>
{{range .Data}}<tr><td>{{.Count}}<td>{{.Field}}</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="no-results">No log data found</p>
{{end}}
</main>
<script>
function applyNumberSeparator() {
// Format 'Count' with a thousands separator based on user's locale.
const numberFormat = new Intl.NumberFormat();
document.querySelectorAll("#stats tbody tr").forEach(row => {
const observationCount = parseInt(row.cells[0].textContent, 10);
if (!isNaN(observationCount)) {
row.cells[0].textContent = numberFormat.format(observationCount);
}
});
}
applyNumberSeparator();
</script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
{{template "nav" .}}
</header>
<main>
<article>
<h2>Honeypot Logs</h2>
<ul class="log-list">
<li><a href="/logs/ssh">SSH Logs</a></li>
<li><a href="/logs/http">HTTP Logs</a></li>
</ul>
<ul class="log-list">
<li><a href="/logs/ssh/ip">Unique: SSH Source IPs</a></li>
<li><a href="/logs/ssh/username">Unique: SSH Usernames</a></li>
<li><a href="/logs/ssh/password">Unique: SSH Passwords</a></li>
<li><a href="/logs/ssh/client">Unique: SSH Clients</a></li>
<li><a href="/logs/http/ip">Unique: HTTP Source IPs</a></li>
<li><a href="/logs/http/useragent">Unique: HTTP User-Agents</a></li>
<li><a href="/logs/http/path">Unique: HTTP Paths</a></li>
<li><a href="/logs/http/query">Unique: HTTP Queries</a></li>
<li><a href="/logs/http/method">Unique: HTTP Methods</a></li>
<li><a href="/logs/http/host">Unique: HTTP Host Headers</a></li>
</ul>
</article>
</main>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="full-width">
<header>
{{template "nav" .NavData}}
</header>
<main class="full-width">
{{if .Data}}
<table id="webfeed" class="webfeed">
<thead>
<tr>
<th><a href="?sort=ip&direction={{if and (eq .SortMethod "ip") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
IP
</a>{{if eq .SortMethod "ip"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
</th>
<th><a href="?sort=added&direction={{if and (eq .SortMethod "added") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
Added
</a>{{if eq .SortMethod "added"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
</th>
<th><a href="?sort=last_seen&direction={{if and (eq .SortMethod "last_seen") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
Last Seen
</a>{{if eq .SortMethod "last_seen"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
</th>
<th><a href="?sort=observations&direction={{if and (eq .SortMethod "observations") (eq .SortDirection "asc")}}desc{{else}}asc{{end}}">
Observations
</a>{{if eq .SortMethod "observations"}}<span class="sort-arrow {{if eq .SortDirection "asc"}}asc{{else}}desc{{end}}"></span>{{end}}
</th>
</tr>
</thead>
<tbody>
{{range .Data}}<tr><td>{{.IP}}<td>{{.Added.UTC.Format "2006-01-02T15:04:05.000Z"}}<td>{{.LastSeen.UTC.Format "2006-01-02T15:04:05.000Z"}}<td>{{.Observations}}
{{end}}
</tbody>
</table>
{{else}}
<p class="no-results">The threat feed is currently empty</p>
{{end}}
</main>
<script>
function formatDatesAndNumbers() {
// Format 'Added' as YYYY-MM-DD.
const addedDateFormat = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// Format 'Last Seen' as YYYY-MM-DD hh:mm.
const lastSeenDateFormat = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
// Format 'Observations' with a thousands separator based on user's locale.
const numberFormat = new Intl.NumberFormat();
// Apply formats to table.
document.querySelectorAll("#webfeed tbody tr").forEach(row => {
// Apply format to 'Added' cell (row.cells[1]).
let date = new Date(row.cells[1].textContent);
if (!isNaN(date.valueOf())) {
row.cells[1].textContent = addedDateFormat.format(date);
}
// Apply format to 'Last Seen' cell (row.cells[2]).
date = new Date(row.cells[2].textContent);
if (!isNaN(date.valueOf())) {
row.cells[2].textContent = lastSeenDateFormat.format(date).replace(',', '');
}
// Apply format to 'Observations' cell (row.cells[3]).
const observationCount = parseInt(row.cells[3].textContent, 10);
if (!isNaN(observationCount)) {
row.cells[3].textContent = numberFormat.format(observationCount);
}
});
}
formatDatesAndNumbers();
</script>
</body>
</html>

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),
@@ -82,7 +72,7 @@ func startUDP(srv *config.Server) error {
)
// Print a simplified version of the interaction to the console.
fmt.Printf("[UDP] %s Data: %s\n", src_ip, strings.TrimSpace(string(buffer[:n])))
fmt.Printf("[UDP] %s Data: %q\n", src_ip, strings.TrimSpace(string(buffer[:n])))
}()
}
}

196
scripts/install.sh Normal file → Executable file
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"
}
# =============================================================================
@@ -156,13 +158,12 @@ install_app() {
if [[ -f "${script_dir}/${source_bin}" ]]; then
# Found in the same directory as the script.
source_bin="${script_dir}/${source_bin}"
elif [[ -f "${script_dir}/../out/${source_bin}" ]]; then
# Found in ../out relative to the script.
source_bin="${script_dir}/../out/${source_bin}"
elif [[ -f "${script_dir}/../bin/${source_bin}" ]]; then
# Found in ../bin relative to the script.
source_bin="${script_dir}/../bin/${source_bin}"
else
# Could not locate.
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_bin}'${clear}" >&2
echo
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_bin}'${clear}\n" >&2
exit 1
fi
@@ -175,29 +176,26 @@ install_app() {
source_cfg="${script_dir}/../configs/${source_cfg}"
else
# Could not locate.
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_cfg}'${clear}" >&2
echo
echo -e "${msg_error} ${white}Unable to locate the file: ${yellow}'${source_cfg}'${clear}\n" >&2
exit 1
fi
# Upgrade check.
if [[ -f "${target_bin}" && -f "${systemd_dir}/${systemd_unit}" ]]; then
# Call the upgrade function.
upgrade_app
exit 0
fi
# Print install banner.
print_banner
# Print the application logo.
print_logo
echo -e " ${msg_info} ${gray}Installing to: ${cyan}${install_dir}/"
# Create the directory structure.
mkdir --parents "${install_dir}/bin/" "${install_dir}/certs/" "${install_dir}/etc/" "${install_dir}/logs/"
mkdir -p "${install_dir}/bin/" "${install_dir}/certs/" "${install_dir}/etc/" "${install_dir}/logs/"
# Copy the binary.
if ! cp --force "${source_bin}" "${target_bin}"; then
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}" >&2
echo
if ! cp -f "${source_bin}" "${target_bin}"; then
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_bin}' ${white}to: ${yellow}'${target_bin}'${clear}\n" >&2
exit 1
fi
@@ -206,9 +204,8 @@ install_app() {
# Don't copy anything. An existing configuration file already exists.
echo -e " ${msg_info} ${gray}Keeping existing configuration found at: ${cyan}${target_cfg}"
else
if ! cp --force "${source_cfg}" "${target_cfg}"; then
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_cfg}' ${white}to: ${yellow}'${target_cfg}'${clear}" >&2
echo
if ! cp -f "${source_cfg}" "${target_cfg}"; then
echo -e " ${msg_error} ${white}Failed to copy file: ${yellow}'${source_cfg}' ${white}to: ${yellow}'${target_cfg}'${clear}\n" >&2
exit 1
fi
fi
@@ -225,15 +222,14 @@ install_app() {
--system \
--shell /usr/sbin/nologin \
--user-group "${username}"; then
echo -e " ${msg_error} ${white}Failed to create user: ${yellow}${username}${clear}" >&2
echo
echo -e " ${msg_error} ${white}Failed to create user: ${yellow}${username}${clear}\n" >&2
exit 1
fi
fi
# Set file and directory permissions.
echo -e " ${msg_info} ${gray}Setting file and directory permissions.${clear}"
chown --recursive "${username}":"${username}" "${install_dir}"
chown -R "${username}":"${username}" "${install_dir}"
chmod 755 "${target_bin}"
chmod 644 "${target_cfg}"
@@ -242,8 +238,7 @@ install_app() {
# Create a systemd unit file.
echo -e " ${msg_info} ${gray}Creating service: ${cyan}${systemd_dir}/${systemd_unit}${clear}"
if [[ ! -f "${systemd_dir}/${systemd_unit}" ]]; then
cat > "${systemd_dir}/${systemd_unit}" << EOF
cat > "${systemd_dir}/${systemd_unit}" << EOF
[Unit]
Description=Deceptifeed
ConditionPathExists=${target_bin}
@@ -261,23 +256,17 @@ ExecStart=${target_bin} -config ${target_cfg}
WantedBy=multi-user.target
EOF
# Reload systemd, enable, and start the service.
systemctl daemon-reload
systemctl enable "${systemd_unit}" &>/dev/null
systemctl start "${systemd_unit}"
else
# Service already exists. Restart it.
echo -e " ${msg_info} ${gray}Restarting the service.${clear}"
systemctl restart "${systemd_unit}"
fi
echo
echo -e " ${green}${white}Installation complete${clear}"
# Reload systemd, enable, and start the service.
systemctl daemon-reload
systemctl enable "${systemd_unit}" &>/dev/null
systemctl start "${systemd_unit}"
# Installation complete.
echo
echo -e " ${green}${white}Installation complete${clear}\n"
echo -e "${yellow} Check service status: ${cyan}systemctl status ${service_short_name}${clear}"
echo -e "${yellow} Log location: ${cyan}${install_dir}/logs/${clear}"
echo -e "${yellow} Configuration file: ${cyan}${target_cfg}${clear}"
echo
echo
echo -e "${yellow} Configuration file: ${cyan}${target_cfg}${clear}\n\n"
}
# =============================================================================
@@ -289,10 +278,8 @@ EOF
# =============================================================================
uninstall_app() {
# Print uninstall banner.
echo
echo -e " ${white}Uninstalling Deceptifeed${clear}"
echo -e " ${dgray}========================${clear}"
echo
echo -e "\n ${white}Uninstalling Deceptifeed${clear}"
echo -e " ${dgray}========================${clear}\n"
# If the service exists: stop, disable, delete the service, and run daemon-reload.
if [[ -f "${systemd_dir}/${systemd_unit}" ]]; then
@@ -301,7 +288,7 @@ uninstall_app() {
echo -e " ${msg_info} ${gray}Disabling service: ${cyan}${systemd_unit}${clear}"
systemctl disable "${systemd_unit}" &>/dev/null
echo -e " ${msg_info} ${gray}Deleting: ${cyan}${systemd_dir}/${systemd_unit}${clear}"
rm --force "${systemd_dir}/${systemd_unit}"
rm -f "${systemd_dir}/${systemd_unit}"
echo -e " ${msg_info} ${gray}Reloading the systemd configuration.${clear}"
systemctl daemon-reload
else
@@ -324,13 +311,18 @@ uninstall_app() {
echo -e " ${red}The installation directory may contain logs and configuration files."
echo -e " ${red}Are you ready to delete ${blue}'${install_dir}'${red}?${clear}"
echo -en " ${gray}(${white}yes${gray}/${white}no${gray}) ${gray}[${yellow}no${gray}]${white}: ${green}"
read -r response
if [[ "${auto_confirm_prompts}" = true ]]; then
echo "yes"
response="yes"
else
read -r response
fi
echo -en "${clear}"
if [[ "${response}" =~ ^[yY][eE][sS]$ || "${response}" =~ ^[yY]$ ]]; then
# Confirmed. Delete directory.
echo
echo -e " ${msg_info} ${gray}Deleting installation directory: ${cyan}${install_dir}/${clear}"
rm --recursive --force "${install_dir}"
rm -rf "${install_dir}"
else
# Skip deleteion.
echo
@@ -341,26 +333,66 @@ uninstall_app() {
fi
# Uninstall complete.
echo -e "\n ${green}${white}Uninstallation complete${clear}\n\n"
}
# =============================================================================
# print_usage:
# Show usage information.
# =============================================================================
print_usage() {
echo "Usage: install.sh [options]"
echo "Install, upgrade, or uninstall Deceptifeed"
echo
echo -e " ${green}${white}Uninstallation complete${clear}"
echo "Options:"
echo " -h, --help Display this help and exit"
echo " -y, --yes Automatically confirm actions without prompting"
echo " --uninstall Uninstall Deceptifeed"
echo
echo "Description:"
echo "Run the script without options to install Deceptifeed or upgrade if it's"
echo "already installed."
echo
}
# =============================================================================
# main:
# The primary entry point of the script. This function:
# 1. Calls the startup_checks function to perform initial setup and checks.
# 2. Checks command-line arguments to determine whether to install (default)
# 1. Checks command-line arguments to determine whether to install (default)
# or uninstall the application.
# 2. Calls the startup_checks function to perform initial setup and checks.
# =============================================================================
main() {
startup_checks
if [[ "$#" -gt 2 ]]; then
print_usage
exit 0
fi
if [[ "$1" == "--uninstall" ]]; then
local uninstall_flag=false
while [[ -n "$1" ]]; do
case "$1" in
-y | --yes)
auto_confirm_prompts=true
shift
;;
--uninstall)
uninstall_flag=true
shift
;;
*)
print_usage
exit 0
;;
esac
done
if [ "${uninstall_flag}" == true ]; then
startup_checks
uninstall_app
exit 0
else
startup_checks
install_app
exit 0
fi