Look twice before saying a probe failed, and say conditions in machine names (hq issue 277)

D2 raised a resolver urgent on one query that timed out while its machine was
loaded, and its summary carried the resolver's address and socket text, so the
operator channel withheld the whole alert.

- D2 asks every question up to three times, all at once; a resolver that
  answers nothing is held for the next run and raised urgent when two runs
  in a row find it silent. A wrong answer is still raised at once.
- Findings a single look can be wrong about carry Confirm: raised on the
  second look in a row, kept while open, never cleared-and-reraised. Used by
  D2 silence, D3 (also asks discovery twice), D6 behind, D9, D13 unmeasured,
  probe-failed of the doctor, and blind watchdog rows.
- Probe seat asks (D8, D13) are asked again when the bus brought no answer.
- Summaries name machines and say things in words; addresses, paths,
  domains and raw errors move to the evidence (D2, D5, D8, D9, D13, S12).
- internal/outward mirrors the messenger's content rule, allowing the mesh's
  machine names; the keeper rewords a summary that would be withheld and keeps
  it whole in the evidence; a TestMain lint fails the suite on any raised or
  linted finding that would be withheld.
This commit is contained in:
jochen
2026-10-06 18:40:33 +02:00
parent 7aa98e64ce
commit 8e8712e352
16 changed files with 1215 additions and 85 deletions
+292
View File
@@ -0,0 +1,292 @@
// Package outward is the operator channel's content rule, as the controller holds its own words to it
// (novox/hq ADR 0234 §6, to-be 45 §5).
//
// **What may leave the mesh is machine names and words.** The messenger refuses a message that carries
// an address, a domain, a path or anything shaped like a secret, and withholds its words — so a
// condition whose summary carried a resolver's address reached the operator as "this message carried an
// IPv4 address, so its words are withheld" instead of the alert (2026-10-06). The rule is the
// messenger's, and stays the messenger's: this package mirrors its patterns so that the controller can
// hold a condition's summary to it where the summary is made, and a test can fail a summary that would
// be withheld. Detail — an address, a socket's error, a path — belongs in a condition's evidence, which
// stays inside the mesh.
//
// Mirrored, not imported: the messenger is a module of the catalogue with its own module path, and a
// pattern changed there is changed here (the table in outward_test.go names the shapes both refuse).
// Where the two differ, this one may only be the stricter: what passes here passes there.
package outward
import (
"math"
"regexp"
"strings"
"unicode"
)
// Refusal says why a text may not leave: the class of what it carried, never the text itself.
type Refusal struct {
Class string // address, path or secret
What string // a few words: "an IPv4 address", "a URL", …
}
func (r Refusal) String() string { return r.Class + " (" + r.What + ")" }
// The messenger's patterns (mesh-catalog modules/messenger content.go), one for one.
var (
reURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://`)
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
reIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}\b`)
reIPv6 = regexp.MustCompile(`(?i)(^|[^0-9a-z:])(([0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)*[0-9a-f]{0,4}::([0-9a-f]{1,4}:)*[0-9a-f]{0,4})([^0-9a-z:]|$)`)
reMAC = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b`)
rePEM = regexp.MustCompile(`-----BEGIN [A-Z ]+-----`)
reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`)
reBotToken = regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{30,}`)
reKnown = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`)
reAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S`)
reHex = regexp.MustCompile(`(?i)\b[0-9a-f]{32,}\b`)
reRun = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`)
reWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\`)
)
// topLevel are names that end a host name, as the messenger reads them.
var topLevel = map[string]bool{}
func init() {
for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz
site online tech page link
be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie
internal lan home local localdomain corp intranet private arpa test example invalid localhost`) {
topLevel[t] = true
}
}
// wordSeparators split a text into the words the messenger reads one by one.
const wordSeparators = "\"'`()[]{}<>,;|"
func isSeparator(r rune) bool { return unicode.IsSpace(r) || strings.ContainsRune(wordSeparators, r) }
// Check says whether a text may leave the mesh, and when not, why. **The mesh's own machine names may
// appear** (ADR 0234 §6): a word that is one of machines is read as a name, whatever its shape —
// never as a host name, a path or a random string. A machine name joined to a domain is a domain.
func Check(text string, machines ...string) (Refusal, bool) {
text = withoutMachines(text, machines)
switch {
case reURL.MatchString(text):
return Refusal{"address", "a URL"}, false
case reEmail.MatchString(text):
return Refusal{"address", "a mail address"}, false
case reIPv4.MatchString(text):
return Refusal{"address", "an IPv4 address"}, false
case reMAC.MatchString(text):
return Refusal{"address", "a hardware address"}, false
case reIPv6.MatchString(text):
return Refusal{"address", "an IPv6 address"}, false
case rePEM.MatchString(text):
return Refusal{"secret", "a key block"}, false
case reJWT.MatchString(text):
return Refusal{"secret", "a signed token"}, false
case reBotToken.MatchString(text):
return Refusal{"secret", "a bot token"}, false
case reKnown.MatchString(text):
return Refusal{"secret", "a known token shape"}, false
case reAssigned.MatchString(text):
return Refusal{"secret", "a value given to a secret's name"}, false
case reHex.MatchString(text):
return Refusal{"secret", "a long hexadecimal string"}, false
case reWinPath.MatchString(text):
return Refusal{"path", "a drive path"}, false
}
for _, run := range reRun.FindAllString(text, -1) {
if looksRandom(run) {
return Refusal{"secret", "a long random-looking string"}, false
}
}
for _, word := range strings.FieldsFunc(text, isSeparator) {
w := strings.TrimRight(word, ".:!?")
if isPath(w) {
return Refusal{"path", "a file path"}, false
}
if isHostName(w) {
return Refusal{"address", "a host name"}, false
}
}
return Refusal{}, true
}
// What Scrub says in words, beyond the messenger's patterns: an address with its port and what a
// socket says around it ("udp 192.0.2.1:53"), a bracketed IPv6 address, a key block whole, a secret's
// value, a drive path whole.
var (
scrubURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S*`)
scrubIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`)
scrubIPv6 = regexp.MustCompile(`\[[0-9a-fA-F:.%]*:[0-9a-fA-F:.%]*\](:\d+)?`)
scrubPEM = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]+-----.*?(-----END [A-Z ]+-----|$)`)
scrubAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S+`)
scrubWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\\S*`)
)
// Scrub is a text with everything Check refuses said in words instead: an address as "an address", a
// path as "a path", a secret's shape as "(withheld)". The text's own words, and the machine names, are
// kept. What Scrub cannot make pass is replaced whole by fallback — never sent as it was.
func Scrub(text, fallback string, machines ...string) string {
if _, ok := Check(text, machines...); ok {
return text
}
out := scrubURL.ReplaceAllString(text, "an address")
out = reEmail.ReplaceAllString(out, "an address")
out = scrubIPv4.ReplaceAllString(out, "an address")
out = reMAC.ReplaceAllString(out, "a hardware address")
out = scrubIPv6.ReplaceAllString(out, "an address")
for i := 0; i < 4 && reIPv6.MatchString(out); i++ {
out = reIPv6.ReplaceAllString(out, "${1}an address${6}")
}
out = scrubPEM.ReplaceAllString(out, "(withheld)")
for _, re := range []*regexp.Regexp{reJWT, reBotToken, reKnown, reHex} {
out = re.ReplaceAllString(out, "(withheld)")
}
out = scrubAssigned.ReplaceAllString(out, "${1} (withheld)")
out = scrubWinPath.ReplaceAllString(out, "a path")
out = reRun.ReplaceAllStringFunc(out, func(run string) string {
if looksRandom(run) {
return "(withheld)"
}
return run
})
out = eachWord(out, func(w string) string {
switch {
case isMachine(w, machines):
return w
case isPath(w):
return "a path"
case isHostName(w):
return "a host name"
}
return w
})
if _, ok := Check(out, machines...); ok {
return out
}
return fallback
}
// withoutMachines is a text with every machine name that stands as a word of its own read as a plain
// word, so the patterns do not read a name as anything else.
func withoutMachines(text string, machines []string) string {
if len(machines) == 0 {
return text
}
return eachWord(text, func(w string) string {
if isMachine(w, machines) {
return "machine"
}
return w
})
}
// eachWord is a text with each word, as the messenger splits and trims it, put through say; what
// separates the words, and the punctuation a word ends in, are kept.
func eachWord(text string, say func(w string) string) string {
var b strings.Builder
start := -1
flush := func(end int) {
if start < 0 {
return
}
word := text[start:end]
w := strings.TrimRight(word, ".:!?")
if w == "" {
b.WriteString(word)
} else {
b.WriteString(say(w) + word[len(w):])
}
start = -1
}
for i, r := range text {
if isSeparator(r) {
flush(i)
b.WriteRune(r)
continue
}
if start < 0 {
start = i
}
}
flush(len(text))
return b.String()
}
func isMachine(w string, machines []string) bool {
for _, m := range machines {
if m != "" && strings.EqualFold(w, m) {
return true
}
}
return false
}
// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one
// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only.
func isPath(w string) bool {
if w == "" {
return false
}
if strings.HasPrefix(w, "/") && len(w) > 1 {
return true
}
for _, p := range []string{"~/", "./", "../", "$HOME", "${"} {
if strings.HasPrefix(w, p) {
return true
}
}
return strings.Count(w, "/") >= 2 || strings.Contains(w, "\\")
}
// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind
// (`machine.ace.silent`), which none of these is.
func isHostName(w string) bool {
w = strings.ToLower(w)
if w == "localhost" {
return true
}
parts := strings.Split(w, ".")
if len(parts) < 2 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
}
return topLevel[parts[len(parts)-1]]
}
// looksRandom: letters and digits mixed, and the characters spread as a random string's are.
func looksRandom(s string) bool {
var letters, digits int
counts := map[rune]int{}
for _, r := range s {
counts[r]++
switch {
case unicode.IsLetter(r):
letters++
case unicode.IsDigit(r):
digits++
}
}
if letters == 0 || digits == 0 {
return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0
}
return entropy(counts, len(s)) >= 3.3
}
func hasUpperAndLower(s string) bool {
return strings.IndexFunc(s, unicode.IsUpper) >= 0 && strings.IndexFunc(s, unicode.IsLower) >= 0
}
func entropy(counts map[rune]int, n int) float64 {
e := 0.0
for _, c := range counts {
p := float64(c) / float64(n)
e -= p * math.Log2(p)
}
return e
}
+88
View File
@@ -0,0 +1,88 @@
package outward
import (
"strings"
"testing"
)
// machines are the names a mesh in these tests has.
var machines = []string{"anchor", "laptop", "g14", "home-server"}
// **The content rule's table** (novox/hq ADR 0234, "machine names pass the content rule; domains,
// addresses, paths and secrets do not"): the shapes the messenger refuses are refused here, and the
// words a condition is made of pass — the mesh's machine names among them.
func TestMachineNamesPassAndAddressesDomainsPathsAndSecretsDoNot(t *testing.T) {
pass := []string{
"the mesh's resolver on anchor has not answered for two runs of the self-check",
"laptop has not been heard from since 2026-10-06 15:02 UTC (bound 3m0s)",
"g14's node-engine would refuse its declaration whole",
"home-server runs the node-engine 3f2a9c1b7d0e, the mesh holds 5e6f7a8b9c0d",
"seat.mesh-dns-resolver.anchor.wrong",
"anchor/postgres.query answers",
"3/4 of the consumers are behind; 1200 message(s) behind its stream's head",
"the plan for novox/app c0ffee00 has been at tier 1 of 2",
"mesh-controller.conditions key=machine.anchor.silent",
"backed-up 2 days ago: tank/data shrank to 1.2 GiB",
}
for _, text := range pass {
if r, ok := Check(text, machines...); !ok {
t.Errorf("refused as %s: %q", r, text)
}
}
refuse := map[string]string{
"AAAA for anchor: no answer from 10.77.0.1: read udp 10.77.0.3:41234->10.77.0.1:53: i/o timeout": "address",
"the resolver at [fd00::1]:53 did not answer": "address",
"fe80::1ff:fe23:4567:890a is banned": "address",
"anchor.internal answered NXDOMAIN": "address",
"the store at https://artifacts.example.org/v2 is away": "address",
"write to jochen@example.org": "address",
"aa:bb:cc:dd:ee:ff": "address",
"data at /srv/postgres/data is gone": "path",
"kept at ~/backups": "path",
`kept at C:\backups`: "path",
"password=hunter2": "secret",
"the token glpat-abcdefghijklmnop12 leaked": "secret",
"0123456789abcdef0123456789abcdef01": "secret",
}
for text, class := range refuse {
r, ok := Check(text, machines...)
if ok || r.Class != class {
t.Errorf("%q: want refused as %s, got %v %v", text, class, r, ok)
}
}
// A machine name joined to a domain is a domain, the machine's name notwithstanding.
if _, ok := Check("anchor.lan answered", machines...); ok {
t.Error("a machine's name with a domain passed")
}
// And a machine whose name has a shape the rule would otherwise refuse still passes as a name.
if _, ok := Check("node.internal is silent", "node.internal"); !ok {
t.Error("a machine name was refused")
}
}
// **Scrub says what Check refuses in words, and what it gives back always passes** — or is the
// fallback, never the text as it was.
func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) {
for _, text := range []string{
"AAAA for anchor.internal: no answer from 10.77.0.1: read udp 10.77.0.3:41234->10.77.0.1:53: i/o timeout",
"dial tcp [fd00::1]:4222: connect: connection refused",
"open /var/lib/mesh/data/x.db: no such file or directory",
"GET https://artifacts.example.org/v2/blobs/sha256:0123456789abcdef0123456789abcdef0123456789abcdef: 404",
"password=hunter2 and a key -----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----",
"laptop's ban list holds 192.0.2.7 (anchor's endpoint)",
} {
got := Scrub(text, "FALLBACK", machines...)
if r, ok := Check(got, machines...); !ok {
t.Errorf("Scrub(%q) = %q still refused as %s", text, got, r)
}
if got == "FALLBACK" {
t.Errorf("Scrub(%q) fell back where words could be kept", text)
}
}
if got := Scrub("laptop's ban list holds 192.0.2.7", "", machines...); !strings.HasPrefix(got, "laptop's ban list holds an address") {
t.Errorf("the machine's name was not kept: %q", got)
}
if got := Scrub("nothing wrong with anchor", "", machines...); got != "nothing wrong with anchor" {
t.Errorf("a text that passes was changed: %q", got)
}
}