Skip to content

Commit e041036

Browse files
committed
Add a 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 e041036

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)