fix(photon): isolate ALL secret-touching prints behind auth.py helpers

CodeQL was still flagging three taint-flow alerts in cli.py — its
flow tracker keeps spreading the 'sensitive' label through every
variable that even touched a credential-returning function, including
'has_token = bool(load_photon_token())' and the redacted-response
dict returned by persist_webhook_signing_secret.

Refactor:

1. cli.py _cmd_status now calls a new auth.credential_summary() that
   returns a {key: pre-formatted display string} dict. All probes +
   bool checks happen inside the helper. cli.py never sees a token
   or secret variable, only literals like '✓ stored' / '✗ missing'.

2. persist_webhook_signing_secret(webhook_data, *, on_summary=print)
   now owns the formatting + writing + status messages. It returns
   only a bool. The redacted-response JSON dump + 'saved to <path>'
   confirmation are emitted via the on_summary callback, so cli.py
   passes  as the sink and never receives the path/dict back.

   cli.py is now mechanical: register_webhook → persist (with print)
   → return 0/1. Zero credential-tainted variables in cli.py at all.

3. Tests updated for the new signatures and a credential_summary
   guard added (the helper must never leak raw token/secret bytes
   into its return strings).

Validation:
  tests/plugins/platforms/photon/ → 25/25 pass
  scripts/check-windows-footguns.py --all → 0 footguns
  py_compile clean
This commit is contained in:
Teknium 2026-05-25 19:34:59 -07:00
parent 91db0ab420
commit 55fb422f6f
3 changed files with 111 additions and 45 deletions

View file

@ -426,9 +426,41 @@ def register_webhook(
return data.get("data") or {}
def credential_summary() -> Dict[str, str]:
"""Return a fully pre-formatted credential status dict.
Caller-safe: every value is one of ``"✓ stored"`` / ``"✗ missing"``
/ ``"⚠ unset — verification disabled"`` / ``"✓ set"`` literals, or a
UUID for the project id. No secret-bearing string ever leaves this
function read-and-bool-cast happens entirely inside the closure.
"""
def _present_token() -> str:
return "✓ stored" if load_photon_token() else "✗ missing (run `hermes photon login`)"
def _present_project_id() -> str:
pid, _sec = load_project_credentials()
return pid or "✗ missing"
def _present_project_secret() -> str:
_pid, sec = load_project_credentials()
return "✓ stored" if sec else "✗ missing"
def _present_webhook_secret() -> str:
return "✓ set" if os.getenv("PHOTON_WEBHOOK_SECRET") else "⚠ unset — verification disabled"
return {
"device_token": _present_token(),
"project_id": _present_project_id(),
"project_key": _present_project_secret(),
"webhook_key": _present_webhook_secret(),
}
def persist_webhook_signing_secret(
webhook_data: Dict[str, Any],
) -> Tuple[Optional[Path], Dict[str, Any]]:
*,
on_summary: Optional[Any] = None,
) -> bool:
"""Persist a webhook signing secret via Hermes' canonical .env writer.
Delegates to :func:`hermes_cli.config.save_env_value` the same
@ -438,35 +470,52 @@ def persist_webhook_signing_secret(
``['secret']`` fallback) and handed to that helper without ever
being bound to a local in any module that prints or logs.
Returns ``(path_written | None, redacted_response)``. The redacted
response has any secret-bearing keys replaced with ``"<redacted>"``
so callers can safely dump the rest of the response.
Returns ``True`` on success, ``False`` if the response had no
secret OR the write failed. The optional ``on_summary`` callable
receives a plain string with no credential material, suitable for
printing e.g. ``"Wrote to /home/u/.hermes/.env"`` or
``"register response: {redacted dict json}"``. We do the
formatting here so callers stay clear of the taint flow CodeQL
tracks through functions that touch secrets.
"""
if not isinstance(webhook_data, dict):
return None, {}
return False
has_secret = bool(webhook_data.get("signingSecret") or webhook_data.get("secret"))
redacted = {
k: ("<redacted>" if k in ("signingSecret", "secret") else v)
for k, v in webhook_data.items()
}
if on_summary is not None:
try:
on_summary("webhook registration response (redacted):")
on_summary(json.dumps(redacted, indent=2))
except Exception:
pass
if not has_secret:
return None, redacted
return False
try:
from hermes_cli.config import save_env_value # type: ignore
except ImportError:
return None, redacted
return False
try:
save_env_value(
"PHOTON_WEBHOOK_SECRET",
webhook_data.get("signingSecret") or webhook_data.get("secret") or "",
)
except Exception:
return None, redacted
try:
from hermes_constants import get_hermes_home # type: ignore
return Path(get_hermes_home()) / ".env", redacted
except Exception:
return Path(os.path.expanduser("~/.hermes")) / ".env", redacted
return False
if on_summary is not None:
try:
from hermes_constants import get_hermes_home # type: ignore
env_path = Path(get_hermes_home()) / ".env"
except Exception:
env_path = Path(os.path.expanduser("~/.hermes")) / ".env"
try:
on_summary(f"signing key saved to {env_path}")
on_summary("(Photon only returns this once — keep the file safe)")
except Exception:
pass
return True
def list_webhooks(project_id: str, project_secret: str) -> list:

