audit 5: findings of the two reviews
- The e2e's numeric-settings test ran a container that passed every gate
and never ended: the good values are checked beside a bad one after
them; each run is named, bounded to a minute and removed.
- The gates refuse a value under the parameter's minimum (a zero window
kills anvil, a zero milter timeout smtpd) and one past an int, in
seconds for a time value (src/global/conv_time.c, src/anvil/anvil.c).
- OpenDKIM waits, five seconds at most, for the relay's socket.
- The relay marks its lines `syslog: `, writes control characters out,
reads into a queue at once and drops past 200 lines a second, saying
how many (security F1, F2; a stuck relay would hold OpenDKIM in
syslog(3)).
- smtp_tls_mandatory_protocols >=TLSv1.2 too.
- extract-cert.sh keeps the old files' group and sets its trap before
mktemp.
- crashexit spares cert-reload; architecture.md "four times running" and
what else exits the container; security.md the restart after five panel
exits and the /dev/log socket.
- Measured against the technical review: smtpd has the selfpost group
(Groups 104 998), so it did reach the supervisor socket before this
stage.
Accepted, reasons in the plan: the logrotate state file in the postfix
user's directory, envdoc_test's reading of `${VAR:-}` only, crashexit's
window pruning untested.
Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 592 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+118
-156
@@ -1,176 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// documentedPublic matches docs/guide.md "Environment variables" table.
|
||||
var documentedPublic = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"SUBMISSION_ENABLE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
// The environment the image reads and the environment the documents describe
|
||||
// are compared as they are, not as lists kept by hand beside them (audit
|
||||
// 2026-10-12, stage 5 - the three lists this file held were compared with each
|
||||
// other and with nothing else). Read: what the panel's code asks of the
|
||||
// environment (cmd/panel and internal, tests aside) and what the start-up
|
||||
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
|
||||
// operator's table in guide.md § Environment variables, with the paragraph
|
||||
// under it, and the internal list in architecture.md § Configuration.
|
||||
|
||||
var (
|
||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]{2,})`")
|
||||
)
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||
var documentedInternal = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"SENDER_CHECK_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"POSTFIX_QUEUE_DIR",
|
||||
"POSTFIX_RELAY_DOMAINS",
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||
var documentedComposeFixed = []string{
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||
// Update together with loadConfig when adding a new key.
|
||||
var loadConfigKeys = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"SENDER_CHECK_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"SELFPOST_HOSTNAME",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"SUBMISSION_ENABLE",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
}
|
||||
|
||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||
var buildScriptKeys = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"SENDER_CHECK_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"POSTFIX_QUEUE_DIR",
|
||||
"SASL_DB_PATH",
|
||||
"SUBMISSION_ENABLE",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
"POSTFIX_RELAY_DOMAINS",
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
func documentedKeys() []string {
|
||||
keys := append([]string{}, documentedPublic...)
|
||||
keys = append(keys, documentedInternal...)
|
||||
keys = append(keys, documentedComposeFixed...)
|
||||
slices.Sort(keys)
|
||||
return slices.Compact(keys)
|
||||
}
|
||||
|
||||
func TestLoadConfigKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range loadConfigKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
|
||||
// envRead is every variable the code and the scripts read.
|
||||
func envRead(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
|
||||
if err != nil || len(scripts) == 0 {
|
||||
t.Fatalf("no build scripts: %v", err)
|
||||
}
|
||||
for _, path := range scripts {
|
||||
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// section is the text of doc from the first line that starts with start up
|
||||
// to the next that starts with any of ends.
|
||||
func section(t *testing.T, doc, start string, ends ...string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(doc, "\n"+start)
|
||||
if i < 0 {
|
||||
t.Fatalf("no %q", start)
|
||||
}
|
||||
rest := doc[i+1+len(start):]
|
||||
end := len(rest)
|
||||
for _, e := range ends {
|
||||
if j := strings.Index(rest, "\n"+e); j >= 0 && j < end {
|
||||
end = j
|
||||
}
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
// envDocumented is every variable the documents name, and where.
|
||||
func envDocumented(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
keys := map[string]string{}
|
||||
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
|
||||
keys[m[1]] = "guide.md § Environment variables"
|
||||
}
|
||||
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
|
||||
if _, ok := keys[m[1]]; !ok {
|
||||
keys[m[1]] = "architecture.md § Configuration"
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestEnvironmentReadIsDocumented(t *testing.T) {
|
||||
doc := envDocumented(t)
|
||||
var missing []string
|
||||
for key := range envRead(t) {
|
||||
if _, ok := doc[key]; !ok {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
slices.Sort(missing)
|
||||
for _, key := range missing {
|
||||
t.Errorf("%s is read but documented nowhere: guide.md § Environment variables for an operator's setting, architecture.md § Configuration for an internal one", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScriptKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range buildScriptKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key)
|
||||
func TestEnvironmentDocumentedIsRead(t *testing.T) {
|
||||
read := envRead(t)
|
||||
var stale []string
|
||||
for key, where := range envDocumented(t) {
|
||||
if !read[key] {
|
||||
stale = append(stale, key+" ("+where+")")
|
||||
}
|
||||
}
|
||||
slices.Sort(stale)
|
||||
for _, key := range stale {
|
||||
t.Errorf("%s is documented but nothing reads it", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentedKeysAreRead(t *testing.T) {
|
||||
read := append([]string{}, loadConfigKeys...)
|
||||
read = append(read, buildScriptKeys...)
|
||||
slices.Sort(read)
|
||||
read = slices.Compact(read)
|
||||
|
||||
for _, key := range documentedKeys() {
|
||||
if !slices.Contains(read, key) {
|
||||
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
|
||||
// The parsers find what they are for: a key of each kind is among the read.
|
||||
func TestEnvironmentParsersFindKeys(t *testing.T) {
|
||||
read := envRead(t)
|
||||
for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
|
||||
if !read[key] {
|
||||
t.Errorf("%s not found among the keys read", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user