fix(api,skills): harden secrets door — no secret value to stdout (DPLAN-0211, clears CodeQL #86-88)
PR #640's only failing required check was Code scanning/CodeQL: 3 HIGH py/clear-text-logging-sensitive-data alerts where get-secret printed raw secret values to stdout. Research (OWASP, CodeQL rule source, secret-CLI survey) confirmed a real exposure — acute for AIPass since it runs inside Claude Code, which captures command stdout into model context, and the telegram skill shelled out to get-secret and parsed the token from stdout. @api (P1): - NEW apps/modules/secrets.py — in-process cross-branch door (get_secret, list_secrets) wrapping the auth handler; consumers import this, not the CLI. - get_secret_cmd rewritten: masked summary by default ('slug: set (N chars)'), --out FILE writes the raw value 0o600 and prints only the path, --list shows slug names via console.print. All 3 raw-value print() sinks removed. - bypass.json reasoning + README + help updated. @skills (P2): - telegram config._get_secret / list_bot_configs rewired from subprocess+stdout parse to the in-process aipass.api.apps.modules.secrets API; subprocess/json imports dropped. Tests + SKILL.md updated. Also: seedgo test_checkers_batch2.py — comment the synthetic sk-or-v1 fixture keys as FAKE (not real credentials). Verified: @api 504 tests + seedgo 100%; telegram 452/452; skills 252/252; no secret reaches stdout by any path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c8ae084f54
commit
a75910a4e6
@@ -172,19 +172,32 @@ def init_env():
|
||||
|
||||
|
||||
def get_secret_cmd(args: List[str]):
|
||||
"""Orchestrate secret retrieval workflow (machine-readable output)"""
|
||||
"""Orchestrate secret retrieval workflow (masked output only — no raw values to stdout)"""
|
||||
import json
|
||||
import os
|
||||
|
||||
if not args:
|
||||
error("Usage: drone @api get-secret <provider/slug> [--json] [--list]")
|
||||
error("Usage: drone @api get-secret <provider/slug> [--out FILE] [--json] [--list]")
|
||||
return
|
||||
|
||||
has_json = "--json" in args
|
||||
has_list = "--list" in args
|
||||
has_out = "--out" in args
|
||||
out_file = None
|
||||
if has_out:
|
||||
out_idx = args.index("--out")
|
||||
if out_idx + 1 < len(args):
|
||||
out_file = args[out_idx + 1]
|
||||
else:
|
||||
error("--out requires a file path argument")
|
||||
return
|
||||
|
||||
clean_args = [a for a in args if not a.startswith("--")]
|
||||
if has_out and out_file in clean_args:
|
||||
clean_args.remove(out_file)
|
||||
|
||||
if not clean_args:
|
||||
error("Usage: drone @api get-secret <provider/slug> [--json] [--list]")
|
||||
error("Usage: drone @api get-secret <provider/slug> [--out FILE] [--json] [--list]")
|
||||
return
|
||||
|
||||
parts = clean_args[0].split("/", 1)
|
||||
@@ -193,7 +206,8 @@ def get_secret_cmd(args: List[str]):
|
||||
if has_list:
|
||||
slugs = secrets.list_secrets(provider)
|
||||
for slug in slugs:
|
||||
print(slug)
|
||||
# codeql[py/clear-text-logging-sensitive-data] # slug names are identifiers, not secret values
|
||||
console.print(slug)
|
||||
return
|
||||
|
||||
if len(parts) != 2 or not parts[1]:
|
||||
@@ -201,19 +215,23 @@ def get_secret_cmd(args: List[str]):
|
||||
return
|
||||
|
||||
slug = parts[1]
|
||||
result = secrets.get_secret(provider, slug, as_json=has_json)
|
||||
|
||||
if has_json:
|
||||
result = secrets.get_secret(provider, slug, as_json=True)
|
||||
if result is not None:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
error(f"Secret not found: {provider}/{slug}")
|
||||
if result is None:
|
||||
error(f"Secret not found: {provider}/{slug}")
|
||||
return
|
||||
|
||||
if out_file:
|
||||
content = json.dumps(result, indent=2) if has_json else str(result)
|
||||
fd = os.open(out_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, content.encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
success(f"Wrote {provider}/{slug} to {out_file}")
|
||||
else:
|
||||
result = secrets.get_secret(provider, slug)
|
||||
if result is not None:
|
||||
print(result)
|
||||
else:
|
||||
error(f"Secret not found: {provider}/{slug}")
|
||||
value_len = len(json.dumps(result)) if has_json else len(str(result))
|
||||
success(f"{provider}/{slug}: set ({value_len} chars)")
|
||||
|
||||
|
||||
def fetch_api_key(provider: str = "openrouter"):
|
||||
@@ -255,12 +273,21 @@ EXAMPLES:
|
||||
# Get key for provider
|
||||
drone @api get-key openrouter
|
||||
|
||||
# Get secret for provider/slug
|
||||
# Check if a secret exists (masked summary, no raw value)
|
||||
drone @api get-secret telegram/bot
|
||||
|
||||
# Write secret to a protected file
|
||||
drone @api get-secret telegram/bot --out /tmp/token.txt
|
||||
|
||||
# Write secret as JSON to a protected file
|
||||
drone @api get-secret telegram/bot --out /tmp/bot.json --json
|
||||
|
||||
# List secrets for a provider
|
||||
drone @api get-secret telegram --list
|
||||
|
||||
# Programmatic access (in-process, no stdout):
|
||||
# from aipass.api.apps.modules.secrets import get_secret
|
||||
|
||||
# Validate key
|
||||
drone @api validate openrouter
|
||||
|
||||
|
||||
Reference in New Issue
Block a user