Docs Contribute to sipnab

Contribute to sipnab

Build from source, run the tests and git hooks, sign the contributor agreement, and take a change through review to merge.

On this page

Orientation

Start with docs/architecture.md — the module map, data flow, and the “where to add things” table. Then the developer index, which is the reading order for everything below the codemap: the SIP/RTP domain primer, the subsystem guide (one packet, wire to screen), the invariants that must not break, the test tiers, ordered walkthroughs for common changes, and build/CI/release. The threading topology and lock discipline live in docs/internals/threading.md.

By participating in this project you agree to abide by the Code of Conduct.

Contributor license agreement

Sign the sipnab Contributor License Agreement before your first pull request merges. It is a one-time step covering all of your contributions, and you keep full ownership of your work. CLA.md holds the text; https://sipnab.com/cla/ republishes it, and CLA Assistant shows the same words to signers. A gate keeps the first two copies identical, and MAINTAINERS.md records who re-checks the third.

Open your pull request as normal. If anyone who committed to it has not signed, the CLAassistant bot comments within a minute or so, and you have two ways to answer it: follow its link and authorize CLA Assistant with your GitHub account, or post this exact sentence as a pull request comment.

I have read the CLA Document and I hereby sign the CLA

Post it verbatim – the bot matches the whole sentence, so a reworded version reads as an ordinary comment and signs nothing. The bot then reports a license/cla status on the pull request, and turns it green once everyone who committed to that branch has signed.

A merge waits for it. Branch protection on main requires license/cla alongside CI success, so a pull request cannot merge until everyone who committed to it has signed. Bot accounts such as Dependabot cannot sign an agreement. They are on the allowlist in the CLA Assistant settings, so their pull requests pass the check without one.

Prerequisites

  • Rust 1.99+ (edition 2024)
  • libpcap headers
    • macOS: xcode-select --install
    • Debian/Ubuntu: apt install libpcap-dev
    • Fedora/RHEL: dnf install libpcap-devel
  • For fuzzing only: a nightly toolchain and cargo-fuzz (rustup toolchain install nightly && cargo install cargo-fuzz).

Build from source

# Run all of these, in order.
git clone https://github.com/NormB/sipnab.git
cd sipnab
cargo build

Running tests

The default feature set, which is the fast pass:

cargo test

Every feature-gated path, which is what CI gates on – the default build leaves out tls, hep, api, mcp, and wasm, so their tests do not run above:

cargo test --all-features

These run the unit tests, the integration tests, the property tests (tests/property_test.rs, proptest — SIP/SDP build→parse round-trips and the filter-DSL total-function invariant), and the always-on smoke-fuzz gate (tests/smoke_fuzz_test.rs, no nightly needed). The TUI has three test tiers (insta snapshots, headless state-machine tests, and a PTY end-to-end suite) — see docs/internals/tui-testing.md, including the cargo insta test --accept flow for updating snapshots.

Fuzzing

The fuzz/ crate holds 15 libFuzzer targets (nightly + cargo-fuzz). Run one from the repository root against its seed corpus — cargo-fuzz passes the corpus argument to the fuzz binary verbatim and never changes its directory, so from fuzz/ it resolves to fuzz/fuzz/corpus/sip_parser and libFuzzer exits with ERROR: The required directory ... does not exist before it fuzzes anything:

cargo +nightly fuzz run fuzz_sip_parser fuzz/corpus/sip_parser

CI compile-checks every target on each push (fuzz-check), and the .github/workflows/fuzz.yml workflow runs the full 15-target matrix weekly (Mondays 05:17 UTC) and on demand (gh workflow run Fuzz -f max_total_time=300). Crash/timeout reproducers land in fuzz/artifacts/ (git-ignored), and CI uploads them as artifacts. Minimize one into fuzz/corpus/<parser>/ to turn it into a regression seed.

Running benchmarks

cargo bench --profile profiling

Pass --profile profiling every time, not as an option: plain cargo bench cannot build because the wasm cdylib crate-type forces the lib dependency unit onto profile.release’s panic = "abort" while bench harness units must unwind, so cargo compiles shared deps twice with incompatible type identities (see the [lib] notes in Cargo.toml). The profiling profile is release codegen with panic = "unwind".

Git hooks

This repo ships hooks in .githooks/. Enable them once per clone:

