openvibers/openvibe.link Commits

Memory: the Go scrubber runs the shared redaction corpus from SDK v0.75.0 (#88)

OP OpenVibers committed 14 days ago · bfc0d0653f · 3 files, +673 −0

Changes

ddiff --git a/.gitleaksignore b/.gitleaksignore
iindex 9bc8da0..6d9b088 100644
---- a/.gitleaksignore
++++ b/.gitleaksignore
@@@ -10,3 +10,15 @@ internal/memory/testdata/roo/project/.roo/rules/general.md:stripe-access-token:4
internal/memory/testdata/copilot/project/.github/copilot-instructions.md:jwt:5
internal/memory/testdata/windsurf/home/.codeium/windsurf/memories/preferences.md:private-key:7
internal/memory/testdata/claude-code/home/.claude/rules/sandbox.md:private-key:6
++# The shared redaction corpus (Memory M2a) holds fake secrets on purpose: each case proves the Go scrubber removes a
++# shape. The values are obviously fake and exist only to be redacted; the same file is OpenVibe.SDK's test corpus.
++internal/memory/testdata/redact-corpus.json:stripe-access-token:440
++internal/memory/testdata/redact-corpus.json:stripe-access-token:442
++internal/memory/testdata/redact-corpus.json:stripe-access-token:497
++internal/memory/testdata/redact-corpus.json:stripe-access-token:500
++internal/memory/testdata/redact-corpus.json:jwt:407
++internal/memory/testdata/redact-corpus.json:jwt:409
++internal/memory/testdata/redact-corpus.json:private-key:6
++internal/memory/testdata/redact-corpus.json:private-key:30
++internal/memory/testdata/redact-corpus.json:private-key:42
++internal/memory/testdata/redact-corpus.json:private-key:474
ddiff --git a/internal/memory/conformance/corpus_test.go b/internal/memory/conformance/corpus_test.go
nnew file mode 100644
iindex 0000000..2e91f86
---- /dev/null
++++ b/internal/memory/conformance/corpus_test.go
@@@ -0,0 +1,111 @@
++// Package conformance runs the shared redaction corpus (Memory M2a) against the Node's Go scrubber. It lives in its
++// own package because it reads the network to compare the copy with the SDK tag it names; internal/memory itself is
++// deliberately local-only (memory_test.go fails on any net import).
++package conformance
++
++import (
++ "encoding/json"
++ "fmt"
++ "net/http"
++ "os"
++ "reflect"
++ "strings"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/memory"
++)
++
++type corpusCase struct {
++ Name string `json:"name"`
++ In string `json:"in"`
++ MustNotContain []string `json:"mustNotContain"`
++ MustContain []string `json:"mustContain"`
++}
++
++type corpusFile struct {
++ From string `json:"from"`
++ Cases []corpusCase `json:"cases"`
++}
++
++const corpusPath = "../testdata/redact-corpus.json"
++
++func readCorpus(t *testing.T) corpusFile {
++ t.Helper()
++ data, err := os.ReadFile(corpusPath)
++ if err != nil {
++ t.Fatal(err)
++ }
++ var c corpusFile
++ if err := json.Unmarshal(data, &c); err != nil {
++ t.Fatal(err)
++ }
++ if c.From != "openvibe-sdk v0.75.0" {
++ t.Fatalf(`the corpus says "from": %q, want "openvibe-sdk v0.75.0"`, c.From)
++ }
++ if len(c.Cases) < 50 {
++ t.Fatalf("the corpus has %d cases, want at least 50", len(c.Cases))
++ }
++ return c
++}
++
++// TestRedactCorpus runs every shared case against the Go scrubber: the secret goes, the words around it stay, the
++// count is right, and harmless text is untouched.
++func TestRedactCorpus(t *testing.T) {
++ for _, c := range readCorpus(t).Cases {
++ if c.Name == "" || c.In == "" {
++ t.Fatalf("a case has no name or input: %+v", c)
++ }
++ out, n := memory.Redact(c.In)
++ for _, secret := range c.MustNotContain {
++ if strings.Contains(out, secret) {
++ t.Errorf("%s: %q is still there: %q", c.Name, secret, out)
++ }
++ }
++ for _, keep := range c.MustContain {
++ if !strings.Contains(out, keep) {
++ t.Errorf("%s: the text lost %q: %q", c.Name, keep, out)
++ }
++ }
++ if len(c.MustNotContain) == 0 {
++ if n != 0 {
++ t.Errorf("%s: harmless text was counted as a secret", c.Name)
++ }
++ if out != c.In {
++ t.Errorf("%s: harmless text was changed: %q", c.Name, out)
++ }
++ continue
++ }
++ if n == 0 {
++ t.Errorf("%s: nothing was redacted", c.Name)
++ }
++ again, more := memory.Redact(out)
++ if again != out || more != 0 {
++ t.Errorf("%s: a second pass changed or counted something: %q (%d)", c.Name, again, more)
++ }
++ }
++}
++
++// TestRedactCorpusMatchesSDK compares the copy with the file at the SDK tag it names. A machine with no network
++// skips it; a mismatch is always a failure.
++func TestRedactCorpusMatchesSDK(t *testing.T) {
++ local := readCorpus(t)
++ version := strings.TrimPrefix(local.From, "openvibe-sdk ")
++ url := fmt.Sprintf("https://raw.githubusercontent.com/OpenVibers/OpenVibe.SDK/%s/test/redact-corpus.json", version)
++ client := &http.Client{Timeout: 10 * time.Second}
++ res, err := client.Get(url)
++ if err != nil {
++ t.Skipf("no network to compare the corpus with %s: %v", url, err)
++ }
++ defer res.Body.Close()
++ if res.StatusCode != http.StatusOK {
++ t.Skipf("the SDK tag did not serve the corpus (%s: %s)", url, res.Status)
++ }
++ var remote corpusFile
++ if err := json.NewDecoder(res.Body).Decode(&remote); err != nil {
++ t.Fatalf("the corpus at %s is not JSON: %v", url, err)
++ }
++ if !reflect.DeepEqual(remote.Cases, local.Cases) {
++ t.Fatalf("the local corpus differs from %s: copy it again and update \"from\"", url)
++ }
++}
ddiff --git a/internal/memory/testdata/redact-corpus.json b/internal/memory/testdata/redact-corpus.json
nnew file mode 100644
iindex 0000000..5a7af80
---- /dev/null
++++ b/internal/memory/testdata/redact-corpus.json
@@@ -0,0 +1,550 @@
++{
++ "from": "openvibe-sdk v0.75.0",
++ "cases": [
++ {
++ "name": "pem rsa",
++ "in": "the deploy key is\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE\n-----END RSA PRIVATE KEY-----\nand the deploy works",
++ "mustNotContain": [
++ "PRIVATE KEY",
++ "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE"
++ ],
++ "mustContain": [
++ "the deploy key is",
++ "and the deploy works"
++ ]
++ },
++ {
++ "name": "pem ec",
++ "in": "the key is -----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n-----END EC PRIVATE KEY-----\nin the vault",
++ "mustNotContain": [
++ "PRIVATE KEY",
++ "MHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
++ ],
++ "mustContain": [
++ "the key is",
++ "in the vault"
++ ]
++ },
++ {
++ "name": "pem openssh",
++ "in": "the key is -----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----\nin the vault",
++ "mustNotContain": [
++ "PRIVATE KEY",
++ "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ"
++ ],
++ "mustContain": [
++ "the key is",
++ "in the vault"
++ ]
++ },
++ {
++ "name": "pem pgp",
++ "in": "the key is -----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOYBFc0OaJBCFAVvJ0lhbXVsC\n-----END PGP PRIVATE KEY BLOCK-----\nin the vault",
++ "mustNotContain": [
++ "PRIVATE KEY",
++ "lQOYBFc0OaJBCFAVvJ0lhbXVsC"
++ ],
++ "mustContain": [
++ "the key is",
++ "in the vault"
++ ]
++ },
++ {
++ "name": "pem encrypted",
++ "in": "the key is -----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE\n-----END ENCRYPTED PRIVATE KEY-----\nin the vault",
++ "mustNotContain": [
++ "PRIVATE KEY",
++ "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE"
++ ],
++ "mustContain": [
++ "the key is",
++ "in the vault"
++ ]
++ },
++ {
++ "name": "env assignment",
++ "in": "the line is DATABASE_PASSWORD=hunter2xyz in the file",
++ "mustNotContain": [
++ "hunter2xyz"
++ ],
++ "mustContain": [
++ "DATABASE_PASSWORD=",
++ "in the file"
++ ]
++ },
++ {
++ "name": "env assignment with a dot in the name",
++ "in": "the line is MY.SECRET=abcdefghijkl in the file",
++ "mustNotContain": [
++ "abcdefghijkl"
++ ],
++ "mustContain": [
++ "MY.SECRET=",
++ "in the file"
++ ]
++ },
++ {
++ "name": "env assignment with a colon",
++ "in": "the line is api_key: abcdefghijklmnop in the file",
++ "mustNotContain": [
++ "abcdefghijklmnop"
++ ],
++ "mustContain": [
++ "api_key:",
++ "in the file"
++ ]
++ },
++ {
++ "name": "env assignment with an arrow",
++ "in": "the line is access_key => abcdefghijklmnop in the file",
++ "mustNotContain": [
++ "abcdefghijklmnop"
++ ],
++ "mustContain": [
++ "access_key =>",
++ "in the file"
++ ]
++ },
++ {
++ "name": "env assignment quoted",
++ "in": "the line is password: \"correct horse battery staple\" in the file",
++ "mustNotContain": [
++ "correct horse battery staple"
++ ],
++ "mustContain": [
++ "password:",
++ "in the file"
++ ]
++ },
++ {
++ "name": "env assignment single quoted",
++ "in": "the line is client_secret: 'correct horse battery staple' in the file",
++ "mustNotContain": [
++ "correct horse battery staple"
++ ],
++ "mustContain": [
++ "client_secret:",
++ "in the file"
++ ]
++ },
++ {
++ "name": "env assignment with a plural name",
++ "in": "the line is max_tokens: 100000 in the file",
++ "mustNotContain": [
++ "100000"
++ ],
++ "mustContain": [
++ "max_tokens:",
++ "in the file"
++ ]
++ },
++ {
++ "name": "prose",
++ "in": "the password is hunter2xyz and it must not be shown",
++ "mustNotContain": [
++ "hunter2xyz"
++ ],
++ "mustContain": [
++ "the password is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "prose with was",
++ "in": "the passphrase was correcthorsebattery and it must not be shown",
++ "mustNotContain": [
++ "correcthorsebattery"
++ ],
++ "mustContain": [
++ "the passphrase was",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "authorization bearer",
++ "in": "the header is Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",
++ "mustNotContain": [
++ "abcdefghijklmnopqrstuvwxyz012345"
++ ],
++ "mustContain": [
++ "Authorization: Bearer",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "authorization basic",
++ "in": "the header is Authorization: Basic ZGVtbzpwYXNzd29yZA== and it must not be shown",
++ "mustNotContain": [
++ "ZGVtbzpwYXNzd29yZA=="
++ ],
++ "mustContain": [
++ "Authorization: Basic",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "x api key",
++ "in": "the header is x-api-key: abcdefghijklmnopqrst and it must not be shown",
++ "mustNotContain": [
++ "abcdefghijklmnopqrst"
++ ],
++ "mustContain": [
++ "x-api-key:",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "bare bearer",
++ "in": "the header is Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",
++ "mustNotContain": [
++ "abcdefghijklmnopqrstuvwxyz012345"
++ ],
++ "mustContain": [
++ "Bearer",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "google api key",
++ "in": "the key is AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and it must not be shown",
++ "mustNotContain": [
++ "AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "gitlab token",
++ "in": "the token is glpat-cccccccccccccccccccc and it must not be shown",
++ "mustNotContain": [
++ "glpat-cccccccccccccccccccc"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "npm token",
++ "in": "the token is npm_dddddddddddddddddddddddddddddddddddd and it must not be shown",
++ "mustNotContain": [
++ "npm_dddddddddddddddddddddddddddddddddddd"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "hugging face token",
++ "in": "the token is hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee and it must not be shown",
++ "mustNotContain": [
++ "hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "sendgrid key",
++ "in": "the key is SG.ffffffffffffffffffff.gggggggggggggggggggg and it must not be shown",
++ "mustNotContain": [
++ "SG.ffffffffffffffffffff.gggggggggggggggggggg"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "telegram bot token",
++ "in": "the token is 123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh and it must not be shown",
++ "mustNotContain": [
++ "123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "discord bot token",
++ "in": "the token is Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk and it must not be shown",
++ "mustNotContain": [
++ "Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "twilio key",
++ "in": "the key is SK1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a and it must not be shown",
++ "mustNotContain": [
++ "SK1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "slack app token",
++ "in": "the token is xapp-llllllllll and it must not be shown",
++ "mustNotContain": [
++ "xapp-llllllllll"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "openvibe token",
++ "in": "the token is hbt_mmmmmmmmmmmmmmmm and it must not be shown",
++ "mustNotContain": [
++ "hbt_mmmmmmmmmmmmmmmm"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "phoenix grove key",
++ "in": "the key is pgsk_nnnnnnnnnnnnnnnn and it must not be shown",
++ "mustNotContain": [
++ "pgsk_nnnnnnnnnnnnnnnn"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "slack webhook",
++ "in": "the hook is https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX and it must not be shown",
++ "mustNotContain": [
++ "https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX"
++ ],
++ "mustContain": [
++ "the hook is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "discord webhook",
++ "in": "the hook is https://discord.com/api/webhooks/1234/abcdefghijklmnop and it must not be shown",
++ "mustNotContain": [
++ "https://discord.com/api/webhooks/1234/abcdefghijklmnop"
++ ],
++ "mustContain": [
++ "the hook is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "card number",
++ "in": "the card is 4111111111111111 and it must not be shown",
++ "mustNotContain": [
++ "4111111111111111"
++ ],
++ "mustContain": [
++ "the card is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "card number with spaces",
++ "in": "the card is 4111 1111 1111 1111 and it must not be shown",
++ "mustNotContain": [
++ "4111 1111 1111 1111"
++ ],
++ "mustContain": [
++ "the card is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "aws key",
++ "in": "the key is AKIAIOSFODNN7EXAMPLE and it must not be shown",
++ "mustNotContain": [
++ "AKIAIOSFODNN7EXAMPLE"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "github token",
++ "in": "the token is ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",
++ "mustNotContain": [
++ "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "github fine grained token",
++ "in": "the token is github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",
++ "mustNotContain": [
++ "github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "jwt",
++ "in": "the token is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U and it must not be shown",
++ "mustNotContain": [
++ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "openai key",
++ "in": "the key is sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 and it must not be shown",
++ "mustNotContain": [
++ "sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "anthropic key",
++ "in": "the key is sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and it must not be shown",
++ "mustNotContain": [
++ "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "stripe key",
++ "in": "the key is sk_live_000000000000000FAKE and it must not be shown",
++ "mustNotContain": [
++ "sk_live_000000000000000FAKE"
++ ],
++ "mustContain": [
++ "the key is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "slack token",
++ "in": "the token is xoxb-0000000000000000FAKE and it must not be shown",
++ "mustNotContain": [
++ "xoxb-0000000000000000FAKE"
++ ],
++ "mustContain": [
++ "the token is",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "connection string",
++ "in": "the url is postgres://dbuser:[email protected]:5432/app and it must not be shown",
++ "mustNotContain": [
++ "hunter2fake"
++ ],
++ "mustContain": [
++ "postgres://dbuser:",
++ "@db.internal:5432/app",
++ "and it must not be shown"
++ ]
++ },
++ {
++ "name": "split key block",
++ "in": "the deploy key:\n\n```text\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE\n\nMIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE2\n-----END RSA PRIVATE KEY-----\n```\n",
++ "mustNotContain": [
++ "PRIVATE KEY",
++ "MIIEow"
++ ],
++ "mustContain": [
++ "the deploy key:"
++ ]
++ },
++ {
++ "name": "unterminated key block",
++ "in": "the vault key:\n\n```text\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE\n```\n\nthe notes after the block stay\n",
++ "mustNotContain": [
++ "PRIVATE KEY",
++ "MIIEow"
++ ],
++ "mustContain": [
++ "the vault key:",
++ "the notes after the block stay"
++ ]
++ },
++ {
++ "name": "env block in a fence",
++ "in": "the environment:\n\n```env\nDATABASE_PASSWORD=\"correct horse battery staple\"\nSTRIPE_SECRET=sk_live_000000000000000FAKE\nSESSION_TOKEN=hunter2xyz\n```\n",
++ "mustNotContain": [
++ "hunter2xyz",
++ "sk_live_000000000000000FAKE",
++ "correct horse battery staple"
++ ],
++ "mustContain": [
++ "DATABASE_PASSWORD=",
++ "STRIPE_SECRET=",
++ "SESSION_TOKEN="
++ ]
++ },
++ {
++ "name": "over-redaction: use 4 spaces for indentation",
++ "in": "use 4 spaces for indentation",
++ "mustNotContain": [],
++ "mustContain": [
++ "use 4 spaces for indentation"
++ ]
++ },
++ {
++ "name": "over-redaction: the build takes 12 minutes",
++ "in": "the build takes 12 minutes",
++ "mustNotContain": [],
++ "mustContain": [
++ "the build takes 12 minutes"
++ ]
++ },
++ {
++ "name": "over-redaction: 5551234567",
++ "in": "5551234567",
++ "mustNotContain": [],
++ "mustContain": [
++ "5551234567"
++ ]
++ },
++ {
++ "name": "over-redaction: the port is 4321 and the timeout is 30 seconds",
++ "in": "the port is 4321 and the timeout is 30 seconds",
++ "mustNotContain": [],
++ "mustContain": [
++ "the port is 4321 and the timeout is 30 seconds"
++ ]
++ },
++ {
++ "name": "over-redaction: the release is 1.24.0 and the build number is 1234",
++ "in": "the release is 1.24.0 and the build number is 1234",
++ "mustNotContain": [],
++ "mustContain": [
++ "the release is 1.24.0 and the build number is 1234"
++ ]
++ }
++ ]
++}