From bcbf7f099219a5f7ad2359ed831d3051fb02d22d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:41:26 +0100 Subject: [PATCH 1/2] Keep PBS credentials, diagnostics and installer preparation private Fail closed before editing unsafe credential paths, ignore curl configuration and retain diagnostic responses privately. Bound requests, reject redirects and publish the local installer path only after a complete HTTPS 200 download without overwriting existing files. Exercise the copied recipes against synthetic TLS peers and file failures. Contract-Neutral: Harden existing PBS documentation recipes and their shipped mirror; no runtime, API, permission, installation contract or dependency change. Change-source: pulse-maintainer --- docs/PBS.md | 131 ++++- frontend-modern/public/docs/PBS.md | 131 ++++- scripts/installtests/pbs_api_guidance_test.go | 447 ++++++++++++++++++ 3 files changed, 659 insertions(+), 50 deletions(-) create mode 100644 scripts/installtests/pbs_api_guidance_test.go diff --git a/docs/PBS.md b/docs/PBS.md index 2c8affcab3..8f31f3cda4 100644 --- a/docs/PBS.md +++ b/docs/PBS.md @@ -86,27 +86,67 @@ for monitoring. On the PBS host, enter an administrator root shell before preparing the private file below. In the editor, save only the Pulse agent token, with no header or -quotes; never put the secret in a command argument: +quotes; never put the secret in a command argument. The preparation preserves +an existing file and refuses symlinked or non-regular credential paths. Stop +if it fails; do not continue to the installer: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -chmod 700 "$HOME/.config/pulse" -touch "$HOME/.config/pulse/pbs-agent-token" -chmod 600 "$HOME/.config/pulse/pbs-agent-token" -vi "$HOME/.config/pulse/pbs-agent-token" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-agent-token" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" +) ``` Download the agent installer from **your Pulse server's HTTPS address**, then inspect the saved script before running it. Replace the example Pulse URL in both commands. Stop if the download fails; run the next command only after a -successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs -the Pulse server, not the agent. +successful HTTP **200** download and inspection. This ignores curl configuration +and saves only a successful download to the installer path. It refuses an +existing installer rather than overwriting it; review that file locally before +deciding whether a new download is needed. Do not substitute GitHub's top-level +`install.sh`: that installs the Pulse server, not the agent. ```bash -curl --fail --silent --show-error \ - --output "$HOME/.config/pulse/pbs-agent-install.sh" \ - https://pulse.example.com/install.sh +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + installer_file="$config_dir/pbs-agent-install.sh" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ ! -d "$config_dir" ]; then + printf 'Prepare the private token directory first.\n' >&2 + exit 1 + fi + if [ -e "$installer_file" ] || [ -L "$installer_file" ]; then + printf 'Refusing to replace an existing installer path.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX") + curl_exit=0 + status=$(curl --disable --fail --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 60 --output "$download_file" \ + --write-out '%{http_code}' https://pulse.example.com/install.sh) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + mv -n "$download_file" "$installer_file" + [ ! -e "$download_file" ] +) ``` ```bash @@ -117,7 +157,10 @@ bash "$HOME/.config/pulse/pbs-agent-install.sh" \ ``` Do not bypass certificate checks to fetch or run the installer. Use a trusted -Pulse certificate or a CA file you verified separately. See +Pulse certificate or add `--cacert` with a CA file you verified separately. +A failed or redirected download is not an installer: do not run its temporary +file. Keep `--disable` first in both curl examples on this page; it ignores +local settings that could enable tracing, bypass TLS or follow redirects. See [Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles. After installation, check a fresh agent report and the host's `pulse-agent --version`; installation or hardware capacity alone does not prove @@ -177,15 +220,30 @@ another machine does not prove that Pulse can reach PBS. ``` - **Authentication/permissions:** test a datastore request, not just `/version`. - Prepare a private header file on the machine running curl: + Prepare a private header file on the machine running curl. Stop if preparation + fails; an existing file is preserved, and symlinked or non-regular credential + paths are refused before opening the editor: ```bash - umask 077 - mkdir -p "$HOME/.config/pulse" - chmod 700 "$HOME/.config/pulse" - touch "$HOME/.config/pulse/pbs-header" - chmod 600 "$HOME/.config/pulse/pbs-header" - vi "$HOME/.config/pulse/pbs-header" + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" + ) ``` In the editor, save this line, replacing `` with the secret @@ -195,12 +253,33 @@ another machine does not prove that Pulse can reach PBS. Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: ``` - Then use curl 7.76 or later, with your PBS hostname: + Then use curl 7.76 or later, with your PBS hostname. The request prints only + the HTTP status and a new private response-file path; inspect that file locally, + not in a public terminal recording or thread: ```bash - curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \ - --header "@$HOME/.config/pulse/pbs-header" \ - https://pbs.example.com:8007/api2/json/admin/datastore + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ] || [ ! -f "$credential_file" ]; then + printf 'Prepare the private header file first.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + chmod 600 "$credential_file" + result_file=$(mktemp "$config_dir/pbs-response.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + curl_exit=0 + status=$(curl --disable --fail-with-body --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 15 --header "@$credential_file" \ + --output "$result_file" --write-out '%{http_code}' \ + https://pbs.example.com:8007/api2/json/admin/datastore) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + ) ``` For curl with a private CA or self-signed certificate, add @@ -209,7 +288,9 @@ another machine does not prove that Pulse can reach PBS. use `--insecure` or `-k`. `401` means authentication was rejected; `403` means the requested access was - refused. Both return a non-zero curl exit while retaining the error body. + refused. Both return a non-zero curl exit while retaining the error body + privately. A redirect or `000` is not successful access; this command neither + follows redirects nor retries. Inspect the existing token and both `Audit` grants before replacing anything. A `200` response listing the expected datastore establishes access to that list, not successful collection of every backup, job or History graph. An diff --git a/frontend-modern/public/docs/PBS.md b/frontend-modern/public/docs/PBS.md index 2c8affcab3..8f31f3cda4 100644 --- a/frontend-modern/public/docs/PBS.md +++ b/frontend-modern/public/docs/PBS.md @@ -86,27 +86,67 @@ for monitoring. On the PBS host, enter an administrator root shell before preparing the private file below. In the editor, save only the Pulse agent token, with no header or -quotes; never put the secret in a command argument: +quotes; never put the secret in a command argument. The preparation preserves +an existing file and refuses symlinked or non-regular credential paths. Stop +if it fails; do not continue to the installer: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -chmod 700 "$HOME/.config/pulse" -touch "$HOME/.config/pulse/pbs-agent-token" -chmod 600 "$HOME/.config/pulse/pbs-agent-token" -vi "$HOME/.config/pulse/pbs-agent-token" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-agent-token" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" +) ``` Download the agent installer from **your Pulse server's HTTPS address**, then inspect the saved script before running it. Replace the example Pulse URL in both commands. Stop if the download fails; run the next command only after a -successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs -the Pulse server, not the agent. +successful HTTP **200** download and inspection. This ignores curl configuration +and saves only a successful download to the installer path. It refuses an +existing installer rather than overwriting it; review that file locally before +deciding whether a new download is needed. Do not substitute GitHub's top-level +`install.sh`: that installs the Pulse server, not the agent. ```bash -curl --fail --silent --show-error \ - --output "$HOME/.config/pulse/pbs-agent-install.sh" \ - https://pulse.example.com/install.sh +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + installer_file="$config_dir/pbs-agent-install.sh" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ ! -d "$config_dir" ]; then + printf 'Prepare the private token directory first.\n' >&2 + exit 1 + fi + if [ -e "$installer_file" ] || [ -L "$installer_file" ]; then + printf 'Refusing to replace an existing installer path.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX") + curl_exit=0 + status=$(curl --disable --fail --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 60 --output "$download_file" \ + --write-out '%{http_code}' https://pulse.example.com/install.sh) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + mv -n "$download_file" "$installer_file" + [ ! -e "$download_file" ] +) ``` ```bash @@ -117,7 +157,10 @@ bash "$HOME/.config/pulse/pbs-agent-install.sh" \ ``` Do not bypass certificate checks to fetch or run the installer. Use a trusted -Pulse certificate or a CA file you verified separately. See +Pulse certificate or add `--cacert` with a CA file you verified separately. +A failed or redirected download is not an installer: do not run its temporary +file. Keep `--disable` first in both curl examples on this page; it ignores +local settings that could enable tracing, bypass TLS or follow redirects. See [Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles. After installation, check a fresh agent report and the host's `pulse-agent --version`; installation or hardware capacity alone does not prove @@ -177,15 +220,30 @@ another machine does not prove that Pulse can reach PBS. ``` - **Authentication/permissions:** test a datastore request, not just `/version`. - Prepare a private header file on the machine running curl: + Prepare a private header file on the machine running curl. Stop if preparation + fails; an existing file is preserved, and symlinked or non-regular credential + paths are refused before opening the editor: ```bash - umask 077 - mkdir -p "$HOME/.config/pulse" - chmod 700 "$HOME/.config/pulse" - touch "$HOME/.config/pulse/pbs-header" - chmod 600 "$HOME/.config/pulse/pbs-header" - vi "$HOME/.config/pulse/pbs-header" + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" + ) ``` In the editor, save this line, replacing `` with the secret @@ -195,12 +253,33 @@ another machine does not prove that Pulse can reach PBS. Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: ``` - Then use curl 7.76 or later, with your PBS hostname: + Then use curl 7.76 or later, with your PBS hostname. The request prints only + the HTTP status and a new private response-file path; inspect that file locally, + not in a public terminal recording or thread: ```bash - curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \ - --header "@$HOME/.config/pulse/pbs-header" \ - https://pbs.example.com:8007/api2/json/admin/datastore + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ] || [ ! -f "$credential_file" ]; then + printf 'Prepare the private header file first.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + chmod 600 "$credential_file" + result_file=$(mktemp "$config_dir/pbs-response.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + curl_exit=0 + status=$(curl --disable --fail-with-body --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 15 --header "@$credential_file" \ + --output "$result_file" --write-out '%{http_code}' \ + https://pbs.example.com:8007/api2/json/admin/datastore) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + ) ``` For curl with a private CA or self-signed certificate, add @@ -209,7 +288,9 @@ another machine does not prove that Pulse can reach PBS. use `--insecure` or `-k`. `401` means authentication was rejected; `403` means the requested access was - refused. Both return a non-zero curl exit while retaining the error body. + refused. Both return a non-zero curl exit while retaining the error body + privately. A redirect or `000` is not successful access; this command neither + follows redirects nor retries. Inspect the existing token and both `Audit` grants before replacing anything. A `200` response listing the expected datastore establishes access to that list, not successful collection of every backup, job or History graph. An diff --git a/scripts/installtests/pbs_api_guidance_test.go b/scripts/installtests/pbs_api_guidance_test.go new file mode 100644 index 0000000000..fa42e79e87 --- /dev/null +++ b/scripts/installtests/pbs_api_guidance_test.go @@ -0,0 +1,447 @@ +package installtests + +import ( + "context" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Extract the actual copied commands, including the old guide for an exact +// parent control. These tests contact only synthetic peers in the proof VM. +func pbsAPIBlocks(doc string) ([4]string, error) { + var blocks [4]string + for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(doc, -1) { + block := match[1] + index := -1 + switch { + case strings.Contains(block, "pbs-agent-token") && strings.Contains(block, "vi "): + index = 0 + case strings.Contains(block, "pbs-header") && strings.Contains(block, "vi "): + index = 1 + case strings.Contains(block, "pbs-agent-install.sh") && strings.Contains(block, "curl "): + index = 2 + case strings.Contains(block, "https://pbs.example.com:8007/api2/json/admin/datastore"): + index = 3 + } + if index >= 0 { + if blocks[index] != "" { + return blocks, fmt.Errorf("duplicate PBS recipe %d", index) + } + blocks[index] = block + } + } + for i, block := range blocks { + if block == "" { + return blocks, fmt.Errorf("missing PBS recipe %d", i) + } + } + return blocks, nil +} + +func pbsAPISafety(blocks [4]string) error { + for i, block := range blocks { + required := []string{"set -eu", "umask 077", `[ -L "$HOME/.config" ]`, `[ -L "$config_dir" ]`, `chmod 700 "$config_dir"`} + if i < 2 { + required = append(required, `[ -L "$credential_file" ]`, `[ ! -f "$credential_file" ]`, `chmod 600 "$credential_file"`, `vi "$credential_file"`) + } else { + required = append(required, "curl --disable ", "--silent --show-error --proto '=https'", "--connect-timeout 5", + `--write-out '%{http_code}'`, `printf 'HTTP %s\n' "$status"`, `[ "$curl_exit" -eq 0 ] || exit "$curl_exit"`, `[ "$status" = 200 ]`) + if i == 2 { + required = append(required, "--fail ", "--max-time 60", `download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX")`, + `[ -e "$installer_file" ] || [ -L "$installer_file" ]`, `--output "$download_file"`, `mv -n "$download_file" "$installer_file"`, `[ ! -e "$download_file" ]`) + } else { + required = append(required, "--fail-with-body ", "--max-time 15", `[ -L "$credential_file" ]`, `[ ! -f "$credential_file" ]`, + `chmod 600 "$credential_file"`, `result_file=$(mktemp "$config_dir/pbs-response.XXXXXX")`, `--header "@$credential_file"`, `--output "$result_file"`) + } + for _, forbidden := range []string{"--insecure", "--location", "--config", "--trace", "--verbose", "--retry", "Authorization:", " | "} { + if strings.Contains(block, forbidden) { + return fmt.Errorf("PBS recipe %d contains %s", i, forbidden) + } + } + if strings.Count(block, "curl ") != 1 { + return fmt.Errorf("PBS recipe %d must make one request", i) + } + } + for _, text := range required { + if !strings.Contains(block, text) { + return fmt.Errorf("PBS recipe %d lacks %s", i, text) + } + } + } + return nil +} + +func pbsAPIGuidance(t *testing.T) [4]string { + t.Helper() + doc, err := os.ReadFile(repoFile("docs", "PBS.md")) + if err != nil { + t.Fatal(err) + } + blocks, err := pbsAPIBlocks(string(doc)) + if err != nil { + t.Fatal(err) + } + if err := pbsAPISafety(blocks); err != nil { + t.Fatal(err) + } + mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "PBS.md")) + if err != nil || string(mirror) != string(doc) { + t.Fatal("shipped PBS guide differs from the verified recipes") + } + return blocks +} + +func runPBSRecipe(t *testing.T, recipe, home, bin string, extraEnv ...string) (string, int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-c", recipe) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH"), + "http_proxy=", "HTTP_PROXY=", "https_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "all_proxy=", "NO_PROXY=", "no_proxy=") + cmd.Env = append(cmd.Env, extraEnv...) + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatal("copied PBS recipe exceeded its bounded test deadline") + } + if err == nil { + return string(output), 0 + } + failure, ok := err.(*exec.ExitError) + if !ok { + t.Fatal(err) + } + return string(output), failure.ExitCode() +} + +func TestPBSAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) { + for index, filename := range []string{"pbs-agent-token", "pbs-header"} { + for _, mode := range []string{"new", "existing", "config symlink", "directory symlink", "file symlink", "dangling symlink", "non-regular", "mkdir failure", "chmod failure", "touch failure", "editor failure"} { + t.Run(filename+"/"+mode, func(t *testing.T) { + home := t.TempDir() + bin := filepath.Join(home, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + edited := filepath.Join(home, "editor-started") + editor := "#!/bin/sh\nprintf started > \"$EDITOR_STARTED\"\n" + if mode == "editor failure" { + editor += "exit 1\n" + } + if err := os.WriteFile(filepath.Join(bin, "vi"), []byte(editor), 0700); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".config", "pulse") + file := filepath.Join(dir, filename) + if mode == "existing" || strings.Contains(mode, "file symlink") || mode == "dangling symlink" || mode == "non-regular" { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + target := filepath.Join(home, "untouched") + if strings.Contains(mode, "symlink") { + link := file + switch mode { + case "file symlink": + if err := os.WriteFile(target, []byte("synthetic-existing-secret"), 0644); err != nil { + t.Fatal(err) + } + case "config symlink", "directory symlink": + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + link = filepath.Join(home, ".config") + if mode == "directory symlink" { + if err := os.Mkdir(link, 0755); err != nil { + t.Fatal(err) + } + link = dir + } + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + } + if mode == "existing" { + if err := os.WriteFile(file, []byte("synthetic-existing-secret"), 0644); err != nil { + t.Fatal(err) + } + } + if mode == "non-regular" { + if err := os.Mkdir(file, 0700); err != nil { + t.Fatal(err) + } + } + if strings.HasSuffix(mode, " failure") && mode != "editor failure" { + if err := os.WriteFile(filepath.Join(bin, strings.TrimSuffix(mode, " failure")), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + } + output, exit := runPBSRecipe(t, pbsAPIGuidance(t)[index], home, bin, "EDITOR_STARTED="+edited) + _, editorErr := os.Stat(edited) + if mode != "new" && mode != "existing" { + if exit == 0 || (mode != "editor failure" && !os.IsNotExist(editorErr)) { + t.Fatal("failed or unsafe preparation was accepted or reached the editor") + } + if strings.Contains(mode, "symlink") && mode != "dangling symlink" { + info, err := os.Stat(target) + wantMode := os.FileMode(0755) + if mode == "file symlink" { + wantMode = 0644 + content, err := os.ReadFile(target) + if err != nil || string(content) != "synthetic-existing-secret" { + t.Fatal("refused credential path modified its target") + } + } + if err != nil || info.Mode().Perm() != wantMode { + t.Fatal("refused path modified target permissions") + } + } + } else { + if exit != 0 || editorErr != nil { + t.Fatalf("safe preparation failed: exit %d, %s", exit, output) + } + for path, permissions := range map[string]os.FileMode{dir: 0700, file: 0600} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != permissions { + t.Fatal("credential path is not private") + } + } + content, err := os.ReadFile(file) + if err != nil || (mode == "existing" && string(content) != "synthetic-existing-secret") { + t.Fatal("existing credential was not preserved") + } + } + if strings.Contains(output, "synthetic-existing-secret") { + t.Fatal("preparation disclosed a credential") + } + }) + } + } +} + +func TestPBSAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) { + blocks := pbsAPIGuidance(t) + for name, edit := range map[string][2]string{ + "curl config": {"curl --disable ", "curl "}, "body disclosure": {`--output "$result_file" `, ""}, + "deadline": {"--max-time 15 ", ""}, "retry": {"curl --disable ", "curl --disable --retry 2 "}, + "redirect": {"curl --disable ", "curl --disable --location "}, "TLS bypass": {"curl --disable ", "curl --disable --insecure "}, + "inline token": {`"@$credential_file"`, `'Authorization: synthetic-inline-secret'`}, + "fail open": {"set -eu", ""}, "symlink": {`[ -L "$credential_file" ]`, "false"}, + "installer overwrite": {`mv -n "$download_file"`, `mv "$download_file"`}, + } { + t.Run(name, func(t *testing.T) { + changed := blocks + for i, block := range changed { + changed[i] = strings.ReplaceAll(block, edit[0], edit[1]) + } + if pbsAPISafety(changed) == nil { + t.Fatal("unsafe mutation was accepted") + } + }) + } + if inputs := os.Getenv("PULSE_PROOF_INPUTS"); inputs != "" { + parent, err := os.ReadFile(filepath.Join(inputs, "pbs-parent.md")) + if os.IsNotExist(err) { + return // Unrelated proof inputs do not require a PBS parent. + } + if err != nil { + t.Fatal(err) + } + old, err := pbsAPIBlocks(string(parent)) + if err != nil { + t.Fatal(err) + } + if err := pbsAPISafety(old); err == nil { + t.Fatal("unchanged exact parent passed the new safety controls") + } else { + t.Logf("exact parent rejected: %s", err) + } + } +} + +// Actual curl sees hostile local defaults and peers that echo a fixture token +// and identifying data. This proves copied transport/file safety, not native +// PBS permissions, installed collection, backup recovery or agent installation. +func TestPBSAPIGuidanceCopiedRequestsKeepSecretsPrivate(t *testing.T) { + for index, recipe := range pbsAPIGuidance(t)[2:] { + for _, scenario := range []string{"success", "401", "403", "redirect", "wrong success status", "untrusted HTTPS", "truncated installer", "existing installer", "symlinked header"} { + if (index == 0 && scenario == "symlinked header") || (index == 1 && strings.Contains(scenario, "installer")) { + continue + } + t.Run(fmt.Sprintf("%d/%s", index, scenario), func(t *testing.T) { + recipe := recipe + realCurl, err := exec.LookPath("curl") + if err != nil { + t.Fatal("curl is required to verify copied PBS guidance") + } + home := t.TempDir() + bin := filepath.Join(home, "bin") + dir := filepath.Join(home, ".config", "pulse") + for _, path := range []string{bin, dir} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + const token = "PBSAPIToken=pulse-monitor@pbs!pulse-token:synthetic-pbs-secret" + const body = "synthetic-pbs-secret private-datastore-name" + header := filepath.Join(dir, "pbs-header") + if scenario == "symlinked header" { + target := filepath.Join(home, "outside-header") + if err := os.WriteFile(target, []byte("Authorization: "+token+"\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, header); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(header, []byte("Authorization: "+token+"\n"), 0600); err != nil { + t.Fatal(err) + } + installer := filepath.Join(dir, "pbs-agent-install.sh") + if scenario == "existing installer" { + if err := os.WriteFile(installer, []byte("original-installer"), 0600); err != nil { + t.Fatal(err) + } + } + capture := filepath.Join(home, "argv") + trace := filepath.Join(home, "must-not-trace") + wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$PBS_ARGV\"\nexec \"$PBS_CURL\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte(wrapper), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".curlrc"), []byte("insecure\nlocation\ntrace = "+trace+"\n"), 0600); err != nil { + t.Fatal(err) + } + requests := make(chan string, 4) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- strings.Join([]string{r.Method, r.URL.Path, r.Header.Get("Authorization")}, "\n") + code := 200 + switch scenario { + case "401": + code = 401 + case "403": + code = 403 + case "redirect": + w.Header().Set("Location", "/must-not-follow") + code = 307 + case "wrong success status": + code = 206 + case "truncated installer": + w.Header().Set("Content-Length", "1024") + } + w.WriteHeader(code) + fmt.Fprint(w, body) + })) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.StartTLS() + t.Cleanup(server.Close) + originalURL := "https://pulse.example.com" + if index == 1 { + originalURL = "https://pbs.example.com:8007" + } + recipe = strings.Replace(recipe, originalURL, server.URL, 1) + if scenario != "untrusted HTTPS" { + ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(filepath.Join(home, "ca.pem"), ca, 0600); err != nil { + t.Fatal(err) + } + recipe = strings.Replace(recipe, "curl --disable ", `curl --disable --cacert "$HOME/ca.pem" `, 1) + } + output, exit := runPBSRecipe(t, recipe, home, bin, "PBS_ARGV="+capture, "PBS_CURL="+realCurl) + wantExit := 0 + switch scenario { + case "401", "403": + wantExit = 22 + case "untrusted HTTPS": + wantExit = 60 + case "truncated installer": + wantExit = 18 + case "redirect", "wrong success status", "existing installer", "symlinked header": + wantExit = 1 + } + if exit != wantExit { + t.Fatalf("copied request exit %d, want %d: %s", exit, wantExit, output) + } + if strings.Contains(output, "synthetic-pbs-secret") || strings.Contains(output, "private-datastore-name") { + t.Fatal("copied request disclosed a credential or response") + } + if _, err := os.Stat(trace); !os.IsNotExist(err) { + t.Fatal("curl configuration enabled tracing") + } + if scenario == "existing installer" || scenario == "symlinked header" { + if _, err := os.Stat(capture); !os.IsNotExist(err) || len(requests) != 0 { + t.Fatal("unsafe local path reached curl") + } + path, expected := installer, "original-installer" + if scenario == "symlinked header" { + path, expected = filepath.Join(home, "outside-header"), "Authorization: "+token+"\n" + } + content, err := os.ReadFile(path) + if err != nil || string(content) != expected { + t.Fatal("refused local path modified existing content") + } + return + } + argv, err := os.ReadFile(capture) + if err != nil || strings.Contains(string(argv), "synthetic-pbs-secret") || !strings.HasPrefix(string(argv), "--disable\x00") { + t.Fatal("curl arguments disclosed credentials or did not disable local configuration first") + } + if !strings.Contains(output, "HTTP ") { + t.Fatal("request failed to expose its safe HTTP result") + } + if scenario == "untrusted HTTPS" { + if len(requests) != 0 { + t.Fatal("untrusted TLS peer received a request") + } + } else { + if len(requests) != 1 { + t.Fatal("request was missing, redirected or retried") + } + path, auth := "/install.sh", "" + if index == 1 { + path, auth = "/api2/json/admin/datastore", token + } + if got := <-requests; got != strings.Join([]string{"GET", path, auth}, "\n") { + t.Fatal("copied request used the wrong method, endpoint or credential") + } + } + if index == 0 { + content, err := os.ReadFile(installer) + if scenario == "success" { + info, statErr := os.Stat(installer) + if err != nil || string(content) != body || statErr != nil || info.Mode().Perm() != 0600 { + t.Fatal("successful installer was not saved completely and privately") + } + } else if !os.IsNotExist(err) { + t.Fatal("failed or redirected download became an executable installer path") + } + } else { + responses, err := filepath.Glob(filepath.Join(dir, "pbs-response.??????")) + if err != nil || len(responses) != 1 { + t.Fatal("request did not retain one new private response") + } + info, err := os.Stat(responses[0]) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("response file is not private") + } + if scenario != "untrusted HTTPS" { + content, err := os.ReadFile(responses[0]) + if err != nil || string(content) != body { + t.Fatal("complete sensitive response was not retained locally") + } + } + } + }) + } + } +} From 50941e431ae9c42094fd84e5c14b542f2e5a012c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:45:13 +0100 Subject: [PATCH 2/2] Compile the copied PBS request controls against addressable recipes Retain the guide recipe array before selecting its request slice; the initial exact-source proof rejected a temporary array slice before running any tests. Contract-Neutral: Correct test compilation only; existing PBS documentation and runtime contracts are unchanged. Change-source: pulse-maintainer --- scripts/installtests/pbs_api_guidance_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/installtests/pbs_api_guidance_test.go b/scripts/installtests/pbs_api_guidance_test.go index fa42e79e87..ddeca21d09 100644 --- a/scripts/installtests/pbs_api_guidance_test.go +++ b/scripts/installtests/pbs_api_guidance_test.go @@ -274,7 +274,8 @@ func TestPBSAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) { // and identifying data. This proves copied transport/file safety, not native // PBS permissions, installed collection, backup recovery or agent installation. func TestPBSAPIGuidanceCopiedRequestsKeepSecretsPrivate(t *testing.T) { - for index, recipe := range pbsAPIGuidance(t)[2:] { + blocks := pbsAPIGuidance(t) + for index, recipe := range blocks[2:] { for _, scenario := range []string{"success", "401", "403", "redirect", "wrong success status", "untrusted HTTPS", "truncated installer", "existing installer", "symlinked header"} { if (index == 0 && scenario == "symlinked header") || (index == 1 && strings.Contains(scenario, "installer")) { continue