Skip to content

Commit 70feb34

Browse files
committed
Add metrics for the checksums of the HTTP body
Add metrics to monitor the integrity of an HTTP resource. These are configured using: - `fail_if_body_not_matches_hash` configures hash-based probe failures. - `hash_algorithm` (`sha256` by default) configures the hash used. - `export_hash` enables exporting the hashed body as a label. This results in the following new metrics: - `probe_http_content_checksum` contains the CRC32 of the page as a value. This is not cryptographically secure, but should work sufficiently well for monitoring changes in normal situations. - `probe_http_content_hash` contains a configurable hash of the page in a label. This *is* cryptographically secure, but may lead to high cardinality when enabled. The hash is configurable. - `probe_failed_due_to_hash` contains a metric that indicates if the probe failed because the content hash did not match the expected value. Signed-off-by: Silke Hofstra <[email protected]>
1 parent c9a32f5 commit 70feb34

File tree

4 files changed

+202
-1
lines changed

4 files changed

+202
-1
lines changed

CONFIGURATION.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ modules:
103103
fail_if_body_not_matches_regexp:
104104
[ - <regex>, ... ]
105105

106+
# Probe fails if the reponse body does not match one of the given hashes.
107+
# Hashes must be valid hexadecimal encoded strings.
108+
fail_if_body_not_matches_hash:
109+
[ - <string>, ... ]
110+
106111
# Probe fails if response header matches regex. For headers with multiple values, fails if *at least one* matches.
107112
fail_if_header_matches:
108113
[ - <http_header_match_spec>, ... ]
@@ -166,6 +171,14 @@ modules:
166171
# It is mutually exclusive with `body`.
167172
[ body_file: <filename> ]
168173

