Skip to content

Commit

Permalink
refactor pcap filter generation
Browse files Browse the repository at this point in the history
Breaks up the components that make up the capture filter. Also, lots of
words written about what the pieces do.

And tests!
  • Loading branch information
robbkidd committed Oct 11, 2023
1 parent d09c6e4 commit e15339a
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 22 deletions.
65 changes: 43 additions & 22 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"encoding/hex"
"errors"
"fmt"
"strings"
"time"

Expand Down Expand Up @@ -156,31 +157,51 @@ func (c *Config) GetMaskedAPIKey() string {
return strings.Repeat("*", len(c.APIKey)-4) + c.APIKey[len(c.APIKey)-4:]
}

// HTTP Payloads start with one of these strings.
// Four characters are given because this feeds into a BPF filter
// and BPF really wants to match on 1, 2, or 4 byte boundaries.
var httpPayloadsStartWith = []string{
// HTTP Methods are request start
"GET ", "POST", "PUT ", "DELE", "HEAD", "OPTI", "PATC", "TRAC", "CONN",
// HTTP/1.x is the response start
"HTTP",
}

// pcapComputeTcpHeaderOffset is a [pcap filter] sub-string for pcap
// to figure out the TCP header length for a given packet.
//
// We use this to find the start of the TCP payload. See a [breakdown of this filter].
//
// [pcap filter]: https://www.tcpdump.org/manpages/pcap-filter.7.html
// [breakdown of this filter]: https://security.stackexchange.com/a/121013
const pcapComputeTcpHeaderOffset = "((tcp[12:1] & 0xf0) >> 2)"

// pcapTcpPayloadStartsWith returns a [pcap filter] string.
// The filter matches a given string against the first bytes of a TCP payload.
// Deeper details this filter can be found at [capturing HTTP requests with tcpdump].
//
// [pcap filter]: https://www.tcpdump.org/manpages/pcap-filter.7.html
// [capturing HTTP requests with tcpdump]: https://www.middlewareinventory.com/blog/tcpdump-capture-http-get-post-requests-apache-weblogic-websphere/
func pcapTcpPayloadStartsWith(s string) (filter string, err error) {
if len(s) != 4 {
return "", fmt.Errorf("pcapTcpPayloadStartsWith: string must be 4 characters long, got %d", len(s))
}

// tcp[O:N] - from TCP traffic, get the N bytes that appear after the offset O
return fmt.Sprintf("tcp[%s:4] = 0x%s", pcapComputeTcpHeaderOffset, hex.EncodeToString([]byte(s))), nil
}

// buildBpfFilter builds a BPF filter to only capture HTTP traffic
// TODO: Move somewhere more appropriate
func buildBpfFilter() string {
// Add filters to only capture common HTTP methods
// TODO: Move this logic somewhere more HTTP-flavored
// TODO "not host me", // how do we get our current IP?
// reference links:
// https://www.middlewareinventory.com/blog/tcpdump-capture-http-get-post-requests-apache-weblogic-websphere/
// https://www.middlewareinventory.com/ascii-table/
// tcp[((tcp[12:1] & 0xf0) >> 2):<num> means skip the ip & tcp headers, then get the next <num> bytes and match hex
// bpf insists that we must use 1, 2, or 4 bytes
matchFirstFourBytesTo := "tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x"

filters := []string{
// HTTP Methods are request start strings
matchFirstFourBytesTo + hex.EncodeToString([]byte("GET ")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("POST")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("PUT ")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("DELE")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("HEAD")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("OPTI")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("PATC")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("TRAC")),
matchFirstFourBytesTo + hex.EncodeToString([]byte("CONN")),
// HTTP 1.1 is the response start string
matchFirstFourBytesTo + hex.EncodeToString([]byte("HTTP")),

filters := []string{}
for _, method := range httpPayloadsStartWith {
filter, err := pcapTcpPayloadStartsWith(method)
if err == nil {
filters = append(filters, filter)
}
}
return strings.Join(filters, " or ")
}
Expand Down
62 changes: 62 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package config

import (
"encoding/hex"
"fmt"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAPIMask(t *testing.T) {
Expand Down Expand Up @@ -103,3 +107,61 @@ func TestEnvVarsDefault(t *testing.T) {
assert.Equal(t, map[string]string{}, config.AdditionalAttributes)
assert.Equal(t, false, config.IncludeRequestURL)
}

func Test_Config_buildBpfFilter(t *testing.T) {
captureFilter := buildBpfFilter()

assert.Equal(t,
len(httpPayloadsStartWith)-1,
strings.Count(captureFilter, " or "),
"complete filter joins all defined HTTP-matching filters with 'or'",
)

for _, httpStart := range httpPayloadsStartWith {
httpStartHex := hex.EncodeToString([]byte(httpStart))
description := fmt.Sprintf("includes %s (%s)", httpStartHex, httpStart)
t.Run(description, func(t *testing.T) {
filter, err := pcapTcpPayloadStartsWith(httpStart)
require.NoError(t, err)
assert.Contains(t, captureFilter, filter)
})
}
}

func Test_Config_pcapTcpPayloadStartsWith(t *testing.T) {
testCases := []struct {
startsWith string
expectSuccess bool
expectedFilter string
}{
{
startsWith: "GET",
expectSuccess: false,
expectedFilter: "",
},
{
startsWith: "GET ",
expectSuccess: true,
expectedFilter: "tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x47455420",
},
{
startsWith: "HEAD",
expectSuccess: true,
expectedFilter: "tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x48454144",
},
}
for _, tC := range testCases {
t.Run(tC.startsWith, func(t *testing.T) {
filter, err := pcapTcpPayloadStartsWith(tC.startsWith)

if tC.expectSuccess {
assert.NoError(t, err)
} else {
assert.Error(t, err)
assert.Contains(t, err.Error(), "string must be 4 characters long")
}

assert.Equal(t, tC.expectedFilter, filter)
})
}
}

0 comments on commit e15339a

Please sign in to comment.