Skip to content

Commit 8e6e1f8

Browse files
authored
Merge pull request #72 from thriqon/improve-logging
Log more interesting information
2 parents 6619acf + 53fd214 commit 8e6e1f8

File tree

6 files changed

+280
-85
lines changed

6 files changed

+280
-85
lines changed

.github/workflows/lint.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ jobs:
1111
name: lint
1212
runs-on: ubuntu-latest
1313
steps:
14+
- name: setup go
15+
uses: actions/setup-go@v3
16+
with:
17+
go-version: 1.19
1418
- uses: actions/checkout@v3
1519
- name: golangci-lint
1620
uses: golangci/golangci-lint-action@v3

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ jobs:
55
test:
66
strategy:
77
matrix:
8-
go-version: [1.17, 1.18, 1.19]
8+
go-version: [1.18, 1.19]
99
os: [ubuntu-latest, macos-latest, windows-latest]
1010
runs-on: ${{ matrix.os }}
1111
steps:

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM golang:1.18-alpine AS build
1+
FROM golang:1.19-alpine AS build
22
COPY . /go/src/jacobbednarz/go-csp-collector
33
WORKDIR /go/src/jacobbednarz/go-csp-collector
44
RUN set -ex \

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ $ CGO_ENABLED=0 go build csp_collector.go
4141
|port |Port to run on, default 8080|
4242
|filter-file|Reads the blocked URI filter list from the specified file. Note one filter per line|
4343
|health-check-path|Sets path for health checkers to use, default \/_healthcheck|
44+
|log-client-ip|Include a field in the log with the IP delivering the report, or the value of the `X-Forwarded-For` header, if present.|
45+
|log-truncated-client-ip|Include a field in the log with the truncated IP (to /24 for IPv4, /64 for IPv6) delivering the report, or the value of the `X-Forwarded-For` header, if present. Conflicts with `log-client-ip`.
46+
|truncated-query-fragment|Remove all query strings and fragments (if set) from all URLs transmitted by the client|
47+
|query-params-metadata|Log all query parameters of the report URL as a map in the `metadata` field|
4448

4549

4650
See the sample.filterlist.txt file as an example of the filter list in a file
@@ -54,6 +58,11 @@ logged report.
5458
For example a report sent to `https://collector.example.com/?metadata=foobar`
5559
will include field `metadata` with value `foobar`.
5660

61+
If `query-params-metadata` is set, instead all query parameters are logged as a
62+
map, e.g. `https://collector.example.com/?env=production&mode=enforce` will
63+
result in `"metadata": {"env": "production", "mode": "enforce"}` in JSON
64+
format, and `metadata="map[env:production mode:enforce]"` in default format.
65+
5766
### Output formats
5867

5968
The output format can be controlled by passing `--output-format <type>`

csp_collector.go

Lines changed: 149 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"encoding/json"
55
"flag"
66
"fmt"
7-
"io/ioutil"
87
"net/http"
8+
"net/netip"
99
"os"
1010
"strconv"
1111
"strings"
@@ -32,20 +32,16 @@ type CSPReportBody struct {
3232
StatusCode interface{} `json:"status-code"`
3333
}
3434

