Skip to content

Commit 4821d61

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 7d61fee commit 4821d61

File tree

4 files changed

+201
-1
lines changed

4 files changed

+201
-1
lines changed

CONFIGURATION.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ modules:
9797
fail_if_body_not_matches_regexp:
9898
[ - <regex>, ... ]
9999

100+
# Probe fails if the reponse body does not match one of the given hashes.
101+
# Hashes must be valid hexadecimal encoded strings.
102+
fail_if_body_not_matches_hash:
103+
[ - <string>, ... ]
104+
100105
# Probe fails if response header matches regex. For headers with multiple values, fails if *at least one* matches.
101106
fail_if_header_matches:
102107
[ - <http_header_match_spec>, ... ]
@@ -160,6 +165,13 @@ modules:
160165
# It is mutually exclusive with `body`.
161166
[ body_file: <filename> ]
162167

168+
# Hashing algorithm used for `fail_if_hash_not_matches` and `export_hash` (sha256, sha512)
169+
[ hash_algorithm: <string> | default = "sha256" ]
170+
171+
# Export a hash of the response body.
172+
# NOTE: only use this on resources that seldom change, as it may lead to high label cardinality.
173+
[ export_hash: <bool> | default = false ]
174+
163175
```
164176

165177
#### `<http_header_match_spec>`

config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,16 @@ type HTTPProbe struct {
215215
Headers map[string]string `yaml:"headers,omitempty"`
216216
FailIfBodyMatchesRegexp []Regexp `yaml:"fail_if_body_matches_regexp,omitempty"`
217217
FailIfBodyNotMatchesRegexp []Regexp `yaml:"fail_if_body_not_matches_regexp,omitempty"`
218+
FailIfBodyNotMatchesHash []string `yaml:"fail_if_body_not_matches_hash,omitempty"`
218219
FailIfHeaderMatchesRegexp []HeaderMatch `yaml:"fail_if_header_matches,omitempty"`
219220
FailIfHeaderNotMatchesRegexp []HeaderMatch `yaml:"fail_if_header_not_matches,omitempty"`
220221
Body string `yaml:"body,omitempty"`
221222
BodyFile string `yaml:"body_file,omitempty"`
222223
HTTPClientConfig config.HTTPClientConfig `yaml:"http_client_config,inline"`
223224
Compression string `yaml:"compression,omitempty"`
224225
BodySizeLimit units.Base2Bytes `yaml:"body_size_limit,omitempty"`
226+
HashAlgorithm string `yaml:"hash_algorithm,omitempty"`
227+
ExportHash bool `yaml:"export_hash,omitempty"`
225228
}
226229

227230
type GRPCProbe struct {

prober/http.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ import (
1717
"compress/flate"
1818
"compress/gzip"
1919
"context"
20+
"crypto/sha256"
21+
"crypto/sha512"
2022
"crypto/tls"
23+
"encoding/hex"
2124
"errors"
2225
"fmt"
26+
"hash"
27+
"hash/crc32"
2328
"io"
2429
"log/slog"
2530
"net"
@@ -29,6 +34,7 @@ import (
2934
"net/textproto"
3035
"net/url"
3136
"os"
37+
"slices"
3238
"strconv"
3339
"strings"
3440
"sync"
@@ -245,6 +251,14 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
245251
Name: "probe_http_content_length",
246252
Help: "Length of http content response",
247253
})
254+
contentChecksumGauge = prometheus.NewGauge(prometheus.GaugeOpts{
255+
Name: "probe_http_content_checksum",
256+
Help: "Contains the CRC32 checksum of the page body as a value",
257+
})
258+
contentHashGaugeVec = prometheus.NewGaugeVec(prometheus.GaugeOpts{
259+
Name: "probe_http_content_hash",
260+
Help: "Contains the cryptographic hash of the page body as a label",
261+
}, []string{module.HTTP.HashAlgorithm})
248262
bodyUncompressedLengthGauge = prometheus.NewGauge(prometheus.GaugeOpts{
249263
Name: "probe_http_uncompressed_body_length",
250264
Help: "Length of uncompressed response body",
@@ -295,6 +309,10 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
295309
Name: "probe_failed_due_to_regex",
296310
Help: "Indicates if probe failed due to regex",
297311
})
312+
probeFailedDueToHash = prometheus.NewGauge(prometheus.GaugeOpts{
313+
Name: "probe_failed_due_to_hash",
314+
Help: "Indicates if probe failed due to a hash mismatch",
315+
})
298316

299317
probeHTTPLastModified = prometheus.NewGauge(prometheus.GaugeOpts{
300318
Name: "probe_http_last_modified_timestamp_seconds",
@@ -310,6 +328,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
310328
registry.MustRegister(statusCodeGauge)
311329
registry.MustRegister(probeHTTPVersionGauge)
312330
registry.MustRegister(probeFailedDueToRegex)
331+
registry.MustRegister(probeFailedDueToHash)
313332

314333
httpConfig := module.HTTP
315334

@@ -548,7 +567,9 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
548567
}
549568

550569
if !requestErrored {
551-
_, err = io.Copy(io.Discard, byteCounter)
570+
enableHash := len(httpConfig.FailIfBodyNotMatchesHash) > 0 || httpConfig.ExportHash
571+
572+
hashStr, crc, err := hashContent(byteCounter, enableHash, httpConfig.HashAlgorithm)
552573
if err != nil {
553574
logger.Info("Failed to read HTTP response body", "err", err)
554575
success = false
@@ -562,6 +583,25 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
562583
// case it contains useful information as to what's the problem.
563584
logger.Info("Error while closing response from server", "error", err.Error())
564585
}
586+
587+
if success {
588+
registry.MustRegister(contentChecksumGauge)
589+
contentChecksumGauge.Set(float64(crc))
590+
591+
if len(httpConfig.FailIfBodyNotMatchesHash) > 0 {
592+
success = slices.Contains(httpConfig.FailIfBodyNotMatchesHash, hashStr)
593+
if success {
594+
probeFailedDueToHash.Set(0)
595+
} else {
596+
probeFailedDueToHash.Set(1)
597+
}
598+
}
599+
600+
if httpConfig.ExportHash {
601+
registry.MustRegister(contentHashGaugeVec)
602+
contentHashGaugeVec.WithLabelValues(hashStr).Set(1)
603+
}
604+
}
565605
}
566606

567607
// At this point body is fully read and we can write end time.
@@ -682,3 +722,34 @@ func getDecompressionReader(algorithm string, origBody io.ReadCloser) (io.ReadCl
682722
return nil, errors.New("unsupported compression algorithm")
683723
}
684724
}
725+
726+
func hashContent(src io.Reader, hashBody bool, useHash string) (hashStr string, crc uint32, err error) {
727+
crcHash := crc32.New(crc32.MakeTable(crc32.IEEE))
728+
cryptoHash := hash.Hash(nil)
729+
730+
if hashBody {
731+
switch useHash {
732+
case "", "sha256":
733+
cryptoHash = sha256.New()
734+
case "sha512":
735+
cryptoHash = sha512.New()
736+
default:
737+
return "", 0, errors.New("unsupported hash algorithm")
738+
}
739+
}
740+
741+
if cryptoHash != nil {
742+
src = io.TeeReader(src, cryptoHash)
743+
}
744+
745+
_, err = io.Copy(crcHash, src)
746+
if err != nil {
747+
return "", 0, err
748+
}
749+
750+
if cryptoHash != nil {
751+
return hex.EncodeToString(cryptoHash.Sum(nil)), crcHash.Sum32(), nil
752+
}
753+
754+
return "", crcHash.Sum32(), nil
755+
}

prober/http_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,3 +1554,117 @@ func TestBody(t *testing.T) {
15541554
}
15551555
}
15561556
}
1557+
1558+
func TestProbeHTTP_checksums(t *testing.T) {
1559+
tests := map[string]struct {
1560+
Body []byte
1561+
Hash string
1562+
Match []string
1563+
Export bool
1564+
ExpCRC float64
1565+
ExpSuccess bool
1566+
ExpHashFail float64
1567+
ExpHash string
1568+
}{
1569+
"no body, no hash": {
1570+
Body: nil,
1571+
Export: false,
1572+
Hash: "no",
1573+
ExpCRC: 0,
1574+
ExpSuccess: true,
1575+
ExpHashFail: 0,
1576+
},
1577+
"export sha256": {
1578+
Body: []byte("testVector"),
1579+
Hash: "sha256",
1580+
Export: true,
1581+
ExpCRC: float64(uint32(0x9D2407FA)),
1582+
ExpSuccess: true,
1583+
ExpHashFail: 0,
1584+
ExpHash: "9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad",
1585+
},
1586+
"export sha512": {
1587+
Body: []byte("testVector"),
1588+
Hash: "sha512",
1589+
Export: true,
1590+
ExpCRC: float64(uint32(0x9D2407FA)),
1591+
ExpSuccess: true,
1592+
ExpHashFail: 0,
1593+
ExpHash: "d32b14dd7cc9bf27a18037c057b27bebe05eb536f9035324a64d598bfd642f32" +
1594+
"d7732d1855be0a8ec7c464cc6b9a4cc74a69883e74875105c7203b751170121e",
1595+
},
1596+
"match sha256": {
1597+
Body: []byte("testVector"),
1598+
Hash: "sha256",
1599+
Match: []string{"9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad"},
1600+
ExpCRC: float64(uint32(0x9D2407FA)),
1601+
ExpSuccess: true,
1602+
ExpHashFail: 0,
1603+
},
1604+
"no match sha256": {
1605+
Body: []byte("tampered body"),
1606+
Hash: "sha256",
1607+
Export: false,
1608+
Match: []string{"9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad"},
1609+
ExpCRC: float64(uint32(0x2280A9F2)),
1610+
ExpSuccess: false,
1611+
ExpHashFail: 1,
1612+
},
1613+
"invalid hash": {
1614+
Body: []byte("testVector"),
1615+
Hash: "invalid",
1616+
Export: true,
1617+
ExpSuccess: false,
1618+
},
1619+
}
1620+
for name, test := range tests {
1621+
t.Run(name, func(t *testing.T) {
1622+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1623+
w.Write(test.Body)
1624+
}))
1625+
defer ts.Close()
1626+
1627+
module := config.Module{
1628+
Timeout: time.Second,
1629+
HTTP: config.HTTPProbe{
1630+
IPProtocolFallback: true,
1631+
FailIfBodyNotMatchesHash: test.Match,
1632+
HashAlgorithm: test.Hash,
1633+
ExportHash: test.Export,
1634+
},
1635+
}
1636+
registry := prometheus.NewRegistry()
1637+
testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1638+
defer cancel()
1639+
1640+
if res := ProbeHTTP(testCTX, ts.URL, module, registry, promslog.NewNopLogger()); res != test.ExpSuccess {
1641+
t.Errorf("Expected result %t, got %t", test.ExpSuccess, res)
1642+
}
1643+
1644+
mfs, err := registry.Gather()
1645+
if err != nil {
1646+
t.Fatal(err)
1647+
}
1648+
1649+
if test.Hash == "invalid" {
1650+
return
1651+
}
1652+
1653+
if test.Export {
1654+
expectedLabels := map[string]map[string]string{
1655+
"probe_http_content_hash": {
1656+
test.Hash: test.ExpHash,
1657+
},
1658+
}
1659+
checkRegistryLabels(expectedLabels, mfs, t)
1660+
}
1661+
1662+
expectedResults := map[string]float64{
1663+
"probe_http_content_length": float64(len(test.Body)),
1664+
"probe_http_content_checksum": test.ExpCRC,
1665+
"probe_failed_due_to_hash": float64(test.ExpHashFail),
1666+
}
1667+
checkRegistryResults(expectedResults, mfs, t)
1668+
})
1669+
}
1670+
}

0 commit comments

Comments
 (0)