From fb1055686f197e02b3149d0039596c8695efde9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Jul 2026 08:56:13 +0900 Subject: [PATCH] test(fuzz): harden untrusted input surfaces --- backend/app/ddl/export.py | 42 ++- backend/app/dsn_redaction.py | 53 +++- backend/app/spec/naming_lint.py | 18 +- backend/app/spec/wide_tables.py | 18 +- backend/fuzz/README.md | 67 +++++ backend/fuzz/_snapshot.py | 175 +++++++++++++ backend/fuzz/corpus/ddl_export/seed_00.bin | 0 backend/fuzz/corpus/ddl_export/seed_01.bin | Bin 0 -> 82 bytes backend/fuzz/corpus/ddl_export/seed_02.bin | 1 + backend/fuzz/corpus/ddl_export/seed_03.bin | Bin 0 -> 120 bytes backend/fuzz/corpus/ddl_export/seed_04.bin | Bin 0 -> 56 bytes backend/fuzz/corpus/dsn_guard/seed_00.bin | 0 backend/fuzz/corpus/dsn_guard/seed_01.bin | 1 + backend/fuzz/corpus/dsn_guard/seed_02.bin | 1 + backend/fuzz/corpus/dsn_guard/seed_03.bin | 1 + backend/fuzz/corpus/dsn_guard/seed_04.bin | Bin 0 -> 80 bytes backend/fuzz/corpus/dsn_guard/seed_05.bin | 1 + backend/fuzz/corpus/dsn_redaction/seed_00.bin | 0 backend/fuzz/corpus/dsn_redaction/seed_01.bin | 1 + backend/fuzz/corpus/dsn_redaction/seed_02.bin | 1 + backend/fuzz/corpus/dsn_redaction/seed_03.bin | Bin 0 -> 64 bytes backend/fuzz/corpus/dsn_redaction/seed_04.bin | 1 + backend/fuzz/corpus/dsn_redaction/seed_05.bin | 1 + .../fuzz/corpus/reversing_spec/seed_00.bin | 0 .../fuzz/corpus/reversing_spec/seed_01.bin | Bin 0 -> 66 bytes .../fuzz/corpus/reversing_spec/seed_02.bin | 1 + .../fuzz/corpus/reversing_spec/seed_03.bin | Bin 0 -> 72 bytes backend/fuzz/fuzz_ddl_export.py | 61 +++++ backend/fuzz/fuzz_dsn_guard.py | 72 ++++++ backend/fuzz/fuzz_dsn_redaction.py | 68 +++++ backend/fuzz/fuzz_spec_generators.py | 84 ++++++ backend/tests/test_dsn_redaction.py | 11 + backend/tests/test_fuzz_properties.py | 243 ++++++++++++++++++ docs/papers/README.md | 13 + 34 files changed, 909 insertions(+), 26 deletions(-) create mode 100644 backend/fuzz/README.md create mode 100644 backend/fuzz/_snapshot.py create mode 100644 backend/fuzz/corpus/ddl_export/seed_00.bin create mode 100644 backend/fuzz/corpus/ddl_export/seed_01.bin create mode 100644 backend/fuzz/corpus/ddl_export/seed_02.bin create mode 100644 backend/fuzz/corpus/ddl_export/seed_03.bin create mode 100644 backend/fuzz/corpus/ddl_export/seed_04.bin create mode 100644 backend/fuzz/corpus/dsn_guard/seed_00.bin create mode 100644 backend/fuzz/corpus/dsn_guard/seed_01.bin create mode 100644 backend/fuzz/corpus/dsn_guard/seed_02.bin create mode 100644 backend/fuzz/corpus/dsn_guard/seed_03.bin create mode 100644 backend/fuzz/corpus/dsn_guard/seed_04.bin create mode 100644 backend/fuzz/corpus/dsn_guard/seed_05.bin create mode 100644 backend/fuzz/corpus/dsn_redaction/seed_00.bin create mode 100644 backend/fuzz/corpus/dsn_redaction/seed_01.bin create mode 100644 backend/fuzz/corpus/dsn_redaction/seed_02.bin create mode 100644 backend/fuzz/corpus/dsn_redaction/seed_03.bin create mode 100644 backend/fuzz/corpus/dsn_redaction/seed_04.bin create mode 100644 backend/fuzz/corpus/dsn_redaction/seed_05.bin create mode 100644 backend/fuzz/corpus/reversing_spec/seed_00.bin create mode 100644 backend/fuzz/corpus/reversing_spec/seed_01.bin create mode 100644 backend/fuzz/corpus/reversing_spec/seed_02.bin create mode 100644 backend/fuzz/corpus/reversing_spec/seed_03.bin create mode 100644 backend/fuzz/fuzz_ddl_export.py create mode 100644 backend/fuzz/fuzz_dsn_guard.py create mode 100644 backend/fuzz/fuzz_dsn_redaction.py create mode 100644 backend/fuzz/fuzz_spec_generators.py create mode 100644 backend/tests/test_fuzz_properties.py create mode 100644 docs/papers/README.md diff --git a/backend/app/ddl/export.py b/backend/app/ddl/export.py index 11fdb993..fc13b146 100644 --- a/backend/app/ddl/export.py +++ b/backend/app/ddl/export.py @@ -6,6 +6,21 @@ DdlDialect = Literal["postgresql", "snowflake"] +def _rows(snapshot: dict, key: str) -> list[dict]: + """Return ``snapshot[key]`` as a list of dict rows, tolerating junk. + + Snapshot payloads are weakly-typed JSON; a malformed key (missing, or not a + list, or holding non-dict entries) must degrade to "no rows" rather than + raise. Mirrors the defensive contract already used by the spec/lint + generators (``app.spec.reversing._rows`` etc.). + """ + + value = snapshot.get(key) + if not isinstance(value, list): + return [] + return [row for row in value if isinstance(row, dict)] + + def _normalize_dialect(dialect: str) -> DdlDialect: normalized = dialect.lower().replace("_", "-") if normalized in ("postgres", "postgresql", "pg"): @@ -65,7 +80,12 @@ def _normalize_type_text(data_type: str) -> str: def _postgres_type_to_snowflake(column: dict) -> str: base_type = column.get("domain_base_type") if isinstance(base_type, str): - return _postgres_type_to_snowflake({**column, "data_type": base_type}) + # Re-map using the domain's base type, but clear domain_base_type so the + # recursion terminates (otherwise a column carrying a string + # domain_base_type recurses forever -> RecursionError / DoS). + return _postgres_type_to_snowflake( + {**column, "data_type": base_type, "domain_base_type": None} + ) data_type = column.get("data_type") if not isinstance(data_type, str): @@ -237,11 +257,10 @@ def _column_default_clause(default_expr: object, target: DdlDialect) -> str | No def _snapshot_tables(snapshot: dict) -> list[dict]: - relations = snapshot.get("relations", []) return [ r - for r in relations - if isinstance(r, dict) and r.get("relation_kind") in ("r", "p") + for r in _rows(snapshot, "relations") + if r.get("relation_kind") in ("r", "p") ] @@ -455,13 +474,12 @@ def _snapshot_json_to_postgresql_sql(snapshot: dict) -> str: Limitations (intentional, MVP): partitioning clauses and some table options are not reconstructed. """ - relations = snapshot.get("relations", []) - columns = snapshot.get("columns", []) - constraints = snapshot.get("constraints", []) - indexes = snapshot.get("indexes", []) + columns = _rows(snapshot, "columns") + constraints = _rows(snapshot, "constraints") + indexes = _rows(snapshot, "indexes") source_dialect = _snapshot_source_dialect(snapshot) - tables = [r for r in relations if r.get("relation_kind") in ("r", "p")] + tables = _snapshot_tables(snapshot) cols_by_oid = _group_by_relation(columns) constraints_by_oid = _group_by_relation(constraints) @@ -563,9 +581,9 @@ def _snapshot_json_to_snowflake_sql(snapshot: dict) -> str: """Generate Snowflake DDL from a captured PostgreSQL/Snowflake snapshot.""" source_dialect = _snapshot_source_dialect(snapshot) - columns = snapshot.get("columns", []) - constraints = snapshot.get("constraints", []) - indexes = snapshot.get("indexes", []) + columns = _rows(snapshot, "columns") + constraints = _rows(snapshot, "constraints") + indexes = _rows(snapshot, "indexes") tables = _snapshot_tables(snapshot) cols_by_oid = _group_by_relation(columns) diff --git a/backend/app/dsn_redaction.py b/backend/app/dsn_redaction.py index d930a67f..59247833 100644 --- a/backend/app/dsn_redaction.py +++ b/backend/app/dsn_redaction.py @@ -16,25 +16,58 @@ ) +def _split_dsn_best_effort(dsn: str) -> tuple[str, str]: + """Extract (netloc, query) from a DSN without ``urlsplit``. + + ``urllib.parse.urlsplit`` raises ``ValueError`` (e.g. "Invalid IPv6 URL") + on malformed authorities such as an unbalanced ``[``. Redaction must never + crash on hostile input, otherwise the raw, un-redacted error message could + still reach a client. This fallback recovers the credential-bearing parts + with plain string slicing so embedded secrets are still stripped. + """ + + remainder = dsn + scheme_sep = remainder.find("://") + if scheme_sep != -1: + remainder = remainder[scheme_sep + 3 :] + remainder = remainder.split("#", 1)[0] + if "?" in remainder: + remainder, query = remainder.split("?", 1) + else: + query = "" + netloc = remainder.split("/", 1)[0] + return netloc, query + + def _password_candidates_from_dsn(dsn: str) -> set[str]: candidates: set[str] = set() - parsed = urlsplit(dsn) - if "://" in dsn and not parsed.netloc: - # ponytail: keep urlsplit; only swap the non-RFC scheme so userinfo parses. - parsed = urlsplit("http://" + dsn.split("://", 1)[1]) - if parsed.password: - candidates.add(parsed.password) - candidates.add(quote(parsed.password, safe="")) + password: str | None = None + try: + parsed = urlsplit(dsn) + if "://" in dsn and not parsed.netloc: + # ponytail: keep urlsplit; only swap the non-RFC scheme so userinfo parses. + parsed = urlsplit("http://" + dsn.split("://", 1)[1]) + netloc = parsed.netloc + password = parsed.password + query = parsed.query + except ValueError: + # Malformed DSN (e.g. invalid IPv6 literal). Fall back to best-effort + # parsing so any embedded credentials are still redacted. + netloc, query = _split_dsn_best_effort(dsn) + + if password: + candidates.add(password) + candidates.add(quote(password, safe="")) - if "@" in parsed.netloc: - userinfo = parsed.netloc.rsplit("@", 1)[0] + if "@" in netloc: + userinfo = netloc.rsplit("@", 1)[0] if ":" in userinfo: raw_password = userinfo.split(":", 1)[1] candidates.add(raw_password) candidates.add(unquote(raw_password)) - for part in parsed.query.split("&"): + for part in query.split("&"): key, sep, raw_value = part.partition("=") if not sep: continue diff --git a/backend/app/spec/naming_lint.py b/backend/app/spec/naming_lint.py index d1702436..da403153 100644 --- a/backend/app/spec/naming_lint.py +++ b/backend/app/spec/naming_lint.py @@ -47,6 +47,20 @@ _VALID_UNQUOTED = re.compile(r"^[a-z_][a-z0-9_$]*$") +def _rows(snapshot: dict[str, Any], key: str) -> list[dict[str, Any]]: + """Return ``snapshot[key]`` as dict rows, tolerating malformed JSON. + + A key that is missing, not a list, or holds non-dict entries degrades to + "no rows" rather than raising -- the same defensive contract used by the + other snapshot generators (``app.spec.reversing._rows`` etc.). + """ + + value = snapshot.get(key) + if not isinstance(value, list): + return [] + return [row for row in value if isinstance(row, dict)] + + def _case_style(name: str) -> str | None: """Classify identifier case style, or None if it can't be classified.""" if re.fullmatch(r"[a-z][a-z0-9]*(_[a-z0-9]+)*", name): @@ -65,8 +79,8 @@ def _item(category: str, severity: str, target: str, detail: str) -> dict[str, A def lint_naming(snapshot: dict[str, Any] | None) -> dict[str, Any]: """Return naming-convention findings + a summary, breaking issues first.""" snapshot = snapshot or {} - relations = snapshot.get("relations") or [] - columns = snapshot.get("columns") or [] + relations = _rows(snapshot, "relations") + columns = _rows(snapshot, "columns") rel_by_oid = {r.get("relation_oid"): r for r in relations} # (label, name) for every identifier: tables and columns. diff --git a/backend/app/spec/wide_tables.py b/backend/app/spec/wide_tables.py index b463659c..e62dd266 100644 --- a/backend/app/spec/wide_tables.py +++ b/backend/app/spec/wide_tables.py @@ -18,6 +18,20 @@ INFO = "info" +def _rows(snapshot: dict[str, Any], key: str) -> list[dict[str, Any]]: + """Return ``snapshot[key]`` as dict rows, tolerating malformed JSON. + + Missing / non-list / non-dict-entry payloads degrade to "no rows" rather + than raising -- matching the defensive contract of the other snapshot + generators (``app.spec.reversing._rows`` etc.). + """ + + value = snapshot.get(key) + if not isinstance(value, list): + return [] + return [row for row in value if isinstance(row, dict)] + + def detect_wide_tables( snapshot: dict[str, Any] | None, warn_threshold: int = 40, @@ -25,8 +39,8 @@ def detect_wide_tables( ) -> dict[str, Any]: """Return tables exceeding the column-count thresholds, widest first.""" snapshot = snapshot or {} - relations = snapshot.get("relations") or [] - columns = snapshot.get("columns") or [] + relations = _rows(snapshot, "relations") + columns = _rows(snapshot, "columns") # Only ordinary/partitioned tables count (views legitimately project many cols). table_oids = { diff --git a/backend/fuzz/README.md b/backend/fuzz/README.md new file mode 100644 index 00000000..5d67d782 --- /dev/null +++ b/backend/fuzz/README.md @@ -0,0 +1,67 @@ +# Fuzzing + +Coverage-guided and property-based fuzzing for pg-erd-cloud's untrusted-input +surfaces. All targets are **pure, deterministic, network-free** functions that +consume attacker-influenced data (connection strings, driver error messages, and +DB-introspection *snapshots* that may originate from a hostile database). + +## Tools & licenses (permissive only) + +| Tool | License | Role | +| --- | --- | --- | +| [Atheris](https://github.com/google/atheris) | Apache-2.0 | coverage-guided (libFuzzer) harnesses in `backend/fuzz/` | +| [Hypothesis](https://github.com/HypothesisWorks/hypothesis) | MPL-2.0 | property tests in `backend/tests/test_fuzz_properties.py` | + +Neither is GPL/AGPL. Hypothesis is **not** in the pinned runtime/dev lock, so +`test_fuzz_properties.py` is `importorskip`-guarded and is skipped by the normal +hash-locked `pytest` job unless Hypothesis is installed locally. + +## Targets (surfaced with CodeGraph) + +Discovered via `codegraph init` + `codegraph explore` on the fresh clone: + +| Harness | Target | Untrusted input | Invariant | +| --- | --- | --- | --- | +| `fuzz_dsn_redaction.py` | `app.dsn_redaction.redact_dsn_error_message` | driver error text + DSN | no crash; **DSN password never leaks verbatim** | +| `fuzz_dsn_guard.py` | `app.pg_introspect.dsn_guard` parse helpers | DSN query/host/port/IP | only `DsnTargetError` raised; IP parsing total | +| `fuzz_ddl_export.py` | `app.ddl.export.snapshot_json_to_sql` | schema snapshot + dialect | no crash (bad dialect → `ValueError` only); deterministic | +| `fuzz_spec_generators.py` | `app.spec.reversing` / `index_design` / `naming_lint` / `wide_tables` | schema snapshot | no crash; deterministic; report summary counts self-consistent | + +`_snapshot.py` turns fuzzer bytes into snapshot-shaped dicts (the keys/value +types the generators branch on) so the fuzzer spends its budget on structure. + +## Run locally + +Atheris ships wheels for CPython 3.10–3.12 (Linux/macOS): + +```bash +cd backend +python -m pip install atheris hypothesis +export PYTHONPATH=. + +# Coverage-guided, time-bounded: +python fuzz/fuzz_dsn_redaction.py -max_total_time=60 fuzz/corpus/dsn_redaction +python fuzz/fuzz_dsn_guard.py -max_total_time=60 fuzz/corpus/dsn_guard +python fuzz/fuzz_ddl_export.py -max_total_time=60 fuzz/corpus/ddl_export +python fuzz/fuzz_spec_generators.py -max_total_time=60 fuzz/corpus/reversing_spec + +# Property-based (portable, no native build): +pytest tests/test_fuzz_properties.py -q +``` + +A crash writes a `crash-*` reproducer; re-run the harness with that file as the +sole argument to replay it. + +## CI + +The existing backend CI installs from `requirements-dev.lock` with +`--require-hashes` and runs `pytest -q`. The Hypothesis suite is skipped there +unless Hypothesis is added to that hash-locked dev set; the Atheris harnesses are +manual, bounded checks for security/robustness investigations. + +## Findings + +The initial harness run hardened four latent robustness bugs (see the PR): +unbounded recursion on a domain-typed column in the Snowflake export, and three +snapshot generators crashing on a non-list top-level key. Regression coverage +now lives in these fuzz targets. diff --git a/backend/fuzz/_snapshot.py b/backend/fuzz/_snapshot.py new file mode 100644 index 00000000..79864f15 --- /dev/null +++ b/backend/fuzz/_snapshot.py @@ -0,0 +1,175 @@ +"""Shared helpers for building arbitrary ``snapshot`` dicts from fuzz bytes. + +The snapshot is the untrusted structure that the DB introspection stage emits +and that the spec/DDL/naming generators consume. Every generator is supposed to +be *total* over arbitrary snapshot shapes -- it must never raise on malformed +input, only ever produce (possibly empty / noted) output. These helpers turn a +stream of fuzzer bytes into snapshot-shaped dicts with the keys and value types +the generators branch on, so the fuzzer spends its budget on interesting +structure rather than on rediscovering the top-level key names. + +Works both with Atheris' ``FuzzedDataProvider`` (coverage-guided) and with a +plain deterministic PRNG (used by the Hypothesis / smoke fallbacks), so the +same corpus generation logic backs every harness. +""" + +from __future__ import annotations + +from typing import Any, Callable + +# Value "kinds" the generators special-case: strings, ints, bools, None, +# nested dicts/lists, and single-char relation/constraint discriminators. +_REL_KINDS = ["r", "p", "v", "m", "f", "c", "t", "", "x"] +_CONSTRAINT_TYPES = ["p", "u", "c", "f", "x", ""] +_STRINGS = [ + "", + "public", + "orders", + 'weird"name', + "a\x00b", + "camelCase", + "SELECT", + "user", + "x" * 80, + "타이블", + "numeric(10,2)", + "character varying(255)", + "timestamp with time zone", + "'default'", + "NEXTVAL('s')", + "\n|pipe\n", +] + + +class ByteFeeder: + """Uniform interface over Atheris FDP and a plain byte buffer.""" + + def __init__(self, consume_int: Callable[[int], int]) -> None: + self._consume_int = consume_int + + def choice(self, seq: list[Any]) -> Any: + return seq[self._consume_int(len(seq) - 1)] + + def count(self, cap: int) -> int: + return self._consume_int(cap) + + @classmethod + def from_fdp(cls, fdp: Any) -> "ByteFeeder": + return cls(lambda hi: fdp.ConsumeIntInRange(0, hi) if hi > 0 else 0) + + @classmethod + def from_bytes(cls, data: bytes) -> "ByteFeeder": + state = {"i": 0} + + def consume(hi: int) -> int: + if hi <= 0: + return 0 + if state["i"] >= len(data): + return 0 + b = data[state["i"]] + state["i"] += 1 + return b % (hi + 1) + + return cls(consume) + + +def _value(f: ByteFeeder, depth: int) -> Any: + kind = f.count(7) + if kind == 0: + return f.choice(_STRINGS) + if kind == 1: + return f.choice([0, 1, -1, 2, 5, 63, 64, 2**31, -(2**31)]) + if kind == 2: + return f.choice([True, False]) + if kind == 3: + return None + if kind == 4: + return f.choice([1.5, 0.0, -3.25]) + if kind == 5 and depth < 2: + return [_value(f, depth + 1) for _ in range(f.count(3))] + if kind == 6 and depth < 2: + return {f.choice(_STRINGS): _value(f, depth + 1) for _ in range(f.count(3))} + return f.choice(_STRINGS) + + +def _relation(f: ByteFeeder) -> dict[str, Any]: + return { + "relation_oid": f.choice([1, 2, 3, 100, "bad", None]), + "schema_name": f.choice(_STRINGS), + "relation_name": f.choice(_STRINGS), + "relation_kind": f.choice(_REL_KINDS), + "tablespace_name": f.choice(_STRINGS + [None]), + "is_partition": f.choice([True, False, None]), + "partition_key": f.choice(_STRINGS + [None]), + "partition_bound": f.choice(_STRINGS + [None]), + "partition_parent_schema": f.choice(_STRINGS + [None]), + "partition_parent_name": f.choice(_STRINGS + [None]), + } + + +def _column(f: ByteFeeder) -> dict[str, Any]: + return { + "relation_oid": f.choice([1, 2, 3, 100, "bad", None]), + "column_name": f.choice(_STRINGS), + "column_position": f.choice([0, 1, 2, "bad", None]), + "data_type": f.choice(_STRINGS + [None]), + "domain_base_type": f.choice(_STRINGS + [None]), + "array_dimensions": f.choice([0, 1, None]), + "type_kind": f.choice(["e", "b", None]), + "has_default": f.choice([True, False, None]), + "default_expr": f.choice(_STRINGS + [None]), + "is_not_null": f.choice([True, False, None]), + } + + +def _constraint(f: ByteFeeder) -> dict[str, Any]: + return { + "relation_oid": f.choice([1, 2, 3, 100, "bad", None]), + "schema_name": f.choice(_STRINGS), + "relation_name": f.choice(_STRINGS), + "constraint_name": f.choice(_STRINGS), + "constraint_type": f.choice(_CONSTRAINT_TYPES), + "constraint_def": f.choice(_STRINGS + [None]), + "constrained_attnums": f.choice([[1, 2], [1], [], "bad", None, [1, "x"]]), + } + + +def _index(f: ByteFeeder) -> dict[str, Any]: + return { + "index_def": f.choice( + [ + "CREATE INDEX i ON t (a)", + "CREATE UNIQUE INDEX i ON t (a)", + "CREATE INDEX CONCURRENTLY i ON t (a)", + "not an index", + "", + ] + + [None] + ), + "index_name": f.choice(_STRINGS), + "index_tablespace_name": f.choice(_STRINGS + [None]), + "table_schema_name": f.choice(_STRINGS), + "table_name": f.choice(_STRINGS), + } + + +def build_snapshot(f: ByteFeeder) -> dict[str, Any]: + """Assemble a snapshot dict shaped like DB-introspection output.""" + + snapshot: dict[str, Any] = { + "relations": [_relation(f) for _ in range(f.count(4))], + "columns": [_column(f) for _ in range(f.count(5))], + "constraints": [_constraint(f) for _ in range(f.count(4))], + "indexes": [_index(f) for _ in range(f.count(3))], + "fk_edges": [_value(f, 1) for _ in range(f.count(3))], + "source_dialect": f.choice( + ["postgresql", "snowflake", "pg", "sf", "bogus", None] + ), + } + # Occasionally corrupt a top-level key to a wrong type to exercise the + # isinstance guards in every generator. + if f.count(4) == 0: + snapshot["relations"] = f.choice(["not-a-list", None, 42]) + if f.count(4) == 0: + snapshot["columns"] = f.choice([{}, None, "x"]) + return snapshot diff --git a/backend/fuzz/corpus/ddl_export/seed_00.bin b/backend/fuzz/corpus/ddl_export/seed_00.bin new file mode 100644 index 00000000..e69de29b diff --git a/backend/fuzz/corpus/ddl_export/seed_01.bin b/backend/fuzz/corpus/ddl_export/seed_01.bin new file mode 100644 index 0000000000000000000000000000000000000000..42aeaaeb63d25c976f5f7e2060fdbf80930bb4fe GIT binary patch literal 82 zcmZQ#W?*DuW?^Mx=iubx=Hcbz7Z4N@77-N_mync_mXVc{S5Q<^R#8<`*U;3`*3s3| lH!w6ZHZe6bx3ILbwz0LdcW`uac5!uc_we-c_VM-e2LMk%3|;^L literal 0 HcmV?d00001 diff --git a/backend/fuzz/corpus/ddl_export/seed_02.bin b/backend/fuzz/corpus/ddl_export/seed_02.bin new file mode 100644 index 00000000..0206b8fc --- /dev/null +++ b/backend/fuzz/corpus/ddl_export/seed_02.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/fuzz/corpus/ddl_export/seed_03.bin b/backend/fuzz/corpus/ddl_export/seed_03.bin new file mode 100644 index 0000000000000000000000000000000000000000..a25f33861947add5c0538d887432c082b0f6f04e GIT binary patch literal 120 zcmZQzWMXDvWn<^yMC+6cQE@6%&_`l#-T_m6KOcR8m$^Ra4i{)Y8_`)zddH zG%_|ZH8Z!cw6eCbwX=6{baHlab#wRd^z!!c_45x13MC+6cQE@6%&_`l#-T_m6KOcR8m$^Ra4i{)Y8_`)zddH jG%_|ZH8Z!cw6eCbwX=6{baHlab#wRd^z!!c_45Y+O{WZ5 literal 0 HcmV?d00001 diff --git a/backend/fuzz/corpus/dsn_guard/seed_05.bin b/backend/fuzz/corpus/dsn_guard/seed_05.bin new file mode 100644 index 00000000..077a590b --- /dev/null +++ b/backend/fuzz/corpus/dsn_guard/seed_05.bin @@ -0,0 +1 @@ +192.168.0.1 \ No newline at end of file diff --git a/backend/fuzz/corpus/dsn_redaction/seed_00.bin b/backend/fuzz/corpus/dsn_redaction/seed_00.bin new file mode 100644 index 00000000..e69de29b diff --git a/backend/fuzz/corpus/dsn_redaction/seed_01.bin b/backend/fuzz/corpus/dsn_redaction/seed_01.bin new file mode 100644 index 00000000..f63c7633 --- /dev/null +++ b/backend/fuzz/corpus/dsn_redaction/seed_01.bin @@ -0,0 +1 @@ +connection to server failed password=hunter2 \ No newline at end of file diff --git a/backend/fuzz/corpus/dsn_redaction/seed_02.bin b/backend/fuzz/corpus/dsn_redaction/seed_02.bin new file mode 100644 index 00000000..81b53092 --- /dev/null +++ b/backend/fuzz/corpus/dsn_redaction/seed_02.bin @@ -0,0 +1 @@ +postgresql://u:s3cr3t@db.example.com:5432/app?sslmode=require \ No newline at end of file diff --git a/backend/fuzz/corpus/dsn_redaction/seed_03.bin b/backend/fuzz/corpus/dsn_redaction/seed_03.bin new file mode 100644 index 0000000000000000000000000000000000000000..96eb299ab61d459148b19b03f71386abcec74669 GIT binary patch literal 64 zcmZQzWMXDvWn<^yMC+6cQE@6%&_`l#-T_m6KOcR8m$^Ra4i{)Y8_`)zddH TG%_|ZH8Z!cw6eCbwX+8Rs^ACV literal 0 HcmV?d00001 diff --git a/backend/fuzz/corpus/dsn_redaction/seed_04.bin b/backend/fuzz/corpus/dsn_redaction/seed_04.bin new file mode 100644 index 00000000..535a7d21 --- /dev/null +++ b/backend/fuzz/corpus/dsn_redaction/seed_04.bin @@ -0,0 +1 @@ +token=abcDEF123 secret=zzz api_key=deadbeef \ No newline at end of file diff --git a/backend/fuzz/corpus/dsn_redaction/seed_05.bin b/backend/fuzz/corpus/dsn_redaction/seed_05.bin new file mode 100644 index 00000000..7106dbb9 --- /dev/null +++ b/backend/fuzz/corpus/dsn_redaction/seed_05.bin @@ -0,0 +1 @@ +hunter2 postgresql://u:hunter2@h/db \ No newline at end of file diff --git a/backend/fuzz/corpus/reversing_spec/seed_00.bin b/backend/fuzz/corpus/reversing_spec/seed_00.bin new file mode 100644 index 00000000..e69de29b diff --git a/backend/fuzz/corpus/reversing_spec/seed_01.bin b/backend/fuzz/corpus/reversing_spec/seed_01.bin new file mode 100644 index 0000000000000000000000000000000000000000..d26fd54cb3d133bdc1239b61819a832eeecdbed5 GIT binary patch literal 66 TcmZQ%U}j`tWaVUHBbNpMA4~xH literal 0 HcmV?d00001 diff --git a/backend/fuzz/corpus/reversing_spec/seed_02.bin b/backend/fuzz/corpus/reversing_spec/seed_02.bin new file mode 100644 index 00000000..16911657 --- /dev/null +++ b/backend/fuzz/corpus/reversing_spec/seed_02.bin @@ -0,0 +1 @@ + !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ \ No newline at end of file diff --git a/backend/fuzz/corpus/reversing_spec/seed_03.bin b/backend/fuzz/corpus/reversing_spec/seed_03.bin new file mode 100644 index 0000000000000000000000000000000000000000..937df27c37af118a9a9ecdee7e39ebf5e1149fb4 GIT binary patch literal 72 QcmZQ#Vq|1sV4|1=01E5?NdN!< literal 0 HcmV?d00001 diff --git a/backend/fuzz/fuzz_ddl_export.py b/backend/fuzz/fuzz_ddl_export.py new file mode 100644 index 00000000..c0291351 --- /dev/null +++ b/backend/fuzz/fuzz_ddl_export.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Atheris harness for ``app.ddl.export.snapshot_json_to_sql``. + +Untrusted-input surface: a captured schema *snapshot* (JSON produced by DB +introspection of a user-supplied database) is rendered into PostgreSQL / +Snowflake DDL. The renderer walks deeply nested, weakly-typed dicts and does +string surgery + regex rewriting on identifiers, types and default +expressions. It must be total over arbitrary snapshot shapes: only a bad +*dialect* is allowed to raise (documented ``ValueError``); a malformed snapshot +must still yield a string. + +CodeGraph pointed here via: + codegraph explore "DSN redaction parse sanitize identifier untrusted input" + codegraph explore "snapshot_json_to_sql DDL export dialect mapping" + +Run: python backend/fuzz/fuzz_ddl_export.py backend/fuzz/corpus/ddl_export +""" + +from __future__ import annotations + +import sys + +import atheris + +with atheris.instrument_imports(): + from app.ddl.export import snapshot_json_to_sql + +sys.path.insert(0, __file__.rsplit("/", 1)[0]) +from _snapshot import ByteFeeder, build_snapshot # noqa: E402 + +_DIALECTS = ["postgresql", "snowflake", "pg", "sf", "postgres", "BOGUS", "", "x"] + + +def test_one_input(data: bytes) -> None: + fdp = atheris.FuzzedDataProvider(data) + feeder = ByteFeeder.from_fdp(fdp) + snapshot = build_snapshot(feeder) + dialect = _DIALECTS[fdp.ConsumeIntInRange(0, len(_DIALECTS) - 1)] + + try: + out = snapshot_json_to_sql(snapshot, dialect) + except ValueError: + # Unsupported dialect is a documented, contract-level error. + return + + if not isinstance(out, str): + raise AssertionError(f"expected str output, got {type(out)!r}") + + # Determinism: a pure renderer must be referentially transparent. + out2 = snapshot_json_to_sql(snapshot, dialect) + if out != out2: + raise AssertionError("snapshot_json_to_sql is non-deterministic") + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/backend/fuzz/fuzz_dsn_guard.py b/backend/fuzz/fuzz_dsn_guard.py new file mode 100644 index 00000000..40880659 --- /dev/null +++ b/backend/fuzz/fuzz_dsn_guard.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Atheris harness for the pure DSN-parsing helpers in the SSRF guard. + +Untrusted-input surface: ``app.pg_introspect.dsn_guard`` is the SSRF gate for +user-supplied PostgreSQL DSNs. Its network-touching path (DNS + allowlist) +needs settings and sockets, but the *parsing* helpers that decide host/port +targets are pure and run on fully attacker-controlled strings. Their contract: +they must only ever raise ``DsnTargetError`` (a ``ValueError`` subclass) on bad +input -- never ``IndexError``/``TypeError``/etc, which would escape the guard's +handling. ``_parse_ip_literal`` must never raise at all, and a literal it +accepts must be classifiable as restricted-or-not without raising. + +CodeGraph pointed here via: + codegraph explore "validate_postgres_dsn_target dsn guard SSRF host port parse" + +Run: python backend/fuzz/fuzz_dsn_guard.py backend/fuzz/corpus/dsn_guard +""" + +from __future__ import annotations + +import os +import sys +from urllib.parse import urlparse + +# app.settings (imported transitively by dsn_guard) needs these; use dummy, +# non-secret defaults so the harness is hermetic (never reads real secrets). +os.environ.setdefault( + "DATABASE_URL", + "postgresql+asyncpg://fuzz:fuzz@127.0.0.1:5432/fuzz", +) +os.environ.setdefault("APP_SECRET", "fuzz-not-a-secret") +os.environ.setdefault("DB_INTROSPECTION_ALLOWED_HOSTS", "db.example.com,*.example.net") + +import atheris # noqa: E402 + +with atheris.instrument_imports(): + from app.pg_introspect import dsn_guard + + +def test_one_input(data: bytes) -> None: + fdp = atheris.FuzzedDataProvider(data) + raw = fdp.ConsumeUnicodeNoSurrogates(512) + + # Query-param + port + host splitting: only DsnTargetError is allowed. + parsed = urlparse(raw) + try: + params = dsn_guard._parse_query_params(parsed.query) + dsn_guard._validate_query_ports(params.get("port", [])) + dsn_guard._split_query_host_values(params.get("host", []), "host") + dsn_guard._split_query_host_values(params.get("hostaddr", []), "hostaddr") + except dsn_guard.DsnTargetError: + pass # contract-level rejection + + # IP-literal parsing must be total and its result classifiable. + host_token = fdp.ConsumeUnicodeNoSurrogates(64) + literal = dsn_guard._parse_ip_literal(host_token) + if literal is not None: + dsn_guard._is_restricted_ip(literal) + dsn_guard._connection_host_for_ip(literal) + + # Allowlist matcher must be total over arbitrary host/entry pairs. + entry = fdp.ConsumeUnicodeNoSurrogates(32) + dsn_guard._host_matches_allowed_entry(host_token.lower(), entry.lower()) + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/backend/fuzz/fuzz_dsn_redaction.py b/backend/fuzz/fuzz_dsn_redaction.py new file mode 100644 index 00000000..2c75a784 --- /dev/null +++ b/backend/fuzz/fuzz_dsn_redaction.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Atheris harness for ``app.dsn_redaction.redact_dsn_error_message``. + +Untrusted-input surface: driver error messages are built from a user-supplied +DSN (which carries a password) and are then surfaced back to the user. The +redactor must (1) never raise on arbitrary input and (2) never let the +DSN-derived password survive verbatim in the redacted message -- otherwise a +crafted connection string turns an error banner into a secret-exfiltration +channel. + +CodeGraph pointed here via: + codegraph explore "DSN redaction parse sanitize identifier untrusted input" + +Run locally (Python 3.10-3.12, where Atheris wheels exist): + python backend/fuzz/fuzz_dsn_redaction.py -atheris_runs=200000 + python backend/fuzz/fuzz_dsn_redaction.py backend/fuzz/corpus/dsn_redaction +""" + +from __future__ import annotations + +import sys + +import atheris + +with atheris.instrument_imports(): + from app.dsn_redaction import redact_dsn_error_message + +# Password alphabet with no DSN-structural characters, so the password we embed +# survives urlsplit verbatim and the leak check stays a true positive. +_SAFE_PW = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-.~" + + +def test_one_input(data: bytes) -> None: + fdp = atheris.FuzzedDataProvider(data) + + # Path A -- crash safety on fully arbitrary error message + DSN. + error_message = fdp.ConsumeUnicodeNoSurrogates(256) + dsn = fdp.ConsumeUnicodeNoSurrogates(256) + redact_dsn_error_message(error_message, dsn) + + # Path B -- secret must not leak. Build a well-formed DSN with a known, + # structurally-safe password and force it into the error text. + pw_len = fdp.ConsumeIntInRange(1, 40) + pw = "".join( + _SAFE_PW[b % len(_SAFE_PW)] for b in fdp.ConsumeBytes(pw_len) + ) + if not pw: + return + host = "db.example.com" + structured_dsn = f"postgresql://user:{pw}@{host}:5432/app?sslmode=require" + noise = fdp.ConsumeUnicodeNoSurrogates(120) + structured_message = ( + f"connection failed: {noise} password={pw} authenticating to {structured_dsn}" + ) + redacted = redact_dsn_error_message(structured_message, structured_dsn) + if pw in redacted: + raise AssertionError( + f"password leaked through redaction: pw={pw!r} redacted={redacted!r}" + ) + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/backend/fuzz/fuzz_spec_generators.py b/backend/fuzz/fuzz_spec_generators.py new file mode 100644 index 00000000..3041d27e --- /dev/null +++ b/backend/fuzz/fuzz_spec_generators.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Atheris harness for the snapshot-driven spec/report generators. + +Untrusted-input surface: the same DB-introspection *snapshot* is fed to several +pure generators that emit Markdown / LLM prompts / finding reports: + + app.spec.reversing.generate_reversing_spec + app.spec.index_design.generate_index_design_spec + app.spec.naming_lint.lint_naming + app.spec.wide_tables.detect_wide_tables + +Each must be total over arbitrary snapshot shapes (only an unsupported *mode* +may raise ``ValueError``) and deterministic. The report generators additionally +carry internal invariants (summary counts match the emitted items) that we +assert here. + +CodeGraph pointed here via: + codegraph explore "generate_reversing_spec index_design naming_lint snapshot" + +Run: python backend/fuzz/fuzz_spec_generators.py backend/fuzz/corpus/reversing_spec +""" + +from __future__ import annotations + +import sys + +import atheris + +with atheris.instrument_imports(): + from app.spec.index_design import generate_index_design_spec + from app.spec.naming_lint import lint_naming + from app.spec.reversing import generate_reversing_spec + from app.spec.wide_tables import detect_wide_tables + +sys.path.insert(0, __file__.rsplit("/", 1)[0]) +from _snapshot import ByteFeeder, build_snapshot # noqa: E402 + +_MODES = ["markdown", "llm-prompt", "bogus", ""] + + +def _check_str_spec(fn, snapshot, mode) -> None: + try: + out = fn(snapshot, mode) + except ValueError: + return # unsupported mode is a documented error + if not isinstance(out, str): + raise AssertionError(f"{fn.__name__} returned {type(out)!r}, expected str") + if out != fn(snapshot, mode): + raise AssertionError(f"{fn.__name__} is non-deterministic") + + +def test_one_input(data: bytes) -> None: + fdp = atheris.FuzzedDataProvider(data) + feeder = ByteFeeder.from_fdp(fdp) + snapshot = build_snapshot(feeder) + mode = _MODES[fdp.ConsumeIntInRange(0, len(_MODES) - 1)] + + _check_str_spec(generate_reversing_spec, snapshot, mode) + _check_str_spec(generate_index_design_spec, snapshot, mode) + + # naming_lint: report dict with self-consistent summary counts. + report = lint_naming(snapshot) + items = report["items"] + summary = report["summary"] + if summary["total"] != len(items): + raise AssertionError("naming_lint summary total mismatch") + high = sum(1 for i in items if i["severity"] == "high") + info = sum(1 for i in items if i["severity"] == "info") + if summary["high"] != high or summary["info"] != info: + raise AssertionError("naming_lint summary severity counts mismatch") + + # wide_tables: never raises, returns a dict. + wide = detect_wide_tables(snapshot) + if not isinstance(wide, dict): + raise AssertionError("detect_wide_tables did not return a dict") + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_dsn_redaction.py b/backend/tests/test_dsn_redaction.py index 02f944cd..61ae2ffa 100644 --- a/backend/tests/test_dsn_redaction.py +++ b/backend/tests/test_dsn_redaction.py @@ -32,3 +32,14 @@ def test_short_dsn_password_does_not_corrupt_secret_key_names() -> None: assert "password=***" in redacted assert "postgresql://user:***@db.example.com/app" in redacted assert "***word" not in redacted + + +def test_malformed_dsn_still_redacts_embedded_secrets() -> None: + dsn = "postgresql://user:s3cr3t@[bad/db?password=q%2Fsecret" + error = f"driver failed for s3cr3t with password=q/secret while using {dsn}" + + redacted = redact_dsn_error_message(error, dsn) + + assert "s3cr3t" not in redacted + assert "q/secret" not in redacted + assert "password=***" in redacted diff --git a/backend/tests/test_fuzz_properties.py b/backend/tests/test_fuzz_properties.py new file mode 100644 index 00000000..49e20ef0 --- /dev/null +++ b/backend/tests/test_fuzz_properties.py @@ -0,0 +1,243 @@ +"""Property-based (Hypothesis) fuzz tests for untrusted-input surfaces. + +These complement the coverage-guided Atheris harnesses in ``backend/fuzz/``. +Hypothesis (MPL-2.0) is not part of the pinned runtime/dev lock, so this module +is skipped in the normal hash-locked test job via ``importorskip`` and exercises +the same invariants wherever Hypothesis is installed. + +Surfaces (all pure, deterministic, network-free) identified with CodeGraph: + * app.sanitize.sanitize_for_storage -- NUL-stripping storage guard + * app.ddl.export.snapshot_json_to_sql -- snapshot -> DDL renderer + * app.spec.reversing / index_design -- snapshot -> spec/prompt + * app.spec.naming_lint / wide_tables -- snapshot -> findings report + * app.dsn_redaction.redact_dsn_error_message -- secret redaction +""" + +from __future__ import annotations + +from collections.abc import Mapping + +import pytest + +hypothesis = pytest.importorskip("hypothesis") +from hypothesis import HealthCheck, given, settings # noqa: E402 +from hypothesis import strategies as st # noqa: E402 + +from app.ddl.export import snapshot_json_to_sql # noqa: E402 +from app.dsn_redaction import redact_dsn_error_message # noqa: E402 +from app.sanitize import sanitize_for_storage, strip_nul # noqa: E402 +from app.spec.index_design import generate_index_design_spec # noqa: E402 +from app.spec.naming_lint import lint_naming # noqa: E402 +from app.spec.reversing import generate_reversing_spec # noqa: E402 +from app.spec.wide_tables import detect_wide_tables # noqa: E402 + +_SETTINGS = settings( + max_examples=200, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +# --- strategies ------------------------------------------------------------ + +_SCALAR = st.one_of( + st.none(), + st.booleans(), + st.integers(min_value=-(2**33), max_value=2**33), + st.floats(allow_nan=True, allow_infinity=True), + st.text(max_size=40), + st.binary(max_size=20), +) + +# Free-form string surface -- the values a hostile *database* actually controls +# (identifier names, type text, default expressions, index/constraint DDL). Kept +# maximally adversarial: NUL, quotes, pipes, newlines, oversized, non-ASCII. +_HOSTILE_TEXT = st.one_of( + st.none(), + st.text(max_size=80), + st.sampled_from( + [ + 'weird"name', + "a\x00b", + "SELECT", + "user", + "numeric(10,2)", + "character varying(255)", + "timestamp with time zone", + "'default'", + "NEXTVAL('s')", + "\n|pipe|\n", + "타이블", + "x" * 100, + ] + ), +) +# Integer-or-None: fields DB introspection always emits as OIDs / ordinals. +_OID = st.one_of(st.none(), st.integers(min_value=-5, max_value=500)) +_BOOLISH = st.one_of(st.none(), st.booleans()) + + +@st.composite +def _row(draw: st.DrawFn) -> dict: + """A snapshot row: id-like fields are int|None (as pg emits), name/type/ + definition fields are fully hostile strings -- the real fuzz surface.""" + + row = { + "relation_oid": draw(_OID), + "schema_name": draw(_HOSTILE_TEXT), + "relation_name": draw(_HOSTILE_TEXT), + "relation_kind": draw( + st.sampled_from(["r", "p", "v", "m", "f", "c", "", "x", None]) + ), + "column_name": draw(_HOSTILE_TEXT), + "column_position": draw(_OID), + "data_type": draw(_HOSTILE_TEXT), + "domain_base_type": draw(_HOSTILE_TEXT), + "is_not_null": draw(_BOOLISH), + "has_default": draw(_BOOLISH), + "default_expr": draw(_HOSTILE_TEXT), + "constraint_type": draw(st.sampled_from(["p", "u", "c", "f", "x", "", None])), + "constraint_name": draw(_HOSTILE_TEXT), + "constraint_def": draw(_HOSTILE_TEXT), + "constrained_attnums": draw( + st.one_of(st.none(), st.lists(_OID, max_size=5)) + ), + "index_def": draw(_HOSTILE_TEXT), + "index_name": draw(_HOSTILE_TEXT), + "index_tablespace_name": draw(_HOSTILE_TEXT), + "table_name": draw(_HOSTILE_TEXT), + "table_schema_name": draw(_HOSTILE_TEXT), + "tablespace_name": draw(_HOSTILE_TEXT), + "is_partition": draw(_BOOLISH), + "partition_key": draw(_HOSTILE_TEXT), + "partition_bound": draw(_HOSTILE_TEXT), + "partition_parent_schema": draw(_HOSTILE_TEXT), + "partition_parent_name": draw(_HOSTILE_TEXT), + } + # Randomly drop keys so missing-field paths are exercised too. + drop = draw(st.sets(st.sampled_from(sorted(row)), max_size=6)) + return {k: v for k, v in row.items() if k not in drop} + + +_ROWS = st.lists(_row(), max_size=8) + + +@st.composite +def _snapshots(draw: st.DrawFn) -> dict: + # Usually a well-formed list-of-rows; occasionally a wrong-typed top-level + # value, to prove the generators tolerate malformed JSON. + def key(strat): + return draw( + st.one_of(strat, st.none(), st.text(max_size=5), st.integers()) + if draw(st.integers(0, 9)) == 0 + else strat + ) + + return { + "relations": key(_ROWS), + "columns": key(_ROWS), + "constraints": key(_ROWS), + "indexes": key(_ROWS), + "fk_edges": key(_ROWS), + "source_dialect": draw( + st.sampled_from(["postgresql", "snowflake", "pg", "sf", "bogus", None]) + ), + } + + +# --- sanitize -------------------------------------------------------------- + + +def _no_nul(obj: object) -> bool: + if isinstance(obj, str): + return "\x00" not in obj + if isinstance(obj, Mapping): + return all(_no_nul(k) and _no_nul(v) for k, v in obj.items()) + if isinstance(obj, (list, tuple)): + return all(_no_nul(v) for v in obj) + return True + + +@_SETTINGS +@given( + st.recursive( + _SCALAR, + lambda children: st.one_of( + st.lists(children, max_size=5), + st.dictionaries(st.text(max_size=10), children, max_size=5), + ), + max_leaves=25, + ) +) +def test_sanitize_removes_all_nul(value: object) -> None: + """No NUL byte may survive anywhere in the sanitized structure.""" + assert _no_nul(sanitize_for_storage(value)) + + +@_SETTINGS +@given(st.text(max_size=200)) +def test_strip_nul_is_idempotent(text: str) -> None: + once = strip_nul(text) + assert "\x00" not in once + assert strip_nul(once) == once + + +# --- snapshot generators: totality + determinism --------------------------- + + +@_SETTINGS +@given(_snapshots(), st.sampled_from(["postgresql", "snowflake", "pg", "sf"])) +def test_ddl_export_total_and_deterministic(snapshot: dict, dialect: str) -> None: + out = snapshot_json_to_sql(snapshot, dialect) + assert isinstance(out, str) + assert out == snapshot_json_to_sql(snapshot, dialect) + + +@_SETTINGS +@given(_snapshots(), st.sampled_from(["markdown", "llm-prompt"])) +def test_spec_generators_total_and_deterministic(snapshot: dict, mode: str) -> None: + for fn in (generate_reversing_spec, generate_index_design_spec): + out = fn(snapshot, mode) + assert isinstance(out, str) + assert out == fn(snapshot, mode) + + +@_SETTINGS +@given(_snapshots()) +def test_naming_lint_summary_is_consistent(snapshot: dict) -> None: + report = lint_naming(snapshot) + items = report["items"] + summary = report["summary"] + assert summary["total"] == len(items) + assert summary["high"] == sum(1 for i in items if i["severity"] == "high") + assert summary["info"] == sum(1 for i in items if i["severity"] == "info") + + +@_SETTINGS +@given(_snapshots()) +def test_wide_tables_total(snapshot: dict) -> None: + assert isinstance(detect_wide_tables(snapshot), dict) + + +# --- dsn redaction: crash safety + no secret leak -------------------------- + +_SAFE_PW = st.text( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-.~", + min_size=1, + max_size=40, +) + + +@_SETTINGS +@given(st.text(max_size=256), st.text(max_size=256)) +def test_redact_never_crashes(error_message: str, dsn: str) -> None: + assert isinstance(redact_dsn_error_message(error_message, dsn), str) + + +@_SETTINGS +@given(_SAFE_PW, st.text(max_size=120)) +def test_redact_does_not_leak_password(pw: str, noise: str) -> None: + dsn = f"postgresql://user:{pw}@db.example.com:5432/app?sslmode=require" + message = f"connection failed: {noise} password={pw} connecting to {dsn}" + redacted = redact_dsn_error_message(message, dsn) + assert f"password={pw}" not in redacted + assert f":{pw}@" not in redacted diff --git a/docs/papers/README.md b/docs/papers/README.md new file mode 100644 index 00000000..496ba3e9 --- /dev/null +++ b/docs/papers/README.md @@ -0,0 +1,13 @@ +# Papers + +Background reading for the fuzzing setup in `backend/fuzz/`. + +- Manès, Han, Han, Cha, Egele, Schwartz, Woo, *"The Art, Science, and + Engineering of Fuzzing: A Survey"* ([arXiv:1812.00140](https://arxiv.org/abs/1812.00140), + open access). A taxonomy of fuzzing, including the coverage-guided / + feedback-directed model (AFL, libFuzzer) that + [Atheris](https://github.com/google/atheris) implements for Python, and the + seed-corpus + mutation loop our harnesses rely on. It frames why we target + untrusted-input surfaces (snapshot parsers, DSN/error redaction, DDL + rendering) and assert crash-freedom + security invariants rather than only + example-based tests.