feat(browser): auto-commit con 60 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
Reference in New Issue
Block a user