diff --git a/.github/workflows/rng-tripwire.yml b/.github/workflows/rng-tripwire.yml new file mode 100644 index 0000000000..061cee6c33 --- /dev/null +++ b/.github/workflows/rng-tripwire.yml @@ -0,0 +1,25 @@ +name: rng-tripwire + +on: [push, pull_request] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Check for vendor-defined random algorithms + run: python3 Builds/rng_tripwire/rng_tripwire.py + - name: What happened? + if: failure() + env: + MESSAGE: | + A standard-library random algorithm showed up outside the + allowlist. These algorithms are not the same on every standard + library, so a new call site can change a deterministic trace. + + Either draw through ripple::rand_int, or add the path to + Builds/rng_tripwire/allowlist.txt with a one-line justification. + Comments are ignored. See Builds/rng_tripwire/allowlist.txt. + run: | + echo "${MESSAGE}" + exit 1 diff --git a/Builds/rng_tripwire/allowlist.txt b/Builds/rng_tripwire/allowlist.txt new file mode 100644 index 0000000000..de150a7891 --- /dev/null +++ b/Builds/rng_tripwire/allowlist.txt @@ -0,0 +1,13 @@ +# path-prefix justification +# A hit under src/ or include/ is accepted only when its path starts with one +# of these prefixes. The justification is for reviewers; the script ignores it. + +src/test/ Tests are not on the consensus schedule. +include/xrpl/basics/random.h Seeding overloads only. rand_int does not call these distributions. +include/xrpl/basics/hardened_hash.h One-time hash salt drawn from OS entropy. +include/xrpl/crypto/csprng.h CSPRNG declaration. OS entropy, not a consensus draw. +src/libxrpl/crypto/csprng.cpp OS entropy for the CSPRNG. +src/xrpld/peerfinder/ default_prng shuffles. Not the harness engine. +src/xrpld/overlay/detail/PeerImp.cpp Fault-injection seeds. RuntimeConfig only, not the harness engine. +src/xrpld/app/consensus/ConsensusExtensions.cpp Fault-injection seeds. RuntimeConfig only, not the harness engine. +src/xrpld/overlay/detail/OverlayImpl.cpp std::shuffle of relay peers. Runs only when TX_REDUCE_RELAY_ENABLE is on, and that flag is off. Uses app_.getPrng when it does run. diff --git a/Builds/rng_tripwire/rng_tripwire.py b/Builds/rng_tripwire/rng_tripwire.py new file mode 100644 index 0000000000..6cb9615337 --- /dev/null +++ b/Builds/rng_tripwire/rng_tripwire.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Fail if a vendor-defined algorithm appears outside the allowlist. + +Scans src/ and include/ for the standard algorithms whose mapping is not +portable across standard libraries. Comments are ignored. A hit is accepted +only when its path is listed in allowlist.txt. Unlisted hits are printed as +file:line and the process exits non-zero. +""" + +import re +import sys +from pathlib import Path + +ALGORITHMS = ( + "uniform_int_distribution", + "uniform_real_distribution", + "bernoulli_distribution", + "binomial_distribution", + "negative_binomial_distribution", + "geometric_distribution", + "poisson_distribution", + "exponential_distribution", + "gamma_distribution", + "weibull_distribution", + "extreme_value_distribution", + "normal_distribution", + "lognormal_distribution", + "chi_squared_distribution", + "cauchy_distribution", + "fisher_f_distribution", + "student_t_distribution", + "discrete_distribution", + "piecewise_constant_distribution", + "piecewise_linear_distribution", + "sample", + "shuffle", + "generate_canonical", + "random_device", +) + +HIT = re.compile(r"\bstd::(?:" + "|".join(ALGORITHMS) + r")\b") +SUFFIXES = {".h", ".hh", ".hpp", ".cpp", ".cc", ".cxx", ".ipp", ".inc"} + + +def repo_root() -> Path: + here = Path(__file__).resolve().parent + for candidate in (here, *here.parents): + if (candidate / "src").is_dir() and (candidate / "include").is_dir(): + return candidate + sys.exit("rng_tripwire: cannot find the repository root") + + +def load_allowlist(path: Path) -> list[tuple[str, str]]: + entries = [] + for raw in path.read_text().splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + prefix, _, why = line.partition(" ") + entries.append((prefix.strip(), why.strip())) + return entries + + +def allowed(rel: str, entries: list[tuple[str, str]]) -> bool: + for prefix, _why in entries: + if rel == prefix or rel.startswith(prefix): + return True + return False + + +def strip_comments(text: str) -> list[str]: + lines = [] + in_block = False + for line in text.splitlines(): + out = [] + i = 0 + while i < len(line): + if in_block: + end = line.find("*/", i) + if end < 0: + i = len(line) + break + in_block = False + i = end + 2 + continue + if line.startswith("//", i): + break + if line.startswith("/*", i): + in_block = True + i += 2 + continue + out.append(line[i]) + i += 1 + lines.append("".join(out)) + return lines + + +def main() -> int: + root = repo_root() + allow_path = Path(__file__).resolve().parent / "allowlist.txt" + entries = load_allowlist(allow_path) + hits = [] + for base in ("src", "include"): + for path in sorted((root / base).rglob("*")): + if not path.is_file() or path.suffix not in SUFFIXES: + continue + rel = path.relative_to(root).as_posix() + try: + text = path.read_text(encoding="utf-8") + except UnicodeError: + text = path.read_text(encoding="latin-1") + for number, line in enumerate(strip_comments(text), start=1): + if HIT.search(line): + hits.append((allowed(rel, entries), f"{rel}:{number}")) + unlisted = [item for ok, item in hits if not ok] + for ok, item in hits: + print(("ALLOW " if ok else "HIT ") + item) + print(f"{len(hits)} hits, {len(unlisted)} unlisted") + for item in unlisted: + print(item) + return 1 if unlisted else 0 + + +if __name__ == "__main__": + sys.exit(main())