Files
mesh-sdk/go/outward/outward.go
T
jochen 7bf4a8053a
mesh/repo-check head 7bf4a8053a49 merged into trunk 36d6f7745c8d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/merge-gate head 7bf4a8053a49 merged into trunk 36d6f7745c8d — pass: group feat/1439-name-runs composed: every machine composes with the change as it d…
mesh/delivery delivered
mesh/delivery-group group feat/1439-name-runs delivered: every member is delivered
Read a lowercase name with digits as a name, not a secret (issue 1439)
mesh-config refused to propose mesh-issues' owners because CheckSecret took
modules/asus-zephyrus-g14, i3status-rust and powerlevel10k for random strings: a run with a
digit in it was judged whole. A run of lowercase letters and digits in short pieces between
-, _ and / is now judged piece by piece; a capital letter or a piece of 20 or more is judged
whole as before.
2026-10-21 09:55:04 +02:00

301 lines
12 KiB
Go

// Package outward is the content rule (novox/hq ADR 0310 §4, to-be 45 §5, research 028 Q8, ADR 0227): what
// may leave the mesh, and what an answer may carry at all. One rule, here, so every module that answers outward
// applies the same one rather than a copy: the operator channel's router and every node-reads answer.
//
// Two strengths:
//
// - Check is the operator channel's: roles and words only. A message carrying an address, a path or
// anything shaped like a secret is refused, because a channel such as Telegram is not end-to-end
// encrypted for bots and this rule is the only thing between a condition's words and someone else's
// server. Deliberately wider than it must be: a commit id or a long random name is refused too.
// - CheckSecret is the secret class alone: what leaves the mesh nowhere and is answered nowhere. A path
// or an address is what a file read or an operator's approval is about; a secret's shape never is.
//
// A refusal says its class and a few words, never the text itself.
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 + ")" }
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`)
// reNamedSecret is a value given to a name that ends in a secret's word after `_` or `-`, as an environment
// or configuration name is written: MESH_TOKEN=, DB_PASS=, aws_secret_access_key = …, SUDO_PASSWORD value.
// The word ends the name: what follows it is `=`, `:` or a space and then a value.
reNamedSecret = regexp.MustCompile(`(?i)(^|[^a-z0-9_-])[a-z0-9]+[_-]([a-z0-9]+[_-])*(password|passwd|passphrase|pass|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key|credentials?|psk)(\s*[=:]\s*|\s+)[^\s=:]`)
// reURLCredential is a password inside an address: scheme://user:password@host.
reURLCredential = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@`)
// reSecretRun is a run CheckSecret judges: reRun with `-`, so a base64url value is judged whole.
reSecretRun = regexp.MustCompile(`[A-Za-z0-9+/=_-]{20,}`)
// reLowerName is a run written as a name is: lowercase letters, digits, and `-`, `_` or `/` between them.
reLowerName = regexp.MustCompile(`^[a-z0-9]+([-_/][a-z0-9]+)*$`)
reUUID = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
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 — the generic and country ones a mesh's names use, and the
// private ones (.internal, .lan, .home, .local) a mesh is likelier to.
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
}
}
// Check says whether a text may leave the mesh, and when not, why.
func Check(text string) (Refusal, bool) {
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
}
if refusal, ok := secretShape(text); !ok {
return refusal, false
}
if reWinPath.MatchString(text) {
return Refusal{"path", "a drive path"}, false
}
if refusal, ok := randomRun(text); !ok {
return refusal, false
}
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
}) {
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
}
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever
// the run holds.
func CheckSecret(text string) (Refusal, bool) {
if refusal, ok := secretShape(text); !ok {
return refusal, false
}
return randomRunOutsidePaths(text)
}
// randomRunOutsidePaths judges every long run of a value's characters, a path's pieces between its slashes
// alone (the review of mesh-sdk #23): a run is read as a path only where it is one — it starts with a slash,
// follows a dot or a tilde (a host's or a home's path), or a name's `=` gives it one — so a base64 value, whose
// slashes fall anywhere, is judged whole. A UUID is a name, never a secret.
func randomRunOutsidePaths(text string) (Refusal, bool) {
for _, loc := range reSecretRun.FindAllStringIndex(text, -1) {
var before byte
if loc[0] > 0 {
before = text[loc[0]-1]
}
run := text[loc[0]:loc[1]]
// Standard base64 ends in its padding: such a run is a value, even when it starts with a slash.
padded := strings.HasSuffix(run, "=") && !strings.Contains(strings.TrimRight(run, "="), "=")
for i, seg := range strings.Split(run, "=") {
path := !padded && (strings.HasPrefix(seg, "/") || (i == 0 && (before == '.' || before == '~')))
pieces := []string{seg}
if path {
pieces = strings.Split(seg, "/")
}
for _, piece := range pieces {
for _, part := range nameParts(piece) {
if len(part) >= 20 && !reUUID.MatchString(part) && looksRandomValue(part) {
return Refusal{"secret", "a long random-looking string"}, false
}
}
}
}
}
return Refusal{}, true
}
// nameParts is a run read as a name when it is one: lowercase letters and digits only, in pieces between
// `-`, `_` and `/` that are each under 20 long, as a module or repository is named (modules/asus-zephyrus-g14,
// i3status-rust: novox/hq issue 1439). Such a run is judged piece by piece, and every piece, being short, is
// a word. A run with a capital letter, or a piece of 20 or more, is judged whole, as a token's is.
func nameParts(run string) []string {
if !reLowerName.MatchString(run) {
return []string{run}
}
parts := strings.FieldsFunc(run, func(r rune) bool { return r == '-' || r == '_' || r == '/' })
if len(parts) < 2 {
return []string{run}
}
for _, p := range parts {
if len(p) >= 20 {
return []string{run}
}
}
return parts
}
// secretShape is the secret shapes a pattern names.
func secretShape(text string) (Refusal, bool) {
switch {
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 reURLCredential.MatchString(text):
return Refusal{"secret", "a password in an address"}, false
case reAssigned.MatchString(text), reNamedSecret.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
}
return Refusal{}, true
}
// randomRun is a long unbroken run of characters spread as a random string's are.
func randomRun(text string) (Refusal, bool) {
for _, run := range reRun.FindAllString(text, -1) {
if looksRandom(run) {
return Refusal{"secret", "a long random-looking string"}, false
}
}
return Refusal{}, true
}
// 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. A
// sentence's words are separated, so only an unbroken run reaches here.
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 {
// One class only: a word, or a number. A long number of digits alone is a count or a time.
return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0
}
return entropy(counts, len(s)) >= 3.3
}
// looksRandomValue is looksRandom for a value in a file: letters and digits mixed and spread as a random string's
// are; letters alone when the case changes as a random string's does — about as many capitals as small letters,
// where a CamelCase name (sshd's PasswordAuthentication) has one a word.
func looksRandomValue(s string) bool {
var upper, lower, digits int
counts := map[rune]int{}
for _, r := range s {
counts[r]++
switch {
case unicode.IsUpper(r):
upper++
case unicode.IsLower(r):
lower++
case unicode.IsDigit(r):
digits++
}
}
if upper+lower == 0 {
return false
}
if digits > 0 {
return entropy(counts, len(s)) >= 3.3
}
share := float64(upper) / float64(upper+lower)
return share >= 0.3 && share <= 0.7 && entropy(counts, len(s)) >= 3.5
}
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
}