---
title: "4.4.5 Credenciales seguras y permisos mínimos"
description: "Dónde guardar las claves de un sistema de trading, cómo evitar que se filtren en logs y repositorios, permisos sin retirada, IP y rotación sin cortes."
url: "https://pythonparatrading.com/curso-python-trading/nivel-4/credenciales-seguras-y-permisos-minimos/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-4/credenciales-seguras-y-permisos-minimos.png"
---

# 4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos

> Una clave de API con permiso de retirada, sin restricción de IP y escrita en un fichero del repositorio es una cuenta abierta. Esta lección recorre el camino de una credencial, desde dónde se guarda hasta cómo se revoca, con claves de juguete que no se pueden confundir con reales: lectura desde ficheros de credenciales, objetos que no se dejan imprimir, logs tachados, un escáner de secretos, un exchange simulado con permisos y lista de IP, y una rotación sin peticiones fallidas.

- Curso: [Nivel 4: Carteras, riesgo, ejecución y trading en real](https://pythonparatrading.com/curso-python-trading/nivel-4/)
- Módulo: 4.4 · Brokers y APIs
- Duración: 60 min
- Requisitos: [0.2.4 · Terminal básica, rutas, variables de entorno y ficheros .env](https://pythonparatrading.com/curso-python-trading/nivel-0/terminal-rutas-y-variables-de-entorno/), [0.4.5 · Logging profesional y testing con pytest](https://pythonparatrading.com/curso-python-trading/nivel-0/logging-y-testing-con-pytest/), [3.7.4 · ccxt para operar en varios exchanges](https://pythonparatrading.com/curso-python-trading/nivel-3/ccxt-multi-exchange/), [4.4.3 · Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20](https://pythonparatrading.com/curso-python-trading/nivel-4/alpaca-tradier-metatrader-5-y-oanda/), [4.4.4 · APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase](https://pythonparatrading.com/curso-python-trading/nivel-4/apis-cripto-ccxt-y-nativas/)

## Al terminar esta lección

- Elegir dónde vive cada credencial (entorno, fichero de credenciales, llavero del sistema, gestor de secretos) según sus riesgos y su coste.
- Evitar que un secreto se escape por `print`, logs, excepciones, procesos hijos o el repositorio, y detectar los que ya se escaparon.
- Diseñar los permisos de cada clave por proceso: lectura, operar sin retirada y restricción por IP, y saber qué ofrece cada bróker o exchange.
- Rotar una clave sin cortar el sistema y tener un procedimiento para cuando una se filtra.

## Qué se protege y de quién

Las lecciones 4.4.3 y 4.4.4 usaron claves falsas contra simuladores. En real, cada bróker da una credencial distinta
y cada una abre una puerta distinta:

| Bróker o exchange | Credencial | Qué permite si se filtra |
|---|---|---|
| Exchanges cripto (Binance, Bybit, Kraken, Coinbase) | Clave y secreto (o clave privada Ed25519, RSA o ECDSA) | Lo que digan sus permisos: leer, operar, retirar |
| Alpaca | Clave y secreto, distintos para papel y real | Leer y operar en la cuenta; según Alpaca, las claves de la Trading API no pueden ordenar transferencias bancarias |
| OANDA | Token personal | Acceso a todas las subcuentas de la cuenta; OANDA no lo guarda, así que si se pierde se revoca y se genera otro |
| Interactive Brokers | Usuario, contraseña y segundo factor en TWS o IB Gateway | La sesión de la API la abre el programa local; la API tiene modo de solo lectura y lista de IP de confianza |
| MetaTrader 5 | Número de cuenta, contraseña y servidor | La cuenta entera desde cualquier terminal |

Las fuentes de cada fila están en la sección de permisos. Lo que hay que proteger no es solo la clave: también el
token de un JWT mientras vale (lección 4.4.4), una URL firmada mientras está en la ventana de tiempo y el fichero
de sesión de un terminal.

De quién se protege, en orden de frecuencia real: de uno mismo (un `print`, una captura de pantalla, un notebook
guardado con la salida, un commit), de los registros (logs que se copian a otros sistemas o a un servicio externo),
de la propia máquina (otros procesos y usuarios, copias de seguridad) y, por último, de un atacante que entra en el
servidor o en una dependencia. Los tres primeros no necesitan a nadie malintencionado para que la clave acabe donde
no debe.

```mermaid
flowchart TB
  G[Gestor o fichero] --> P[Proceso del bot]
  P --> F[Firma y cabecera]
  F --> X[Bróker o exchange]
  P -.-> L[Logs]
  P -.-> H[Procesos hijos]
  P -.-> R[Repo y notebooks]
```

Las flechas continuas son el camino que debe seguir la credencial; las discontinuas, las fugas que esta lección
cierra una por una.

### Claves de juguete

Las celdas usan claves que no se pueden confundir con reales: empiezan por `FALSA_` y el resto sale del SHA-256 del
nombre. Ni siquiera esas se imprimen: para identificar una clave sin revelarla basta su **huella**, los primeros
caracteres del SHA-256 de su valor, que es lo que se puede escribir en un log o en un informe.

```python
import hashlib

def falsa(nombre, largo=40):
    """Clave de juguete: prefijo FALSA_ y relleno determinista."""
    return "FALSA_" + hashlib.sha256(nombre.encode()).hexdigest()[
        : largo - 6]

def huella(valor):
    """Identifica una clave sin revelarla: 8 hex de su SHA-256."""
    return hashlib.sha256(valor.encode()).hexdigest()[:8]

CLAVES = {n: falsa(n) for n in ["ALPACA_KEY_ID", "ALPACA_SECRET",
                                "BINANCE_SECRET", "OANDA_TOKEN"]}
for n, v in CLAVES.items():
    print(f"{n:<15} {len(v)} caracteres  huella {huella(v)}")
```
```output
ALPACA_KEY_ID   40 caracteres  huella eed46cb8
ALPACA_SECRET   40 caracteres  huella e9633bee
BINANCE_SECRET  40 caracteres  huella 45d99b76
OANDA_TOKEN     40 caracteres  huella fcdf7418
```

## Dónde vive cada credencial

La lección 0.2.4 enseñó a leer variables de entorno y ficheros `.env` y a mostrar la configuración sin los secretos.
Es el mínimo, y para un ordenador personal suele bastar. En un servidor que opera solo, hay que ir más lejos, porque
las variables de entorno tienen dos problemas que su propia documentación reconoce:

- **Se heredan y se exponen.** La página de systemd sobre la ejecución de servicios dice que las variables de entorno
  «no son adecuadas para pasar secretos» a un servicio: se exponen a clientes sin privilegios por D-Bus y se
  propagan a todo el árbol de procesos. Recomienda sus **credenciales** (`LoadCredential=`), que se entregan como
  ficheros en un directorio cuya ruta está en `$CREDENTIALS_DIRECTORY` y que no se propagan a los hijos. La guía de
  secretos de Docker Compose dice lo mismo con otras palabras («las variables de entorno suelen estar disponibles para
  todos los procesos» y pueden acabar en los logs) y monta cada secreto como un fichero en `/run/secrets/<nombre>`[^1].
- **Se pueden leer desde fuera del proceso.** En Linux, `/proc/<pid>/environ` contiene el entorno inicial de un
  proceso; lo puede leer quien pase la comprobación de acceso de `ptrace`, que en la práctica es el mismo usuario o
  root[^2].

La guía de gestión de secretos de OWASP resume la recomendación: las variables de entorno «no se recomiendan salvo
que los otros métodos no sean posibles»[^3]. Una función que lee una credencial con ese orden de preferencia, y que
se niega a leer un fichero que pueden leer otros usuarios:

```python
import os
import stat
import tempfile
from pathlib import Path

class CredencialAusente(LookupError):
    pass

def leer_credencial(nombre):
    """Fichero en $CREDENTIALS_DIRECTORY (systemd) o /run/secrets
    (Docker); si no, variable de entorno; si no, error."""
    for d in filter(None, [os.environ.get("CREDENTIALS_DIRECTORY"),
                           "/run/secrets"]):
        p = Path(d) / nombre
        if p.is_file():
            modo = p.stat().st_mode
            if modo & (stat.S_IRWXG | stat.S_IRWXO):
                raise PermissionError(
                    f"{nombre}: permisos {stat.filemode(modo)}; solo "
                    "debe poder leerlo su dueño (chmod 600)")
            return p.read_text().strip(), "fichero"
    if nombre in os.environ:
        return os.environ[nombre], "entorno"
    raise CredencialAusente(f"falta {nombre}; sin valor por defecto")

credenciales = Path(tempfile.mkdtemp(prefix="cred-"))
os.environ["CREDENTIALS_DIRECTORY"] = str(credenciales)
fichero = credenciales / "ALPACA_SECRET"
fichero.write_text(CLAVES["ALPACA_SECRET"] + "\n")
fichero.chmod(0o644)                       # legible por todos
os.environ["ALPACA_KEY_ID"] = CLAVES["ALPACA_KEY_ID"]

for n in ["ALPACA_KEY_ID", "ALPACA_SECRET", "OANDA_TOKEN"]:
    try:
        v, origen = leer_credencial(n)
        print(f"{n:<14} {origen:<8} huella {huella(v)}")
    except (PermissionError, CredencialAusente) as e:
        print(f"{n:<14} {type(e).__name__}: {e}")
fichero.chmod(0o600)
v, origen = leer_credencial("ALPACA_SECRET")
print("tras chmod 600:", origen, huella(v))
```
```output
ALPACA_KEY_ID  entorno  huella eed46cb8
ALPACA_SECRET  PermissionError: ALPACA_SECRET: permisos -rw-r--r--; solo debe poder leerlo su dueño (chmod 600)
OANDA_TOKEN    CredencialAusente: falta OANDA_TOKEN; sin valor por defecto
tras chmod 600: fichero e9633bee
```

Tres comportamientos deliberados. La clave de Alpaca llega del entorno porque no hay fichero. El secreto está en un
fichero que pueden leer todos (`-rw-r--r--`) y la función se niega a usarlo hasta que los permisos son de solo dueño:
es el mismo control que hace `ssh` con las claves privadas. Y el token de OANDA no existe: la función lanza un error
en lugar de devolver un valor por defecto, porque un secreto por defecto es un secreto en el código.

Con systemd, el servicio declara la credencial y el programa la lee del directorio, sin variables de entorno:

```ini
# /etc/systemd/system/bot.service (fragmento)
[Service]
User=bot
LoadCredential=ALPACA_SECRET:/etc/bot/credenciales/alpaca_secret
ExecStart=/opt/bot/.venv/bin/python -m bot
```

### Llaveros del sistema y gestores de secretos

Para un ordenador de escritorio, el llavero del sistema operativo guarda la credencial cifrada con la sesión del
usuario. La librería `keyring` (versión 25.7.0, licencia MIT) usa el Llavero de macOS, el almacén de credenciales de
Windows y Secret Service o KWallet en Linux; en un servidor sin escritorio necesita levantar un servicio de llavero
y desbloquearlo, lo que le quita gran parte de la ventaja[^4]. `python-dotenv` (1.2.4, BSD-3-Clause) carga un `.env`
en `os.environ` sin sobrescribir las variables que ya existen, la misma precedencia que el lector propio de la
lección 0.2.4. Ninguna de las dos está en el entorno del curso:

```python skip title="llavero_y_dotenv.py"
import keyring                                 # uv add keyring
from dotenv import load_dotenv                 # uv add python-dotenv

keyring.set_password("bot-trading", "binance", "<se pega una vez>")
secreto = keyring.get_password("bot-trading", "binance")

load_dotenv(".env", override=False)            # el entorno manda
```

En la nube, cada proveedor tiene su gestor de secretos: guarda el valor cifrado, registra quién lo lee, da permisos
por identidad de la máquina (sin claves para leer las claves) y puede rotar el secreto. La llamada para leer uno es
parecida en todos[^5]:

```python skip title="gestores_en_la_nube.py"
# AWS Secrets Manager (boto3)
import boto3
sm = boto3.client("secretsmanager")
secreto = sm.get_secret_value(SecretId="bot/binance")["SecretString"]

# Google Cloud Secret Manager
from google.cloud import secretmanager
cliente = secretmanager.SecretManagerServiceClient()
nombre = "projects/mi-proyecto/secrets/binance/versions/latest"
secreto = cliente.access_secret_version(
    request={"name": nombre}).payload.data.decode("UTF-8")

# Azure Key Vault
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
kv = SecretClient(vault_url="https://mi-boveda.vault.azure.net/",
                  credential=DefaultAzureCredential())
secreto = kv.get_secret("binance").value

# HashiCorp Vault (hvac), motor KV versión 2
import hvac
secreto = hvac.Client().secrets.kv.v2.read_secret_version(
    path="bot/binance")["data"]["data"]["secret"]
```

| Opción | Cifrado en reposo | Auditoría de lecturas | Rotación | Coste y riesgo propio |
|---|---|---|---|---|
| Variable de entorno o `.env` | No | No | Manual | Se hereda y se filtra con facilidad |
| Fichero de credenciales (systemd, Docker) | Opcional (`LoadCredentialEncrypted=` en systemd) | No | Manual | Depende de los permisos del fichero |
| Llavero del sistema (`keyring`) | Sí, con la sesión del usuario | No | Manual | Incómodo en servidores sin escritorio |
| Gestor en la nube o Vault | Sí | Sí | Sí, programable | Otra dependencia: si no responde, el bot no arranca; coste por lectura |

La documentación de AWS recomienda guardar en caché el valor leído: un bot que pide el secreto en cada orden
multiplica el coste y añade un punto de fallo a cada petición. La caché, a su vez, es la que hace que una rotación
tarde en llegar a todas las réplicas, como se verá en la simulación de rotación.

## Que no se escape

### Un tipo que no se deja imprimir

La mayoría de las fugas son un valor que se imprime sin querer: en un `print` de depuración, en el `repr` de un
diccionario de configuración, en un `f-string` de un mensaje de error. Un tipo propio cuyo `repr` es la huella
corta todas esas vías a la vez, y obliga a escribir `.revelar()` en el único sitio donde hace falta el valor: al
firmar.

```python
import json
import pickle

class Secreto:
    __slots__ = ("nombre", "_valor")

    def __init__(self, nombre, valor):
        self.nombre, self._valor = nombre, valor

    def revelar(self):
        return self._valor

    def __repr__(self):
        return f"<Secreto {self.nombre} {huella(self._valor)}>"

    __str__ = __repr__

    def __reduce__(self):
        raise TypeError("un Secreto no se serializa")

s = Secreto("BINANCE_SECRET", CLAVES["BINANCE_SECRET"])
config = {"exchange": "binance", "secret": s}
print(s)
print(f"firmando con {s}")
print(config)
for nombre, f in [("json", json.dumps), ("pickle", pickle.dumps)]:
    try:
        f(config)
    except TypeError as e:
        print(f"{nombre}: {e}")
```
```output
<Secreto BINANCE_SECRET 45d99b76>
firmando con <Secreto BINANCE_SECRET 45d99b76>
{'exchange': 'binance', 'secret': <Secreto BINANCE_SECRET 45d99b76>}
json: Object of type Secreto is not JSON serializable
pickle: un Secreto no se serializa
```

El secreto aparece como su huella en `print`, en el `f-string` y dentro del diccionario. `json.dumps` no sabe
serializarlo y `pickle` lo tiene prohibido: la configuración no se puede volcar a un fichero o a una caché con el
secreto dentro por accidente. No es un cifrado: el valor sigue en la memoria del proceso y `revelar()` lo devuelve.
Protege de los errores, no de alguien que ya está dentro del proceso.

### Logs tachados

La guía de logging de OWASP lista lo que no debe registrarse tal cual: identificadores de sesión, tokens de acceso,
contraseñas, cadenas de conexión y claves de cifrado, entre otros[^3]. Un sistema de trading registra peticiones
HTTP para poder depurarlas, y una petición firmada lleva la clave en una cabecera y la firma en la URL. La firma no
es la clave, pero mientras está dentro de la ventana de tiempo sirve para repetir la petición.

Un `logging.Filter` puede modificar el registro, pero no ve el texto de la traza de una excepción, que se añade al
formatear. Por eso el tachado se hace en un `Formatter`, sobre el texto final: los valores conocidos se cambian por
su huella y los patrones (firmas, cabeceras de clave, tokens de portador), por una marca. Para que la salida de la
lección no dependa de rutas de ficheros, la traza se reduce a su última línea:

```python
import logging
import re
import sys

class Tachador(logging.Formatter):
    PATRONES = [
        (re.compile(r"(signature=)[0-9a-fA-F]+"), r"\1[TACHADO]"),
        (re.compile(r"(Bearer )[\w.~+/=-]+"), r"\1[TACHADO]"),
        (re.compile(r"('X-MBX-APIKEY': ')[^']+"), r"\1[TACHADO]"),
    ]

    def __init__(self, fmt, secretos):
        super().__init__(fmt)
        self.secretos = [x.revelar() for x in secretos]

    def formatException(self, ei):
        return "  " + super().formatException(ei).splitlines()[-1]

    def format(self, record):
        texto = super().format(record)
        for v in self.secretos:
            texto = texto.replace(v, f"[TACHADO {huella(v)}]")
        for patron, cambio in self.PATRONES:
            texto = patron.sub(cambio, texto)
        return texto

log = logging.getLogger("bot.http")
log.setLevel(logging.DEBUG)
log.propagate = False
salida = logging.StreamHandler(sys.stdout)
salida.setFormatter(Tachador("%(levelname)s %(message)s", [s]))
log.addHandler(salida)

firma = hashlib.sha256(b"una firma cualquiera").hexdigest()
log.debug("POST /api/v3/order?symbol=XSNUSDT&timestamp=1760449080805"
          "&signature=%s cabeceras %s", firma,
          {"X-MBX-APIKEY": falsa("BINANCE_KEY")})
log.info("websocket: Authorization: Bearer %s", falsa("JWT", 60))
try:
    raise ValueError(f"secreto con formato raro: {s.revelar()}")
except ValueError:
    log.exception("fallo al firmar")
```
```output
DEBUG POST /api/v3/order?symbol=XSNUSDT&timestamp=1760449080805&signature=[TACHADO] cabeceras {'X-MBX-APIKEY': '[TACHADO]'}
INFO websocket: Authorization: Bearer [TACHADO]
ERROR fallo al firmar
  ValueError: secreto con formato raro: [TACHADO 45d99b76]
```

La URL conserva todo lo útil para depurar (ruta, símbolo, hora) y pierde la firma; la cabecera, la clave; el token,
su valor. La última línea es el caso que se escapa a casi todos: un mensaje de excepción construido con el valor del
secreto (aquí a propósito; en la vida real, una librería que incluye la petición en su error). El formateador lo
sustituye por la huella. Los patrones son una red para lo que no se conoce de antemano; lo que sí se conoce (los
valores cargados al arrancar) se tacha por valor, que no depende de acertar con una expresión regular.

### Lo que hereda un proceso hijo

```python
import subprocess

os.environ["PPT_FALSA_SECRET"] = falsa("PPT_FALSA_SECRET")
hijo = [sys.executable, "-c",
        "import os; print('PPT_FALSA_SECRET' in os.environ)"]
r = subprocess.run(hijo, capture_output=True, text=True)
print("hijo con el entorno heredado:", r.stdout.strip())
limpio = {k: v for k, v in os.environ.items()
          if not k.startswith("PPT_") and "SECRET" not in k
          and k not in ("ALPACA_KEY_ID", "CREDENTIALS_DIRECTORY")}
r = subprocess.run(hijo, capture_output=True, text=True, env=limpio)
print("hijo con entorno filtrado:   ", r.stdout.strip())
```
```output
hijo con el entorno heredado: True
hijo con entorno filtrado:    False
```

El hijo ve la variable con el secreto; con `env=` filtrado, no. Un bot lanza hijos más a menudo de lo que parece: un
informe en otro proceso, un `git` para registrar la versión, una herramienta de compresión de logs. Cada uno recibe
las claves si viven en el entorno, y cualquiera de ellos puede escribirlas en un volcado o un mensaje de error. Con
ficheros de credenciales el problema no existe: el hijo solo lee lo que se le pasa.

## Detectar fugas antes del commit

Si una clave llega al repositorio, borrarla en el commit siguiente no sirve: sigue en el historial, en los clones y
en las copias que hicieran otros. Hay que detectarla **antes** del commit. Las herramientas reales combinan patrones
de proveedores conocidos con medidas de lo aleatorio que parece un texto; la versión mínima de la segunda es la
**entropía de Shannon** por carácter, $H = -\sum_i p_i \log_2 p_i$, con $p_i$ la frecuencia de cada carácter. Un
texto en hexadecimal aleatorio se acerca a 4 bits por carácter; una palabra o un identificador legible queda por
debajo de 3,5.

La celda crea un repositorio de juguete con las fugas típicas (una clave escrita en `config.py`, una cabecera con un
token guardada en la salida de un notebook, un `.env` que sí está en `.gitignore`) y un fichero de tests con un
identificador de orden de Alpaca:

```python
repo = Path(tempfile.mkdtemp(prefix="repo-"))
(repo / ".gitignore").write_text(".env\n__pycache__/\n")
(repo / ".env").write_text(
    f"BINANCE_SECRET={CLAVES['BINANCE_SECRET']}\n")
(repo / "config.py").write_text(
    'SIMBOLO = "XSNUSDT"\n'
    'ESTRATEGIA = "momentum_veinte_sesiones"\n'
    f'API_SECRET = "{falsa("API_SECRET")}"   # solo para probar\n')
(repo / "analisis.ipynb").write_text(json.dumps({"cells": [{
    "outputs": [{"text": [
        f"Authorization: Bearer {falsa('OANDA_TOKEN', 48)}\n"]}]}]}))
(repo / "test_ordenes.py").write_text(
    'RELLENO = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"\n'
    'ID_ORDEN = "6f1c2a9e-4b7d-4c1a-9e55-000000039595"\n')
(repo / "README.md").write_text(
    "Estrategia de rebalanceo_diario_al_cierre con ETF.\n")
print(sorted(p.name for p in repo.iterdir()))
```
```output
['.env', '.gitignore', 'README.md', 'analisis.ipynb', 'config.py', 'test_ordenes.py']
```

```python
import collections
import fnmatch
import math

TOKEN = re.compile(r"[A-Za-z0-9_+/=-]{20,}")

def entropia(texto):
    n = len(texto)
    return -sum(c / n * math.log2(c / n)
                for c in collections.Counter(texto).values())

def escanear(repo, umbral=3.5, permitidos=()):
    ignorados = (repo / ".gitignore").read_text().split()
    hallazgos, todos = [], []
    for p in sorted(repo.rglob("*")):
        if not p.is_file() or p.name == ".gitignore" or any(
                fnmatch.fnmatch(p.name, g.rstrip("/"))
                for g in ignorados):
            continue
        for i, linea in enumerate(p.read_text().splitlines(), 1):
            for t in TOKEN.findall(linea):
                h = entropia(t)
                todos.append((p.name, len(t), round(h, 2)))
                if h >= umbral and not any(
                        re.fullmatch(x, t) for x in permitidos):
                    hallazgos.append((f"{p.name}:{i}", len(t),
                                      round(h, 2), huella(t)))
    return hallazgos, todos

hallazgos, todos = escanear(repo)
for h in hallazgos:
    print("posible secreto en {} ({} caracteres, H = {}) huella {}"
          .format(*h))
print("textos largos revisados:", len(todos))
```
```output
posible secreto en analisis.ipynb:1 (48 caracteres, H = 4.17) huella 3ed2901d
posible secreto en config.py:3 (40 caracteres, H = 4.01) huella bc1df40e
posible secreto en test_ordenes.py:2 (36 caracteres, H = 3.68) huella 0d85cfe0
textos largos revisados: 6
```

El escáner encuentra la clave de `config.py` y el token del notebook, que es una fuga especialmente traicionera:
el código del notebook no tiene ninguna clave, pero su salida guardada sí. No mira el `.env`, porque está en
`.gitignore` y no se va a subir. Marca además el identificador de orden de Alpaca, un UUID: tiene entropía alta y no
es un secreto. Ese falso positivo se excluye con un patrón permitido (ejercicio 2), no subiendo el umbral para todo.
El relleno de equis no se marca: entropía cero.

*Fig. — Entropía por carácter de los textos largos del repositorio de juguete y umbral del escáner.* Barras horizontales con la entropía de seis textos. Por encima del umbral de 3,5 bits por carácter (destacado) quedan el token del notebook (4,17), la clave de config.py (4,01) y el UUID de una orden (3,68), un falso positivo. Por debajo, un identificador del README (3,29), un nombre de estrategia (3,17) y un relleno de equis (0).

Para un repositorio real hay herramientas mantenidas que hacen esto y más: Gitleaks (licencia MIT; versión 8.30.1)
funciona como gancho previo al commit o como acción de GitHub y trae reglas para muchos proveedores, entre ellas
tokens de Coinbase y de Kraken pero no de Binance, Bybit ni Alpaca; detect-secrets (Apache-2.0) trabaja con una línea
base de hallazgos aceptados[^6]. GitHub bloquea por defecto los envíos de secretos que reconoce a repositorios
públicos (*push protection*), pero su lista de patrones no incluye claves de Binance, Bybit, Kraken, Coinbase, Alpaca
ni OANDA: para esas claves, solo lo genérico. El escáner propio de esta lección, o una regla propia en Gitleaks, cubre
el hueco.

```yaml
# .pre-commit-config.yaml: Gitleaks antes de cada commit
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks
```

## Permisos mínimos

Cada bróker o exchange ofrece controles distintos sobre lo que puede hacer una credencial[^7]:

| Bróker o exchange | Permisos de la clave | Restricción por IP | Otros |
|---|---|---|---|
| Binance | Lectura, operar en spot y margen, retirada (y otros) | Sí; obligatoria para activar la retirada | Según su FAQ, una clave HMAC sin restricción de IP solo puede tener permiso de lectura |
| Bybit | Solo lectura o lectura y escritura, por producto; retirada solo en la cuenta principal | Sí | Una clave sin IP asociada deja de valer a los 90 días |
| Kraken | Consultar fondos, órdenes y operaciones; crear, modificar y cancelar órdenes; depósito; **retirada**; websocket | Sí | Caducidad de la clave y ventana de nonce configurables |
| Coinbase (CDP) | `view`, `trade` y `transfer` | Lista de IP recomendada, no obligatoria | Portafolio al que se limita la clave |
| Alpaca | Sin permisos por clave; claves separadas de papel y real | No documentada | Las claves de la Trading API no pueden ordenar transferencias bancarias |
| Interactive Brokers | Modo de solo lectura de la API (activado por defecto) | IP de confianza en TWS o IB Gateway | Opción de aceptar solo conexiones locales |
| OANDA | Token sin permisos por función | No documentada | Revocar y generar desde la gestión de acceso |

La guía de claves de Kraken lo resume con dos consejos: una clave **por propósito** y la **lista de IP**, que «evita
casi todo el mal uso de una clave aunque se filtre»; para operar en spot de forma algorítmica bastan consultar
fondos, consultar órdenes, crear y cancelar órdenes, y una clave que solo envía órdenes no necesita la retirada.

Aplicado a un sistema con tres procesos (un recolector de datos y saldos, el motor de órdenes y un informe diario),
el diseño son tres claves, ninguna con retirada y todas atadas a la IP del servidor. La retirada, si hace falta, la
hace una persona desde la web, con su segundo factor. El simulador de la celda siguiente aplica las dos reglas que
comparten todos los exchanges que las tienen (la IP se comprueba antes que el permiso) y prueba qué consigue cada
clave desde el servidor y desde la máquina de un atacante. Las IP son de los rangos reservados para documentación
(RFC 5737):

```python
import pandas as pd

IP_SERVIDOR, IP_ATACANTE = "198.51.100.7", "203.0.113.50"
NECESITA = {"saldo": "lectura", "orden": "operar",
            "retirada": "retirar"}

class ExchangeSim:
    def __init__(self):
        self.claves = {}

    def alta(self, kid, permisos, ips=None):
        self.claves[kid] = {"permisos": set(permisos), "ips": ips,
                            "activa": True}

    def atender(self, kid, ip, accion):
        c = self.claves.get(kid)
        if c is None or not c["activa"]:
            return "401 clave"
        if c["ips"] is not None and ip not in c["ips"]:
            return "401 IP"
        if NECESITA[accion] not in c["permisos"]:
            return "403"
        return "200"

ex = ExchangeSim()
ex.alta("datos", {"lectura"}, ips={IP_SERVIDOR})
ex.alta("motor", {"lectura", "operar"}, ips={IP_SERVIDOR})
ex.alta("motor sin IP", {"lectura", "operar"})
ex.alta("tutorial", {"lectura", "operar", "retirar"})
filas = {}
for kid in ex.claves:
    for origen, ip in [("servidor", IP_SERVIDOR),
                       ("atacante", IP_ATACANTE)]:
        filas[(kid, origen)] = {a: ex.atender(kid, ip, a)
                                for a in NECESITA}
pd.DataFrame(filas).T
```
```output
                        saldo   orden retirada
datos        servidor     200     403      403
             atacante  401 IP  401 IP   401 IP
motor        servidor     200     200      403
             atacante  401 IP  401 IP   401 IP
motor sin IP servidor     200     200      403
             atacante     200     200      403
tutorial     servidor     200     200      200
             atacante     200     200      200
```

Desde el servidor, cada clave hace lo que debe y nada más: la de datos no puede operar (403) y ninguna de las del
sistema puede retirar. Desde la máquina del atacante, las claves con lista de IP no sirven para nada (401 por IP).
La clave del motor sin IP es el caso intermedio: el atacante no puede llevarse los fondos, pero puede operar con
ellos, y operar es suficiente para hacer daño (comprar un activo ilíquido a precios absurdos contra órdenes propias
puestas en otra cuenta traslada el saldo sin retirarlo). La clave «de tutorial», con todos los permisos y sin IP,
es la cuenta entera: es la que se crea en cinco minutos para probar un ejemplo y se olvida en un fichero. La figura
de la cabecera resume la columna del atacante.

El coste de estos controles hay que tenerlo en cuenta al diseñar. La lista de IP corta el sistema en seco si cambia
la IP de salida del servidor (un proveedor que reasigna direcciones, una IP dinámica, un cambio de red): todas las
peticiones dan 401 hasta que alguien actualiza la lista. Un servidor con IP fija y una alarma por 401 lo resuelven.
Separar claves por proceso multiplica las que hay que guardar y rotar.

## Rotar sin cortar el sistema

La guía de OWASP pide rotar los secretos con regularidad, para que una credencial robada sirva poco tiempo, y
automatizarlo, porque a mano es fácil equivocarse[^3]. El error típico es revocar la clave antigua en el mismo
momento en que se crea la nueva: el sistema rara vez lee la clave en el mismo instante en todas partes. La
simulación tiene tres réplicas del bot que leen la clave del gestor con distinta caché (0, 2 y 5 minutos) y hacen
una petición por minuto. A los 20 minutos se crea y publica la clave nueva; en un plan, la antigua se revoca en ese
mismo minuto; en el otro, a los 30, cuando el exchange confirma que nadie la usa desde hace cinco minutos.

```mermaid
stateDiagram-v2
  [*] --> Crear
  Crear --> Publicar: clave nueva
  Publicar --> Comprobar: réplicas la leen
  Comprobar --> Comprobar: uso de la antigua
  Comprobar --> Revocar: sin uso
  Revocar --> [*]
```

```python
def rotar(plan, minutos=40, cache=(0, 2, 5)):
    ex = ExchangeSim()
    ex.alta("k1", {"lectura", "operar"})
    publicadas = [("k1", -60)]            # (clave, minuto publicada)
    uso, registro = {}, []
    for m in range(minutos):
        for (t, accion, kid) in plan:
            if t == m and accion == "crear":
                ex.alta(kid, {"lectura", "operar"})
            elif t == m and accion == "publicar":
                publicadas.append((kid, m))
            elif t == m and accion == "revocar":
                ex.claves[kid]["activa"] = False
        for r, retraso in enumerate(cache):
            kid = [k for k, t in publicadas if t <= m - retraso][-1]
            res = ex.atender(kid, IP_SERVIDOR, "orden")
            uso[kid] = m
            registro.append((m, r, kid, res))
    return registro, uso

a_la_vez = [(20, "crear", "k2"), (20, "publicar", "k2"),
            (20, "revocar", "k1")]
con_solape = [(20, "crear", "k2"), (20, "publicar", "k2"),
              (30, "revocar", "k1")]
for nombre, plan in [("a la vez", a_la_vez),
                     ("con solape", con_solape)]:
    registro, uso = rotar(plan)
    fallos = [(m, r) for m, r, k, res in registro if res != "200"]
    print(f"{nombre:<11} peticiones fallidas: {len(fallos)}"
          f"  último uso de k1: minuto {uso['k1']}")
    if fallos:
        print("  (minuto, réplica):", fallos)
```
```output
a la vez    peticiones fallidas: 7  último uso de k1: minuto 24
  (minuto, réplica): [(20, 1), (20, 2), (21, 1), (21, 2), (22, 2), (23, 2), (24, 2)]
con solape  peticiones fallidas: 0  último uso de k1: minuto 24
```

Revocando a la vez fallan 7 peticiones: las de la réplica con 2 minutos de caché en los minutos 20 y 21 y las de la
de 5 minutos del 20 al 24. Con solape no falla ninguna, y el dato que permite revocar con seguridad es el último uso
de la clave antigua (minuto 24): muchos exchanges muestran en su panel cuándo se usó por última vez cada clave, y el
procedimiento consiste en mirarlo antes de revocar. En un bot que opera, 7 peticiones fallidas pueden ser órdenes
que no se envían o cancelaciones que no llegan: el coste de rotar mal no es un error en un log, es una posición sin
controlar durante cinco minutos.

*Fig. — Rotación revocando la clave antigua en el mismo minuto en que se publica la nueva: petición de cada réplica por minuto.* Tres filas, una por réplica con 0, 2 y 5 minutos de caché, y una marca por minuto del 14 al 31. Hasta el minuto 20 todas usan la clave antigua; la réplica sin caché pasa a la nueva en el 20. Destacadas, las 7 peticiones fallidas: las de la réplica de 2 minutos en los minutos 20 y 21 y las de la de 5 minutos del 20 al 24. Con solape, ninguna falla.

### Un inventario con fechas

Rotar con regularidad exige saber qué claves hay, dónde se usan y cuándo se crearon. Algunas caducan solas: Bybit
invalida a los 90 días las claves sin IP asociada, y Kraken permite crear claves con fecha de caducidad. Un
inventario mínimo, con una política de 90 días y avisos a 15:

```python
from datetime import date

HOY = date(2026, 10, 22)
inventario = pd.DataFrame([
    ("binance", "motor", date(2026, 9, 1), True, "lectura,operar"),
    ("binance", "datos", date(2026, 3, 2), True, "lectura"),
    ("bybit", "motor", date(2026, 7, 30), False, "lectura,operar"),
    ("kraken", "informe", date(2026, 8, 10), True, "lectura"),
    ("kraken", "pruebas", date(2025, 11, 5), False,
     "lectura,operar,retirar"),
], columns=["exchange", "uso", "creada", "con_ip", "permisos"])

def revisar(fila, politica=90, aviso=15):
    edad = (HOY - fila.creada).days
    notas = []
    if "retirar" in fila.permisos:
        notas.append("quitar retirada")
    if not fila.con_ip:
        notas.append("sin IP")
        if fila.exchange == "bybit" and edad >= 90 - aviso:
            notas.append(f"Bybit la invalida en {90 - edad} días")
    if edad >= politica:
        notas.append("rotar ya")
    elif edad >= politica - aviso:
        notas.append(f"rotar en {politica - edad} días")
    return "; ".join(notas) or "en regla"

for f in inventario.itertuples():
    print(f"{f.exchange:<8} {f.uso:<8} {(HOY - f.creada).days:>4} días"
          f"  {revisar(f)}")
```
```output
binance  motor      51 días  en regla
binance  datos     234 días  rotar ya
bybit    motor      84 días  sin IP; Bybit la invalida en 6 días; rotar en 6 días
kraken   informe    73 días  en regla
kraken   pruebas   351 días  quitar retirada; sin IP; rotar ya
```

El inventario no guarda ninguna clave: guarda dónde está cada una, para qué sirve y cuándo se creó. La clave de
datos de Binance pasa de la política (234 días) aunque esté bien restringida; la del motor de Bybit, sin IP, caduca
en 6 días por la regla del propio exchange, y el sistema se pararía si nadie la rota antes. La clave de pruebas de
Kraken tiene de todo lo que no debe: casi un año, retirada y ninguna IP. Es el caso más común en la
práctica: no la del sistema en producción, sino la que se creó para un experimento y nadie borró.

## Si una clave se filtra

El orden importa, y el primer paso no admite espera:

```mermaid
flowchart TB
  A[Revocar la clave] --> B[Revisar actividad]
  B --> C[Clave nueva]
  C --> D[Buscar la vía]
  D --> E[Cerrar la vía]
```

1. **Revocar** en el bróker o exchange. No borrar el commit, no cambiar la variable de entorno: revocar. Mientras la
   clave sea válida, cualquiera que la tenga puede usarla, y una clave en un repositorio público puede copiarse en
   segundos.
2. **Revisar la actividad** desde la última vez que se sabe que la clave estaba a salvo: órdenes, operaciones,
   retiradas, cambios de configuración, inicios de sesión. El registro propio del sistema (lección 4.5.5) y el del
   exchange tienen que cuadrar.
3. **Crear una clave nueva** con los permisos mínimos y desplegarla. Si otras credenciales estaban en el mismo sitio
   (el mismo `.env`, el mismo servidor), se rotan todas.
4. **Encontrar la vía** por la que salió: el commit, el log, la captura, la dependencia.
5. **Cerrarla**: el escáner antes del commit, el tachado en los logs, el fichero de credenciales en lugar del entorno.

## Ejercicios

1. Amplía `Tachador` para que tache también la cabecera `API-Sign` de Kraken (base64) y cualquier JWT (tres partes en
   base64url separadas por puntos, la primera empieza por `eyJ`), aunque no vaya precedido de `Bearer`.

**Solución.**

```python
class TachadorPlus(Tachador):
    PATRONES = Tachador.PATRONES + [
        (re.compile(r"('API-Sign': ')[A-Za-z0-9+/=]+"), r"\1[TACHADO]"),
        (re.compile(r"eyJ[\w-]+\.[\w-]+\.[\w-]+"), "[JWT TACHADO]"),
    ]

log2 = logging.getLogger("bot.http2")
log2.setLevel(logging.DEBUG)
log2.propagate = False
h2 = logging.StreamHandler(sys.stdout)
h2.setFormatter(TachadorPlus("%(levelname)s %(message)s", [s]))
log2.addHandler(h2)
import base64

cab = {"API-Key": "FALSA_kraken",
       "API-Sign": base64.b64encode(b"FALSA" * 13).decode()}
jwt = ".".join(["eyJhbGciOiJFUzI1NiJ9", "eyJzdWIiOiJmYWxzYSJ9",
                "ZmFsc2EtZmlybWE"])
log2.debug("POST /0/private/AddOrder cabeceras %s", cab)
log2.debug("suscripción user con jwt=%s", jwt)
```
```output
DEBUG POST /0/private/AddOrder cabeceras {'API-Key': 'FALSA_kraken', 'API-Sign': '[TACHADO]'}
DEBUG suscripción user con jwt=[JWT TACHADO]
```

La firma de Kraken queda tachada por el patrón de su cabecera y el JWT por su forma, que es reconocible: la cabecera
de un JWT es un JSON que empieza por `{"`, y `{"` en base64url es siempre `eyJ`. Es un patrón con pocos falsos
positivos, a diferencia de la entropía, y por eso las herramientas de escaneo lo usan.

2. Añade al escáner una lista de patrones permitidos para que no marque UUID y una regla que marque cualquier línea
   con la cabecera de una clave privada PEM (`-----BEGIN … PRIVATE KEY-----`), tenga la entropía que tenga.

**Solución.**

```python
UUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
PEM = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
(repo / "clave_ed25519.pem").write_text(
    "-----BEGIN PRIVATE KEY-----\nFALSA\n-----END PRIVATE KEY-----\n")

def escanear_v2(repo):
    hallazgos, _ = escanear(repo, permitidos=[UUID])
    for p in sorted(repo.glob("*.pem")) + sorted(repo.glob("*.py")):
        for i, linea in enumerate(p.read_text().splitlines(), 1):
            if PEM.search(linea):
                hallazgos.append((f"{p.name}:{i}", "clave privada PEM"))
    return hallazgos

for h in escanear_v2(repo):
    print(h[0], "->", h[1] if len(h) == 2 else f"H = {h[2]}")
```
```output
analisis.ipynb:1 -> H = 4.17
config.py:3 -> H = 4.01
clave_ed25519.pem:1 -> clave privada PEM
```

El UUID ya no aparece y la clave PEM se detecta por su cabecera, aunque su contenido (aquí, la palabra `FALSA`) no
tenga entropía alta. Las claves privadas de Ed25519, RSA y ECDSA que piden Binance y Coinbase (lección 4.4.4) se
guardan en ficheros PEM: son el secreto más sensible del sistema y deben estar fuera del repositorio y con permisos
de solo dueño, igual que el fichero de credenciales de la primera sección.

3. Diseña las credenciales del proyecto del nivel: una cartera multi-estrategia en paper trading con Alpaca o IBKR
   durante 4 a 8 semanas, en un servidor propio. Qué credenciales hay, dónde se guardan, qué permisos tienen y cómo
   se pasa a real sin reutilizar nada de papel.

**Solución.**

En Alpaca, dos pares de claves distintos por diseño (papel y real), y la documentación dice que no se pueden cruzar.
Durante el proyecto solo existe el de papel, en un fichero de credenciales del servicio (`LoadCredential=` de systemd
o `/run/secrets` de Docker) con permisos 600, leído con una función como `leer_credencial` y envuelto en `Secreto`
desde el arranque. El modo (papel o real) lo decide una variable no secreta del entorno, comprobada contra la URL
como en la lección 4.4.3. Alpaca no tiene permisos por clave ni lista de IP documentados, así que la protección está
en el servidor: un usuario propio para el bot, el fichero de credenciales, el escáner antes de cada commit y los logs
tachados. En IBKR la credencial es el usuario de TWS o IB Gateway, que vive en ese programa y no en el bot; el bot
se conecta por el puerto local, y se configuran el modo de solo lectura en el proceso de datos, las IP de confianza
y solo conexiones locales. Para pasar a real: claves nuevas creadas ese día (nunca las de papel), un inventario con
su fecha, una alarma por 401 y una rotación con solape planificada a los 90 días. Ninguna credencial del sistema
tiene permiso de retirada.

[^1]: [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html) (sección de credenciales, `LoadCredential=` y `$CREDENTIALS_DIRECTORY`), [System and Service Credentials](https://systemd.io/CREDENTIALS/) y Docker, [Manage secrets securely in Docker Compose](https://docs.docker.com/compose/how-tos/use-secrets/). Consultadas el 22/10/2026.
[^2]: Linux man-pages, [proc_pid_environ(5)](https://man7.org/linux/man-pages/man5/proc_pid_environ.5.html). Consultada el 22/10/2026.
[^3]: OWASP, [Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) y [Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) (sección «Data to exclude»). Consultadas el 22/10/2026.
[^4]: [keyring en PyPI](https://pypi.org/project/keyring/) y su [README](https://github.com/jaraco/keyring) (sección sobre Linux sin escritorio); [python-dotenv en PyPI](https://pypi.org/project/python-dotenv/). Consultadas el 22/10/2026.
[^5]: [AWS Secrets Manager con Python](https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets-python-sdk.html), [Google Cloud Secret Manager: acceder a una versión](https://cloud.google.com/secret-manager/docs/access-secret-version), [Azure Key Vault Secrets para Python](https://learn.microsoft.com/en-us/python/api/overview/azure/keyvault-secrets-readme) y [hvac: KV v2](https://hvac.readthedocs.io/en/stable/usage/secrets_engines/kv_v2.html). Consultadas el 22/10/2026.
[^6]: [Gitleaks](https://github.com/gitleaks/gitleaks), [detect-secrets](https://github.com/Yelp/detect-secrets), GitHub, [patrones de secret scanning admitidos](https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns) y [push protection](https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection). Consultadas el 22/10/2026.
[^7]: Binance, [How to Create API Keys on Binance](https://www.binance.com/en/support/faq/how-to-create-api-keys-on-binance-360002502072) y [permisos de la clave](https://developers.binance.com/docs/wallet/account/api-key-permission); Bybit, [crear una clave de subcuenta](https://bybit-exchange.github.io/docs/v5/user/create-subuid-apikey) e [información de la clave](https://bybit-exchange.github.io/docs/v5/user/apikey-info); Kraken, [API keys](https://docs.kraken.com/exchange/guides/rest/api-keys) y [permisos](https://support.kraken.com/gb/articles/360000919966-how-to-create-an-api-key); Coinbase, [autenticación](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication) y [permisos por endpoint](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/rest-scopes); Alpaca, [Authentication](https://docs.alpaca.markets/docs/authentication) y [buenas prácticas de seguridad de claves](https://alpaca.markets/learn/api-key-security-best-practices-for-alpaca-builders); Interactive Brokers, [configuración inicial](https://interactivebrokers.github.io/tws-api/initial_setup.html) («Read Only» activado por defecto), [configuración de la API](https://www.interactivebrokers.com/docs/tws-api/protobuf/api-settings-config) y [conexiones remotas](https://www.interactivebrokers.com/docs/tws-api/doc/connectivity/remote-tws-api-connections-with-trader-workstation); OANDA, [Authentication](https://developer.oanda.com/rest-live-v20/authentication/). Consultadas el 22/10/2026.

## Autoevaluación

1. Un bot lee su clave de una variable de entorno y lanza un proceso hijo para generar un informe. ¿Qué ve el hijo?

   - A. Nada: el entorno es privado de cada proceso
   - B. Solo las variables que empiezan por `PATH`
   - C. Todo el entorno del padre, clave incluida, salvo que se le pase un entorno filtrado con `env=`
   - D. Solo si el hijo es un programa de Python

   *Respuesta C: Todo el entorno del padre, clave incluida, salvo que se le pase un entorno filtrado con `env=`.* Un proceso hijo hereda el entorno del padre. En la lección, el hijo ve la variable con la clave; con un `env` filtrado, no. Por eso systemd y Docker recomiendan ficheros de credenciales en lugar de variables para los secretos.

2. ¿Qué combinación de permisos es la adecuada para el motor de órdenes de un sistema en un exchange?

   - A. Lectura y operar, sin retirada, restringida a la IP del servidor
   - B. Todos los permisos, para no tener problemas
   - C. Solo retirada
   - D. Lectura y retirada, sin operar

   *Respuesta A: Lectura y operar, sin retirada, restringida a la IP del servidor.* El motor necesita ver saldos y órdenes y enviar órdenes; nunca retirar fondos. Restringida a la IP del servidor, una clave filtrada no sirve desde otra máquina. En el simulador de la lección, esa clave filtrada no consigue nada desde la IP del atacante.

3. En la simulación de rotación, revocar la clave antigua en el mismo minuto en que se publica la nueva da 7 peticiones fallidas. ¿Por qué?

   - A. Porque la clave nueva está mal generada
   - B. Porque dos réplicas del sistema tardan 2 y 5 minutos en leer la clave nueva y siguen usando la revocada
   - C. Porque el exchange limita las peticiones
   - D. Por azar

   *Respuesta B: Porque dos réplicas del sistema tardan 2 y 5 minutos en leer la clave nueva y siguen usando la revocada.* Las réplicas guardan la credencial en caché un tiempo. Con solape (las dos claves válidas hasta comprobar que nadie usa la antigua) no falla ninguna petición.

4. Una clave se ha subido por error a un repositorio público. ¿Qué es lo primero?

   - A. Borrar el commit y forzar el push
   - B. Cambiar el nombre de la variable de entorno
   - C. Esperar a ver si alguien la usa
   - D. Revocar la clave en el bróker o exchange; después, revisar la actividad, crear otra y limpiar el repositorio

   *Respuesta D: Revocar la clave en el bróker o exchange; después, revisar la actividad, crear otra y limpiar el repositorio.* Borrar el commit no deshace la exposición: la clave pudo copiarse en segundos y sigue en clones y cachés. La única medida que cierra la puerta es revocarla en el emisor.

5. ¿Qué hace el escáner por entropía de la lección con el identificador de orden `6f1c2a9e-…` de Alpaca?

   - A. Lo marca como posible secreto: un UUID tiene entropía alta, y hay que excluirlo con una lista de patrones permitidos
   - B. Lo ignora porque es corto
   - C. Lo borra del fichero
   - D. Lo cifra

   *Respuesta A: Lo marca como posible secreto: un UUID tiene entropía alta, y hay que excluirlo con una lista de patrones permitidos.* La entropía mide lo aleatorio que parece un texto, no si es secreto. Los escáneres reales combinan patrones conocidos, entropía y listas de excepciones; un falso positivo se excluye de forma explícita, no bajando el umbral para todo.

## Ideas clave

- Un secreto no tiene valor por defecto: si falta, el programa no arranca. Se lee de un fichero de credenciales (systemd, Docker) o del entorno, con permisos de solo dueño, y nunca se escribe en el código ni en el repositorio.
- Las fugas más comunes no son ataques: un `print`, un log de una URL firmada, una excepción, un proceso hijo que hereda el entorno, una celda de notebook guardada con su salida. Un tipo `Secreto` que no se imprime y un formateador que tacha cortan casi todas.
- Permisos mínimos por proceso: lectura para datos e informes, operar sin retirada para el motor, restringidas a la IP del servidor. La retirada no la necesita ningún programa del sistema.
- Rotar es crear la nueva, desplegarla, comprobar que nadie usa la antigua y solo entonces revocarla; si se filtra, se revoca primero y se investiga después.
- Cada control tiene un coste: la lista de IP corta el sistema si cambia la IP del servidor; la rotación, si se hace sin solape; el gestor de secretos es otra dependencia que puede fallar.

## Recursos

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min

### Documentación

- [OWASP: Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) · cheatsheetseries.owasp.org
- [OWASP: Logging Cheat Sheet (datos que no deben registrarse)](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) · cheatsheetseries.owasp.org
- [systemd.exec: credenciales (LoadCredential=, $CREDENTIALS_DIRECTORY)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html) · freedesktop.org
- [Kraken: claves de API, permisos y buenas prácticas](https://docs.kraken.com/exchange/guides/rest/api-keys) · docs.kraken.com
- [Docker: secretos en Compose (/run/secrets)](https://docs.docker.com/compose/how-tos/use-secrets/) · docs.docker.com

### Datos

- [Datos del nivel 4: mensajes de ejemplo de brókeres (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Descarga · brokers4_ejemplos.json

Lección anterior: [4.4.4 · APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase](https://pythonparatrading.com/curso-python-trading/nivel-4/apis-cripto-ccxt-y-nativas/)

Lección siguiente: [4.5.1 · Arquitectura de un sistema en vivo: de los datos a la reconciliación](https://pythonparatrading.com/curso-python-trading/nivel-4/arquitectura-de-un-sistema-en-vivo/)