git config core.hooksPath .githooks

pre-commit runs nine numbered gates, starting at 0: cargo fmt --all -- --check, clippy (--features full, -D warnings), the full cargo test --features full suite, no unwrap()/expect() or abort macro (panic!, unreachable!, todo!, unimplemented!) in production code, WASM exports in sync with the site’s JS, the homepage test count plus the site version matching Cargo.toml, no TODO stubs, and an advisory developer-docs coupling notice. Gates 0-5b block the commit. Gate 6 prints WARN: N TODO/FIXME comments and falls through — a count, not a veto — and gate 8 only prints REVIEW and a file list.

Gate 0 runs first because it is the cheapest check in either hook (~1.4s), so an unformatted tree fails in seconds rather than after clippy and the whole suite. pre-push checks formatting again — that copy is what guarantees nothing unformatted reaches the remote — but it cannot catch the mistake early, and a formatting slip that only surfaces at push time costs a full commit-and-push cycle to undo.

Because gate 2 runs the whole suite, every commit takes minutes, and gate 5 means adding a test obliges you to update the count in website/templates/index.html in the same commit.

pre-push adds thirteen hard gates, all of which mirror CI exactly and any of which blocks the push:

GateWhy it is not covered by cargo test
scripts/preflight.shRun this first. About a minute, and it checks only the things that actually bounce a commit — Vale at CI’s pinned version, codespell, both site-mirror generators, the documentation ratchets, and whether a changed test count left the homepage tile behind. On 2026-08-08 four commits bounced on exactly these at ~25 minutes each; none needed the suite to find. It does NOT run the suite, clippy, the corpus gate or the feature matrix, so a green preflight means the paperwork is right, not that the change is. A tool it cannot find — no vale, no codespell, no python3 — warns at an interactive terminal and FAILS anywhere else: under CI, with output redirected, or with PREFLIGHT_STRICT=1. PREFLIGHT_STRICT=0 keeps the warning everywhere. Automation reading “Preflight clean” from a gate that never ran is how two Vale errors reached CI on 2026-08-10.
cargo fmt --all -- --checkFormatting is never checked by a build.
cargo clippy --workspace --all-features --all-targets -- -D warningsBroader than pre-commit’s --features full: also lints tests, benches, examples, and every feature-gated path.
RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features --workspaceRustdoc lints (e.g. private intra-doc links) build independently of the test build.
cd fuzz && cargo checkfuzz/ is a separate workspace nothing else compiles.
cargo check --no-default-features --features <combo> --tests over the reduced combinations--all-features never builds a tree without native, so #[cfg] rot is invisible to it. The --tests part matters: without it no test file compiles and the gate passes over nothing.
sh scripts/check-non-linux.shRe-checks a copy of the tree with the target_os values swapped, so the macOS arm of every platform split compiles here. CI is the only non-Linux build in this project, and two macOS breaks reached it on 2026-08-07 with every other gate green. Runs on Linux hosts only — on macOS or a BSD your ordinary cargo clippy already is that build, and the gate says NOT CHECKED rather than pretending.
python3 scripts/check-yang.pyA generator writes the sipnab-diagnosis YANG module from the analysis’s tables, and a Rust test proves the committed file matches them; only a YANG implementation can say it is valid YANG. pyang --lint and yanglint compile it, pyang --check-update-from holds a new revision to the last, and yanglint -t data validates the RFC 7951 export every door writes. NOT CHECKED on a host with neither tool; CI installs both and fails without them.
vale docs/ website/content/ README.md SUPPORT.md MAINTAINERS.mdProse style is invisible to every cargo command. Turned main red on 2026-08-03.
codespell over CI’s path listSpelling likewise, and it reads src/ too — the hits that broke CI were in doc comments.
scripts/tag-signature-check.sh on every pushed v* tagA release tag carries the name of the maintainer who publishes it. The tag must be an annotated tag with a good SSH signature from a key in .github/allowed_signers. Nothing in cargo test sees a tag.

CI’s full feature matrix (every combination .github/workflows/ci.yml lists, with its RUSTFLAGS=-Dwarnings) is not in the hook: the Features job builds it on the project’s aarch64 self-hosted runners within minutes of every push. To see it before you push, run python3 scripts/check-feature-matrix.py, which reads the combos and the flags out of ci.yml rather than restating them. The reduced combinations above still catch the commonest #[cfg] rot at the push.

