From a39e8f893c8e9299f453f735b3d37195018ccd6d Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Mon, 24 Aug 2026 17:06:47 -0400 Subject: [PATCH] requestlog: publish collector-readable evidence boundary Publish the reviewed Gamertan Web Foundations v0.1.0-preview.6 snapshot with a narrow mode-0640 collector boundary, private mode-0600 default, explicit setgid ownership guidance, and native macOS-safe release verification. Exported from reviewed private source 120d660fa432761f85316ca3dde990e2dd142f19 after trusted Gitea CI run 681 and the complete native Mac verification suite. Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow. Signed-off-by: Cole Speelman --- CHANGELOG.md | 12 ++++++++++++ README.md | 6 +++--- docs/GETTING_STARTED.md | 10 +++++++++- docs/MODULES.md | 2 +- docs/THREAT_MODEL.md | 6 ++++-- requestlog/jsonl.go | 24 ++++++++++++++++++++++-- requestlog/requestlog_test.go | 27 +++++++++++++++++++++++++++ scripts/check-embedded-webauthn.sh | 26 +++++++++++++++++++------- scripts/check-vendored-webauthn.sh | 6 +++++- scripts/export-public.sh | 5 ++++- scripts/test-public-snapshot.sh | 6 +++--- scripts/verify.sh | 1 + 12 files changed, 110 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b463093..5ec2dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## Unreleased +## v0.1.0-preview.6 — 2026-08-24 + +- Add an explicit mode-`0640` JSONL option for applications that authorize one + narrowly scoped collector group, while keeping private mode `0600` as the + default and rejecting permissive modes. +- Document the setgid-directory ownership boundary for Observatory-style + collection without granting the collector broader application access. +- Make vendored dependency and public-snapshot verification portable across + the maintained Linux gate and native macOS development environments. +- Keep Previews 1–5 immutable; applications select Preview 6 explicitly when + adopting collector-readable request evidence. + ## v0.1.0-preview.5 — 2026-08-21 - Add storage-neutral passkey registration, discoverable login, and diff --git a/README.md b/README.md index d806114..e79006c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Gamertan Web Foundations -> Status: `v0.1.0-preview.5` public preview. APIs may change before a stable +> Status: `v0.1.0-preview.6` public preview. APIs may change before a stable > release; Linux is the maintained release platform. Small, composable Go packages for the unglamorous boundaries of a careful web @@ -24,14 +24,14 @@ Pin the preview in an application module, then import only the packages that application needs: ```bash -go get gamertan.com/web@v0.1.0-preview.5 +go get gamertan.com/web@v0.1.0-preview.6 go mod verify ``` An application may also name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.5 +go get gamertan.com/web/requestmeta@v0.1.0-preview.6 ``` The version belongs to the `gamertan.com/web` module. Go compiles and links diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index fec9b81..fff63b4 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -25,7 +25,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.5 +go get gamertan.com/web/requestmeta@v0.1.0-preview.6 go mod verify ``` @@ -45,6 +45,14 @@ handler = resolver.Middleware(handler) The complete, copyable composition is in [`starters/basic`](../starters/basic). It binds to loopback, shuts down gracefully, and keeps request logging optional. +`requestlog.OpenJSONL` creates a private mode-`0600` file. If a separate, +unprivileged collector such as Observatory is the only approved reader, prepare +a trusted setgid directory whose group is that collector, then opt into +`requestlog.OpenJSONLWithOptions(path, requestlog.JSONLOptions{FileMode: 0o640})`. +The application still owns rotation, retention, disk monitoring, and sink-error +health. Never use a world-readable log or add the collector to the application +account's broader groups merely to make collection convenient. + Configure trusted proxy networks narrowly. A forwarding header is not evidence by itself; it becomes usable only when the immediate peer and skipped proxy hops satisfy the resolver's trust policy. Metadata, authentication, or storage diff --git a/docs/MODULES.md b/docs/MODULES.md index 19b98db..ba0fc49 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.5 +go get gamertan.com/web/requestmeta@v0.1.0-preview.6 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index a15b9a3..210fa80 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -66,8 +66,10 @@ accepted from request input. Break-glass access lasts at most one hour and is not a substitute for ordinary role policy. Local storage adapters assume the parent directory and host account are trusted. -They reject a symlink at the configured final path and apply private file modes, +They reject a symlink at the configured final path and apply bounded file modes, but they do not defend against a concurrent privileged actor replacing path ancestors during an open. The synchronous JSONL adapter deliberately favors durable, bounded evidence over maximum request throughput; the application owns -rotation, retention, disk monitoring, and health escalation. +rotation, retention, disk monitoring, and health escalation. Its default is +mode `0600`; the sole wider option is mode `0640` for a deployment-assigned +collector group. The toolkit does not select or change that group. diff --git a/requestlog/jsonl.go b/requestlog/jsonl.go index 84c8457..ff9e982 100644 --- a/requestlog/jsonl.go +++ b/requestlog/jsonl.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "io/fs" "os" "path/filepath" "sync" @@ -20,20 +21,39 @@ type JSONL struct { err error } +// JSONLOptions controls the local file boundary. A zero FileMode preserves the +// private 0600 default. Mode 0640 may be used when the deployment has assigned +// the file to one explicit collector group; world-readable or writable modes +// are never accepted. +type JSONLOptions struct { + FileMode fs.FileMode +} + func OpenJSONL(path string) (*JSONL, error) { + return OpenJSONLWithOptions(path, JSONLOptions{}) +} + +func OpenJSONLWithOptions(path string, options JSONLOptions) (*JSONL, error) { if !filepath.IsAbs(path) || filepath.Clean(path) != path { return nil, errors.New("requestlog: JSONL path must be clean and absolute") } + mode := options.FileMode + if mode == 0 { + mode = 0o600 + } + if mode != 0o600 && mode != 0o640 { + return nil, errors.New("requestlog: JSONL mode must be 0600 or 0640") + } if info, err := os.Lstat(path); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) { return nil, errors.New("requestlog: JSONL destination must be a regular file") } else if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } - file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, mode) if err != nil { return nil, err } - if err = file.Chmod(0o600); err != nil { + if err = file.Chmod(mode); err != nil { file.Close() return nil, err } diff --git a/requestlog/requestlog_test.go b/requestlog/requestlog_test.go index b6b5ea2..e308d7c 100644 --- a/requestlog/requestlog_test.go +++ b/requestlog/requestlog_test.go @@ -101,6 +101,33 @@ func TestJSONLRoundTripAndMode(t *testing.T) { } } +func TestJSONLAllowsExplicitCollectorGroupRead(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.jsonl") + sink, err := OpenJSONLWithOptions(path, JSONLOptions{FileMode: 0o640}) + if err != nil { + t.Fatal(err) + } + if err = sink.Close(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 { + t.Fatalf("mode=%o", info.Mode().Perm()) + } +} + +func TestJSONLRejectsOverlyPermissiveMode(t *testing.T) { + for _, mode := range []os.FileMode{0o400, 0o620, 0o644, 0o660, 0o666} { + path := filepath.Join(t.TempDir(), "access.jsonl") + if _, err := OpenJSONLWithOptions(path, JSONLOptions{FileMode: mode}); err == nil { + t.Fatalf("accepted mode %o", mode) + } + } +} + func TestPanicIsRecordedAndRepanicked(t *testing.T) { sink := &memorySink{} handler := Middleware(sink, Policy{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("expected") })) diff --git a/scripts/check-embedded-webauthn.sh b/scripts/check-embedded-webauthn.sh index a6c79ef..e7aa277 100755 --- a/scripts/check-embedded-webauthn.sh +++ b/scripts/check-embedded-webauthn.sh @@ -17,22 +17,34 @@ packages=( webauthn ) -install -D -m 0644 "$source_root/LICENSE" "$derived/LICENSE" +install_file() { + source=$1 + destination=$2 + mkdir -p "$(dirname "$destination")" + install -m 0644 "$source" "$destination" +} + +install_file "$source_root/LICENSE" "$derived/LICENSE" for package in "${packages[@]}"; do - while IFS= read -r source; do + for source in "$source_root/$package"/*.go; do + case $source in + *_test.go) continue ;; + esac relative=${source#"$source_root"/} - install -D -m 0644 "$source" "$derived/$relative" - done < <(find "$source_root/$package" -maxdepth 1 -type f -name '*.go' ! -name '*_test.go' | sort) + install_file "$source" "$derived/$relative" + done done while IFS= read -r source; do - sed -i \ + temporary="$source.tmp" + sed \ 's#github.com/go-webauthn/webauthn#gamertan.com/web/internal/webauthnvendored#g' \ - "$source" + "$source" >"$temporary" + mv "$temporary" "$source" done < <(find "$derived" -type f -name '*.go' | sort) cmp -s LICENSES/BSD-3-Clause-go-webauthn.txt "$embedded_root/LICENSE" -if ! diff -ru --no-dereference "$derived" "$embedded_root"; then +if ! diff -ru "$derived" "$embedded_root"; then echo 'compiled WebAuthn verifier differs from its audited mechanical derivation' >&2 exit 1 fi diff --git a/scripts/check-vendored-webauthn.sh b/scripts/check-vendored-webauthn.sh index a374a57..0cd9a59 100755 --- a/scripts/check-vendored-webauthn.sh +++ b/scripts/check-vendored-webauthn.sh @@ -6,7 +6,11 @@ cd "$root" test -f third_party/go-webauthn/LICENSE cmp -s LICENSES/BSD-3-Clause-go-webauthn.txt third_party/go-webauthn/LICENSE -sha256sum -c third_party/go-webauthn.SHA256SUMS >/dev/null +if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c third_party/go-webauthn.SHA256SUMS >/dev/null +else + shasum -a 256 -c third_party/go-webauthn.SHA256SUMS >/dev/null +fi expected=$(sed -n 's# third_party/go-webauthn/.*#&#p' third_party/go-webauthn.SHA256SUMS | wc -l) actual=$(find third_party/go-webauthn -type f | wc -l) test "$expected" -eq "$actual" diff --git a/scripts/export-public.sh b/scripts/export-public.sh index 17f7346..87cff6e 100755 --- a/scripts/export-public.sh +++ b/scripts/export-public.sh @@ -8,7 +8,10 @@ output=$1 [[ $output = /* && $output != / && ! -e $output ]] || usage cd "$root" [[ -z $(git status --porcelain=v1 --untracked-files=all) ]] || { echo "private source must be clean" >&2; exit 1; } -mapfile -t files < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) +files=() +while IFS= read -r file; do + files+=("$file") +done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) [[ ${#files[@]} -gt 0 ]] || exit 1 for file in "${files[@]}"; do [[ $file != /* && $file != *..* ]] || { echo "invalid allowlisted path: $file" >&2; exit 1; } diff --git a/scripts/test-public-snapshot.sh b/scripts/test-public-snapshot.sh index 200e93d..67ecb18 100755 --- a/scripts/test-public-snapshot.sh +++ b/scripts/test-public-snapshot.sh @@ -6,14 +6,14 @@ cd "$root" temporary=$(mktemp -d) trap 'rm -rf "$temporary"' EXIT ./scripts/export-public.sh "$temporary/export" -(cd "$temporary/export" && find . -type f -printf '%P\n' | sort) >"$temporary/actual" +(cd "$temporary/export" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort) >"$temporary/actual" while IFS= read -r path; do if [[ $path = */ ]]; then - find "${path%/}" -type f -printf '%p\n' + find "${path%/}" -type f -print else echo "$path" fi -done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | sort >"$temporary/expected" +done < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) | LC_ALL=C sort >"$temporary/expected" diff -u "$temporary/expected" "$temporary/actual" private_word='PRI''VATE' token_word='to''ken' diff --git a/scripts/verify.sh b/scripts/verify.sh index 4a4c8ed..ba0e754 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -5,6 +5,7 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) cd "$root" ./scripts/check-licenses.sh ./scripts/check-dependencies.sh +./scripts/test-public-snapshot.sh test -z "$(find . \( -path ./third_party -o -path ./internal/webauthnvendored \) -prune -o -name '*.go' -print0 | xargs -0 gofmt -l)" go test ./... go test -race ./...