mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-12 05:48:58 +00:00
test(requestlog): pin the real payloads and the fleet-scale behaviour
The branch shipped 245 tests and 72 of them covered redaction, all passing,
while six real endpoints of this application still wrote secrets to
`request_logs`. That is not a gap in effort, it is a gap in kind: those tests
pin the RULES - which key names match, which value shapes fire - and a rule test
proves the rule, not the coverage. Nothing was measuring what this system
actually sends.
51 tests in two files, every case built from a real handler's request or
response shape with the field names taken from the source and cited in the
docstring.
test_request_log_real_payloads.py drives payloads through `decode_body()`, the
same entry point the writer uses, rather than calling `redact()` on a dict. That
is load-bearing: a config upload is routinely larger than the capture cap, so it
never reaches redaction as a dict at all - it arrives as one truncated `_raw`
string where the line breaks are still the escape `\n`. A test that starts from
a dict reports a pass on a payload that leaks, and on one that gets masked into
uselessness. Both properties are asserted on both paths: the secret is gone AND
the rest of the config is still readable.
test_request_log_fleet_scale.py pins the four behavioural fixes, each of which
only appears at scale or at the edge of a setting's documented range:
* successful agent polls are dropped and failures never are, including a
transport error with no HTTP response at all;
* agent traffic is identified from headers, and `offer()` is asserted to
contain no `await` and no connection call, because it runs on the request
coroutine;
* `requestlog.read` scoping admits agent rows but NOT `user_id IS NULL`, so
anonymous traffic and the usernames in failed logins stay admin-only;
* background passes get one id each, and unwrapped background code does not
collapse onto one either;
* queue memory stays inside its budget with `max_body_bytes` at its 256 KB
ceiling, and the budget is released as rows drain - a budget that only
counts up is a leak, not a limit.
Also closes a hole in the branch's own auth tests: they asserted that every
endpoint calls `_require`, but not that it is called BEFORE the try block. The
repo's GHSA-3p5c pattern exists because a permission check inside `try` is
swallowed by the handler's `except Exception -> 500`, which turns a 403 into a
server error and hides that the check ran. Now asserted per endpoint.
Both halves of each trade are pinned: alongside every "this must be redacted"
there is an "and this must not be", so a later tightening cannot quietly blank
the fields the feature exists to show.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""v1.11.0: the log's cost must follow operator activity, not fleet size.
|
||||
|
||||
Every property here was a real defect measured on the feature branch, and each
|
||||
one only shows up at scale or at the edge of a setting's documented range, which
|
||||
is why none of them were caught by the rule-level tests.
|
||||
|
||||
* one row per API call becomes millions per day once the fleet is a few
|
||||
hundred nodes, and the row cap then evicts the forensic history the feature
|
||||
exists for;
|
||||
* the operator role could not see the rows its grant was written for;
|
||||
* every background call ever made shared one correlation id;
|
||||
* queue memory was a function of an operator-editable setting, not a limit.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dataclasses import replace # noqa: E402
|
||||
|
||||
from utils.http_instrumentation import _correlation_id, begin_background_trace # noqa: E402
|
||||
from utils.request_log_settings import ( # noqa: E402
|
||||
DEFAULT_CONFIG,
|
||||
get_config,
|
||||
set_config,
|
||||
)
|
||||
from utils.request_log_sink import ( # noqa: E402
|
||||
TARGET_INBOUND_AGENT,
|
||||
RequestLogRow,
|
||||
RequestLogSink,
|
||||
request_id_context,
|
||||
)
|
||||
|
||||
_BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_ROUTER = os.path.join(_BACKEND, "routers", "request_logs.py")
|
||||
_MIDDLEWARE = os.path.join(_BACKEND, "middleware", "request_logger.py")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_config():
|
||||
"""These tests mutate the module-global snapshot; put it back."""
|
||||
before = get_config()
|
||||
yield
|
||||
set_config(before)
|
||||
|
||||
|
||||
def _row(**kw):
|
||||
kw.setdefault("request_id", "a" * 32)
|
||||
kw.setdefault("direction", "inbound")
|
||||
kw.setdefault("method", "GET")
|
||||
kw.setdefault("url", "/api/agents/prod-lb-1/config")
|
||||
kw.setdefault("status_code", 200)
|
||||
return RequestLogRow(**kw)
|
||||
|
||||
|
||||
class _CountingSink(RequestLogSink):
|
||||
"""Counts what survives `offer()` without needing an event loop."""
|
||||
|
||||
def __init__(self, **kw):
|
||||
super().__init__(kw.pop("maxsize", 10000), 100, 500, **kw)
|
||||
self.accepted = []
|
||||
|
||||
def _ensure_queue(self):
|
||||
sink = self
|
||||
|
||||
class _Q:
|
||||
def put_nowait(self, row):
|
||||
sink.accepted.append(row)
|
||||
|
||||
def qsize(self):
|
||||
return len(sink.accepted)
|
||||
|
||||
return _Q()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Volume: successful agent polls are not rows
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_successful_agent_polls_are_dropped_by_default():
|
||||
"""~9 800 rows/day PER AGENT, all of them 200s meaning "nothing changed".
|
||||
|
||||
At 200 nodes that is ~2M rows/day and the 500 000 row cap is reached in
|
||||
about six hours, so the configured "7 days of successes, 30 days of
|
||||
failures" silently becomes about six hours of each — for everything in the
|
||||
table, not just for the agent rows.
|
||||
"""
|
||||
assert DEFAULT_CONFIG.capture_agent_success is False, (
|
||||
"the default must be off; on, the table's size is a function of node "
|
||||
"count rather than of anything anyone did"
|
||||
)
|
||||
sink = _CountingSink()
|
||||
for _ in range(100):
|
||||
sink.offer(_row(target=TARGET_INBOUND_AGENT, status_code=200))
|
||||
assert sink.accepted == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 422, 500, None])
|
||||
def test_failed_agent_calls_are_always_kept(status):
|
||||
"""The half an operator actually needs, and rare enough to be free.
|
||||
|
||||
`None` is a transport error with no HTTP response at all, which
|
||||
status_class reports as 0.
|
||||
"""
|
||||
sink = _CountingSink()
|
||||
sink.offer(_row(target=TARGET_INBOUND_AGENT, status_code=status))
|
||||
assert len(sink.accepted) == 1, f"a {status} agent call must be recorded"
|
||||
|
||||
|
||||
def test_operator_traffic_is_unaffected_by_the_agent_gate():
|
||||
sink = _CountingSink()
|
||||
sink.offer(_row(target=None, status_code=200, user_id=7))
|
||||
assert len(sink.accepted) == 1
|
||||
|
||||
|
||||
def test_the_gate_can_be_turned_on_for_debugging():
|
||||
set_config(replace(get_config(), capture_agent_success=True))
|
||||
sink = _CountingSink()
|
||||
sink.offer(_row(target=TARGET_INBOUND_AGENT, status_code=200))
|
||||
assert len(sink.accepted) == 1
|
||||
|
||||
|
||||
def test_agent_traffic_is_identified_by_headers_not_by_a_database_lookup():
|
||||
"""The hot path runs on every request; a lookup per call is not affordable.
|
||||
|
||||
The installed agent sends `X-API-Key` and never `Authorization`; the UI
|
||||
sends a JWT and never an agent key.
|
||||
"""
|
||||
from middleware.request_logger import _is_agent_call
|
||||
|
||||
def scope(headers):
|
||||
return {"type": "http", "headers": [(k.encode(), v.encode()) for k, v in headers.items()]}
|
||||
|
||||
assert _is_agent_call(scope({"x-api-key": "agt_x"})) is True
|
||||
assert _is_agent_call(scope({"authorization": "Bearer x.y.z"})) is False
|
||||
# generate-install-script accepts either; self-upgrade sends only the key.
|
||||
assert _is_agent_call(scope({"authorization": "Bearer x.y.z", "x-api-key": "agt_x"})) is False
|
||||
assert _is_agent_call(scope({})) is False
|
||||
|
||||
|
||||
def test_agent_gate_does_not_reach_for_a_connection():
|
||||
"""`offer()` is called from the request coroutine and must stay pure."""
|
||||
src = open(os.path.join(_BACKEND, "utils", "request_log_sink.py"), encoding="utf-8").read()
|
||||
body = src.split("def offer(", 1)[1].split("\n # -- consumer", 1)[0]
|
||||
for forbidden in ("await ", "get_database_connection", "fetch"):
|
||||
assert forbidden not in body, f"offer() must not {forbidden.strip()!r} — it runs on the hot path"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Visibility: the operator grant has to mean something
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_read_only_scoping_admits_agent_rows_but_not_other_users():
|
||||
"""`operator` holds requestlog.read to "debug failing applies" — but an
|
||||
apply fails on the NODE, and the node reports over its own API key, so that
|
||||
row has user_id NULL and own-rows-only scoping hid it.
|
||||
|
||||
Keyed on `target`, NOT on `user_id IS NULL`: anonymous traffic (failed
|
||||
logins and their usernames, unauthenticated probes) is not agent traffic
|
||||
and must stay admin-only.
|
||||
"""
|
||||
src = open(_ROUTER, encoding="utf-8").read()
|
||||
clause = re.search(r"if not can_manage:(.*?)where_sql =", src, re.S)
|
||||
assert clause, "the self-scoping block moved; re-check this test"
|
||||
body = clause.group(1)
|
||||
assert "TARGET_INBOUND_AGENT" in body, "agent rows are still hidden from requestlog.read"
|
||||
assert "user_id IS NULL" not in body, (
|
||||
"scoping on NULL would also expose anonymous traffic, including failed "
|
||||
"logins and the usernames they carry"
|
||||
)
|
||||
|
||||
|
||||
def test_detail_endpoint_uses_the_same_scoping_rule_as_the_list():
|
||||
src = open(_ROUTER, encoding="utf-8").read()
|
||||
detail = src.split('@router.get("/{log_id}")', 1)[1]
|
||||
assert "TARGET_INBOUND_AGENT" in detail, (
|
||||
"the detail endpoint would 404 on the very rows the list now shows"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decorator", [
|
||||
'@router.get("/settings")', '@router.put("/settings")',
|
||||
'@router.get("/stats")', '@router.post("/purge")',
|
||||
'@router.get("")', '@router.get("/{log_id}")',
|
||||
])
|
||||
def test_permission_is_enforced_before_the_try_block(decorator):
|
||||
"""The repo's GHSA-3p5c pattern: a permission check inside `try` gets
|
||||
swallowed by the handler's own `except Exception -> 500`, turning a 403
|
||||
into a server error and, worse, hiding that the check ran at all."""
|
||||
src = open(_ROUTER, encoding="utf-8").read()
|
||||
body = src.split(decorator, 1)[1]
|
||||
body = body.split("\n@router.")[0]
|
||||
require_at = body.find("_require(authorization")
|
||||
try_at = body.find("\n try:")
|
||||
assert require_at != -1, f"{decorator} does not call _require at all"
|
||||
assert try_at == -1 or require_at < try_at, (
|
||||
f"{decorator} checks permissions INSIDE its try block"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Correlation: a trace that groups the wrong rows is worse than no trace
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_each_background_pass_gets_its_own_correlation_id():
|
||||
"""Nothing in main.py names its tasks, so the old `bg:<task name>` fallback
|
||||
gave one long-lived loop a single id for its entire life — measured, 15
|
||||
ACME calls across 5 ticks came out as 1 id. `related` (LIMIT 100) then
|
||||
presents up to a hundred unrelated calls as this request's trace.
|
||||
"""
|
||||
async def loop():
|
||||
per_tick = []
|
||||
for _ in range(5):
|
||||
begin_background_trace("acme_renewals")
|
||||
per_tick.append([_correlation_id() for _ in range(3)])
|
||||
await asyncio.sleep(0)
|
||||
return per_tick
|
||||
|
||||
ticks = asyncio.run(loop())
|
||||
for tick in ticks:
|
||||
assert len(set(tick)) == 1, "calls within one pass must share an id"
|
||||
ids = [t[0] for t in ticks]
|
||||
assert len(set(ids)) == 5, f"passes must not share an id, got {ids}"
|
||||
|
||||
|
||||
def test_unwrapped_background_code_does_not_collapse_onto_one_id():
|
||||
"""Erring toward too little grouping: a row that stands alone is honest, a
|
||||
row falsely grouped with a hundred others is not."""
|
||||
async def unwrapped():
|
||||
request_id_context.set(None)
|
||||
return [_correlation_id() for _ in range(4)]
|
||||
|
||||
ids = asyncio.run(unwrapped())
|
||||
assert len(set(ids)) == 4
|
||||
|
||||
|
||||
def test_the_background_loops_that_make_outbound_calls_open_a_trace():
|
||||
src = open(os.path.join(_BACKEND, "main.py"), encoding="utf-8").read()
|
||||
for loop_name in ("complete_pending_acme_orders", "check_letsencrypt_renewals",
|
||||
"monitor_agent_status"):
|
||||
body = src.split(f"async def {loop_name}", 1)[1].split("\nasync def ")[0]
|
||||
assert "begin_background_trace(" in body, (
|
||||
f"{loop_name} makes outbound calls but never opens a per-pass trace"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Memory: a limit, not a setting
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_queue_memory_is_bounded_even_at_the_max_body_size_ceiling():
|
||||
"""`max_body_bytes` is editable from Settings and its documented ceiling is
|
||||
256 KB, which a row carries twice. Against the default 2 000-row queue that
|
||||
is ~1 GiB — the entire pod limit — reachable from in-range values.
|
||||
"""
|
||||
set_config(replace(get_config(), max_body_bytes=262144, capture_agent_success=True))
|
||||
budget = 8 * 1024 * 1024
|
||||
sink = _CountingSink(maxsize=2000, max_bytes=budget)
|
||||
blob = b"x" * 262144
|
||||
for _ in range(2000):
|
||||
sink.offer(_row(target=None, request_body_raw=blob, response_body_raw=blob))
|
||||
|
||||
held = sum(r.queue_weight() for r in sink.accepted)
|
||||
assert held <= budget, f"queue held {held} bytes against a {budget} byte budget"
|
||||
assert sink.stats["dropped"] > 0, "over-budget rows must be dropped, and counted"
|
||||
unbounded = 2000 * (2 * 262144 + 1400)
|
||||
assert held < unbounded / 10, (
|
||||
f"without the byte budget this queue would hold {unbounded // 1024 // 1024} MiB"
|
||||
)
|
||||
|
||||
|
||||
def test_the_byte_budget_is_released_as_rows_drain():
|
||||
"""A budget that only ever counts up is a slow leak, not a limit."""
|
||||
async def drain():
|
||||
sink = RequestLogSink(2000, 100, 10, max_bytes=8 * 1024 * 1024)
|
||||
blob = b"x" * 4096
|
||||
set_config(replace(get_config(), capture_agent_success=True))
|
||||
for _ in range(50):
|
||||
sink.offer(_row(target=None, request_body_raw=blob, response_body_raw=blob))
|
||||
assert sink.stats["queued_bytes"] > 0
|
||||
await sink._collect()
|
||||
return sink.stats["queued_bytes"]
|
||||
|
||||
assert asyncio.run(drain()) == 0
|
||||
|
||||
|
||||
def test_stats_say_the_sink_counters_are_per_worker():
|
||||
"""The sink is a module global; with UVICORN_WORKERS > 1 each process keeps
|
||||
its own. A number that looks fleet-wide but is not understates drops by
|
||||
exactly the worker count."""
|
||||
src = open(_ROUTER, encoding="utf-8").read()
|
||||
assert '"scope"' in src.split('"sink"', 1)[1][:400], (
|
||||
"the stats response must label the sink counters as this-worker-only"
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""v1.11.0: redaction pinned against THIS codebase's real payloads.
|
||||
|
||||
test_request_log_redaction.py pins the RULES — which key names match, which
|
||||
value shapes fire. It passed 72/72 while six real endpoints of this application
|
||||
still wrote secrets to `request_logs`, because a rule test proves the rule, not
|
||||
the coverage. Every case here is built from an actual handler's request or
|
||||
response shape, with the field names taken from the source and named in the
|
||||
docstring, so a future change to redaction is measured against what this system
|
||||
actually sends rather than against what someone remembered to imagine.
|
||||
|
||||
Method note: the payloads go through `decode_body()`, the same entry point the
|
||||
writer task uses, rather than calling `redact()` directly. That is deliberate —
|
||||
two of the findings below only appear on the way in (an oversized body never
|
||||
reaches `redact()` as a dict at all, it arrives as one `_raw` string), so a test
|
||||
that starts from a dict would report a pass on a payload that leaks in
|
||||
production.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.request_log_redaction import ( # noqa: E402
|
||||
decode_body,
|
||||
is_secret_key,
|
||||
redact_headers,
|
||||
)
|
||||
|
||||
VRRP_SECRET = "S3cr3tVrrpPass!"
|
||||
TOTP_SECRET = "JBSWY3DPEHPK3PXP"
|
||||
STATS_PASSWORD = "StatsPa55word"
|
||||
USERLIST_HASH = "$6$rounds=5000$abcdefgh$XyZ"
|
||||
JWT = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiJ9"
|
||||
".dQw4w9WgXcQdQw4w9WgXcQdQw4w9WgXcQ"
|
||||
)
|
||||
PEM_KEY = (
|
||||
"-----BEGIN RSA PRIVATE KEY-----\n"
|
||||
+ "MIIEowIBAAKCAQEA" + "A" * 200 + "\n"
|
||||
+ "-----END RSA PRIVATE KEY-----\n"
|
||||
)
|
||||
|
||||
# The rendered file `GET /api/agents/{n}/keepalived-config` hands to an agent,
|
||||
# and the one `POST /api/agents/{n}/keepalived-discovery` sends back.
|
||||
KEEPALIVED_CONF = f"""! Managed by HAProxy OpenManager
|
||||
vrrp_instance VI_1 {{
|
||||
state MASTER
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 200
|
||||
advert_int 1
|
||||
authentication {{
|
||||
auth_type PASS
|
||||
auth_pass {VRRP_SECRET}
|
||||
}}
|
||||
virtual_ipaddress {{
|
||||
10.20.30.40/24
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
# A production haproxy.cfg as the agent uploads it verbatim from the node
|
||||
# (`config_content=$(cat "$config_path")` -> POST .../config-response).
|
||||
HAPROXY_CFG = f"""global
|
||||
log stdout local0
|
||||
stats socket /var/run/haproxy.sock mode 660
|
||||
|
||||
userlist admins
|
||||
user ops password {USERLIST_HASH}
|
||||
user dev insecure-password Hunter2Plain
|
||||
|
||||
listen stats
|
||||
bind *:8404
|
||||
stats enable
|
||||
stats auth admin:{STATS_PASSWORD}
|
||||
stats uri /stats
|
||||
|
||||
backend web
|
||||
server web1 10.0.0.1:80 check
|
||||
"""
|
||||
|
||||
|
||||
def _capture(payload, *, cap=8192, content_type="application/json"):
|
||||
"""Run a payload through the capture path exactly as the writer does.
|
||||
|
||||
`cap` is `requestlog.max_body_bytes`. Bodies larger than it arrive
|
||||
truncated, do not parse as JSON, and land in the `{"_raw": ...}` fallback —
|
||||
which is the common case for config uploads and the case a dict-based test
|
||||
never exercises.
|
||||
"""
|
||||
body = json.dumps(payload).encode()
|
||||
value, truncated = decode_body(body[:cap], content_type, len(body))
|
||||
return json.dumps(value, default=str), truncated
|
||||
|
||||
|
||||
def _assert_absent(rendered, *secrets):
|
||||
for secret in secrets:
|
||||
assert secret not in rendered, (
|
||||
f"{secret!r} reached request_logs. Rendered row: {rendered[:400]}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The VRRP password. routers/vip.py: "the secret never leaves the server in
|
||||
# cleartext ... only the at-rest Fernet token and the agent-delivery endpoint
|
||||
# ever see the real value."
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_vip_create_body_does_not_store_auth_pass():
|
||||
"""POST/PUT /api/vip — `payload.auth_pass`, routers/vip.py:585,679."""
|
||||
rendered, _ = _capture({
|
||||
"name": "vip-prod", "virtual_ip": "10.20.30.40", "interface": "eth0",
|
||||
"virtual_router_id": 51, "auth_pass": VRRP_SECRET,
|
||||
})
|
||||
_assert_absent(rendered, VRRP_SECRET)
|
||||
|
||||
|
||||
def test_keepalived_config_delivery_does_not_store_the_rendered_secret():
|
||||
"""GET /api/agents/{n}/keepalived-config — `keepalived.config_content`.
|
||||
|
||||
Polled on the SSL cadence, so an unmasked capture rewrites the secret to the
|
||||
audit table roughly 576 times a day per member node.
|
||||
"""
|
||||
rendered, _ = _capture({
|
||||
"agent_name": "prod-lb-01", "status": "available",
|
||||
"config_path": "/etc/keepalived/keepalived.conf",
|
||||
"keepalived": {
|
||||
"vip_id": 3, "vip_name": "vip-prod",
|
||||
"config_content": KEEPALIVED_CONF, "config_hash": "abc123",
|
||||
},
|
||||
})
|
||||
_assert_absent(rendered, VRRP_SECRET)
|
||||
assert "auth_pass" in rendered, "the directive should stay visible, only its value masked"
|
||||
assert "vrrp_instance VI_1" in rendered, "masking must not destroy the rest of the config"
|
||||
|
||||
|
||||
def test_keepalived_discovery_body_does_not_store_the_found_secret():
|
||||
"""POST /api/agents/{n}/keepalived-discovery — `config_content`.
|
||||
|
||||
routers/agent.py already pops auth_pass out of the parsed analysis,
|
||||
Fernet-encrypts it into its own column and stores only
|
||||
`vip_discoveries.raw_config_masked`. Capturing the request that produced all
|
||||
that, unmasked, would put the plaintext straight back next to it.
|
||||
"""
|
||||
rendered, _ = _capture({
|
||||
"agent_name": "prod-lb-01", "exists": True, "is_managed": False,
|
||||
"config_path": "/etc/keepalived/keepalived.conf",
|
||||
"config_content": KEEPALIVED_CONF,
|
||||
})
|
||||
_assert_absent(rendered, VRRP_SECRET)
|
||||
|
||||
|
||||
def test_auth_pass_is_masked_when_the_body_is_too_large_to_parse():
|
||||
"""The truncated `_raw` path, where line breaks are the escape `\\n`.
|
||||
|
||||
A value pattern that stops only at a REAL newline runs to the end of the
|
||||
string here: no leak, but the whole remainder of the config is masked and
|
||||
the row is useless. Both properties are asserted.
|
||||
"""
|
||||
payload = {"config_content": KEEPALIVED_CONF + "backend b\n server s1 10.0.0.1:80 check\n" * 400}
|
||||
rendered, truncated = _capture(payload)
|
||||
assert truncated, "this fixture must exercise the truncated path"
|
||||
_assert_absent(rendered, VRRP_SECRET)
|
||||
assert "server s1 10.0.0.1:80" in rendered, (
|
||||
"masking ran past the end of the auth_pass line and ate the rest of the config"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# TOTP. routers/mfa.py logs `{"secret_len": ...}` with the comment
|
||||
# "NEVER log the secret itself".
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_mfa_enroll_response_does_not_store_the_totp_secret_in_either_field():
|
||||
"""POST /api/mfa/enroll — returns `secret` AND `otpauth_uri`.
|
||||
|
||||
Redacting one while the same value sits in the other is not redaction.
|
||||
"""
|
||||
rendered, _ = _capture({
|
||||
"secret": TOTP_SECRET,
|
||||
"otpauth_uri": f"otpauth://totp/OpenManager:admin?secret={TOTP_SECRET}&issuer=OpenManager",
|
||||
"qr_size": 256,
|
||||
})
|
||||
_assert_absent(rendered, TOTP_SECRET)
|
||||
|
||||
|
||||
def test_userinfo_credentials_in_a_url_are_dropped():
|
||||
rendered, _ = _capture({"webhook": "https://svc:Sup3rSecret@hooks.example.com/notify?api_key=abc123"})
|
||||
_assert_absent(rendered, "Sup3rSecret", "abc123")
|
||||
assert "hooks.example.com" in rendered, "the host is the diagnostic value; keep it"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HAProxy config. We never RENDER credentials into one, but the agent uploads
|
||||
# the node's real file and the operator can paste one.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("cap,label", [(8192, "truncated _raw path"), (10 ** 6, "parsed path")])
|
||||
def test_uploaded_haproxy_config_masks_credentials_on_both_paths(cap, label):
|
||||
"""POST /api/configuration/agents/{n}/config-response, and
|
||||
POST /api/config/validate."""
|
||||
rendered, _ = _capture({"config_content": HAPROXY_CFG, "config_path": "/etc/haproxy/haproxy.cfg"}, cap=cap)
|
||||
_assert_absent(rendered, STATS_PASSWORD, USERLIST_HASH, "Hunter2Plain")
|
||||
assert "stats auth admin:" in rendered, f"[{label}] the account name is diagnostic; keep it"
|
||||
assert "server web1 10.0.0.1:80" in rendered, f"[{label}] the rest of the config must survive"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prose", [
|
||||
"invalid password format",
|
||||
"the password must be at least 8 characters",
|
||||
"authentication failed for user admin",
|
||||
])
|
||||
def test_ordinary_prose_is_not_mangled(prose):
|
||||
"""Over-matching would blank the messages the log exists to show."""
|
||||
rendered, _ = _capture({"detail": prose})
|
||||
assert prose in rendered, f"redaction damaged an ordinary message: {rendered}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Regressions guarding what already worked, so a later rule change cannot
|
||||
# quietly trade one of these away for one of the above.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_login_exchange_stores_neither_the_password_nor_the_token():
|
||||
req, _ = _capture({"username": "admin", "password": "hunter2hunter2"})
|
||||
_assert_absent(req, "hunter2hunter2")
|
||||
res, _ = _capture({"access_token": JWT, "token_type": "bearer", "user": {"id": 1}})
|
||||
_assert_absent(res, JWT)
|
||||
|
||||
|
||||
def test_private_key_is_redacted_even_under_an_innocent_key_name():
|
||||
"""The value-shape guard is the net under the key-name rules."""
|
||||
rendered, _ = _capture({"blob": PEM_KEY, "note": "backup"})
|
||||
_assert_absent(rendered, "MIIEowIBAAKCAQEA")
|
||||
|
||||
|
||||
def test_dns_provider_credentials_are_redacted():
|
||||
cf, _ = _capture({"provider": "cloudflare", "api_token": "cf_live_abcdefghijklmnop", "zone_id": "z1"})
|
||||
_assert_absent(cf, "cf_live_abcdefghijklmnop")
|
||||
gd, _ = _capture({"provider": "godaddy", "api_key": "gd_key_1234567890", "api_secret": "gd_secret_098"})
|
||||
_assert_absent(gd, "gd_key_1234567890", "gd_secret_098")
|
||||
|
||||
|
||||
def test_innocent_urls_survive_untouched():
|
||||
"""Scrubbing must not rewrite the ACME URLs an operator reads back."""
|
||||
for url in (
|
||||
"https://acme-v02.api.letsencrypt.org/directory",
|
||||
"https://acme-v02.api.letsencrypt.org/acme/acct/12345",
|
||||
):
|
||||
rendered, _ = _capture({"directory_url": url})
|
||||
assert url in rendered, f"an innocent URL was rewritten: {rendered}"
|
||||
|
||||
|
||||
def test_credential_headers_are_presence_only_and_the_rest_are_dropped():
|
||||
out = redact_headers({
|
||||
"authorization": f"Bearer {JWT}",
|
||||
"x-api-key": "agt_" + "a" * 32,
|
||||
"cookie": "session=abc123",
|
||||
"user-agent": "curl/8.4.0",
|
||||
"x-forwarded-for": "10.20.30.5",
|
||||
"x-internal-secret": "not-on-the-allowlist",
|
||||
})
|
||||
rendered = json.dumps(out)
|
||||
_assert_absent(rendered, JWT, "agt_" + "a" * 32, "abc123", "not-on-the-allowlist")
|
||||
assert out["user-agent"] == "curl/8.4.0"
|
||||
assert out["x-forwarded-for"] == "10.20.30.5"
|
||||
assert "x-internal-secret" not in out, "an unlisted header must be dropped, not kept"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["auth_pass", "authPass", "auth-pass", "AUTH_PASS"])
|
||||
def test_auth_pass_key_matches_in_every_spelling(key):
|
||||
assert is_secret_key(key), (
|
||||
f"{key!r} normalizes to something no rule matches. 'password' is not a "
|
||||
f"substring of 'authpass' and the bare 'auth' entry is an exact match."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"monkey", "key_suffix", "payload_size", "nonce_count", "keyboard_layout",
|
||||
"config_path", "authenticated", "author",
|
||||
])
|
||||
def test_innocent_field_names_are_still_kept(key):
|
||||
"""The other half of the trade: over-redaction blanks the fields the
|
||||
feature exists to show."""
|
||||
assert not is_secret_key(key)
|
||||
Reference in New Issue
Block a user