SKIP_FMT_HOOK=1 git push bypasses all nine — it is an emergency valve, not a clippy-only escape, and CI runs the same gates anyway. Verify the hooks themselves with scripts/test-pre-commit.sh and scripts/test-pre-push.sh.

Code style

This project enforces consistent style through tooling and convention:

  • Format: cargo fmt before every commit. The project uses a rustfmt.toml config.
  • Lint: cargo clippy -- -D warnings must pass with zero warnings.
  • No .unwrap() on external input. The library surface returns typed thiserror errors (Error, ParseError, CaptureError in src/error.rs); anyhow is for binary/app/ orchestration only. clippy::unwrap_used bans .unwrap()/.expect() on library production paths, and they are acceptable only on compile-time-known values (regex literals) or in tests. scripts/check-unwrap.py bans panic!, unreachable!, todo! and unimplemented! there too. A site that no input can reach keeps the macro only with a // gate: <macro> because <reason> comment directly above it; the scanner reports a marker that names no reason.
  • Rustdoc on public types. Every pub fn, pub struct, and pub enum must have a /// doc comment.
  • No unsafe without justification. Where code needs unsafe, add a // SAFETY: comment explaining the invariant.

Never publish a machine, an account, or a network

This repository is public, and a private name that reaches main becomes public the moment someone pushes it – removing it later leaves it in the history. It is also, every time, worse documentation: a reader cannot resolve your hostname or reach your LAN, so the example fails for them in a way that looks like a fault in the tool.

Write what a reader can act on:

Instead ofWriteWhy
a hostname (invented here: buildbox-7, sbc-east-2)what the machine IS – the aarch64 self-hosted runner, Jetson AGX Thor, 14 coresA benchmark needs the hardware; it never needs the box’s name.
an address on your LAN (192.0.2.40 stands in for one here)RFC 5737 – 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24Reserved for documentation, and a reader can tell at a glance it is an example.
a global IPv6 addressRFC 3849 – 2001:db8::/32Same reason.
a real domain (corp.example-isp.com)RFC 2606 – example.com, or a .test / .invalid nameAn address at a real domain reaches a real person.
/home/you/pcaps$HOME, /srv/pcaps, or a path relative to the repoAn absolute home path names your account and runs on one machine.
a gate log or a scratch filenothing – do not commit it, and add the pattern to .gitignoreA transcript carries the paths of the machine that produced it.

tests/private_identity_test.rs enforces all of it and tells you exactly which line to change. One exception is allowlisted, and it is functional: the self-hosted runner’s label on a runs-on: line in .github/workflows/ is how a workflow reaches the one machine that can run it, and renaming it there would not rename it on the machine.

Capture corpora are the same rule one layer down. The captures that prove this project carry real signaling. They live outside the tree, nobody commits them, and pages do not say where they are. A capture that IS in the tree is public, with a source and a license, or synthetic, with the generator that writes it, and says which: in tests/pcap-samples/PROVENANCE.md for that directory and in tests/PROVENANCE.md for everything else. every_committed_capture_is_public_or_synthetic finds captures by their leading bytes, not their names, and refuses a staged one with no entry.

Documentation

Prose is US English. behavior, normalize, recognize, analyze. the_tree_spells_in_us_english checks whole words across every tracked file – including test files, which is where it usually catches one.

docs/ is the source of truth. Edit there. The generators build every operator page on sipnab.com from it:

TreeSource of truth forPublished by
docs/The in-repo docs. Read directly on GitHub.scripts/build-wiki.py → the GitHub wiki, via wiki-sync.yml on push to main.
website/content/docs/Zola content for the site. Mostly generated — do not hand-edit a page carrying the “Generated by” banner.pages.yml on push to main.

Regenerate after any docs change, and commit the result:

# Run all of these, in order.
python3 scripts/build-site-pages.py
python3 scripts/build-site-internals.py

site_pages_mirror_is_current re-runs both and fails if a committed mirror is stale, so CI catches a forgotten regeneration rather than shipping it. It also fails if a page carrying the banner is no longer written by the generator — dropping a page from PAGES leaves its mirror on disk, still stamped “do not edit”, quietly a hand-maintained copy again.

