mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
Integrate reviewed caller token-file retention repair
Retain exact Delivery candidate identity; preserve caller-owned credential files until lifecycle outcomes are known without widening authorised runtime cleanup. Change-source: pulse-maintainer
This commit is contained in:
@@ -30,13 +30,25 @@ state as before; confirmation does not widen trusted path-removal authority.
|
||||
Credential-free removal remains local-only, with or without a saved URL.
|
||||
|
||||
`TestInstallSHUninstallRetainsCredentialsWhenSavedURLIsMissing` runs the
|
||||
production admission, credential-presence, record-confirmation and initial
|
||||
state-directory cleanup functions through `agent_uninstall_state.py`. Its
|
||||
seventeen confined cases use private files and service/transport doubles,
|
||||
not a privileged installer or native service manager. These checks do not
|
||||
production argument parser, token-file ingress, admission, credential-presence,
|
||||
record-confirmation and initial state-directory cleanup through
|
||||
`agent_uninstall_state.py`. Its forty-eight confined cases use private files
|
||||
and service/transport doubles, not a privileged installer or native service
|
||||
manager. The legacy shell reader's root ownership is projected onto the private
|
||||
fixture owner; root refusal runs as the ordinary proof user. These checks do not
|
||||
establish Windows, actual TLS, saved-state discovery, a published fix or
|
||||
whole-issue installed removal acceptance.
|
||||
|
||||
Reading `--token-file` is read-only, including in non-interactive mode and
|
||||
before permission checks. Supplying a credential does not grant ownership of
|
||||
its file: a missing URL, root refusal, refused or unknown server result, or
|
||||
identity mismatch cannot erase it. Confirmed removal retains a caller-owned
|
||||
file outside the authorised runtime state directory; tokens inside that state
|
||||
are removed only by its existing post-confirmation path-authorised cleanup.
|
||||
Installer-created temporary credentials still belong to the installer cleanup
|
||||
trap. Generated setup commands already remove their own private bootstrap
|
||||
files; this does not remove that caller-owned cleanup or widen token/TLS trust.
|
||||
|
||||
|
||||
### Selected agent credential files retain their own authority
|
||||
|
||||
|
||||
@@ -30,13 +30,25 @@ state as before; confirmation does not widen trusted path-removal authority.
|
||||
Credential-free removal remains local-only, with or without a saved URL.
|
||||
|
||||
`TestInstallSHUninstallRetainsCredentialsWhenSavedURLIsMissing` runs the
|
||||
production admission, credential-presence, record-confirmation and initial
|
||||
state-directory cleanup functions through `agent_uninstall_state.py`. Its
|
||||
seventeen confined cases use private files and service/transport doubles,
|
||||
not a privileged installer or native service manager. These checks do not
|
||||
production argument parser, token-file ingress, admission, credential-presence,
|
||||
record-confirmation and initial state-directory cleanup through
|
||||
`agent_uninstall_state.py`. Its forty-eight confined cases use private files
|
||||
and service/transport doubles, not a privileged installer or native service
|
||||
manager. The legacy shell reader's root ownership is projected onto the private
|
||||
fixture owner; root refusal runs as the ordinary proof user. These checks do not
|
||||
establish Windows, actual TLS, saved-state discovery, a published fix or
|
||||
whole-issue installed removal acceptance.
|
||||
|
||||
Reading `--token-file` is read-only, including in non-interactive mode and
|
||||
before permission checks. Supplying a credential does not grant ownership of
|
||||
its file: a missing URL, root refusal, refused or unknown server result, or
|
||||
identity mismatch cannot erase it. Confirmed removal retains a caller-owned
|
||||
file outside the authorised runtime state directory; tokens inside that state
|
||||
are removed only by its existing post-confirmation path-authorised cleanup.
|
||||
Installer-created temporary credentials still belong to the installer cleanup
|
||||
trap. Generated setup commands already remove their own private bootstrap
|
||||
files; this does not remove that caller-owned cleanup or widen token/TLS trust.
|
||||
|
||||
|
||||
### Docker server update plans preserve the saved deployment
|
||||
|
||||
|
||||
+4
-5
@@ -588,7 +588,7 @@ Options:
|
||||
--retarget Point an existing agent at --url using saved identity and token
|
||||
--uninstall Remove the agent
|
||||
--non-interactive Skip TTY prompts (for automated/scripted installs)
|
||||
--token-file <path> Read token from file (alternative to --token)
|
||||
--token-file <path> Read token from a private file; the caller owns its cleanup
|
||||
--pulse-url <url> Alias for --url
|
||||
--preflight-only Run preflight checks and exit (no install)
|
||||
--output <format> Output format: text (default) or json
|
||||
@@ -5214,10 +5214,9 @@ if [[ -n "$TOKEN_FILE_PATH" ]]; then
|
||||
if [[ -z "$PULSE_TOKEN" ]]; then
|
||||
fail "Token file must be a readable private regular file containing one bounded token: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
|
||||
fi
|
||||
# Clean up token file after reading in non-interactive mode (deploy bootstrap tokens are one-time use)
|
||||
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||
rm -f "$TOKEN_FILE_PATH" 2>/dev/null || true
|
||||
fi
|
||||
# Reading a caller-supplied credential does not grant ownership of its
|
||||
# file. Keep it available after refusal and recovery; callers clean up
|
||||
# their own bootstrap files, while the installer trap owns only TMP_FILES.
|
||||
fi
|
||||
|
||||
if [[ -n "$ACTION_TOKEN_FILE_PATH" ]]; then
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
Production credential detection, record confirmation and directory-authority
|
||||
functions remain intact. Only transport and service/process operations are
|
||||
private doubles; deletion is confined to three owned temporary directories.
|
||||
These controls are not native installation, TLS or provider acceptance.
|
||||
The argument parser and token-file ingress also execute before the removal gate.
|
||||
Root-trust is projected onto the private fixture owner for the shell reader;
|
||||
these controls are not native installation, TLS or provider acceptance.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -38,7 +40,9 @@ def snapshot(root):
|
||||
class UnixUninstallState(unittest.TestCase):
|
||||
source = SOURCE.read_text()
|
||||
|
||||
def exercise(self, credential="runtime", url="", reply="confirmed", authority=True):
|
||||
def exercise(self, credential="runtime", url="", reply="confirmed", authority=True,
|
||||
input_token=False, interactive=False, root_refusal=False, action="uninstall",
|
||||
ingress_only=False):
|
||||
source = self.source
|
||||
# Keep the real entry and all initial credential/service/state teardown.
|
||||
# Stop before unrelated platform boot/log cleanup; never run it on a host.
|
||||
@@ -73,6 +77,26 @@ class UnixUninstallState(unittest.TestCase):
|
||||
path.write_text("" if credential == "empty" else "synthetic-collector-credential\n")
|
||||
path.chmod(0 if credential == "unreadable" else 0o600)
|
||||
before = snapshot(root)
|
||||
ingress = ""
|
||||
arguments = []
|
||||
if input_token:
|
||||
parser = source.split("# --- Parse Arguments ---\n", 1)[1].split(
|
||||
'\nif [[ "$RETARGET_ONLY"', 1)[0]
|
||||
token_reader = source.split("# Read token from file if --token-file was provided\n", 1)[1].split(
|
||||
'\nif [[ -n "$ACTION_TOKEN_FILE_PATH" ]]', 1)[0]
|
||||
ingress = parser + "\n" + token_reader
|
||||
arguments = {"uninstall": ["--uninstall"], "install": [],
|
||||
"update": ["--update"], "runner": ["--uninstall-action-runner"]}[action]
|
||||
arguments += ["--token-file", str(path)]
|
||||
if not interactive:
|
||||
arguments.append("--non-interactive")
|
||||
if url:
|
||||
arguments += ["--url", url]
|
||||
if root_refusal:
|
||||
if os.geteuid() == 0:
|
||||
self.fail("root-refusal control requires the ordinary proof user")
|
||||
ingress += "\n" + source.split("# --- Check Root ---\n", 1)[1].split(
|
||||
'\nif [[ "$SAFE_PROFILE_ACTION" == "inspect" ]]', 1)[0]
|
||||
values = {"STATE_DIR": state, "PRIVILEGED_HELPER_STATE_DIR": helper,
|
||||
"PRIVILEGED_HELPER_CREDENTIAL_DIR": helper, "SAFE_PROFILE_STATE_DIR": profile,
|
||||
"STATE_DIR_REMOVAL_AUTHORITY": state if authority else "",
|
||||
@@ -83,14 +107,28 @@ class UnixUninstallState(unittest.TestCase):
|
||||
"EVENTS": root / "events", "SERVICE": service, "REPLY": reply}
|
||||
setup = "\n".join(f"{key}={shlex.quote(str(value))}" for key, value in values.items())
|
||||
functions = "\n".join(function(source, name) for name in names)
|
||||
if input_token:
|
||||
functions += "\n" + function(source, "read_collector_token_file_safely")
|
||||
# A whitelist protects the fixture even if production cleanup changes.
|
||||
shell = r'''
|
||||
set -euo pipefail
|
||||
UNINSTALL=true AGENT_NAME=pulse-agent EXIT_GENERAL=1 EXIT_MISSING_ARGS=16
|
||||
OUTPUT_FORMAT=text NON_INTERACTIVE=true
|
||||
TOKEN_FILE_PATH="" UNINSTALL_ACTION_RUNNER=false UPDATE_ONLY=false
|
||||
RETARGET_ONLY=false PREFLIGHT_ONLY=false SAFE_PROFILE_ACTION=""
|
||||
log_info() { :; }
|
||||
log_warn() { printf 'warning:%s\n' "$*"; }
|
||||
log_error() { printf 'error:%s\n' "$*"; }
|
||||
# Project only the fixture's ownership onto the legacy root-file boundary.
|
||||
# The production reader still performs its ordinary bounded read.
|
||||
collector_lifecycle_binary() { return 1; }
|
||||
trusted_private_lifecycle_regular_file() {
|
||||
[[ -f "$1" && ! -L "$1" ]] || return 1
|
||||
local mode="" owner=""
|
||||
mode=$(stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1") || return 1
|
||||
owner=$(stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1") || return 1
|
||||
[[ "$owner" == "$EUID" && "$mode" =~ ^[0-7]{3,4}$ ]] && (( (8#$mode & 8#077) == 0 ))
|
||||
}
|
||||
recover_agent_id_from_state_file() { printf 'owned-agent\n'; }
|
||||
run_collector_lifecycle_command() {
|
||||
printf 'request:%s\n' "$*" >> "$EVENTS"
|
||||
@@ -109,14 +147,25 @@ teardown_action_runner_service() { printf 'runner-stop\n' >> "$EVENTS"; }
|
||||
teardown_privileged_helper_service() { printf 'helper-stop\n' >> "$EVENTS"; }
|
||||
teardown_systemd_agent_service() { printf 'service-stop\n' >> "$EVENTS"; printf 'stopped\n' > "$SERVICE"; }
|
||||
rm() {
|
||||
if [[ $# == 2 && "$1" == -f && -n "$TOKEN_FILE_PATH" && "$2" == "$TOKEN_FILE_PATH" ]]; then
|
||||
[[ "$2" == "$RUNTIME_TOKEN_FILE" || "$2" == "$STATE_DIR/runtime.token" || "$2" == "$STATE_DIR/token" ]] || return 94
|
||||
printf 'input-token-remove\n' >> "$EVENTS"
|
||||
command rm "$@"
|
||||
return
|
||||
fi
|
||||
[[ $# == 3 && "$1" == -rf && "$2" == -- ]] || return 92
|
||||
[[ "$3" == "$STATE_DIR" || "$3" == "$PRIVILEGED_HELPER_STATE_DIR" || "$3" == "$SAFE_PROFILE_STATE_DIR" ]] || return 93
|
||||
printf 'state-remove\n' >> "$EVENTS"
|
||||
command rm "$@"
|
||||
}
|
||||
'''
|
||||
invocation = ""
|
||||
if input_token:
|
||||
invocation = "UNINSTALL=false NON_INTERACTIVE=false\nset -- " + shlex.join(arguments) + "\n" + ingress + "\n"
|
||||
if ingress_only:
|
||||
invocation += "exit 0\n"
|
||||
result = subprocess.run(["bash", "-c", shell + "\n" + setup + "\n" + functions +
|
||||
"\nmain() {\n" + entry + "}\nmain\n"],
|
||||
"\nmain() {\n" + invocation + entry + "}\nmain\n"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
events = (root / "events").read_text() if (root / "events").exists() else ""
|
||||
after = snapshot(root)
|
||||
@@ -169,6 +218,67 @@ rm() {
|
||||
for name in ("state", "state/runtime.token", "state/agent-id", "state/connection.env"):
|
||||
self.assertEqual(after[name], before[name])
|
||||
|
||||
def assert_retained(self, result, events, before, after, expected_exit, expected_events=""):
|
||||
self.assertEqual(result.returncode, expected_exit, result.stdout + result.stderr)
|
||||
self.assertNotIn("synthetic-collector-credential", result.stdout + result.stderr)
|
||||
self.assertEqual(events, expected_events)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_noninteractive_input_token_survives_missing_url_refusal(self):
|
||||
for kind in ("selected", "runtime", "legacy"):
|
||||
with self.subTest(credential=kind):
|
||||
result, events, before, after = self.exercise(kind, input_token=True)
|
||||
self.assertIn("same trusted Pulse instance", result.stdout)
|
||||
self.assert_retained(result, events, before, after, 16)
|
||||
|
||||
def test_noninteractive_input_token_survives_unconfirmed_removal(self):
|
||||
for kind in ("selected", "runtime", "legacy"):
|
||||
for reply in ("refused", "lost-response", "empty", "mismatch"):
|
||||
with self.subTest(credential=kind, reply=reply):
|
||||
result, events, before, after = self.exercise(
|
||||
kind, url="https://pulse.invalid", reply=reply, input_token=True)
|
||||
self.assert_retained(result, events, before, after, 1,
|
||||
"request:collector-uninstall --agent-id owned-agent --hostname owned-host\n")
|
||||
|
||||
def test_confirmed_removal_retains_caller_owned_input_token(self):
|
||||
result, events, before, after = self.exercise(
|
||||
"selected", url="https://pulse.invalid", input_token=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(events.splitlines()[0],
|
||||
"request:collector-uninstall --agent-id owned-agent --hostname owned-host")
|
||||
self.assertNotIn("input-token-remove", events)
|
||||
self.assertEqual(set(after), {"service", "unrelated", "selected-token"})
|
||||
self.assertEqual(after["selected-token"], before["selected-token"])
|
||||
self.assertEqual(after["unrelated"], before["unrelated"])
|
||||
|
||||
def test_confirmed_removal_deletes_runtime_token_only_with_authorised_state(self):
|
||||
for kind in ("runtime", "legacy"):
|
||||
with self.subTest(credential=kind):
|
||||
result, events, before, after = self.exercise(
|
||||
kind, url="https://pulse.invalid", input_token=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(events.splitlines()[0],
|
||||
"request:collector-uninstall --agent-id owned-agent --hostname owned-host")
|
||||
self.assertNotIn("input-token-remove", events)
|
||||
self.assertEqual(set(after), {"service", "unrelated"})
|
||||
self.assertEqual(after["unrelated"], before["unrelated"])
|
||||
|
||||
def test_noninteractive_input_token_survives_root_refusal(self):
|
||||
for action in ("uninstall", "install", "update", "runner"):
|
||||
for kind in ("selected", "runtime", "legacy"):
|
||||
with self.subTest(action=action, credential=kind):
|
||||
result, events, before, after = self.exercise(
|
||||
kind, input_token=True, root_refusal=True, action=action)
|
||||
self.assertIn("This script must be run as root", result.stdout)
|
||||
self.assert_retained(result, events, before, after, 1)
|
||||
|
||||
def test_interactive_input_token_is_read_only_at_ingress(self):
|
||||
# Compare interactive ingress without invoking the unrelated TTY exit
|
||||
# prompt in a subprocess with no controlling terminal.
|
||||
result, events, before, after = self.exercise(
|
||||
"selected", input_token=True, interactive=True, ingress_only=True)
|
||||
self.assert_retained(result, events, before, after, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8289,12 +8289,18 @@ func TestInstallSHUninstallRetainsCredentialsWhenSavedURLIsMissing(t *testing.T)
|
||||
"test_exact_confirmation_precedes_process_service_and_state_teardown",
|
||||
"test_credential_free_local_only_removal_needs_no_url_or_request",
|
||||
"test_server_confirmation_does_not_grant_unknown_path_removal",
|
||||
"test_noninteractive_input_token_survives_missing_url_refusal",
|
||||
"test_noninteractive_input_token_survives_unconfirmed_removal",
|
||||
"test_confirmed_removal_retains_caller_owned_input_token",
|
||||
"test_confirmed_removal_deletes_runtime_token_only_with_authorised_state",
|
||||
"test_noninteractive_input_token_survives_root_refusal",
|
||||
"test_interactive_input_token_is_read_only_at_ingress",
|
||||
} {
|
||||
if !strings.Contains(string(output), name+" (__main__.UnixUninstallState."+name+") ... ok") {
|
||||
t.Fatalf("Unix removal control %s did not pass:\n%s", name, output)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(output), "\nRan 5 tests in ") || !strings.Contains(string(output), "\nOK\n") {
|
||||
if !strings.Contains(string(output), "\nRan 11 tests in ") || !strings.Contains(string(output), "\nOK\n") {
|
||||
t.Fatalf("incomplete Unix removal state verdict:\n%s", output)
|
||||
}
|
||||
t.Logf("%s", output)
|
||||
|
||||
Reference in New Issue
Block a user