mesh/merge-gate head b0e4a12c8840 merged into trunk 21a499ba5976 — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head b0e4a12c8840 merged into trunk 21a499ba5976 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Review round 1: the test judged Headline+Explanation+Details, but messenger refuses an ask as a whole on
Headline, Explanation, OnExpiry and each option's Label and Does alone (asks.go's TakeAsk); Details is
filtered line by line instead (holder.go's sayOn). send.go's Ask() now builds that exact judged text and
checks it with messenger's real content rule, carried at run time as content_rule.go (messenger's
content.go, renamed, byte-identical, pinned by copies_test.go) rather than only in a test copy. When a
subject or first line the masking does not catch (an IP, a hardware address, a path-shaped reference, a
hex hash, ...) would still refuse the whole ask, the Explanation drops the subject and first lines and
points at Details instead, so the ask always reaches the operator. A recipient written with a display
name ('Name <addr>') is now matched against the operator's own accounts, and masked, by its address alone
(net/mail.ParseAddress).
216 lines
7.7 KiB
Go
216 lines
7.7 KiB
Go
package main
|
|
|
|
// What may leave the mesh (novox/hq to-be 45 §5, research 028 Q8, ADR 0227): roles and words. A
|
|
// message carrying an address, a path or anything shaped like a secret is refused here, by the holder,
|
|
// because 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. It is not trusted to each source.
|
|
//
|
|
// Deliberately wider than it must be: a commit id or a long random name is refused too. A source
|
|
// that wants its message through says it in words; a refusal is said, never silent.
|
|
|
|
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`)
|
|
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 is randomRun with each run that holds a slash judged by its pieces between the slashes.
|
|
func randomRunOutsidePaths(text string) (Refusal, bool) {
|
|
for _, run := range reRun.FindAllString(text, -1) {
|
|
for _, piece := range strings.Split(run, "/") {
|
|
if len(piece) >= 20 && looksRandom(piece) {
|
|
return Refusal{"secret", "a long random-looking string"}, false
|
|
}
|
|
}
|
|
}
|
|
return Refusal{}, true
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|