The filenames differ, which is why two places declare the mapping, and they must agree — PAGES in scripts/build-site-pages.py (what the generator writes) and DOCS_TO_SITE in scripts/build-site-internals.py (how a link to that page is rewritten):

docs/website/content/docs/
cli-reference.mdcli.md
examples.mdcookbook.md
rest-api.mdapi.md
config-reference.mdconfig.md
theme-guide.mdtheme.md
tui-walkthrough.mdtui.md

The asymmetries are deliberate: auth.md, library.md and fault-model.md have no site counterpart, while api-clients.md, build.md and integrations.md are site-only and hand-maintained.

Two pages outside docs/ publish to the site the same way: scripts/build-site-pages.py writes website/content/docs/contributing.md from this file and website/content/docs/contrib.md from contrib/README.md, and contributor_docs_are_generated_site_pages fails if either page is missing. Edit the source, then regenerate.

docs/internals/ does publish to the site — ten pages under website/content/docs/internals/, rendered by scripts/build-site-internals.py and gated by every_internals_page_is_published_to_the_site and site_internals_mirror_is_current in tests/dev_docs_drift_test.rs. (Corrected 2026-08-05: this paragraph used to list “all of docs/internals/” among the pages with no site counterpart and call the developer docs “wiki-only by design”, contradicting the instruction twenty lines above it to run build-site-internals.py.)

benchmarks.md is the one page that exists on both sides and is deliberately not generated: the two copies frame the numbers differently on purpose, and benchmark_tables_match_between_docs_and_website gates the part that must not differ — the measured tables.

Both trees are in the flag-drift corpus in tests/docs_drift_test.rs and the link corpus in tests/link_integrity_test.rs, so the gates check each one on its own for phantom flags and dead links.

They are also checked against each other, and the check is a byte comparison: site_pages_mirror_is_current in tests/dev_docs_drift_test.rs re-runs scripts/build-site-pages.py into a temporary directory and fails on any page whose committed output differs from a fresh render. So the site copies in the table above are generated artifacts — edit the docs/ source and re-run the generator. The next render reverts a hand edit to website/content/docs/cli.md, and forgetting to regenerate fails CI rather than passing it.

Corrected 2026-08-05: this section used to read “Nothing checks them against each other — documenting a new flag in docs/cli-reference.md and forgetting website/content/docs/cli.md passes every gate. That parity is yours to keep.” Both sentences were false, and the advice they gave — hand-maintain the generated side — was the opposite of the workflow.

Citing code from the developer docs

Pages under docs/internals/ link into the source tree, and tests/dev_docs_drift_test.rs enforces the form:

the [`classify_packet()`](../../src/pipeline.rs) router
  • Relative paths only. An absolute github.com/NormB/sipnab/blob/main/… URL pins a branch and rots silently. build-wiki.py rewrites the relative form into a blob URL when publishing.
  • Never file:line. Line numbers are stale within a commit. A path plus a ()-suffixed symbol in the link text survives a refactor, and the drift test checks that the path exists and that the symbol still has a definition.
  • Diagrams are mermaid sequenceDiagram, each preceded by a prose line carrying the same point, so the page still reads where mermaid does not render. No markdown links inside a fence — build-wiki.py rewrites links with no fence awareness and would corrupt the diagram.
  • Register a new page in PAGES and GROUPS in scripts/build-wiki.py, or it never publishes to the wiki.
  • Add a new top-level directory to .config/code-trees.txt. That file is the one list of trees a documentation link may point into: the wiki and site generators build their link-rewriting pattern from it, the fixer scripts/link-repo-paths.py decides from it what it may link under docs/internals/, and the Rust gates read it with include_str!. code_tree_list_matches_the_repository fails until the file names the new directory, because a tree missing from it is one whose links nothing rewrites and nothing checks.

The coupling rule: a change to linked code updates the page that links it, in the same pull request. The pre-commit hook’s gate 8 prints a REVIEW list when you stage a cited file without touching docs/internals/. It is advisory because only you can tell whether the prose is still true. The hard gate is dev_docs_drift_test, and it catches only the mechanical half — a link that no longer resolves. Nothing catches prose that has quietly become wrong.

Dependencies

A dependency is a crate that sipnab’s code pulls in from someone else. Each one is code that runs with sipnab’s privileges, so adding one is a review decision, not a convenience.

