diff --git a/docs/API.md b/docs/API.md index 8ecbce7731..7c5aea6f7a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -17,7 +17,8 @@ secret does not appear in shell history or process arguments. On a trusted machine, as the account running curl, prepare the file and open it in an editor. This preserves an existing file, restricts its directory and -stops before editing if preparation fails or a credential path is a symlink: +stops before editing if preparation fails or a credential path is symlinked or +not a regular file: ```bash ( @@ -29,6 +30,10 @@ stops before editing if preparation fails or a credential path is a symlink: printf 'Refusing a symlinked credential path.\n' >&2 exit 1 fi + if [ -e "$auth_file" ] && [ ! -f "$auth_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi mkdir -p "$auth_dir" chmod 700 "$auth_dir" touch "$auth_file" @@ -43,20 +48,82 @@ In the editor, save just this line, replacing `` with the API token: X-API-Token: ``` -Then make a read-only request (curl 7.76 or later): +Define this helper in the same Bash session (curl 7.76 or later). The examples +on this page use it to make **one request**, print only the HTTP status and save +each response to a new owner-only file. This keeps resource names, audit details +and credential-bearing error bodies out of terminal output and recordings. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/state/summary +pulse_api() ( + set -eu + umask 077 + if [ "$#" -ne 2 ]; then + printf 'Usage: pulse_api GET|POST /api/path\n' >&2 + exit 2 + fi + method=$1 + api_path=$2 + case "$api_path" in + /api/*) ;; + *) printf 'Use an API path on this Pulse instance, not a full URL.\n' >&2; exit 2 ;; + esac + case "$method" in + GET) set -- ;; + POST) set -- --header 'Content-Type: application/json' --request POST --data-binary @- ;; + *) printf 'This example helper accepts only GET and POST.\n' >&2; exit 2 ;; + esac + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/api-header" + if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ] || [ ! -f "$auth_file" ]; then + printf 'Prepare a regular, private header file first.\n' >&2 + exit 1 + fi + chmod 700 "$auth_dir" || exit "$?" + chmod 600 "$auth_file" || exit "$?" + result_file=$(mktemp "$auth_dir/api-response.XXXXXX") || exit "$?" + curl_exit=0 + status=$(curl --disable --fail-with-body --silent --show-error \ + --proto '=http' --noproxy 127.0.0.1 --connect-timeout 5 --max-time 20 \ + --header "@$auth_file" --output "$result_file" --write-out '%{http_code}' \ + "$@" "http://127.0.0.1:7655$api_path") || curl_exit=$? + printf 'HTTP %s\nPrivate response: %s\n' "$status" "$result_file" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + case "$status" in + 2??) ;; + *) exit 1 ;; + esac +) ``` -This loopback URL applies only when curl runs on the Pulse host. For remote -access, use your Pulse HTTPS URL and keep certificate verification enabled; -do not use `--insecure`. On an authentication-enabled instance, the protected -summary checks token access; `/api/health` is public and does not verify authentication. -`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and -403, while retaining the error response. Keep `--disable` first: it ignores -local curl configuration that could otherwise enable credential-bearing trace output. +Then make a read-only request with a `monitoring:read` token: + +```bash +pulse_api GET /api/state/summary +``` + +The default loopback origin applies only when curl runs on the Pulse host and +explicitly bypasses proxy environment settings. For remote access, replace +`http://127.0.0.1:7655` **in the helper** with your Pulse HTTPS origin, and replace +`--proto '=http' --noproxy 127.0.0.1` with `--proto '=https'`. Keep certificate +verification enabled; for a private CA, add `--cacert` with a CA file you +verified separately, never `--insecure`. Do not put credentials in the URL. + +Open the reported response file privately to inspect it, including on failure; +share only the relevant redacted error, not the whole response or file. Each +call preserves earlier responses. HTTP 401 and 403 return curl's non-zero exit +and retain the error body; redirects are not followed and are not success. +The connection limit is five seconds and the whole request limit is twenty +seconds. A partial file after a transport error is not a complete result. +No request is retried automatically, especially a POST whose outcome is +uncertain: inspect the existing action and target before deciding what to do. +A 2xx response is only HTTP success, not proof that an action completed. + +Keep `--disable` first: it ignores local curl configuration that could enable +trace output, bypass TLS or follow redirects. On an authentication-enabled +instance, the protected summary checks token access; `/api/health` is public +and does not verify authentication. The helper is local example code, not an +installed Pulse command; define it again in a new shell before using these +examples. Dispose of response files under your normal private-data policy. Do not paste tokens into command lines, URLs or issue reports. Keep the header file private and outside shared repositories and diagnostics; do not use curl @@ -67,7 +134,7 @@ needed in Pulse's API Access settings. ### Bearer Token The same API token can use a Bearer header instead. Replace the header file's -line in the editor with the following, then use the same curl command above; +line in the editor with the following, then use the same helper above; do not send both authentication headers: ```text @@ -326,12 +393,11 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels. -Use the private header file prepared in [Authentication](#-authentication), +Define the private-response helper in [Authentication](#-authentication), with `settings:read` on the token: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/connections +pulse_api GET /api/connections ``` ### Unified Action Planning @@ -339,9 +405,10 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ The existing action API separates planning, approval and execution. Run these steps separately, inspecting each response before moving on; do not paste the whole sequence as an unattended recovery script. Use the private header file -from [Authentication](#-authentication), not a token in a command or environment -assignment. For remote access, replace the loopback origin with your Pulse HTTPS -URL and keep certificate verification enabled. +and `pulse_api` helper from [Authentication](#-authentication), not a token in a +command or environment assignment. Inspect each reported private response file +before proceeding. For remote access, change the helper as described there, +using your Pulse HTTPS origin with certificate verification enabled. | Endpoint | Token scope and access | | --- | --- | @@ -365,8 +432,7 @@ not an action-control or audit token. An empty capabilities list means there is nothing to plan for that resource. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42 +pulse_api GET /api/agent/resource-capabilities/vm%3A42 ``` 2. **Plan only.** Use an advertised capability and its actual parameter schema; @@ -375,9 +441,7 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ authenticated credential, not a caller-supplied `requestedBy` value. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST --data-binary @- \ - http://127.0.0.1:7655/api/actions/plan <<'JSON' +pulse_api POST /api/actions/plan <<'JSON' { "requestId": "manual-recovery-123", "resourceId": "vm:42", @@ -398,9 +462,7 @@ or currently available. response rather than weakening the policy or creating another recovery action. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST --data-binary @- \ - http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON' +pulse_api POST /api/actions/act_.../decision <<'JSON' { "outcome": "approved", "reason": "Inside maintenance window", @@ -413,9 +475,7 @@ JSON approval-free executable plan, use the executor's authorised header file: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST --data-binary @- \ - http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON' +pulse_api POST /api/actions/act_.../execute <<'JSON' { "reason": "Execute approved recovery", "planHash": "sha256:..." @@ -431,13 +491,11 @@ resource's observed state. 5. **Read the audit and events**, using a private header file with audit access: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - 'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10' +pulse_api GET '/api/audit/actions?resourceId=vm%3A42&limit=10' ``` ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/audit/actions/act_.../events +pulse_api GET /api/audit/actions/act_.../events ``` When audit logging is unavailable, use Pulse's action detail in the signed-in diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 8ecbce7731..7c5aea6f7a 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -17,7 +17,8 @@ secret does not appear in shell history or process arguments. On a trusted machine, as the account running curl, prepare the file and open it in an editor. This preserves an existing file, restricts its directory and -stops before editing if preparation fails or a credential path is a symlink: +stops before editing if preparation fails or a credential path is symlinked or +not a regular file: ```bash ( @@ -29,6 +30,10 @@ stops before editing if preparation fails or a credential path is a symlink: printf 'Refusing a symlinked credential path.\n' >&2 exit 1 fi + if [ -e "$auth_file" ] && [ ! -f "$auth_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi mkdir -p "$auth_dir" chmod 700 "$auth_dir" touch "$auth_file" @@ -43,20 +48,82 @@ In the editor, save just this line, replacing `` with the API token: X-API-Token: ``` -Then make a read-only request (curl 7.76 or later): +Define this helper in the same Bash session (curl 7.76 or later). The examples +on this page use it to make **one request**, print only the HTTP status and save +each response to a new owner-only file. This keeps resource names, audit details +and credential-bearing error bodies out of terminal output and recordings. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/state/summary +pulse_api() ( + set -eu + umask 077 + if [ "$#" -ne 2 ]; then + printf 'Usage: pulse_api GET|POST /api/path\n' >&2 + exit 2 + fi + method=$1 + api_path=$2 + case "$api_path" in + /api/*) ;; + *) printf 'Use an API path on this Pulse instance, not a full URL.\n' >&2; exit 2 ;; + esac + case "$method" in + GET) set -- ;; + POST) set -- --header 'Content-Type: application/json' --request POST --data-binary @- ;; + *) printf 'This example helper accepts only GET and POST.\n' >&2; exit 2 ;; + esac + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/api-header" + if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ] || [ ! -f "$auth_file" ]; then + printf 'Prepare a regular, private header file first.\n' >&2 + exit 1 + fi + chmod 700 "$auth_dir" || exit "$?" + chmod 600 "$auth_file" || exit "$?" + result_file=$(mktemp "$auth_dir/api-response.XXXXXX") || exit "$?" + curl_exit=0 + status=$(curl --disable --fail-with-body --silent --show-error \ + --proto '=http' --noproxy 127.0.0.1 --connect-timeout 5 --max-time 20 \ + --header "@$auth_file" --output "$result_file" --write-out '%{http_code}' \ + "$@" "http://127.0.0.1:7655$api_path") || curl_exit=$? + printf 'HTTP %s\nPrivate response: %s\n' "$status" "$result_file" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + case "$status" in + 2??) ;; + *) exit 1 ;; + esac +) ``` -This loopback URL applies only when curl runs on the Pulse host. For remote -access, use your Pulse HTTPS URL and keep certificate verification enabled; -do not use `--insecure`. On an authentication-enabled instance, the protected -summary checks token access; `/api/health` is public and does not verify authentication. -`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and -403, while retaining the error response. Keep `--disable` first: it ignores -local curl configuration that could otherwise enable credential-bearing trace output. +Then make a read-only request with a `monitoring:read` token: + +```bash +pulse_api GET /api/state/summary +``` + +The default loopback origin applies only when curl runs on the Pulse host and +explicitly bypasses proxy environment settings. For remote access, replace +`http://127.0.0.1:7655` **in the helper** with your Pulse HTTPS origin, and replace +`--proto '=http' --noproxy 127.0.0.1` with `--proto '=https'`. Keep certificate +verification enabled; for a private CA, add `--cacert` with a CA file you +verified separately, never `--insecure`. Do not put credentials in the URL. + +Open the reported response file privately to inspect it, including on failure; +share only the relevant redacted error, not the whole response or file. Each +call preserves earlier responses. HTTP 401 and 403 return curl's non-zero exit +and retain the error body; redirects are not followed and are not success. +The connection limit is five seconds and the whole request limit is twenty +seconds. A partial file after a transport error is not a complete result. +No request is retried automatically, especially a POST whose outcome is +uncertain: inspect the existing action and target before deciding what to do. +A 2xx response is only HTTP success, not proof that an action completed. + +Keep `--disable` first: it ignores local curl configuration that could enable +trace output, bypass TLS or follow redirects. On an authentication-enabled +instance, the protected summary checks token access; `/api/health` is public +and does not verify authentication. The helper is local example code, not an +installed Pulse command; define it again in a new shell before using these +examples. Dispose of response files under your normal private-data policy. Do not paste tokens into command lines, URLs or issue reports. Keep the header file private and outside shared repositories and diagnostics; do not use curl @@ -67,7 +134,7 @@ needed in Pulse's API Access settings. ### Bearer Token The same API token can use a Bearer header instead. Replace the header file's -line in the editor with the following, then use the same curl command above; +line in the editor with the following, then use the same helper above; do not send both authentication headers: ```text @@ -326,12 +393,11 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels. -Use the private header file prepared in [Authentication](#-authentication), +Define the private-response helper in [Authentication](#-authentication), with `settings:read` on the token: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/connections +pulse_api GET /api/connections ``` ### Unified Action Planning @@ -339,9 +405,10 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ The existing action API separates planning, approval and execution. Run these steps separately, inspecting each response before moving on; do not paste the whole sequence as an unattended recovery script. Use the private header file -from [Authentication](#-authentication), not a token in a command or environment -assignment. For remote access, replace the loopback origin with your Pulse HTTPS -URL and keep certificate verification enabled. +and `pulse_api` helper from [Authentication](#-authentication), not a token in a +command or environment assignment. Inspect each reported private response file +before proceeding. For remote access, change the helper as described there, +using your Pulse HTTPS origin with certificate verification enabled. | Endpoint | Token scope and access | | --- | --- | @@ -365,8 +432,7 @@ not an action-control or audit token. An empty capabilities list means there is nothing to plan for that resource. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42 +pulse_api GET /api/agent/resource-capabilities/vm%3A42 ``` 2. **Plan only.** Use an advertised capability and its actual parameter schema; @@ -375,9 +441,7 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ authenticated credential, not a caller-supplied `requestedBy` value. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST --data-binary @- \ - http://127.0.0.1:7655/api/actions/plan <<'JSON' +pulse_api POST /api/actions/plan <<'JSON' { "requestId": "manual-recovery-123", "resourceId": "vm:42", @@ -398,9 +462,7 @@ or currently available. response rather than weakening the policy or creating another recovery action. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST --data-binary @- \ - http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON' +pulse_api POST /api/actions/act_.../decision <<'JSON' { "outcome": "approved", "reason": "Inside maintenance window", @@ -413,9 +475,7 @@ JSON approval-free executable plan, use the executor's authorised header file: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST --data-binary @- \ - http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON' +pulse_api POST /api/actions/act_.../execute <<'JSON' { "reason": "Execute approved recovery", "planHash": "sha256:..." @@ -431,13 +491,11 @@ resource's observed state. 5. **Read the audit and events**, using a private header file with audit access: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - 'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10' +pulse_api GET '/api/audit/actions?resourceId=vm%3A42&limit=10' ``` ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/audit/actions/act_.../events +pulse_api GET /api/audit/actions/act_.../events ``` When audit logging is unavailable, use Pulse's action detail in the signed-in diff --git a/scripts/installtests/api_guidance_test.go b/scripts/installtests/api_guidance_test.go new file mode 100644 index 0000000000..f8f461a8f1 --- /dev/null +++ b/scripts/installtests/api_guidance_test.go @@ -0,0 +1,495 @@ +package installtests + +import ( + "context" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Execute the actual reference recipes against synthetic peers only. In +// particular, action examples must not acquire an automatic retry or emit a +// private response just because a POST failed or its response was incomplete. +func pulseAPIRecipes(doc string) (preparation, helper string, calls []string, err error) { + for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(doc, -1) { + block := match[1] + switch { + case strings.Contains(block, "api-header") && strings.Contains(block, "vi "): + preparation = block + case strings.HasPrefix(block, "pulse_api() ("): + helper = block + case strings.HasPrefix(block, "pulse_api "): + calls = append(calls, block) + case strings.Contains(block, "curl "): + return "", "", nil, fmt.Errorf("request bypasses the private-response helper") + } + } + if preparation == "" || helper == "" || len(calls) != 8 { + err = fmt.Errorf("missing credential/helper recipe or request examples (got %d)", len(calls)) + } + return +} + +func pulseAPIReference(t *testing.T) (string, string, []string) { + t.Helper() + doc, err := os.ReadFile(repoFile("docs", "API.md")) + if err != nil { + t.Fatal(err) + } + mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "API.md")) + if err != nil || string(doc) != string(mirror) { + t.Fatal("shipped API reference differs from the tested recipes") + } + prep, helper, calls, err := pulseAPIRecipes(string(doc)) + if err != nil { + t.Fatal(err) + } + return prep, helper, calls +} + +func runPulseAPIRecipe(t *testing.T, script, home, bin string, extraEnv ...string) (string, int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-c", script) + 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...) + out, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatal("API recipe exceeded the test watchdog; its own deadline did not complete") + } + if err == nil { + return string(out), 0 + } + if failure, ok := err.(*exec.ExitError); ok { + return string(out), failure.ExitCode() + } + t.Fatal(err) + return "", -1 +} + +func TestPulseAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) { + prep, _, _ := pulseAPIReference(t) + 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(mode, func(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + dir := filepath.Join(home, ".config", "pulse") + file := filepath.Join(dir, "api-header") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if mode == "new" || mode == "mkdir failure" { + if err := os.Remove(dir); err != nil { + t.Fatal(err) + } + } + if mode == "existing" { + if err := os.WriteFile(file, []byte("synthetic-existing-header"), 0644); err != nil { + t.Fatal(err) + } + } + if mode == "non-regular" { + if err := os.Mkdir(file, 0700); err != nil { + t.Fatal(err) + } + } + target := filepath.Join(home, "unchanged") + if strings.Contains(mode, "symlink") { + link := file + if mode == "config symlink" || mode == "directory symlink" { + link = dir + if mode == "config symlink" { + link = filepath.Join(home, ".config") + } + if err := os.Remove(dir); err != nil { + t.Fatal(err) + } + if mode == "config symlink" { + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + } else if mode == "file symlink" { + if err := os.WriteFile(target, []byte("synthetic-existing-header"), 0644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + } + edited := filepath.Join(home, "editor-started") + editor := "#!/bin/sh\nprintf started > \"$API_EDITED\"\n" + if mode == "editor failure" { + editor += "exit 7\n" + } + if err := os.WriteFile(filepath.Join(bin, "vi"), []byte(editor), 0700); err != nil { + t.Fatal(err) + } + if strings.HasSuffix(mode, " failure") && mode != "editor failure" { + name := strings.TrimSuffix(mode, " failure") + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 7\n"), 0700); err != nil { + t.Fatal(err) + } + } + output, exit := runPulseAPIRecipe(t, prep, home, bin, "API_EDITED="+edited) + _, editorErr := os.Stat(edited) + if mode == "new" || mode == "existing" { + if exit != 0 || editorErr != nil { + t.Fatalf("safe preparation failed: exit %d", exit) + } + for path, perm := range map[string]os.FileMode{dir: 0700, file: 0600} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != perm { + t.Fatal("credential paths are not private") + } + } + if mode == "existing" { + data, err := os.ReadFile(file) + if err != nil || string(data) != "synthetic-existing-header" { + t.Fatal("preparation did not preserve the existing header") + } + } + } else if exit == 0 || (mode != "editor failure" && !os.IsNotExist(editorErr)) { + t.Fatal("unsafe or failed preparation reached the editor or succeeded") + } + if mode == "file symlink" { + data, err := os.ReadFile(target) + info, statErr := os.Stat(target) + if err != nil || statErr != nil || string(data) != "synthetic-existing-header" || info.Mode().Perm() != 0644 { + t.Fatal("preparation modified the symlink target") + } + } + if mode == "config symlink" || mode == "directory symlink" { + info, err := os.Stat(target) + if err != nil || info.Mode().Perm() != 0755 { + t.Fatal("preparation changed the target directory") + } + } + if strings.Contains(output, "synthetic-existing-header") { + t.Fatal("preparation disclosed a header") + } + }) + } +} + +func TestPulseAPIGuidanceHelperStopsBeforeCurlOnUnsafeFilesOrPreparation(t *testing.T) { + _, helper, _ := pulseAPIReference(t) + for _, mode := range []string{"missing", "file symlink", "config symlink", "directory symlink", "non-regular", "chmod failure", "mktemp failure", "unsupported method", "full URL", "extra argument"} { + t.Run(mode, func(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + dir := filepath.Join(home, ".config", "pulse") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "api-header") + if mode != "missing" && mode != "file symlink" && mode != "non-regular" { + if err := os.WriteFile(file, []byte("synthetic-header"), 0600); err != nil { + t.Fatal(err) + } + } + if mode == "file symlink" { + if err := os.Symlink(filepath.Join(home, "absent"), file); err != nil { + t.Fatal(err) + } + } + if mode == "config symlink" || mode == "directory symlink" { + link := dir + if mode == "config symlink" { + link = filepath.Join(home, ".config") + } + if err := os.Rename(link, link+"-retained"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(link+"-retained", link); err != nil { + t.Fatal(err) + } + } + if mode == "non-regular" { + if err := os.Mkdir(file, 0700); err != nil { + t.Fatal(err) + } + } + called := filepath.Join(home, "curl-called") + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte("#!/bin/sh\nprintf started > \"$API_CALLED\"\n"), 0700); err != nil { + t.Fatal(err) + } + if strings.HasSuffix(mode, " failure") { + if err := os.WriteFile(filepath.Join(bin, strings.TrimSuffix(mode, " failure")), []byte("#!/bin/sh\nexit 7\n"), 0700); err != nil { + t.Fatal(err) + } + } + call := "pulse_api GET /api/state/summary" + switch mode { + case "unsupported method": + call = "pulse_api DELETE /api/actions/act_example" + case "full URL": + call = "pulse_api GET https://not-this-instance.invalid/api/state" + case "extra argument": + call += " --location" + } + // Bash disables implicit errexit inside functions used as conditions. + // The documented helper must still stop on chmod/mktemp failure. + script := helper + "\nif " + call + "; then exit 0; else exit $?; fi\n" + _, exit := runPulseAPIRecipe(t, script, home, bin, "API_CALLED="+called) + _, err := os.Stat(called) + if exit == 0 || !os.IsNotExist(err) { + t.Fatal("invalid input or failed preparation reached curl") + } + }) + } +} + +type apiRecipeRequest struct{ method, uri, token, contentType, body string } + +func TestPulseAPIGuidanceCopiedRequestsKeepResponsesPrivateAndOneShot(t *testing.T) { + _, helper, calls := pulseAPIReference(t) + paths := []string{"/api/state/summary", "/api/connections", "/api/agent/resource-capabilities/vm%3A42", "/api/actions/plan", "/api/actions/act_.../decision", "/api/actions/act_.../execute", "/api/audit/actions?resourceId=vm%3A42&limit=10", "/api/audit/actions/act_.../events"} + for index, call := range calls { + for _, scenario := range []string{"success", "401", "403", "redirect", "partial", "untrusted HTTPS", "trusted HTTPS"} { + t.Run(fmt.Sprintf("request-%d/%s", index, scenario), func(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + dir := filepath.Join(home, ".config", "pulse") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + const token = "synthetic-fixture-secret" + const response = `{"token":"synthetic-fixture-secret","resource":"private-fixture-name","audit":"private-fixture-event"}` + if err := os.WriteFile(filepath.Join(dir, "api-header"), []byte("X-API-Token: "+token+"\n"), 0644); err != nil { + t.Fatal(err) + } + retained := filepath.Join(dir, "api-response.retained") + if err := os.WriteFile(retained, []byte("previous-result"), 0600); err != nil { + t.Fatal(err) + } + capture, trace := filepath.Join(home, "argv"), filepath.Join(home, "trace") + realCurl, err := exec.LookPath("curl") + if err != nil { + t.Fatal("curl is required to verify copied API requests") + } + wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$API_ARGV\"\nexec \"$API_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 apiRecipeRequest, 4) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- apiRecipeRequest{r.Method, r.URL.RequestURI(), r.Header.Get("X-API-Token"), r.Header.Get("Content-Type"), string(body)} + switch scenario { + case "401": + w.WriteHeader(http.StatusUnauthorized) + case "403": + w.WriteHeader(http.StatusForbidden) + case "redirect": + w.Header().Set("Location", "/should-not-receive-header") + w.WriteHeader(http.StatusFound) + case "partial": + w.Header().Set("Content-Length", fmt.Sprint(len(response)+100)) + } + _, _ = io.WriteString(w, response) + }) + var server *httptest.Server + recipe := helper + if strings.Contains(scenario, "HTTPS") { + server = httptest.NewUnstartedServer(handler) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.StartTLS() + recipe = strings.Replace(recipe, "--proto '=http' --noproxy 127.0.0.1", "--proto '=https'", 1) + if scenario == "trusted 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) + } + } else { + server = httptest.NewServer(handler) + } + t.Cleanup(server.Close) + recipe = strings.Replace(recipe, "http://127.0.0.1:7655", server.URL, 1) + extra := []string{"API_ARGV=" + capture, "API_CURL=" + realCurl} + if !strings.Contains(scenario, "HTTPS") { + // The local recipe must not send credentials to an ambient proxy. + extra = append(extra, "http_proxy=http://127.0.0.1:1", "ALL_PROXY=http://127.0.0.1:1") + } + output, exit := runPulseAPIRecipe(t, recipe+"\n"+call, home, bin, extra...) + wantExit := map[string]int{"success": 0, "401": 22, "403": 22, "redirect": 1, "partial": 18, "untrusted HTTPS": 60, "trusted HTTPS": 0}[scenario] + if exit != wantExit { + t.Fatalf("request exit %d, want %d", exit, wantExit) + } + argv, err := os.ReadFile(capture) + if err != nil || !strings.HasPrefix(string(argv), "--disable\x00") { + t.Fatal("curl did not ignore configuration first") + } + for _, sensitive := range []string{token, "private-fixture-name", "private-fixture-event"} { + if strings.Contains(output, sensitive) || strings.Contains(string(argv), sensitive) { + t.Fatal("request printed private credentials or response data") + } + } + if _, err := os.Stat(trace); !os.IsNotExist(err) { + t.Fatal("local curl configuration enabled tracing") + } + responses, err := filepath.Glob(filepath.Join(dir, "api-response.??????")) + if err != nil || len(responses) != 1 || !strings.Contains(output, responses[0]) { + t.Fatal("response was not saved to one new, reported private file") + } + for path, perm := range map[string]os.FileMode{dir: 0700, filepath.Join(dir, "api-header"): 0600, responses[0]: 0600} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != perm { + t.Fatal("request paths are not private") + } + } + if data, err := os.ReadFile(retained); err != nil || string(data) != "previous-result" { + t.Fatal("request replaced an earlier response") + } + if scenario == "untrusted HTTPS" { + if len(requests) != 0 { + t.Fatal("untrusted TLS peer received credentials") + } + } else { + if len(requests) != 1 { + t.Fatal("request was missing, redirected or retried") + } + got := <-requests + method := "GET" + contentType, body := "", "" + if index >= 3 && index <= 5 { + method, contentType = "POST", "application/json" + _, body, _ = strings.Cut(call, "<<'JSON'\n") + body = strings.TrimSuffix(body, "JSON\n") + } + if got != (apiRecipeRequest{method, paths[index], token, contentType, body}) { + t.Fatal("request method, endpoint, private header or JSON body differs from the example") + } + if data, err := os.ReadFile(responses[0]); err != nil || string(data) != response { + t.Fatal("HTTP response, including failure or partial body, was not retained privately") + } + } + }) + } + } +} + +func TestPulseAPIGuidanceTimedOutPOSTIsNotRepeated(t *testing.T) { + _, helper, calls := pulseAPIReference(t) + home, bin := t.TempDir(), t.TempDir() + dir := filepath.Join(home, ".config", "pulse") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "api-header"), []byte("X-API-Token: synthetic-timeout-secret\n"), 0600); err != nil { + t.Fatal(err) + } + requests := make(chan struct{}, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + requests <- struct{}{} + w.Header().Set("Content-Length", "100") + _, _ = io.WriteString(w, "private-partial-response") + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer server.Close() + if !strings.Contains(helper, "--connect-timeout 5 --max-time 20") { + t.Fatal("documented request deadlines are missing") + } + // Use the actual documented twenty-second limit, not a test-only shortened + // deadline. An execution endpoint can have acted even when this read fails. + helper = strings.Replace(helper, "http://127.0.0.1:7655", server.URL, 1) + start := time.Now() + output, exit := runPulseAPIRecipe(t, helper+"\n"+calls[5], home, bin) + if exit != 28 || time.Since(start) > 25*time.Second || len(requests) != 1 { + t.Fatal("POST did not stop at its request deadline or was retried") + } + if strings.Contains(output, "private-partial-response") || strings.Contains(output, "synthetic-timeout-secret") { + t.Fatal("timed-out POST disclosed a private body or credential") + } + responses, err := filepath.Glob(filepath.Join(dir, "api-response.??????")) + if err != nil || len(responses) != 1 { + t.Fatal("timed-out POST did not retain its response") + } + if data, err := os.ReadFile(responses[0]); err != nil || string(data) != "private-partial-response" { + t.Fatal("partial response was not retained for private reconciliation") + } +} + +func TestPulseAPIGuidanceExactParentPrintsPrivateResponse(t *testing.T) { + inputs := os.Getenv("PULSE_PROOF_INPUTS") + if inputs == "" { + t.Skip("exact-parent input is provided by the candidate source proof") + } + old, err := os.ReadFile(filepath.Join(inputs, "api-parent.md")) + if os.IsNotExist(err) { + t.Skip("unrelated proof input does not supply the exact parent") + } + if err != nil { + t.Fatal(err) + } + var request string + for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(string(old), -1) { + if strings.Contains(match[1], "/api/state/summary") && strings.Contains(match[1], "curl ") { + request = match[1] + break + } + } + if request == "" { + t.Fatal("exact old reference summary command is missing") + } + home, bin := t.TempDir(), t.TempDir() + dir := filepath.Join(home, ".config", "pulse") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "api-header"), []byte("X-API-Token: synthetic-parent-secret\n"), 0600); err != nil { + t.Fatal(err) + } + const response = "synthetic-parent-secret private-parent-resource" + requests := make(chan struct{}, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- struct{}{} + if r.Header.Get("X-API-Token") != "synthetic-parent-secret" { + w.WriteHeader(http.StatusUnauthorized) + } + _, _ = io.WriteString(w, response) + })) + defer server.Close() + request = strings.Replace(request, "http://127.0.0.1:7655", server.URL, 1) + output, exit := runPulseAPIRecipe(t, request, home, bin) + if exit != 0 || len(requests) != 1 || !strings.Contains(output, response) { + t.Fatal("exact parent did not reproduce the private-response console exposure") + } +} + +func TestPulseAPIGuidanceRejectsExactOldReference(t *testing.T) { + inputs := os.Getenv("PULSE_PROOF_INPUTS") + if inputs == "" { + t.Skip("exact-parent input is provided by the candidate source proof") + } + old, err := os.ReadFile(filepath.Join(inputs, "api-parent.md")) + if os.IsNotExist(err) { + t.Skip("unrelated proof input does not supply the exact parent") + } + if err != nil { + t.Fatal(err) + } + if _, _, _, err := pulseAPIRecipes(string(old)); err == nil { + t.Fatal("old direct, unbounded console-response examples were accepted") + } +}