35+
const (
36+
// Default health check url.
37+
defaultHealthCheckPath = "/_healthcheck"
38+
)
39+
3540
var (
3641
// Rev is set at build time and holds the revision that the package
3742
// was created at.
3843
Rev = "dev"
3944

40-
// Flag for toggling verbose output.
41-
debugFlag bool
42-
43-
// Flag for toggling output format.
44-
outputFormat string
45-
46-
// Flag for health check url.
47-
healthCheckPath = "/_healthcheck"
48-
4945
// Shared defaults for the logger output. This ensures that we are
5046
// using the same keys for the `FieldKey` values across both formatters.
5147
logFieldMapDefaults = log.FieldMap{
@@ -54,11 +50,8 @@ var (
5450
log.FieldKeyMsg: "message",
5551
}
5652

57-
// Path to file which has blocked URI's per line.
58-
blockedURIfile string
59-
6053
// Default URI Filter list.
61-
ignoredBlockedURIs = []string{
54+
defaultIgnoredBlockedURIs = []string{
6255
"resource://",
6356
"chromenull://",
6457
"chrome-extension://",
@@ -84,9 +77,6 @@ var (
8477
"nativebaiduhd://adblock",
8578
"bdvideo://error",
8679
}
87-
88-
// TCP Port to listen on.
89-
listenPort int
9080
)
9181

9282
func init() {
@@ -113,11 +103,17 @@ func trimEmptyAndComments(s []string) []string {
113103

114104
func main() {
115105
version := flag.Bool("version", false, "Display the version")
116-
flag.BoolVar(&debugFlag, "debug", false, "Output additional logging for debugging")
117-
flag.StringVar(&outputFormat, "output-format", "text", "Define how the violation reports are formatted for output.\nDefaults to 'text'. Valid options are 'text' or 'json'")
118-
flag.StringVar(&blockedURIfile, "filter-file", "", "Blocked URI Filter file")
119-
flag.IntVar(&listenPort, "port", 8080, "Port to listen on")
120-
flag.StringVar(&healthCheckPath, "health-check-path", healthCheckPath, "Health checker path")
106+
debugFlag := flag.Bool("debug", false, "Output additional logging for debugging")
107+
outputFormat := flag.String("output-format", "text", "Define how the violation reports are formatted for output.\nDefaults to 'text'. Valid options are 'text' or 'json'")
108+
blockedURIFile := flag.String("filter-file", "", "Blocked URI Filter file")
109+
listenPort := flag.Int("port", 8080, "Port to listen on")
110+
healthCheckPath := flag.String("health-check-path", defaultHealthCheckPath, "Health checker path")
111+
truncateQueryStringFragment := flag.Bool("truncate-query-fragment", false, "Truncate query string and fragment from document-uri, referrer and blocked-uri before logging (to reduce chances of accidentally logging sensitive data)")
112+
113+
logClientIP := flag.Bool("log-client-ip", false, "Log the reporting client IP address")
114+
logTruncatedClientIP := flag.Bool("log-truncated-client-ip", false, "Log the truncated client IP address (IPv4: /24, IPv6: /64")
115+
116+
metadataObject := flag.Bool("query-params-metadata", false, "Write query parameters of the report URI as JSON object under metadata instead of the single metadata string")
121117

122118
flag.Parse()
123119

@@ -126,19 +122,11 @@ func main() {
126122
os.Exit(0)
127123
}
128124

129-
if blockedURIfile != "" {
130-
content, err := ioutil.ReadFile(blockedURIfile)
131-
if err != nil {
132-
fmt.Printf("Error reading Blocked File list: %s", blockedURIfile)
133-
}
134-
ignoredBlockedURIs = trimEmptyAndComments(strings.Split(string(content), "\n"))
135-
}
136-
137-
if debugFlag {
125+
if *debugFlag {
138126
log.SetLevel(log.DebugLevel)
139127
}
140128

141-
if outputFormat == "json" {
129+
if *outputFormat == "json" {
142130
log.SetFormatter(&log.JSONFormatter{
143131
FieldMap: logFieldMapDefaults,
144132
})
@@ -153,29 +141,58 @@ func main() {
153141
}
154142

155143
log.Debug("Starting up...")
156-
if blockedURIfile != "" {
157-
log.Debugf("Using Filter list from file at: %s\n", blockedURIfile)
144+
ignoredBlockedURIs := defaultIgnoredBlockedURIs
145+
if *blockedURIFile != "" {
146+
log.Debugf("Using Filter list from file at: %s\n", *blockedURIFile)
147+
148+
content, err := os.ReadFile(*blockedURIFile)
149+
if err != nil {
150+
log.Fatalf("Error reading Blocked File list: %s", *blockedURIFile)
151+
}
152+
ignoredBlockedURIs = trimEmptyAndComments(strings.Split(string(content), "\n"))
158153
} else {
159154
log.Debug("Using Filter list from internal list")
160155
}
156+
161157
log.Debugf("Blocked URI List: %s", ignoredBlockedURIs)
162-
log.Debugf("Listening on TCP Port: %s", strconv.Itoa(listenPort))
158+
log.Debugf("Listening on TCP Port: %s", strconv.Itoa(*listenPort))
163159

164-
http.HandleFunc("/", handleViolationReport)
165-
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", strconv.Itoa(listenPort)), nil))
166-
}
160+
http.HandleFunc(*healthCheckPath, func(w http.ResponseWriter, r *http.Request) {
161+
if r.Method != http.MethodGet {
162+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
163+
return
164+
}
167165

168-
func handleViolationReport(w http.ResponseWriter, r *http.Request) {
169-
if r.Method == "GET" && r.URL.Path == healthCheckPath {
170166
w.WriteHeader(http.StatusOK)
171-
return
172-
}
167+
})
168+
169+
http.Handle("/", &violationReportHandler{
170+
blockedURIs: ignoredBlockedURIs,
171+
truncateQueryStringFragment: *truncateQueryStringFragment,
172+
173+
logClientIP: *logClientIP,
174+
logTruncatedClientIP: *logTruncatedClientIP,
175+
metadataObject: *metadataObject,
176+
})
177+
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", strconv.Itoa(*listenPort)), nil))
178+
}
173179

174-
if r.Method != "POST" {
180+
type violationReportHandler struct {
181+
truncateQueryStringFragment bool
182+
blockedURIs []string
183+
184+
logClientIP bool
185+
logTruncatedClientIP bool
186+
metadataObject bool
187+
}
188+
189+
func (vrh *violationReportHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
190+
if r.Method != http.MethodPost {
175191
w.WriteHeader(http.StatusMethodNotAllowed)
176192
log.WithFields(log.Fields{
177193
"http_method": r.Method,
178194
}).Debug("Received invalid HTTP method")
195+
179196
return
180197
}
181198

@@ -185,25 +202,36 @@ func handleViolationReport(w http.ResponseWriter, r *http.Request) {
185202
err := decoder.Decode(&report)
186203
if err != nil {
187204
w.WriteHeader(http.StatusUnprocessableEntity)
188-
log.Debug(fmt.Sprintf("Unable to decode invalid JSON payload: %s", err))
205+
log.Debugf("Unable to decode invalid JSON payload: %s", err)
189206
return
190207
}
191208
defer r.Body.Close()
192209

193-
reportValidation := validateViolation(report)
210+
reportValidation := vrh.validateViolation(report)
194211
if reportValidation != nil {
195212
http.Error(w, reportValidation.Error(), http.StatusBadRequest)
196-
log.Debug(fmt.Sprintf("Received invalid payload: %s", reportValidation.Error()))
213+
log.Debugf("Received invalid payload: %s", reportValidation.Error())
197214
return
198215
}
199216

200-
metadatas, gotMetadata := r.URL.Query()["metadata"]
201-
var metadata string
202-
if gotMetadata {
203-
metadata = metadatas[0]
217+
var metadata interface{}
218+
if vrh.metadataObject {
219+
metadataMap := make(map[string]string)
220+
query := r.URL.Query()
221+
222+
for k, v := range query {
223+
metadataMap[k] = v[0]
224+
}
225+
226+
metadata = metadataMap
227+
} else {
228+
metadatas, gotMetadata := r.URL.Query()["metadata"]
229+
if gotMetadata {
230+
metadata = metadatas[0]
231+
}
204232
}
205233

206-
log.WithFields(log.Fields{
234+
lf := log.Fields{
207235
"document_uri": report.Body.DocumentURI,
208236
"referrer": report.Body.Referrer,
209237
"blocked_uri": report.Body.BlockedURI,
@@ -214,11 +242,36 @@ func handleViolationReport(w http.ResponseWriter, r *http.Request) {
214242
"script_sample": report.Body.ScriptSample,
215243
"status_code": report.Body.StatusCode,
216244
"metadata": metadata,
217-
}).Info()
245+
"path": r.URL.Path,
246+
}
247+
248+
if vrh.truncateQueryStringFragment {
249+
lf["document_uri"] = truncateQueryStringFragment(report.Body.DocumentURI)
250+
lf["referrer"] = truncateQueryStringFragment(report.Body.Referrer)
251+
lf["blocked_uri"] = truncateQueryStringFragment(report.Body.BlockedURI)
252+
}
253+
254+
if vrh.logClientIP {
255+
ip, err := getClientIP(r)
256+
if err != nil {
257+
log.Warnf("unable to parse client ip: %s", err)
258+
}
259+
lf["client_ip"] = ip.String()
260+
}
261+
262+
if vrh.logTruncatedClientIP {
263+
ip, err := getClientIP(r)
264+
if err != nil {
265+
log.Warnf("unable to parse client ip: %s", err)
266+
}
267+
lf["client_ip"] = truncateClientIP(ip)
268+
}
269+
270+
log.WithFields(lf).Info()
218271
}
219272

220-
func validateViolation(r CSPReport) error {
221-
for _, value := range ignoredBlockedURIs {
273+
func (vrh *violationReportHandler) validateViolation(r CSPReport) error {
274+
for _, value := range vrh.blockedURIs {
222275
if strings.HasPrefix(r.Body.BlockedURI, value) {
223276
err := fmt.Errorf("blocked URI ('%s') is an invalid resource", value)
224277
return err
@@ -231,3 +284,45 @@ func validateViolation(r CSPReport) error {
231284

232285
return nil
233286
}
287+
288+
func truncateQueryStringFragment(uri string) string {
289+
idx := strings.IndexAny(uri, "#?")
290+
if idx != -1 {
291+
return uri[:idx]
292+
}
293+
294+
return uri
295+
}
296+
297+
func truncateClientIP(addr netip.Addr) string {
298+
// Ignoring the error is statically safe, as there are always enough bits.
299+
if addr.Is4() {
300+
p, _ := addr.Prefix(24)
301+
return p.String()
302+
}
303+
304+
if addr.Is6() {
305+
p, _ := addr.Prefix(64)
306+
return p.String()
307+
}
308+
309+
return "unknown-address"
310+
}
311+
312+
func getClientIP(r *http.Request) (netip.Addr, error) {
313+
if s := r.Header.Get("X-Forwarded-For"); s != "" {
314+
addr, err := netip.ParseAddr(s)
315+
if err != nil {
316+
return netip.Addr{}, fmt.Errorf("unable to parse address from X-Forwarded-For=%s: %w", s, err)
317+
}
318+
319+
return addr, nil
320+
}
321+
322+
addrp, err := netip.ParseAddrPort(r.RemoteAddr)
323+
if err != nil {
324+
return netip.Addr{}, fmt.Errorf("unable to parse remote address %s: %w", r.RemoteAddr, err)
325+
}
326+
327+
return addrp.Addr(), nil
328+
}

0 commit comments

Comments
 (0)