Choosing a new crate

Before you add a crate, check it against these rules. cargo deny check enforces the first three: it reads deny.toml and fails the pull request when a crate breaks one.

  • Its license is on the allow list. sipnab’s own license is MIT OR Apache-2.0. [licenses] in deny.toml lists the licenses a dependency may carry. Adding a license to that list takes its own reviewed change, with the reason written next to it.
  • It comes from crates.io. [sources] in deny.toml rejects git dependencies and any other registry. That also rules out swapping in a patched fork of a crate: fix the problem upstream instead.
  • It has no open security advisory. The advisories come from the RustSec database. The project accepts an advisory that does not apply to sipnab only with a written reason, as the one rsa exception in deny.toml shows.

The reviewer checks the rest:

  • Prefer what you already have. Use the standard library or a crate already in Cargo.lock before adding a new one. cargo deny check warns when two versions of the same crate end up in the build.
  • Take only the features you need. When a crate’s default features bring in more than sipnab uses, set default-features = false and list the features you need, as several entries in Cargo.toml do. If only one sipnab feature needs the crate, mark it optional = true and let that feature turn it on.
  • Someone still maintains it. Look for recent releases and answered issues. Say in the pull request why you chose this crate over the alternatives.

Tracking the crates you have

  • The repository commits its lock files. Cargo.lock pins the exact version of every crate in the build. The fuzz targets form a separate workspace with their own fuzz/Cargo.lock, also committed.
  • Dependabot opens update pull requests weekly for both lock files, as .github/dependabot.yml sets up. It groups minor and patch updates into one pull request.
  • CI scans every pull request to main. The Security audit job in .github/workflows/ci.yml runs cargo audit on both lock files and cargo deny check on the build. A failure turns the required CI success check red, which blocks the merge.
  • OSV-Scanner checks every lock file on each pull request, each push to main, and every Wednesday, from .github/workflows/osv-scanner.yml. It uses the osv.dev database, which covers more than Rust crates, and reports findings as code scanning alerts. osv-scanner.toml lists the accepted advisories, each with its reason.

When you add or update a dependency, run the same checks CI runs before you push. Install the tools once with cargo install cargo-audit cargo-deny.

# Run all of these, in order.
cargo audit --ignore RUSTSEC-2023-0071
cargo audit --file fuzz/Cargo.lock --ignore RUSTSEC-2023-0071
cargo deny check

Updating vendored files

A few files come from other projects’ releases, copied into the repository rather than fetched by a package manager. Dependabot and cargo audit do not see them, so nothing tells you when a new version comes out. Each one has a row in the “Vendored files” table of THIRD-PARTY-NOTICES.md naming its version, where it came from, its license and its SHA-256.

FileWhat it isWhere a new version comes from
website/static/js/mermaid.min.jsMermaid, which draws the site’s diagramsdist/mermaid.min.js in the mermaid package on the npm registry
website/static/js/scalar.min.jsScalar, which renders the REST API referencedist/browser/standalone.js in the @scalar/api-reference package on the npm registry
tests/schemas/publisher/vcon_json_schema.jsonThe vCon working group’s JSON schema, a test fixturevcon_json_schema.json at a commit of draft-ietf-vcon-vcon-core

To update one:

  1. Download the new release and copy the file over the old one unchanged. For a package on the npm registry, npm pack <package>@<version> downloads the release tarball without installing anything.
  2. In VENDORED in scripts/build-third-party-notices.py, change the file’s version and SHA-256. sha256sum <file> prints the new hash.
  3. Regenerate the notices with python3 scripts/build-third-party-notices.py and commit both files.
  4. For the vCon schema, also change VCON_PUBLISHER_COMMIT and VCON_PUBLISHER_SHA256 in tests/json_schema_test.rs. A test there then checks that sipnab’s own tests/schemas/vcon.schema.json still differs from the new file only where it documents a deviation.

every_vendored_file_is_recorded_with_its_version in tests/docs_drift_test.rs fails when a file’s hash is not the one recorded, or a script’s recorded version is not the one the script itself contains. A new minified script under website/static/js/ also fails it until it has a row.

Commit messages

Use Conventional Commits format:

