feat(browser): auto-commit con 60 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:42:31 +02:00
parent 37aacfcfa9
commit 8742cb25be
71 changed files with 5660 additions and 192 deletions
@@ -0,0 +1,80 @@
---
name: har_extract_calls
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "def har_extract_calls(entries: list[dict], *, drop_headers: list[str] | None = None) -> list[dict]"
description: "Normaliza una lista de entries HAR (salida de har_filter_flows) en call specs reproducibles: extrae cookies del header Cookie, limpia headers hop-by-hop, infiere body_type y expone los datos de auth para parametrizar luego con {{param}}. Segundo paso del patron grabar->destilar->reproducir un flujo web. NO auto-parametriza."
tags: [flow-replay, har, http, proxy, cybersecurity, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: entries
desc: "lista de entries HAR (cada uno con request y, opcional, response). Tipicamente la salida de har_filter_flows."
- name: drop_headers
desc: "nombres extra de headers a eliminar (case-insensitive), aparte de los hop-by-hop por defecto. None = no quitar extras."
output: "lista de call specs (una por entry) con claves: method (upper), url, headers (sin hop-by-hop ni Cookie), cookies (parseadas del header Cookie), body, body_type (json|form|raw|None), status (int|None), sets_cookies (nombres de cookies que setea la respuesta)"
tested: true
tests:
- "test_golden_post_con_cookie_y_body_json"
- "test_edge_get_sin_body"
- "test_drop_headers_extra_respetado"
- "test_form_body_y_set_cookie_desde_headers"
- "test_lista_vacia"
test_file_path: "python/functions/cybersecurity/har_extract_calls_test.py"
file_path: "python/functions/cybersecurity/har_extract_calls.py"
---
## Ejemplo
```python
from har_extract_calls import har_extract_calls
entries = [
{
"request": {
"method": "post",
"url": "https://api.example.com/login",
"headers": [
{"name": "Host", "value": "api.example.com"},
{"name": "Content-Type", "value": "application/json"},
{"name": "Cookie", "value": "session=abc; csrf=xyz"},
],
"postData": {
"mimeType": "application/json",
"text": '{"user":"neo","pass":"secret"}',
},
},
"response": {"status": 200, "cookies": [{"name": "session", "value": "new"}]},
}
]
har_extract_calls(entries)
# [{
# "method": "POST",
# "url": "https://api.example.com/login",
# "headers": {"Content-Type": "application/json"}, # Host (hop-by-hop) y Cookie removidos
# "cookies": {"session": "abc", "csrf": "xyz"}, # parseadas del header Cookie
# "body": '{"user":"neo","pass":"secret"}',
# "body_type": "json", # inferido del mimeType
# "status": 200,
# "sets_cookies": ["session"], # cookies que setea la respuesta
# }]
```
## Cuando usarla
Usala tras `har_filter_flows`, una vez tienes los entries HAR del flujo que te interesa, para obtener el boceto normalizado de los requests. Las call specs resultantes son el punto de partida para: (1) marcar a mano los valores dinamicos con `{{param}}` y guardar el flujo como funcion-accion del registry, o (2) reproducir la secuencia con `http_replay_sequence_py_infra`. Tambien para auditar rapido que cookies/headers de auth lleva cada peticion de un flujo capturado.
## Gotchas
- **NO auto-parametriza.** Deja todos los valores tal cual aparecen en el HAR. La deteccion de CSRF tokens, anti-forgery y otros valores dinamicos es responsabilidad del humano/Claude, que los marca despues con `{{param}}`. La auto-deteccion es v2, fuera de scope.
- **El output contiene secretos.** Las cookies de sesion, tokens `Authorization` y demas auth del HAR viajan tal cual en las call specs. NO commitear el output crudo ni pegarlo en sitios publicos: redactar/parametrizar antes de persistir.
- **Headers hop-by-hop se descartan siempre** (host, content-length, connection, keep-alive, proxy-connection, accept-encoding, te, trailer, transfer-encoding, upgrade). Si necesitas conservar alguno para reproducir un caso especial, tendras que reañadirlo manualmente en la call spec.
- **El header `Cookie` se mueve a `cookies`** y desaparece de `headers`: al reproducir, el cliente HTTP debe re-serializar las cookies (no asumir que siguen en headers).
@@ -0,0 +1,168 @@
"""Normaliza entries HAR en call specs reproducibles.
Segundo paso del patron "grabar -> destilar -> reproducir" un flujo web como
funcion del registry. Toma la salida de `har_filter_flows` (lista de entries
HAR) y produce call specs limpias, con auth (cookies/headers) expuesta para
que el humano/Claude marque luego los valores dinamicos con `{{param}}`.
Funcion PURA: sin I/O, transforma listas/dicts de forma determinista.
"""
# Headers hop-by-hop / ruidosos que se eliminan por defecto (case-insensitive).
# `cookie` se trata aparte: se extrae a `cookies` y se quita de `headers`.
_HOP_BY_HOP = frozenset(
{
"host",
"content-length",
"connection",
"keep-alive",
"proxy-connection",
"accept-encoding",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
)
def _parse_cookie_header(value: str) -> dict:
"""Parsea el valor de un header `Cookie` en un dict {name: value}.
Formato: `a=1; b=2; c=3`. El ultimo gana si hay nombres repetidos.
"""
cookies: dict = {}
for pair in value.split(";"):
pair = pair.strip()
if not pair:
continue
name, sep, val = pair.partition("=")
name = name.strip()
if not name:
continue
cookies[name] = val.strip() if sep else ""
return cookies
def _infer_body_type(mime_type: str | None) -> str | None:
"""Infiere el tipo de body a partir del mimeType de postData.
application/json -> "json"
application/x-www-form-... -> "form"
multipart/* -> "raw"
otro / None -> "raw" si hay body, None lo decide el caller.
"""
if not mime_type:
return None
mt = mime_type.split(";", 1)[0].strip().lower()
if mt == "application/json":
return "json"
if mt == "application/x-www-form-urlencoded":
return "form"
if mt.startswith("multipart/"):
return "raw"
return "raw"
def _set_cookie_names_from_headers(headers: list[dict]) -> list[str]:
"""Extrae nombres de cookies de los headers `Set-Cookie` de la respuesta."""
names: list[str] = []
for h in headers:
if str(h.get("name", "")).lower() != "set-cookie":
continue
raw = str(h.get("value", ""))
# Set-Cookie: name=value; Path=/; HttpOnly -> nos quedamos con `name`.
first = raw.split(";", 1)[0].strip()
name = first.split("=", 1)[0].strip()
if name:
names.append(name)
return names
def har_extract_calls(
entries: list[dict],
*,
drop_headers: list[str] | None = None,
) -> list[dict]:
"""Convierte entries HAR en call specs normalizadas y reproducibles.
Por cada entry HAR produce un dict call spec con method, url, headers
(sin hop-by-hop, sin Cookie), cookies (parseadas del header Cookie),
body, body_type inferido, status de respuesta y nombres de cookies que
la respuesta setea. NO auto-parametriza: deja los valores tal cual para
que el humano marque despues los dinamicos con `{{param}}`.
Args:
entries: lista de entries HAR (cada uno con `request` y, opcional,
`response`). Tipicamente la salida de `har_filter_flows`.
drop_headers: nombres extra de headers a eliminar (case-insensitive),
aparte de los hop-by-hop por defecto. None = no quitar extras.
Returns:
lista de call specs, una por entry, con las claves: method, url,
headers, cookies, body, body_type, status, sets_cookies.
"""
extra_drop = {h.lower() for h in (drop_headers or [])}
specs: list[dict] = []
for entry in entries:
request = entry.get("request") or {}
response = entry.get("response") or {}
method = str(request.get("method", "")).upper()
url = request.get("url", "")
# Headers: lista [{name, value}] -> dict, con drop de hop-by-hop +
# extras, y extraccion del header Cookie a `cookies`.
headers: dict = {}
cookies: dict = {}
for h in request.get("headers") or []:
name = str(h.get("name", ""))
value = str(h.get("value", ""))
lname = name.lower()
if lname == "cookie":
cookies.update(_parse_cookie_header(value))
continue
if lname in _HOP_BY_HOP or lname in extra_drop:
continue
headers[name] = value # ultimo gana si repetidos
# Body desde postData.
post_data = request.get("postData") or {}
body = post_data.get("text")
mime_type = post_data.get("mimeType")
body_type = _infer_body_type(mime_type) if body is not None else None
# Status de respuesta.
raw_status = response.get("status")
status = int(raw_status) if isinstance(raw_status, (int, float)) and raw_status else None
if isinstance(raw_status, str) and raw_status.isdigit():
status = int(raw_status)
# Cookies que setea la respuesta: preferir response.cookies (HAR);
# si no, parsear los headers Set-Cookie.
sets_cookies: list[str] = []
resp_cookies = response.get("cookies")
if resp_cookies:
sets_cookies = [
str(c.get("name", "")) for c in resp_cookies if c.get("name")
]
else:
sets_cookies = _set_cookie_names_from_headers(
response.get("headers") or []
)
specs.append(
{
"method": method,
"url": url,
"headers": headers,
"cookies": cookies,
"body": body,
"body_type": body_type,
"status": status,
"sets_cookies": sets_cookies,
}
)
return specs
@@ -0,0 +1,150 @@
"""Tests para har_extract_calls."""
from har_extract_calls import har_extract_calls
def test_golden_post_con_cookie_y_body_json():
"""Golden: POST con header Cookie + body json -> spec correcta,
cookie extraida, hop-by-hop dropeados, set-cookie de respuesta."""
entries = [
{
"request": {
"method": "post",
"url": "https://api.example.com/login",
"headers": [
{"name": "Host", "value": "api.example.com"},
{"name": "Content-Length", "value": "42"},
{"name": "Accept-Encoding", "value": "gzip"},
{"name": "Content-Type", "value": "application/json"},
{"name": "Authorization", "value": "Bearer tok123"},
{"name": "Cookie", "value": "session=abc; csrf=xyz"},
],
"postData": {
"mimeType": "application/json",
"text": '{"user":"neo","pass":"secret"}',
},
},
"response": {
"status": 200,
"cookies": [
{"name": "session", "value": "newsess"},
{"name": "remember", "value": "1"},
],
"headers": [],
},
}
]
[spec] = har_extract_calls(entries)
assert spec["method"] == "POST"
assert spec["url"] == "https://api.example.com/login"
# Hop-by-hop dropeados, Cookie extraido fuera de headers.
assert spec["headers"] == {
"Content-Type": "application/json",
"Authorization": "Bearer tok123",
}
assert "Host" not in spec["headers"]
assert "Content-Length" not in spec["headers"]
assert "Accept-Encoding" not in spec["headers"]
assert "Cookie" not in spec["headers"]
# Cookies parseadas del header Cookie.
assert spec["cookies"] == {"session": "abc", "csrf": "xyz"}
# Body + tipo inferido.
assert spec["body"] == '{"user":"neo","pass":"secret"}'
assert spec["body_type"] == "json"
# Status de respuesta.
assert spec["status"] == 200
# Cookies que setea la respuesta.
assert spec["sets_cookies"] == ["session", "remember"]
def test_edge_get_sin_body():
"""Edge: GET sin body -> body None, body_type None, sin cookies."""
entries = [
{
"request": {
"method": "get",
"url": "https://api.example.com/me",
"headers": [
{"name": "Accept", "value": "application/json"},
],
},
"response": {"status": 304, "headers": []},
}
]
[spec] = har_extract_calls(entries)
assert spec["method"] == "GET"
assert spec["body"] is None
assert spec["body_type"] is None
assert spec["cookies"] == {}
assert spec["headers"] == {"Accept": "application/json"}
assert spec["status"] == 304
assert spec["sets_cookies"] == []
def test_drop_headers_extra_respetado():
"""drop_headers extra elimina headers adicionales (case-insensitive)."""
entries = [
{
"request": {
"method": "GET",
"url": "https://api.example.com/data",
"headers": [
{"name": "User-Agent", "value": "curl/8"},
{"name": "X-Trace-Id", "value": "noise-123"},
{"name": "Accept", "value": "*/*"},
],
},
"response": {"status": 200, "headers": []},
}
]
[spec] = har_extract_calls(entries, drop_headers=["x-trace-id"])
assert "X-Trace-Id" not in spec["headers"]
assert spec["headers"] == {"User-Agent": "curl/8", "Accept": "*/*"}
def test_form_body_y_set_cookie_desde_headers():
"""Body form-urlencoded -> body_type form; set-cookie parseado de headers
cuando no hay response.cookies."""
entries = [
{
"request": {
"method": "POST",
"url": "https://api.example.com/form",
"headers": [
{
"name": "Content-Type",
"value": "application/x-www-form-urlencoded",
},
],
"postData": {
"mimeType": "application/x-www-form-urlencoded",
"text": "a=1&b=2",
},
},
"response": {
"status": 201,
"headers": [
{"name": "Set-Cookie", "value": "auth=tok; Path=/; HttpOnly"},
{"name": "Content-Type", "value": "text/html"},
{"name": "Set-Cookie", "value": "lang=es; Path=/"},
],
},
}
]
[spec] = har_extract_calls(entries)
assert spec["body_type"] == "form"
assert spec["body"] == "a=1&b=2"
assert spec["sets_cookies"] == ["auth", "lang"]
def test_lista_vacia():
"""Sin entries -> lista vacia."""
assert har_extract_calls([]) == []
@@ -0,0 +1,77 @@
---
name: har_filter_flows
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "def har_filter_flows(har: dict, *, hosts: list[str] | None = None, methods: list[str] | None = None, drop_static: bool = True, drop_analytics: bool = True) -> list[dict]"
description: "Filtra un HAR (formato W3C, el que exporta query_mitm_flows --har) dejando solo los flujos relevantes para reconstruir una accion HTTP: descarta recursos estaticos y dominios de analytics, y restringe por host/metodo. Primer paso del patron grabar->destilar->reproducir."
tags: [flow-replay, har, proxy, cybersecurity, web-proxy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [urllib.parse]
params:
- name: har
desc: "HAR ya parseado como dict (formato W3C). Se leen los entries de har['log']['entries']; si la estructura no existe devuelve []"
- name: hosts
desc: "lista de hosts a conservar (match exacto del host de cada URL). None = no filtra por host"
- name: methods
desc: "lista de metodos HTTP a conservar (GET/POST/...). Se normaliza a mayusculas en ambos lados. None = no filtra por metodo"
- name: drop_static
desc: "si True (default) descarta recursos estaticos: mimeType image/*, font/*, text/css, application|text/javascript, o ruta terminada en .css .js .mjs .map .png .jpg .jpeg .gif .svg .webp .ico .woff .woff2 .ttf .eot"
- name: drop_analytics
desc: "si True (default) descarta entries cuyo host caiga en la blocklist heuristica de telemetria (google-analytics, googletagmanager, doubleclick, sentry.io, segment, mixpanel, hotjar, datadoghq, etc.) por substring sobre el host"
output: "lista de dicts: subconjunto de los entries HAR de entrada (sin mutarlos) que pasan todos los filtros, en su orden original"
tested: true
tests: ["test_golden_solo_sobrevive_el_post_de_api", "test_har_vacio_devuelve_lista_vacia", "test_har_sin_log_entries_devuelve_lista_vacia", "test_filtro_por_hosts", "test_filtro_por_methods"]
test_file_path: "python/functions/cybersecurity/har_filter_flows_test.py"
file_path: "python/functions/cybersecurity/har_filter_flows.py"
---
## Ejemplo
```python
har = {
"log": {
"entries": [
{ # estatico: CSS -> se descarta
"request": {"method": "GET", "url": "https://app.example.com/styles/main.css"},
"response": {"content": {"mimeType": "text/css"}},
},
{ # analytics -> se descarta
"request": {"method": "POST", "url": "https://www.google-analytics.com/collect"},
"response": {"content": {"mimeType": "application/json"}},
},
{ # el POST de API que queremos reproducir -> sobrevive
"request": {"method": "POST", "url": "https://api.example.com/v1/login"},
"response": {"content": {"mimeType": "application/json"}},
},
]
}
}
flows = har_filter_flows(har)
print(len(flows)) # 1
print(flows[0]["request"]["url"]) # https://api.example.com/v1/login
# Restringir aun mas al host y metodo de interes:
flows = har_filter_flows(har, hosts=["api.example.com"], methods=["POST"])
print(len(flows)) # 1
```
## Cuando usarla
- Justo despues de capturar trafico con `web_proxy` / `query_mitm_flows --har`, cuando quieres **destilar** un HAR ruidoso a los pocos flujos que componen una accion (login, alta, transferencia) antes de convertirla en una funcion reproducible del registry.
- Cuando necesitas quedarte solo con las llamadas de API (`hosts=[...]`, `methods=["POST","PUT"]`) y descartar de un golpe estaticos y telemetria.
- Como primer paso del patron **grabar -> destilar -> reproducir** un flujo web: graba con el proxy, filtra con esta funcion, y reproduce los entries resultantes.
## Gotchas
- Funcion pura: no hace I/O. Recibe el HAR ya parseado (carga el `.har` con `json.load` antes de llamarla).
- La blocklist de analytics es **heuristica y ampliable** (substring sobre el host). Si un dominio de telemetria propio no esta en la lista, no se descarta; pasa `drop_analytics=False` y filtra a mano, o amplia la blocklist en el codigo.
- El filtro `hosts` es **match exacto de host** (no substring, no subdominios): `api.example.com` no captura `www.api.example.com`. Lista cada host que quieras conservar.
- El host se obtiene con `urllib.parse.urlsplit(...).hostname`; URLs sin host valido cuentan como host vacio `""`.
@@ -0,0 +1,148 @@
"""Filtra los flujos relevantes de un HAR para reconstruir una accion HTTP.
Primer paso del patron "grabar -> destilar -> reproducir": dado un HAR
(formato estandar W3C, el que exporta `query_mitm_flows --har` de mitmproxy),
descarta el ruido (recursos estaticos, dominios de analytics/telemetria) y
opcionalmente restringe por host y metodo, dejando solo los entries que
importan para reproducir una accion como funcion del registry.
Funcion pura: recibe el HAR ya parseado como dict, no hace I/O y no muta los
dicts de entrada (devuelve un subconjunto de los entries originales tal cual).
"""
from urllib.parse import urlsplit
# Extensiones de recursos estaticos (sobre el path, ignorando querystring).
_STATIC_EXTENSIONS = (
".css",
".js",
".mjs",
".map",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".webp",
".ico",
".woff",
".woff2",
".ttf",
".eot",
)
# Prefijos de mimeType considerados estaticos.
_STATIC_MIME_PREFIXES = (
"image/",
"font/",
"text/css",
)
# mimeTypes exactos considerados estaticos (JavaScript en sus dos formas).
_STATIC_MIME_EXACT = (
"application/javascript",
"text/javascript",
)
# Blocklist heuristica de dominios de telemetria/analytics (substring sobre host).
_ANALYTICS_BLOCKLIST = (
"google-analytics.com",
"googletagmanager.com",
"analytics.google.com",
"doubleclick.net",
"facebook.com/tr",
"connect.facebook.net",
"sentry.io",
"segment.io",
"segment.com",
"mixpanel.com",
"hotjar.com",
"fullstory.com",
"clarity.ms",
"cdn.amplitude.com",
"stats.g.doubleclick.net",
"datadoghq.com",
"bugsnag.com",
)
def _is_static(entry: dict) -> bool:
"""True si el entry HAR es un recurso estatico (por mimeType o extension)."""
mime = (
entry.get("response", {})
.get("content", {})
.get("mimeType", "")
)
mime = (mime or "").split(";", 1)[0].strip().lower()
if mime:
if mime.startswith(_STATIC_MIME_PREFIXES):
return True
if mime in _STATIC_MIME_EXACT:
return True
url = entry.get("request", {}).get("url", "") or ""
path = urlsplit(url).path.lower()
return path.endswith(_STATIC_EXTENSIONS)
def _is_analytics(host: str) -> bool:
"""True si el host coincide (substring) con la blocklist de analytics."""
host = (host or "").lower()
return any(blocked in host for blocked in _ANALYTICS_BLOCKLIST)
def har_filter_flows(
har: dict,
*,
hosts: list[str] | None = None,
methods: list[str] | None = None,
drop_static: bool = True,
drop_analytics: bool = True,
) -> list[dict]:
"""Devuelve solo los entries HAR relevantes para reproducir una accion HTTP.
Args:
har: HAR ya parseado como dict (formato W3C). Se leen los entries de
`har["log"]["entries"]`; si la estructura no existe, devuelve [].
hosts: si no es None, mantiene solo entries cuyo host (de urlsplit)
este en la lista (match exacto de host).
methods: si no es None, mantiene solo entries cuyo metodo HTTP este en
la lista (ambos lados normalizados a mayusculas).
drop_static: si True, descarta recursos estaticos (CSS/JS/imagenes/
fuentes) por mimeType o por extension de la ruta.
drop_analytics: si True, descarta entries cuyo host caiga en la
blocklist de dominios de telemetria/analytics.
Returns:
Sublista de los dicts de entrada (los entries HAR originales, sin
mutar) que pasan todos los filtros.
"""
entries = har.get("log", {}).get("entries")
if not isinstance(entries, list):
return []
methods_upper = (
{m.upper() for m in methods} if methods is not None else None
)
hosts_set = set(hosts) if hosts is not None else None
result: list[dict] = []
for entry in entries:
request = entry.get("request", {})
url = request.get("url", "") or ""
host = urlsplit(url).hostname or ""
if drop_static and _is_static(entry):
continue
if drop_analytics and _is_analytics(host):
continue
if hosts_set is not None and host not in hosts_set:
continue
if methods_upper is not None:
method = (request.get("method", "") or "").upper()
if method not in methods_upper:
continue
result.append(entry)
return result
@@ -0,0 +1,93 @@
"""Tests para har_filter_flows."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from har_filter_flows import har_filter_flows
def _entry(method: str, url: str, mime: str = "application/json") -> dict:
return {
"request": {"method": method, "url": url},
"response": {"content": {"mimeType": mime}},
}
def _mixed_har() -> dict:
return {
"log": {
"entries": [
# Recursos estaticos por mimeType.
_entry("GET", "https://app.example.com/styles/main.css", "text/css"),
_entry("GET", "https://app.example.com/bundle.js", "application/javascript"),
_entry("GET", "https://cdn.example.com/logo.png", "image/png"),
_entry("GET", "https://cdn.example.com/font.woff2", "font/woff2"),
# Estatico por extension de la ruta aunque el mime no lo delate.
_entry("GET", "https://cdn.example.com/icons/star.svg?v=2", "application/octet-stream"),
# Analytics / telemetria.
_entry("POST", "https://www.google-analytics.com/collect", "application/json"),
_entry("GET", "https://browser.sentry.io/api/123/envelope/", "application/json"),
# El POST de API que queremos reproducir.
_entry("POST", "https://api.example.com/v1/login", "application/json"),
]
}
}
def test_golden_solo_sobrevive_el_post_de_api():
flows = har_filter_flows(_mixed_har())
assert len(flows) == 1
assert flows[0]["request"]["method"] == "POST"
assert flows[0]["request"]["url"] == "https://api.example.com/v1/login"
def test_har_vacio_devuelve_lista_vacia():
assert har_filter_flows({}) == []
def test_har_sin_log_entries_devuelve_lista_vacia():
assert har_filter_flows({"log": {}}) == []
assert har_filter_flows({"log": {"entries": None}}) == []
def test_filtro_por_hosts():
har = {
"log": {
"entries": [
_entry("POST", "https://api.example.com/v1/login"),
_entry("POST", "https://other.example.com/v1/track"),
]
}
}
flows = har_filter_flows(har, hosts=["api.example.com"])
assert len(flows) == 1
assert flows[0]["request"]["url"] == "https://api.example.com/v1/login"
def test_filtro_por_methods():
har = {
"log": {
"entries": [
_entry("GET", "https://api.example.com/v1/me"),
_entry("POST", "https://api.example.com/v1/login"),
_entry("post", "https://api.example.com/v1/refresh"),
]
}
}
flows = har_filter_flows(har, methods=["post"])
assert len(flows) == 2
assert {f["request"]["url"] for f in flows} == {
"https://api.example.com/v1/login",
"https://api.example.com/v1/refresh",
}
def test_no_muta_los_entries_de_entrada():
har = _mixed_har()
original_count = len(har["log"]["entries"])
flows = har_filter_flows(har)
assert len(har["log"]["entries"]) == original_count
# El entry devuelto es el mismo objeto, no una copia.
assert flows[0] is har["log"]["entries"][-1]