| 1 | #!/usr/bin/env bash |
| 2 | # Helpers for the query-netdata-agents skill. |
| 3 | # Sourced from per-action scripts (and from any other skill that |
| 4 | # wants to call Netdata Cloud / Netdata Agent through token-safe |
| 5 | # wrappers). Not executed directly. |
| 6 | # |
| 7 | # Token-safety contract (HARD requirement): |
| 8 | # * No PUBLIC function (named `agents_*`, no leading underscore) |
| 9 | # ever emits NETDATA_CLOUD_TOKEN, a per-agent bearer, or a |
| 10 | # claim_id to stdout. |
| 11 | # * Internal helpers (named `_agents_*`, leading underscore) may |
| 12 | # handle token bytes inside their own scope but must return |
| 13 | # them only through validated caller-local variable names -- |
| 14 | # never to stdout. |
| 15 | # * `_agents_log_masked` redacts token / bearer bytes in stderr |
| 16 | # argv echoes. |
| 17 | # * The unit test `agents_selftest_no_token_leak` drives every |
| 18 | # public wrapper with a sentinel token and asserts the |
| 19 | # sentinel never reaches captured stdout. |
| 20 | # |
| 21 | # Conventions mirrored from .agents/skills/coverity-audit/scripts/_lib.sh: |
| 22 | # * set -euo pipefail at the top |
| 23 | # * color vars defined with $'...' so ESC bytes are real |
| 24 | # * <prefix>_repo_root via `git rev-parse --show-toplevel` |
| 25 | # * <prefix>_load_env sources <repo>/.env, validates required keys |
| 26 | # * <prefix>_audit_dir creates <repo>/.local/audits/<topic>/ |
| 27 | # |
| 28 | # Audit topic: "query-netdata-agents". |
| 29 | |
| 30 | # Capture our source-file path BEFORE `set -u`. Bash exposes |
| 31 | # BASH_SOURCE[0]; zsh exposes the equivalent as `${(%):-%x}` (which |
| 32 | # bash cannot parse, so we gate it through `eval`). |
| 33 | if [ -n "${ZSH_VERSION-}" ]; then |
| 34 | eval '_agents_lib_self="${(%):-%x}"' |
| 35 | elif [ -n "${BASH_VERSION-}" ]; then |
| 36 | _agents_lib_self="${BASH_SOURCE[0]}" |
| 37 | else |
| 38 | _agents_lib_self="$0" |
| 39 | fi |
| 40 | |
| 41 | set -euo pipefail |
| 42 | |
| 43 | # shellcheck disable=SC2034 |
| 44 | AGENTS_RED=$'\033[0;31m' |
| 45 | # shellcheck disable=SC2034 |
| 46 | AGENTS_GREEN=$'\033[0;32m' |
| 47 | # shellcheck disable=SC2034 |
| 48 | AGENTS_YELLOW=$'\033[1;33m' |
| 49 | # shellcheck disable=SC2034 |
| 50 | AGENTS_GRAY=$'\033[0;90m' |
| 51 | # shellcheck disable=SC2034 |
| 52 | AGENTS_NC=$'\033[0m' |
| 53 | |
| 54 | # --------------------------------------------------------------------------- |
| 55 | # Repo + env helpers |
| 56 | # --------------------------------------------------------------------------- |
| 57 | |
| 58 | agents_repo_root() { |
| 59 | git -C "$(dirname "${_agents_lib_self}")" rev-parse --show-toplevel |
| 60 | } |
| 61 | |
| 62 | # Source <repo>/.env. Validate the keys this skill needs. |
| 63 | # Required: |
| 64 | # NETDATA_CLOUD_TOKEN -- long-lived Cloud REST token |
| 65 | # NETDATA_CLOUD_HOSTNAME -- Cloud REST host (e.g. app.netdata.cloud) |
| 66 | agents_load_env() { |
| 67 | local root env |
| 68 | root="$(agents_repo_root)" |
| 69 | env="${root}/.env" |
| 70 | if [[ ! -f "${env}" || ! -r "${env}" ]]; then |
| 71 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Missing ${env}. Copy .env.template to .env and fill it in. See ${root}/.agents/ENV.md." >&2 |
| 72 | return 1 |
| 73 | fi |
| 74 | set -a |
| 75 | # shellcheck disable=SC1090 |
| 76 | source "${env}" |
| 77 | set +a |
| 78 | |
| 79 | : "${NETDATA_CLOUD_TOKEN:?NETDATA_CLOUD_TOKEN is empty -- see <repo>/.agents/ENV.md to set it.}" |
| 80 | : "${NETDATA_CLOUD_HOSTNAME:?NETDATA_CLOUD_HOSTNAME is empty -- see <repo>/.agents/ENV.md to set it.}" |
| 81 | export NETDATA_CLOUD_TOKEN NETDATA_CLOUD_HOSTNAME |
| 82 | } |
| 83 | |
| 84 | agents_audit_dir() { |
| 85 | local root dir |
| 86 | root="$(agents_repo_root)" |
| 87 | dir="${root}/.local/audits/query-netdata-agents" |
| 88 | mkdir -p "${dir}" |
| 89 | echo "${dir}" |
| 90 | } |
| 91 | |
| 92 | # Autodetect the Netdata install prefix. Returns "" for system installs |
| 93 | # (paths like /var/lib/netdata, /etc/netdata) or e.g. "/opt/netdata" for |
| 94 | # bundled installs (paths under /opt/netdata/var/lib/netdata). |
| 95 | # |
| 96 | # Rule (per .agents/sow/specs/sensitive-data-discipline.md): probe |
| 97 | # candidates and pick the first whose <prefix>/var/lib/netdata or |
| 98 | # <prefix>/etc/netdata exists. NOT a config knob. |
| 99 | agents_netdata_prefix() { |
| 100 | local p |
| 101 | for p in "" "/opt/netdata" "/usr/local/netdata"; do |
| 102 | if [[ -d "${p}/var/lib/netdata" || -d "${p}/etc/netdata" ]]; then |
| 103 | printf '%s' "${p}" |
| 104 | return 0 |
| 105 | fi |
| 106 | done |
| 107 | printf '' |
| 108 | return 0 |
| 109 | } |
| 110 | |
| 111 | # --------------------------------------------------------------------------- |
| 112 | # Masked-curl execution wrappers |
| 113 | # --------------------------------------------------------------------------- |
| 114 | |
| 115 | # Print a curl invocation to stderr with the cloud token (and any |
| 116 | # minted bearer) masked. Then execute it. Honors AGENTS_DRY_RUN=1 |
| 117 | # (write paths skip execution but still log). |
| 118 | agents_run() { |
| 119 | _agents_log_masked "$@" |
| 120 | if [[ "${AGENTS_DRY_RUN:-0}" == "1" ]]; then |
| 121 | return 0 |
| 122 | fi |
| 123 | "$@" |
| 124 | } |
| 125 | |
| 126 | agents_run_read() { |
| 127 | _agents_log_masked "$@" |
| 128 | "$@" |
| 129 | } |
| 130 | |
| 131 | _agents_log_masked() { |
| 132 | local arg |
| 133 | printf >&2 '%s> %s' "${AGENTS_GRAY}" "${AGENTS_YELLOW}" |
| 134 | for arg in "$@"; do |
| 135 | # Mask the cloud token wherever it appears. |
| 136 | if [[ -n "${NETDATA_CLOUD_TOKEN:-}" && "${arg}" == *"${NETDATA_CLOUD_TOKEN}"* ]]; then |
| 137 | arg="${arg//${NETDATA_CLOUD_TOKEN}/<CLOUD_TOKEN>}" |
| 138 | fi |
| 139 | # Mask any UUID-shaped bearer in `Bearer <uuid>` form. |
| 140 | if [[ "${arg}" =~ Bearer\ [0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} ]]; then |
| 141 | arg="${arg%% Bearer *} Bearer <AGENT_BEARER>" |
| 142 | # The replace above also rebuilds the leading header |
| 143 | # name; the rejoined arg is harmless even if the |
| 144 | # leading text is the bare header. Tests cover this. |
| 145 | fi |
| 146 | arg="$(printf '%s' "${arg}" | sed -E \ |
| 147 | -e 's/(node_id=)[^&]+/\1<NODE_ID>/g' \ |
| 148 | -e 's/(machine_guid=)[^&]+/\1<MACHINE_GUID>/g' \ |
| 149 | -e 's/(claim_id=)[^&]+/\1<CLAIM_ID>/g' \ |
| 150 | -e 's#(/api/v2/nodes/)[0-9a-fA-F-]{36}#\1<NODE_ID>#g' \ |
| 151 | -e 's#(/api/v[0-9]+/spaces/)[0-9a-fA-F-]{36}#\1<SPACE_ID>#g' \ |
| 152 | -e 's#(/rooms/)[0-9a-fA-F-]{36}#\1<ROOM_ID>#g' \ |
| 153 | -e 's#(/host/)[0-9a-fA-F-]{36}#\1<NODE_ID>#g')" |
| 154 | printf >&2 '%q ' "${arg}" |
| 155 | done |
| 156 | printf >&2 '%s\n' "${AGENTS_NC}" |
| 157 | } |
| 158 | |
| 159 | # --------------------------------------------------------------------------- |
| 160 | # Internal: claim_id / bearer mint / cache |
| 161 | # --------------------------------------------------------------------------- |
| 162 | |
| 163 | _agents_set_outvar() { |
| 164 | local _agents_out_name="${1:?output variable name required}" |
| 165 | local _agents_out_value="${2-}" |
| 166 | if [[ ! "${_agents_out_name}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then |
| 167 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Invalid output variable name: ${_agents_out_name}" >&2 |
| 168 | return 1 |
| 169 | fi |
| 170 | # Avoid eval here because values come from curl/jq output. |
| 171 | if ! printf -v "${_agents_out_name}" '%s' "${_agents_out_value}"; then |
| 172 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Failed to set output variable: ${_agents_out_name}" >&2 |
| 173 | return 1 |
| 174 | fi |
| 175 | } |
| 176 | |
| 177 | # Resolve claim_id from a node's /api/v3/info. The /info endpoint |
| 178 | # is unauthenticated. INTERNAL: writes to a caller-local variable; |
| 179 | # never prints to stdout. |
| 180 | # |
| 181 | # Args: |
| 182 | # $1 = OUTVAR -- caller-local variable name to receive the claim_id |
| 183 | # $2 = HOST -- host:port (e.g. "agent-events:19999") |
| 184 | _agents_get_claim_id() { |
| 185 | local _out_var="${1:?usage: _agents_get_claim_id OUTVAR <host:port>}"; shift |
| 186 | local host="${1:?usage: _agents_get_claim_id OUTVAR <host:port>}" |
| 187 | local resp resolved_claim |
| 188 | if resp="$(curl -sS --max-time 10 "http://${host}/api/v3/info" 2>/dev/null)"; then |
| 189 | resolved_claim="$(jq -r '.agents[0].cloud.claim_id // empty' <<< "${resp}" 2>/dev/null)" |
| 190 | if [[ -n "${resolved_claim}" && "${resolved_claim}" != "null" ]]; then |
| 191 | _agents_set_outvar "${_out_var}" "${resolved_claim}" || return 1 |
| 192 | return 0 |
| 193 | fi |
| 194 | fi |
| 195 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Could not resolve claim_id from http://${host}/api/v3/info" >&2 |
| 196 | return 1 |
| 197 | } |
| 198 | |
| 199 | # Mint a per-agent bearer via Cloud. INTERNAL: prints the response |
| 200 | # JSON to stdout for the caller to capture in a local variable. |
| 201 | # stdout still carries the bearer here -- callers MUST capture into |
| 202 | # a local and never propagate. The PUBLIC wrapper that uses this |
| 203 | # does exactly that and emits only the response body. |
| 204 | _agents_mint_bearer_json() { |
| 205 | local node_id="${1:?usage: _agents_mint_bearer_json <node_id> <machine_guid> <claim_id>}" |
| 206 | local mg="${2:?machine_guid required}" |
| 207 | local claim="${3:?claim_id required}" |
| 208 | agents_run_read curl --fail --silent --show-error --max-time 30 \ |
| 209 | -H "Authorization: Bearer ${NETDATA_CLOUD_TOKEN}" \ |
| 210 | "https://${NETDATA_CLOUD_HOSTNAME}/api/v2/bearer_get_token?node_id=${node_id}&machine_guid=${mg}&claim_id=${claim}" |
| 211 | } |
| 212 | |
| 213 | # Convert an `expiration` value (which may be unix-seconds or |
| 214 | # unix-milliseconds, depending on cloud version) to seconds. |
| 215 | # Heuristic: values > 10^12 are ms; lower are seconds. Returns 0 |
| 216 | # for unparseable values so the caller treats the cache as expired. |
| 217 | _agents_exp_to_seconds() { |
| 218 | local exp="$1" |
| 219 | if [[ -z "${exp}" || "${exp}" == "null" ]]; then |
| 220 | echo 0; return |
| 221 | fi |
| 222 | if ! [[ "${exp}" =~ ^[0-9]+$ ]]; then |
| 223 | echo 0; return |
| 224 | fi |
| 225 | if (( exp > 1000000000000 )); then |
| 226 | echo $(( exp / 1000 )) |
| 227 | else |
| 228 | echo "${exp}" |
| 229 | fi |
| 230 | } |
| 231 | |
| 232 | # Cache-aware bearer resolution. INTERNAL: writes to a caller-local |
| 233 | # variable; never prints the bearer to stdout. |
| 234 | # |
| 235 | # Args: |
| 236 | # $1 = OUTVAR -- caller-local variable to receive the bearer |
| 237 | # $2 = NODE_ID -- node UUID |
| 238 | # $3 = MACHINE_GUID -- agent machine_guid (cache key) |
| 239 | # $4 = HOST -- host:port for claim_id resolution and direct probe |
| 240 | # |
| 241 | # Cache file: <repo>/.local/audits/query-netdata-agents/bearers/<machine_guid>.json |
| 242 | # Mode 0600. Stamps `_cached_at` (unix-seconds) so the cache window |
| 243 | # survives Cloud responses with expiration=0. |
| 244 | _agents_resolve_bearer() { |
| 245 | local _out_var="${1:?usage: _agents_resolve_bearer OUTVAR <node_id> <machine_guid> <host:port>}"; shift |
| 246 | local node_id="${1:?usage: _agents_resolve_bearer OUTVAR <node_id> <machine_guid> <host:port>}" |
| 247 | local mg="${2:?machine_guid required}" |
| 248 | local host="${3:?host required}" |
| 249 | |
| 250 | local cache_dir cache_file now exp_s |
| 251 | cache_dir="$(agents_audit_dir)/bearers" |
| 252 | mkdir -p "${cache_dir}" |
| 253 | chmod 0700 "${cache_dir}" 2>/dev/null || true |
| 254 | cache_file="${cache_dir}/${mg}.json" |
| 255 | |
| 256 | now=$(date +%s) |
| 257 | |
| 258 | if [[ -s "${cache_file}" ]]; then |
| 259 | local cached_exp cached_token cached_at |
| 260 | cached_exp=$(jq -r '.expiration // 0' "${cache_file}" 2>/dev/null || echo 0) |
| 261 | cached_token=$(jq -r '.token // empty' "${cache_file}" 2>/dev/null || true) |
| 262 | cached_at=$(jq -r '._cached_at // 0' "${cache_file}" 2>/dev/null || echo 0) |
| 263 | exp_s=$(_agents_exp_to_seconds "${cached_exp}") |
| 264 | if [[ -n "${cached_token}" && "${cached_token}" != "null" ]]; then |
| 265 | # Two cases: |
| 266 | # (a) Cloud returned a real expiration -- 1h refresh buffer |
| 267 | # (matches cloud-frontend useAgentBearer.js). |
| 268 | # (b) Cloud returned expiration=0 -- fall back to a fixed |
| 269 | # 2h window from our mint timestamp. The agent issues |
| 270 | # ~3h-TTL bearers, so 2h leaves a 1h safety margin. |
| 271 | if (( exp_s > 0 )); then |
| 272 | if (( exp_s - now > 3600 )); then |
| 273 | _agents_set_outvar "${_out_var}" "${cached_token}" || return 1 |
| 274 | return 0 |
| 275 | fi |
| 276 | elif (( cached_at > 0 )) && (( now - cached_at < 7200 )); then |
| 277 | _agents_set_outvar "${_out_var}" "${cached_token}" || return 1 |
| 278 | return 0 |
| 279 | fi |
| 280 | fi |
| 281 | fi |
| 282 | |
| 283 | # Need to mint -- resolve claim_id first. |
| 284 | local claim |
| 285 | _agents_get_claim_id claim "${host}" || return 1 |
| 286 | |
| 287 | local resp |
| 288 | resp="$(_agents_mint_bearer_json "${node_id}" "${mg}" "${claim}")" |
| 289 | if ! jq -e '.token' >/dev/null 2>&1 <<< "${resp}"; then |
| 290 | rm -f "${cache_file}" |
| 291 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Bearer mint failed; first 200 chars: $(head -c 200 <<< "${resp}")" >&2 |
| 292 | return 1 |
| 293 | fi |
| 294 | |
| 295 | # Stamp cache and persist. |
| 296 | jq --argjson t "${now}" '. + {_cached_at: $t}' <<< "${resp}" > "${cache_file}" |
| 297 | chmod 0600 "${cache_file}" |
| 298 | _agents_set_outvar "${_out_var}" "$(jq -r '.token' "${cache_file}")" || return 1 |
| 299 | } |
| 300 | |
| 301 | # --------------------------------------------------------------------------- |
| 302 | # PUBLIC wrappers (token-safe). These are what the assistant invokes. |
| 303 | # --------------------------------------------------------------------------- |
| 304 | |
| 305 | # Call any Netdata Cloud REST endpoint. Reads NETDATA_CLOUD_TOKEN |
| 306 | # from .env internally; emits ONLY the response body to stdout. |
| 307 | # stderr shows the curl invocation with `<CLOUD_TOKEN>` masked. |
| 308 | # |
| 309 | # Args: |
| 310 | # $1 = METHOD -- GET / POST / PUT / DELETE / ... |
| 311 | # $2 = PATH -- e.g. /api/v2/spaces |
| 312 | # $3 = BODY (json) -- optional; passed via -d |
| 313 | # |
| 314 | # Example: |
| 315 | # agents_query_cloud GET /api/v2/spaces |
| 316 | # agents_query_cloud POST /api/v2/nodes/$NODE/function?function=systemd-journal '{"info":true}' |
| 317 | agents_query_cloud() { |
| 318 | local method="${1:?usage: agents_query_cloud METHOD PATH [BODY]}" |
| 319 | local api_path="${2:?path required}" |
| 320 | local body="${3:-}" |
| 321 | |
| 322 | local args=(curl --fail --silent --show-error --max-time 120 -X "${method}" \ |
| 323 | -H "Authorization: Bearer ${NETDATA_CLOUD_TOKEN}" \ |
| 324 | -H 'Content-Type: application/json' \ |
| 325 | "https://${NETDATA_CLOUD_HOSTNAME}${api_path}") |
| 326 | if [[ -n "${body}" ]]; then |
| 327 | args+=(-d "${body}") |
| 328 | fi |
| 329 | agents_run "${args[@]}" |
| 330 | } |
| 331 | |
| 332 | # Call any Netdata Agent direct-HTTP path. Resolves the per-agent |
| 333 | # bearer internally (cache or mint via Cloud). Emits ONLY the |
| 334 | # response body to stdout. stderr shows curl with both |
| 335 | # `<CLOUD_TOKEN>` and `<AGENT_BEARER>` masked. |
| 336 | # |
| 337 | # Required flags (provided in any order before METHOD PATH): |
| 338 | # --node <node_id> -- target node UUID |
| 339 | # --host <host:port> -- agent's bind, e.g. "agent-events:19999" |
| 340 | # --machine-guid <mg> -- agent's machine_guid (bearer cache key) |
| 341 | # |
| 342 | # Example: |
| 343 | # agents_query_agent --node $NODE --host $HOST --machine-guid $MG \ |
| 344 | # POST /api/v3/function?function=systemd-journal '{"info":true}' |
| 345 | agents_query_agent() { |
| 346 | local node="" host="" mg="" method="" api_path="" body="" |
| 347 | while (( $# > 0 )); do |
| 348 | local arg="$1" |
| 349 | case "$arg" in |
| 350 | --node) node="${2-}"; shift 2 ;; |
| 351 | --host) host="${2-}"; shift 2 ;; |
| 352 | --machine-guid) mg="${2-}"; shift 2 ;; |
| 353 | --) shift; break ;; |
| 354 | -*) |
| 355 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Unknown flag: $arg" >&2 |
| 356 | return 1 |
| 357 | ;; |
| 358 | *) break ;; |
| 359 | esac |
| 360 | done |
| 361 | method="${1:?usage: agents_query_agent --node N --host H --machine-guid M METHOD PATH [BODY]}" |
| 362 | api_path="${2:?path required}" |
| 363 | body="${3:-}" |
| 364 | |
| 365 | : "${node:?--node required}" |
| 366 | : "${host:?--host required}" |
| 367 | : "${mg:?--machine-guid required}" |
| 368 | |
| 369 | local bearer |
| 370 | _agents_resolve_bearer bearer "${node}" "${mg}" "${host}" || return 1 |
| 371 | |
| 372 | local args=(curl --fail --silent --show-error --max-time 120 -X "${method}" \ |
| 373 | -H "X-Netdata-Auth: Bearer ${bearer}" \ |
| 374 | -H 'Content-Type: application/json' \ |
| 375 | "http://${host}/host/${node}${api_path}") |
| 376 | if [[ -n "${body}" ]]; then |
| 377 | args+=(-d "${body}") |
| 378 | fi |
| 379 | agents_run "${args[@]}" |
| 380 | } |
| 381 | |
| 382 | # Convenience: Function call with automatic transport selection. |
| 383 | # Wraps agents_query_cloud (preferred) or agents_query_agent. |
| 384 | # |
| 385 | # Flags: |
| 386 | # --via cloud|agent default: cloud |
| 387 | # --node <node_id> REQUIRED |
| 388 | # --host <host:port> REQUIRED for --via agent |
| 389 | # --machine-guid <mg> REQUIRED for --via agent |
| 390 | # --function <name> REQUIRED (e.g. systemd-journal) |
| 391 | # --body <json> default: {"info":true} |
| 392 | agents_call_function() { |
| 393 | local via="cloud" node="" mg="" host="" fn="" body='{"info":true}' |
| 394 | while (( $# > 0 )); do |
| 395 | local arg="$1" |
| 396 | case "$arg" in |
| 397 | --via) via="${2-}"; shift 2 ;; |
| 398 | --node) node="${2-}"; shift 2 ;; |
| 399 | --machine-guid) mg="${2-}"; shift 2 ;; |
| 400 | --host) host="${2-}"; shift 2 ;; |
| 401 | --function) fn="${2-}"; shift 2 ;; |
| 402 | --body) body="${2-}"; shift 2 ;; |
| 403 | *) |
| 404 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Unknown arg: $arg" >&2 |
| 405 | return 1 |
| 406 | ;; |
| 407 | esac |
| 408 | done |
| 409 | : "${node:?--node required}" |
| 410 | : "${fn:?--function required}" |
| 411 | |
| 412 | case "${via}" in |
| 413 | cloud) |
| 414 | agents_query_cloud POST "/api/v2/nodes/${node}/function?function=${fn}" "${body}" |
| 415 | ;; |
| 416 | agent) |
| 417 | : "${mg:?--machine-guid required for --via agent}" |
| 418 | : "${host:?--host required for --via agent}" |
| 419 | agents_query_agent --node "${node}" --host "${host}" --machine-guid "${mg}" \ |
| 420 | POST "/api/v3/function?function=${fn}" "${body}" |
| 421 | ;; |
| 422 | *) |
| 423 | echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Unknown --via: ${via}" >&2 |
| 424 | return 1 |
| 425 | ;; |
| 426 | esac |
| 427 | } |
| 428 | |
| 429 | # --------------------------------------------------------------------------- |
| 430 | # Self-test: assert no token bytes leak through public wrappers. |
| 431 | # Run with: bash -c 'source _lib.sh; agents_selftest_no_token_leak' |
| 432 | # --------------------------------------------------------------------------- |
| 433 | |
| 434 | agents_selftest_no_token_leak() { |
| 435 | local sentinel='UNIQUE_SENTINEL_TOKEN_xK4mP7qR9sT2vW8y' |
| 436 | local fake_bearer='deadbeef-1234-5678-9abc-def012345678' |
| 437 | local fake_claim='11111111-2222-3333-4444-555555555555' |
| 438 | local fake_node='22222222-3333-4444-5555-666666666666' |
| 439 | local fake_mg='33333333-4444-5555-6666-777777777777' |
| 440 | |
| 441 | # Save real values, swap in sentinels, run wrappers in dry-run, |
| 442 | # capture stdout, restore. |
| 443 | local real_token="${NETDATA_CLOUD_TOKEN:-}" |
| 444 | local real_host="${NETDATA_CLOUD_HOSTNAME:-app.netdata.cloud}" |
| 445 | NETDATA_CLOUD_TOKEN="${sentinel}" |
| 446 | NETDATA_CLOUD_HOSTNAME="${real_host}" |
| 447 | AGENTS_DRY_RUN=1 |
| 448 | |
| 449 | local out="" |
| 450 | |
| 451 | # 1. agents_query_cloud should not echo the sentinel. |
| 452 | out="$(agents_query_cloud GET /api/v2/spaces 2>/dev/null || true)" |
| 453 | if [[ "${out}" == *"${sentinel}"* ]]; then |
| 454 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} agents_query_cloud leaked NETDATA_CLOUD_TOKEN to stdout" >&2 |
| 455 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 456 | return 1 |
| 457 | fi |
| 458 | |
| 459 | # 2. _agents_log_masked must mask Bearer <uuid> patterns. |
| 460 | out="$(_agents_log_masked curl -H "Authorization: Bearer ${sentinel}" \ |
| 461 | -H "X-Netdata-Auth: Bearer ${fake_bearer}" \ |
| 462 | "https://app.netdata.cloud/api/v2/bearer_get_token?node_id=${fake_node}&machine_guid=${fake_mg}&claim_id=${fake_claim}" \ |
| 463 | "https://app.netdata.cloud/api/v3/spaces/${fake_node}/rooms/${fake_mg}/nodes" \ |
| 464 | "http://agent.test:19999/host/${fake_node}/api/v3/function?function=flows:netflow" \ |
| 465 | https://example.invalid 2>&1 1>/dev/null)" |
| 466 | if [[ "${out}" == *"${sentinel}"* ]]; then |
| 467 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_log_masked leaked NETDATA_CLOUD_TOKEN to stderr" >&2 |
| 468 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 469 | return 1 |
| 470 | fi |
| 471 | if [[ "${out}" == *"${fake_bearer}"* ]]; then |
| 472 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_log_masked leaked Bearer <uuid> to stderr" >&2 |
| 473 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 474 | return 1 |
| 475 | fi |
| 476 | if [[ "${out}" == *"${fake_claim}"* || "${out}" == *"${fake_node}"* || "${out}" == *"${fake_mg}"* ]]; then |
| 477 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_log_masked leaked node identity to stderr" >&2 |
| 478 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 479 | return 1 |
| 480 | fi |
| 481 | |
| 482 | # 3. _agents_get_claim_id must write through the caller-provided |
| 483 | # output variable even when the caller names the output variable `claim`. |
| 484 | # This mirrors _agents_resolve_bearer and catches local-variable |
| 485 | # shadowing regressions before direct-agent calls need credentials. |
| 486 | local fake_bin_dir old_path claim |
| 487 | fake_bin_dir="$(mktemp -d)" |
| 488 | old_path="${PATH}" |
| 489 | cat > "${fake_bin_dir}/curl" <<EOF |
| 490 | #!/usr/bin/env bash |
| 491 | printf '%s\n' '{"agents":[{"cloud":{"claim_id":"${fake_claim}"}}]}' |
| 492 | EOF |
| 493 | chmod +x "${fake_bin_dir}/curl" |
| 494 | PATH="${fake_bin_dir}:${PATH}" |
| 495 | claim="" |
| 496 | if ! _agents_get_claim_id claim "agent.test:19999"; then |
| 497 | PATH="${old_path}" |
| 498 | rm -rf "${fake_bin_dir}" |
| 499 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_get_claim_id failed with fake agent info" >&2 |
| 500 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 501 | return 1 |
| 502 | fi |
| 503 | if [[ "${claim}" != "${fake_claim}" ]]; then |
| 504 | PATH="${old_path}" |
| 505 | rm -rf "${fake_bin_dir}" |
| 506 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_get_claim_id did not populate caller output variable" >&2 |
| 507 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 508 | return 1 |
| 509 | fi |
| 510 | if _agents_get_claim_id "not-a-valid-name" "agent.test:19999" 2>/dev/null; then |
| 511 | PATH="${old_path}" |
| 512 | rm -rf "${fake_bin_dir}" |
| 513 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_get_claim_id ignored invalid output variable failure" >&2 |
| 514 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 515 | return 1 |
| 516 | fi |
| 517 | PATH="${old_path}" |
| 518 | rm -rf "${fake_bin_dir}" |
| 519 | |
| 520 | # 4. _agents_set_outvar must preserve shell metacharacters as data. |
| 521 | # This protects bearer/claim assignment from accidental eval-style |
| 522 | # interpretation of external command output. |
| 523 | local assigned marker weird_value |
| 524 | marker="unchanged" |
| 525 | weird_value=$'space * ? ; marker=changed $(echo bad) `bad`\nline2 "quote"' |
| 526 | assigned="" |
| 527 | if ! _agents_set_outvar assigned "${weird_value}"; then |
| 528 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_set_outvar failed on metacharacter payload" >&2 |
| 529 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 530 | return 1 |
| 531 | fi |
| 532 | if [[ "${assigned}" != "${weird_value}" || "${marker}" != "unchanged" ]]; then |
| 533 | echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_set_outvar interpreted metacharacters instead of assigning data" >&2 |
| 534 | NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN |
| 535 | return 1 |
| 536 | fi |
| 537 | |
| 538 | # 5. The unit test passes if all checks above passed. |
| 539 | NETDATA_CLOUD_TOKEN="${real_token}" |
| 540 | unset AGENTS_DRY_RUN |
| 541 | echo -e "${AGENTS_GREEN}[PASS]${AGENTS_NC} no-token-leak self-test" >&2 |
| 542 | return 0 |
| 543 | } |