feat: add --nat-issues diagnostic alias
fix: handle empty Contact header without panic
docs: update CLI reference with new output flags
refactor: extract SDP parser into its own module
test: add pcap round-trip tests for IPv6

Pull request process

  1. Fork the repository and create a feature branch from main.
  2. Keep changes focused – one logical change per PR.
  3. Ensure the CI gate passes locally. Beyond cargo fmt and cargo test --all-features, CI enforces:
    • cargo clippy --workspace --all-features --all-targets -- -D warnings
    • a reduced-feature matrix that must compile (native, tls, api, mcp, hep, tls,api, native,tui,audio, native,tui,tls,hep,api, native,hep,api,mcp,mcp-http, wasm)
    • a docs gate (RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features)
    • cargo audit + cargo deny
    • fuzz-check (the fuzz targets must compile on nightly)
  4. Add or update tests for new functionality.
  5. Update documentation if you add or change CLI flags or config keys.
  6. Describe the “why” in the PR body, not just the “what”.

Code review

A reviewer reads every change to main before it merges. This section says who reviews it, how, what the review checks, and what a change needs before it can merge. Where a setting or a file enforces a rule rather than a person, the rule names it.

Who reviews

The maintainer listed in MAINTAINERS.md reviews every pull request. .github/CODEOWNERS assigns the whole tree (* @NormB), so GitHub requests that review automatically when a pull request opens.

sipnab has one maintainer today, so nobody else can review changes the maintainer writes. Those changes go through the same pull request, the same checklist and the same required checks as a contribution from anyone, and the maintainer reviews the diff before merging. Branch protection on main therefore requires zero approving reviews: requiring one would block every change the only maintainer makes. When a second maintainer joins (see Getting commit access), the requirement becomes one approving review from someone other than the author.

How a review works

  • Through a pull request only. Branch protection on main requires a pull request and applies to administrators too, so nobody pushes to main directly. tests/branch_protection_drift_test.rs fails if that setting and this documentation disagree.
  • Against the checklist. The reviewer works through the checklist in .github/PULL_REQUEST_TEMPLATE.md and the points below.
  • In the pull request’s conversation. Questions and requested changes go in review comments. Branch protection requires every conversation to be resolved before the pull request merges. A new push dismisses an earlier approval, so an approval always covers the code that merges.

What the reviewer checks

  • Correctness, with tests. The change does what its description says, and it comes with a test that fails without the change and passes with it.
  • Documentation. The change updates the docs for any new or changed flag, config key or behavior, as Documentation describes.
  • Security impact. The reviewer asks which trust boundary in the threat model the change touches, such as capture input, HEP senders, API clients or plugins, and whether it weakens the checks listed there. A change that moves a boundary updates that page.
  • Dependencies. A new or updated crate meets the rules in Dependencies. cargo deny check enforces some of them, and the reviewer checks the rest.
  • No secrets and no private names. Nothing in the diff is a token, a password, or a private hostname, address or path. The repository has GitHub secret scanning with push protection turned on, and tests/private_identity_test.rs enforces the rules in Never publish a machine, an account, or a network.
  • Public claims match the code. Anything the change says in the README, the site or the docs is true of the code as merged.
  • A changelog entry. A user-visible change adds an entry under ## [Unreleased] in CHANGELOG.md.
  • The contributor agreement. Everyone who committed to the branch has signed the CLA.

What a change needs before it can merge

A pull request is acceptable when all of these hold:

  1. The required checks are green. Branch protection on main requires CI success and license/cla, and requires the branch to be up to date with main before it merges. CI success passes only when every CI job it depends on passes. license/cla passes when everyone who committed to the branch has signed the CLA.
  2. Every commit carries a signature. Branch protection on main requires signed commits, so GitHub refuses a merge that contains an unsigned or unverified one.
  3. No review conversation stays open. Branch protection blocks the merge until someone marks each one resolved.
  4. The reviewer agrees that the change meets the pull request template checklist and the points under What the reviewer checks. The contributor says so in the template checklist, and the reviewer confirms it.

Reporting bugs

Open a GitHub issue with:

  • sipnab version (sipnab --version)
  • OS and architecture
  • Steps to reproduce
  • Expected vs. actual behavior
  • A pcap or SIP trace if applicable (sanitize credentials first)

Security vulnerabilities

Do not open a public issue. See SECURITY.md for responsible disclosure instructions.