174+
# Hashing algorithm used for `fail_if_hash_not_matches` and `export_hash` (sha256, sha512)
175+
[ hash_algorithm: <string> | default = "sha256" ]
176+
177+
# Export a hash of the response body.
178+
# The processed data is limited by `body_size_limit`.
179+
# NOTE: only use this on resources that seldom change, as it may lead to high label cardinality.
180+
[ export_hash: <bool> | default = false ]
181+
169182
```
170183

171184
#### `<http_header_match_spec>`

config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ type HTTPProbe struct {
282282
Headers map[string]string `yaml:"headers,omitempty"`
283283
FailIfBodyMatchesRegexp []Regexp `yaml:"fail_if_body_matches_regexp,omitempty"`
284284
FailIfBodyNotMatchesRegexp []Regexp `yaml:"fail_if_body_not_matches_regexp,omitempty"`
285+
FailIfBodyNotMatchesHash []string `yaml:"fail_if_body_not_matches_hash,omitempty"`
285286
FailIfBodyJsonMatchesCEL *CELProgram `yaml:"fail_if_body_json_matches_cel,omitempty"`
286287
FailIfBodyJsonNotMatchesCEL *CELProgram `yaml:"fail_if_body_json_not_matches_cel,omitempty"`
287288
FailIfHeaderMatchesRegexp []HeaderMatch `yaml:"fail_if_header_matches,omitempty"`
@@ -291,6 +292,8 @@ type HTTPProbe struct {
291292
HTTPClientConfig config.HTTPClientConfig `yaml:"http_client_config,inline"`
292293
Compression string `yaml:"compression,omitempty"`
293294
BodySizeLimit units.Base2Bytes `yaml:"body_size_limit,omitempty"`
295+
HashAlgorithm string `yaml:"hash_algorithm,omitempty"`
296+
ExportHash bool `yaml:"export_hash,omitempty"`
294297
}
295298

296299
type GRPCProbe struct {

prober/http.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ import (
1717
"compress/flate"
1818
"compress/gzip"
1919
"context"
20+
"crypto/sha256"
21+
"crypto/sha512"
2022
"crypto/tls"
23+
"encoding/hex"
2124
"encoding/json"
2225
"errors"
2326
"fmt"
27+
"hash"
28+
"hash/fnv"
2429
"io"
2530
"log/slog"
2631
"net"
@@ -30,6 +35,7 @@ import (
3035
"net/textproto"
3136
"net/url"
3237
"os"
38+
"slices"
3339
"strconv"
3440
"strings"
3541
"sync"
@@ -299,6 +305,14 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
299305
Name: "probe_http_content_length",
300306
Help: "Length of http content response",
301307
})
308+
contentChecksumGauge = prometheus.NewGauge(prometheus.GaugeOpts{
309+
Name: "probe_http_content_checksum",
310+
Help: "Contains the FNV-1a checksum of the page body as a value",
311+
})
312+
contentHashGaugeVec = prometheus.NewGaugeVec(prometheus.GaugeOpts{
313+
Name: "probe_http_content_hash",
314+
Help: "Contains the cryptographic hash of the page body as a label",
315+
}, []string{module.HTTP.HashAlgorithm})
302316
bodyUncompressedLengthGauge = prometheus.NewGauge(prometheus.GaugeOpts{
303317
Name: "probe_http_uncompressed_body_length",
304318
Help: "Length of uncompressed response body",
@@ -349,6 +363,10 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
349363
Name: "probe_failed_due_to_regex",
350364
Help: "Indicates if probe failed due to regex",
351365
})
366+
probeFailedDueToHash = prometheus.NewGauge(prometheus.GaugeOpts{
367+
Name: "probe_failed_due_to_hash",
368+
Help: "Indicates if probe failed due to a hash mismatch",
369+
})
352370

353371
probeFailedDueToCEL = prometheus.NewGauge(prometheus.GaugeOpts{
354372
Name: "probe_failed_due_to_cel",
@@ -369,6 +387,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
369387
registry.MustRegister(statusCodeGauge)
370388
registry.MustRegister(probeHTTPVersionGauge)
371389
registry.MustRegister(probeFailedDueToRegex)
390+
registry.MustRegister(probeFailedDueToHash)
372391

373392
httpConfig := module.HTTP
374393

@@ -620,7 +639,9 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
620639
}
621640

622641
if !requestErrored {
623-
_, err = io.Copy(io.Discard, byteCounter)
642+
enableHash := len(httpConfig.FailIfBodyNotMatchesHash) > 0 || httpConfig.ExportHash
643+
644+
hashStr, checksum, err := hashContent(byteCounter, enableHash, httpConfig.HashAlgorithm)
624645
if err != nil {
625646
logger.Info("Failed to read HTTP response body", "err", err)
626647
success = false
@@ -634,6 +655,25 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
634655
// case it contains useful information as to what's the problem.
635656
logger.Info("Error while closing response from server", "error", err.Error())
636657
}
658+
659+
if success {
660+
registry.MustRegister(contentChecksumGauge)
661+
contentChecksumGauge.Set(float64(checksum))
662+
663+
if len(httpConfig.FailIfBodyNotMatchesHash) > 0 {
664+
success = slices.Contains(httpConfig.FailIfBodyNotMatchesHash, hashStr)
665+
if success {
666+
probeFailedDueToHash.Set(0)
667+
} else {
668+
probeFailedDueToHash.Set(1)
669+
}
670+
}
671+
672+
if httpConfig.ExportHash {
673+
registry.MustRegister(contentHashGaugeVec)
674+
contentHashGaugeVec.WithLabelValues(hashStr).Set(1)
675+
}
676+
}
637677
}
638678

639679
// At this point body is fully read and we can write end time.
@@ -754,3 +794,34 @@ func getDecompressionReader(algorithm string, origBody io.ReadCloser) (io.ReadCl
754794
return nil, errors.New("unsupported compression algorithm")
755795
}
756796
}
797+
798+
func hashContent(src io.Reader, hashBody bool, useHash string) (hashStr string, checksum uint32, err error) {
799+
fnvHash := fnv.New32a()
800+
cryptoHash := hash.Hash(nil)
801+
802+
if hashBody {
803+
switch useHash {
804+
case "", "sha256":
805+
cryptoHash = sha256.New()
806+
case "sha512":
807+
cryptoHash = sha512.New()
808+
default:
809+
return "", 0, errors.New("unsupported hash algorithm")
810+
}
811+
}
812+
813+
if cryptoHash != nil {
814+
src = io.TeeReader(src, cryptoHash)
815+
}
816+
817+
_, err = io.Copy(fnvHash, src)
818+
if err != nil {
819+
return "", 0, err
820+
}
821+
822+
if cryptoHash != nil {
823+
return hex.EncodeToString(cryptoHash.Sum(nil)), fnvHash.Sum32(), nil
824+
}
825+
826+
return "", fnvHash.Sum32(), nil
827+
}

prober/http_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1766,3 +1766,117 @@ func TestBody(t *testing.T) {
17661766
}
17671767
}
17681768
}
1769+
1770+
func TestProbeHTTP_checksums(t *testing.T) {
1771+
tests := map[string]struct {
1772+
Body []byte
1773+
Hash string
1774+
Match []string
1775+
Export bool
1776+
ExpFNV float64
1777+
ExpSuccess bool
1778+
ExpHashFail float64
1779+
ExpHash string
1780+
}{
1781+
"no body, no hash": {
1782+
Body: nil,
1783+
Export: false,
1784+
Hash: "no",
1785+
ExpFNV: float64(uint32(0x811c9dc5)),
1786+
ExpSuccess: true,
1787+
ExpHashFail: 0,
1788+
},
1789+
"export sha256": {
1790+
Body: []byte("testVector"),
1791+
Hash: "sha256",
1792+
Export: true,
1793+
ExpFNV: float64(uint32(0xe1ff1c02)),
1794+
ExpSuccess: true,
1795+
ExpHashFail: 0,
1796+
ExpHash: "9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad",
1797+
},
1798+
"export sha512": {
1799+
Body: []byte("testVector"),
1800+
Hash: "sha512",
1801+
Export: true,
1802+
ExpFNV: float64(uint32(0xe1ff1c02)),
1803+
ExpSuccess: true,
1804+
ExpHashFail: 0,
1805+
ExpHash: "d32b14dd7cc9bf27a18037c057b27bebe05eb536f9035324a64d598bfd642f32" +
1806+
"d7732d1855be0a8ec7c464cc6b9a4cc74a69883e74875105c7203b751170121e",
1807+
},
1808+
"match sha256": {
1809+
Body: []byte("testVector"),
1810+
Hash: "sha256",
1811+
Match: []string{"9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad"},
1812+
ExpFNV: float64(uint32(0xe1ff1c02)),
1813+
ExpSuccess: true,
1814+
ExpHashFail: 0,
1815+
},
1816+
"no match sha256": {
1817+
Body: []byte("tampered body"),
1818+
Hash: "sha256",
1819+
Export: false,
1820+
Match: []string{"9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad"},
1821+
ExpFNV: float64(uint32(0x03a95ccb)),
1822+
ExpSuccess: false,
1823+
ExpHashFail: 1,
1824+
},
1825+
"invalid hash": {
1826+
Body: []byte("testVector"),
1827+
Hash: "invalid",
1828+
Export: true,
1829+
ExpSuccess: false,
1830+
},
1831+
}
1832+
for name, test := range tests {
1833+
t.Run(name, func(t *testing.T) {
1834+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1835+
w.Write(test.Body)
1836+
}))
1837+
defer ts.Close()
1838+
1839+
module := config.Module{
1840+
Timeout: time.Second,
1841+
HTTP: config.HTTPProbe{
1842+
IPProtocolFallback: true,
1843+
FailIfBodyNotMatchesHash: test.Match,
1844+
HashAlgorithm: test.Hash,
1845+
ExportHash: test.Export,
1846+
},
1847+
}
1848+
registry := prometheus.NewRegistry()
1849+
testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1850+
defer cancel()
1851+
1852+
if res := ProbeHTTP(testCTX, ts.URL, module, registry, promslog.NewNopLogger()); res != test.ExpSuccess {
1853+
t.Errorf("Expected result %t, got %t", test.ExpSuccess, res)
1854+
}
1855+
1856+
mfs, err := registry.Gather()
1857+
if err != nil {
1858+
t.Fatal(err)
1859+
}
1860+
1861+
if test.Hash == "invalid" {
1862+
return
1863+
}
1864+
1865+
if test.Export {
1866+
expectedLabels := map[string]map[string]string{
1867+
"probe_http_content_hash": {
1868+
test.Hash: test.ExpHash,
1869+
},
1870+
}
1871+
checkRegistryLabels(expectedLabels, mfs, t)
1872+
}
1873+
1874+
expectedResults := map[string]float64{
1875+
"probe_http_content_length": float64(len(test.Body)),
1876+
"probe_http_content_checksum": test.ExpFNV,
1877+
"probe_failed_due_to_hash": float64(test.ExpHashFail),
1878+
}
1879+
checkRegistryResults(expectedResults, mfs, t)
1880+
})
1881+
}
1882+
}

0 commit comments

Comments
 (0)