mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
243 lines
9.4 KiB
Python
243 lines
9.4 KiB
Python
"""sf CLI recipe loader — reads assets/cli/*.yaml, runs argv via subprocess.
|
|
|
|
Security: YAML recipes are loaded via yaml.safe_load only; yaml.load is banned.
|
|
PyYAML's `yaml.load` deserializes arbitrary Python objects (including
|
|
constructor-injected code in some tags) and must never run on attacker-reachable
|
|
YAML. The recipes live inside this repo and are author-controlled, but we
|
|
still use `safe_load` as defence in depth — a compromised dist/ or a botched
|
|
copy-paste can't escalate to code execution.
|
|
|
|
the `_SAFE_LOADER` module-level binding is THE loader used everywhere
|
|
in this module; tests assert its identity against `yaml.safe_load`.
|
|
|
|
every stringification of a subprocess.CalledProcessError or related
|
|
exception is routed through `rest_client.redact_error` so stderr containing
|
|
an access token (rare but observed on some `sf` failure paths) cannot leak
|
|
into logs or RESULT blocks.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
import yaml
|
|
|
|
from config import CLI_DIR
|
|
# hoisted `redact_text` to module-top alongside
|
|
# `redact_error`. Previously `_redact_subprocess_stderr` performed a lazy
|
|
# import of the module-private `_redact_text` on every call, which (a)
|
|
# tied this module to a name that could be renamed silently and (b) meant
|
|
# an ImportError from rest_client would surface only at redaction time —
|
|
# possibly in a frame whose locals held the raw, un-redacted stderr.
|
|
# Hoisting is safe: rest_client does not import sf_cli (no cycle).
|
|
from rest_client import redact_error, redact_text
|
|
|
|
# the ONE allowed loader for this module. Test asserts identity.
|
|
# NEVER change this to yaml.load (which can construct arbitrary Python
|
|
# objects via tag hooks).
|
|
_SAFE_LOADER = yaml.safe_load
|
|
|
|
|
|
class AuthRequired(RuntimeError):
|
|
"""sf CLI returned a known auth-failure pattern.
|
|
|
|
Carries a redacted stderr snippet — any bearer token in stderr is scrubbed
|
|
via redact_error before reaching this message.
|
|
"""
|
|
|
|
|
|
class SfCliError(RuntimeError):
|
|
"""sf CLI returned non-zero exit or JSON status != 0.
|
|
|
|
Message is redacted before construction.
|
|
"""
|
|
|
|
|
|
def _load_recipe(name: str) -> Dict[str, Any]:
|
|
"""Read and parse a CLI recipe file with yaml.safe_load.
|
|
|
|
_SAFE_LOADER is bound to yaml.safe_load at module top; using it
|
|
here (instead of `yaml.safe_load` directly) makes the identity testable
|
|
and gives a single point to review if the loader ever changes.
|
|
"""
|
|
path = CLI_DIR / f"{name}.yaml"
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
recipe = _SAFE_LOADER(fh)
|
|
if not isinstance(recipe, dict):
|
|
raise SfCliError(
|
|
f"recipe {name!r} did not parse to a mapping (got {type(recipe).__name__})"
|
|
)
|
|
return recipe
|
|
|
|
|
|
def _sub(arg: str, params: Dict[str, str]) -> str:
|
|
"""Substitute {{KEY}} placeholders in a single argv element.
|
|
|
|
Input validation: `params` values are expected to be already-validated
|
|
strings (SKILL.md phase 1 runs fs_guard on every user-supplied input).
|
|
We do not revalidate here because argv substitution is shell-escape-safe
|
|
by construction — subprocess.run with a list argv never invokes a shell.
|
|
"""
|
|
out = arg
|
|
for key, value in params.items():
|
|
out = out.replace(f"{{{{{key}}}}}", value)
|
|
return out
|
|
|
|
|
|
def _parse_stdout_json(stdout: str) -> Dict[str, Any]:
|
|
"""Parse sf CLI's --json stdout.
|
|
|
|
sf CLI emits `{"status": 0, "result": {...}}` on success; malformed
|
|
stdout (e.g. when the CLI crashes before JSON serialization) raises
|
|
SfCliError with a redacted message.
|
|
"""
|
|
try:
|
|
return json.loads(stdout)
|
|
except json.JSONDecodeError as e:
|
|
# stdout could in theory contain a token on some crash paths;
|
|
# redact before surfacing.
|
|
raise SfCliError(
|
|
f"sf CLI stdout is not JSON: {redact_error(e)}"
|
|
) from None
|
|
|
|
|
|
_AUTH_LINE_PREFIX_RE = re.compile(r"^(?:Error|Warning):\s") # @rule-suppress starter-sec-002 — re.compile, not python compile()
|
|
|
|
|
|
def _stderr_matches_auth(stderr: str, patterns) -> bool:
|
|
"""Check if any auth-failure pattern appears on an Error:/Warning: line.
|
|
|
|
previously a substring scan across the entire stderr, which
|
|
false-positived on Node ESM warnings ("Warning: @gthoppae/sf-cli-
|
|
plugin-data360 is a linked ESM module") if they happened to echo an
|
|
auth pattern substring. Now we only match on lines starting with
|
|
`Error:` or `Warning:` followed by whitespace. This keeps every
|
|
true-positive we care about (real sf auth errors always surface as
|
|
`Error: NoOrgAuthenticationError ...`) while pruning the noise.
|
|
|
|
Matching rules:
|
|
* Line starts with `Error:<ws>` or `Warning:<ws>` (anchored at
|
|
start-of-line, so embedded-in-prose mentions don't match).
|
|
* Pattern is a substring of the REST of that line (after the
|
|
prefix). We strip only the prefix — we do not re-scope to word
|
|
boundaries, since sf auth tokens like `NoOrgAuthenticationError`
|
|
are always well-formed identifiers without adjacent alphanum.
|
|
* Empty stderr / empty patterns → no match.
|
|
"""
|
|
if not stderr or not patterns:
|
|
return False
|
|
for line in stderr.splitlines():
|
|
m = _AUTH_LINE_PREFIX_RE.match(line)
|
|
if not m:
|
|
continue
|
|
tail = line[m.end():]
|
|
if any(p in tail for p in patterns):
|
|
return True
|
|
return False
|
|
|
|
|
|
def run_sf(name: str, **params: str) -> Dict[str, Any]:
|
|
"""Execute a recipe-defined sf CLI command and return parsed stdout.
|
|
|
|
Steps:
|
|
1. Load recipe (yaml.safe_load).
|
|
2. Enforce required_params.
|
|
3. Substitute {{KEY}} into argv.
|
|
4. subprocess.run with list argv (no shell, no bash -c).
|
|
5. Parse stdout JSON; check `status == 0`.
|
|
6. On failure: classify as AuthRequired (stderr pattern match) or
|
|
SfCliError; every message is redacted via redact_error .
|
|
|
|
Exception semantics:
|
|
* AuthRequired — user needs to `sf org login`
|
|
* SfCliError — anything else (timeout, non-zero exit, JSON parse,
|
|
status != 0)
|
|
|
|
NEVER uses shell=True. NEVER calls log.exception(). NEVER echoes raw
|
|
stderr without redaction .
|
|
"""
|
|
recipe = _load_recipe(name)
|
|
|
|
required = set(recipe.get("required_params") or [])
|
|
missing = required - params.keys()
|
|
if missing:
|
|
raise SfCliError(f"missing required params for {name!r}: {sorted(missing)}")
|
|
|
|
argv_template = recipe.get("argv") or []
|
|
if not isinstance(argv_template, list):
|
|
raise SfCliError(f"recipe {name!r} argv must be a list")
|
|
|
|
argv = [_sub(str(e), params) for e in argv_template]
|
|
timeout = int(recipe.get("timeout_seconds", 60))
|
|
auth_patterns = recipe.get("auth_required_stderr_patterns") or []
|
|
|
|
# SF_TEMP_SHOW_SECRETS=true is required for `sf org display --verbose
|
|
# --json` to emit `accessToken` instead of the literal redaction string
|
|
# `"[REDACTED] Use 'sf org auth show-access-token' to view"` introduced
|
|
# in sf CLI v2. Without it, downstream callers receive the redaction
|
|
# string as the bearer token and every Tooling/REST call returns
|
|
# INVALID_AUTH_HEADER 401. Set on every recipe — recipes that don't
|
|
# touch tokens are unaffected.
|
|
env = {**os.environ, "SF_TEMP_SHOW_SECRETS": "true"}
|
|
try:
|
|
cp = subprocess.run(
|
|
argv,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
env=env,
|
|
)
|
|
except subprocess.TimeoutExpired as e:
|
|
# timeout exceptions carry stderr via e.stderr — redact.
|
|
raise SfCliError(
|
|
f"sf CLI {name!r} timed out after {timeout}s: {redact_error(e)}"
|
|
) from None
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
# any subprocess-layer error gets redacted.
|
|
raise SfCliError(
|
|
f"sf CLI {name!r} invocation failed: {redact_error(e)}"
|
|
) from None
|
|
|
|
# Even on nonzero exit sf CLI usually still emits JSON on stdout; try
|
|
# to parse before deciding failure mode.
|
|
try:
|
|
data = _parse_stdout_json(cp.stdout) if cp.stdout else {}
|
|
except SfCliError:
|
|
data = {}
|
|
|
|
status_field = data.get("status", 0) if isinstance(data, dict) else 1
|
|
if cp.returncode != 0 or status_field != 0:
|
|
# stderr could contain a token on some failure paths; redact
|
|
# the ENTIRE string before deciding how to classify or what to show.
|
|
safe_stderr = _redact_subprocess_stderr(cp.stderr)
|
|
if _stderr_matches_auth(cp.stderr, auth_patterns):
|
|
raise AuthRequired(safe_stderr or "auth required")
|
|
# Include exit code + JSON status in the message (both are
|
|
# numeric/well-known, no token risk) plus the redacted stderr tail.
|
|
raise SfCliError(
|
|
f"sf CLI {name!r} failed "
|
|
f"(exit={cp.returncode}, json_status={status_field}): {safe_stderr}"
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
def _redact_subprocess_stderr(stderr: str) -> str:
|
|
"""run stderr text through the rest_client redaction regexes.
|
|
|
|
stderr may contain `Authorization: Bearer ...` or `accessToken=...` if
|
|
the sf CLI echoes a failing request; scrub before surfacing.
|
|
|
|
calls the public `redact_text` (imported at module
|
|
top). Previously imported the module-private `_redact_text` lazily on
|
|
every call — a rename could have silently broken redaction, and an
|
|
ImportError would have propagated through `run_sf` with the raw stderr
|
|
still live in the traceback's frame locals.
|
|
"""
|
|
return redact_text(stderr or "")
|