Files
deceptifeed/internal/threatfeed/handler.go
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

469 lines
14 KiB
Go

package threatfeed
import (
"embed"
"encoding/csv"
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
"strings"
"time"
"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
// 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
}
}
// If a custom threat file is supplied in the configuration, append the
// contents of the file to the HTTP response. To allow for flexibility, the
// contents of the file are not parsed or validated.
if len(configuration.CustomThreatsPath) > 0 {
data, err := os.ReadFile(configuration.CustomThreatsPath)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to read custom threats file:", err)
return
}
_, err = w.Write(data)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to serve threat feed:", err)
}
}
}
// handleJSON handles HTTP requests to serve the full threat feed in JSON
// format. It returns a JSON array containing all IoC data (IP addresses and
// their associated data).
func handleJSON(w http.ResponseWriter, r *http.Request) {
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]interface{}{"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.ThreatScore),
}); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
return
}
}
c.Flush()
if err := c.Error(); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to CSV:", err)
}
}
// handleSTIXIndicators 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 handleSTIXIndicators(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)
}
}
// handleSTIXObservables handles HTTP requests to serve a simplified version of
// the threat feed in STIX 2.1 format. The response is structured as a STIX
// Bundle containing `Observables` (STIX Cyber-observable Objects) for each IP
// address in the threat feed.
func handleSTIXObservables(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).convertToObservables(),
}
w.Header().Set("Content-Type", stix.ContentType)
e := json.NewEncoder(w)
e.SetIndent("", " ")
if err := e.Encode(result); err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to STIX:", err)
}
}
// 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]interface{}{"collections": collections}
}
w.Header().Set("Content-Type", taxii.ContentType)
e := json.NewEncoder(w)
e.SetIndent("", " ")
if err := e.Encode(result); err != nil {
http.Error(w, "Error encoding TAXII response", http.StatusInternalServerError)
}
}
// handleTAXIIObjects returns the threat feed as STIX objects. The objects are
// structured according to the requested TAXII collection and wrapped in a
// TAXII Envelope. Request URL format: `{api-root}/collections/{id}/objects/`.
func handleTAXIIObjects(w http.ResponseWriter, r *http.Request) {
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()
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.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) {
tmpl := template.Must(template.ParseFS(templates, "templates/home.html"))
tmpl.Execute(w, nil)
}
// 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 byThreatScore:
m = "threat_score"
}
tmpl := template.Must(template.ParseFS(templates, "templates/htmlfeed.html"))
err = tmpl.Execute(w, map[string]interface{}{"Data": prepareFeed(opt), "SortDirection": d, "SortMethod": m})
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to encode threat feed to HTML:", err)
return
}
}
// 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 "threat_score":
opt.sortMethod = byThreatScore
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 outside the defined API.
func handleNotFound(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}