View file

@ -199,26 +199,20 @@ def _cmd_setup(args: argparse.Namespace) -> int:
def _cmd_status(_args: argparse.Namespace) -> int:
has_token = bool(photon_auth.load_photon_token())
proj_id_raw, proj_secret_raw = photon_auth.load_project_credentials()
has_project_id = bool(proj_id_raw)
has_project_secret = bool(proj_secret_raw)
project_id_display = proj_id_raw if has_project_id else "✗ missing"
summary = photon_auth.credential_summary()
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
has_webhook_secret = bool(os.getenv("PHOTON_WEBHOOK_SECRET"))
# All values are pre-formatted display strings from auth.credential_summary;
# no secret-bearing variable enters this function's scope.
print("Photon iMessage status")
print("──────────────────────")
print(f" device token : {'✓ stored' if has_token else '✗ missing (run `hermes photon login`)'}")
print(f" project id : {project_id_display}")
# Label intentionally avoids the word "secret" so static taint
# analyzers don't flag the literal "✓ stored" / "✗ missing" string
# as sensitive-data exposure.
print(f" project key : {'✓ stored' if has_project_secret else '✗ missing'}")
print(f" device token : {summary['device_token']}")
print(f" project id : {summary['project_id']}")
print(f" project key : {summary['project_key']}")
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
print(f" webhook key : {'✓ set' if has_webhook_secret else '⚠ unset — verification disabled'}")
print(f" webhook key : {summary['webhook_key']}")
return 0
@ -265,11 +259,12 @@ def _cmd_webhook(args: argparse.Namespace) -> int:
except Exception as e:
print(f"register failed: {e}", file=sys.stderr)
return 1
# Hand the raw response straight to the persistence helper —
# the signing-secret value never gets bound to a local here.
wrote, redacted = photon_auth.persist_webhook_signing_secret(data)
print(json.dumps(redacted, indent=2))
if wrote is None:
# The helper does all the formatting + writing; cli.py never
# touches the signing-secret value, the path it was written
# to, or even the redacted-response dict. on_summary is a
# plain printer callback.
ok = photon_auth.persist_webhook_signing_secret(data, on_summary=print)
if not ok:
print(
"‼ Photon returned no signing secret in the response, "
"or the file write failed. Inspect your home directory "
@ -278,9 +273,6 @@ def _cmd_webhook(args: argparse.Namespace) -> int:
file=sys.stderr,
)
return 1
print()
print(f"✓ Wrote PHOTON_WEBHOOK_SECRET to {wrote}")
print(" (Photon only returns this once — keep the .env file safe)")
return 0
if sub == "list":

View file

@ -215,27 +215,52 @@ def test_persist_webhook_signing_secret_writes_env(
tmp_hermes_home: Path,
) -> None:
"""The helper hands the secret to save_env_value, never returns it."""
summary: list = []
response = {
"id": "wh-uuid",
"webhookUrl": "https://x.example.com/hook",
"signingSecret": "ABCDEF1234567890" * 4,
}
path, redacted = photon_auth.persist_webhook_signing_secret(response)
ok = photon_auth.persist_webhook_signing_secret(
response, on_summary=summary.append,
)
assert path is not None
assert path.exists()
env_text = path.read_text()
assert ok is True
env_path = tmp_hermes_home / ".env"
assert env_path.exists()
env_text = env_path.read_text()
assert "PHOTON_WEBHOOK_SECRET=ABCDEF1234567890" in env_text
# The returned redacted copy must not leak the secret.
assert redacted["signingSecret"] == "<redacted>"
assert redacted["webhookUrl"] == "https://x.example.com/hook"
# The on_summary callback gets the redacted response + a saved-to path;
# none of those strings should leak the raw secret.
joined = "\n".join(summary)
assert "<redacted>" in joined
assert "ABCDEF1234567890" not in joined
def test_persist_webhook_signing_secret_no_secret_no_write(
tmp_hermes_home: Path,
) -> None:
path, redacted = photon_auth.persist_webhook_signing_secret(
{"id": "wh-uuid", "webhookUrl": "https://x"}
summary: list = []
ok = photon_auth.persist_webhook_signing_secret(
{"id": "wh-uuid", "webhookUrl": "https://x"},
on_summary=summary.append,
)
assert path is None
assert "<redacted>" not in redacted.values()
assert ok is False
# No env file written; summary callback still received the redacted
# response (without a signingSecret key, nothing to redact).
assert not (tmp_hermes_home / ".env").exists()
def test_credential_summary_returns_only_display_strings(
tmp_hermes_home: Path,
) -> None:
"""credential_summary must not leak raw token/secret material."""
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
photon_auth.store_project_credentials("proj-uuid", "secret-bbbbbbbbbbb")
summary = photon_auth.credential_summary()
blob = "\n".join(summary.values())
assert "token-aaaa" not in blob
assert "secret-bbbb" not in blob
assert summary["device_token"].startswith("")
assert summary["project_key"].startswith("")
assert summary["project_id"] == "proj-uuid"