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.
293 lines
10 KiB
Go
293 lines
10 KiB
Go
// 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
|
|
}
|