Skip to content

Latest commit

 

History

History
653 lines (552 loc) · 36.7 KB

security.md

File metadata and controls

653 lines (552 loc) · 36.7 KB

GitHub Security Controls

Rivulet uses GitHub-native security controls plus repository-owned CI checks. The controls are intentionally layered: repository settings prevent credential leaks, while workflows scan source and dependency changes.

Public security reports are handled through SECURITY.md: support scope, the responsible-disclosure procedure, the private advisory channel, and the coordination timeline live there. This document records the enabled controls and the operational incident-response runbook.

Enabled Repository Features

The following settings are enabled for thoser666/Rivulet:

  • Secret Scanning detects credentials in the repository and its history.
  • Push Protection blocks supported secrets before they are pushed.
  • Dependabot Security Updates is enabled for vulnerable dependency updates.
  • Renovate is configured for grouped Cargo version updates and its dependency dashboard; Dependabot remains responsible for GitHub Actions SHA pins only.

Secret Scanning and Push Protection are repository settings, not workflow files. They must therefore be checked through the GitHub UI or API after repository migration, fork creation, or permission changes:

gh api repos/thoser666/rivulet \
  --jq '.security_and_analysis | {
    secret_scanning: .secret_scanning.status,
    push_protection: .secret_scanning_push_protection.status,
    dependabot_updates: .dependabot_security_updates.status
  }'

Expected output:

{"secret_scanning":"enabled","push_protection":"enabled","dependabot_updates":"enabled"}

The API command reports configuration state only. It does not expose secret values. A token with repository administration permission is required to change these settings.

CI Checks

.github/workflows/security.yml runs:

  • CodeQL for Rust on pushes to develop, pull requests, weekly scheduled scans, and manual dispatch. SARIF results are uploaded to GitHub Code Scanning.
  • Dependency Review on pull requests. The check fails for high-severity or critical dependency advisories and posts a summary to the pull request.
  • OpenSSF Scorecard on pushes to develop, pull requests, weekly scheduled scans, and manual dispatch. The workflow publishes the result to the OpenSSF API, stores the SARIF artifact for 14 days, and uploads a separate openssf-scorecard category to GitHub Code Scanning. Its required check is named OpenSSF Scorecard.
  • Cargo Audit checks the committed lockfile against the RustSec Advisory Database and fails on vulnerabilities; informational warnings remain visible in the step summary.
  • Cargo Deny enforces the checked-in deny.toml policy for advisories, bans, licenses, and dependency sources.
  • Security is an aggregate required check. It requires CodeQL, Cargo Audit, and Cargo Deny to succeed and accepts the Dependency Review job as either successful or skipped when the event is not a pull request.

Dependency update ownership is deliberately split: Renovate manages Cargo updates, while Dependabot manages GitHub Actions SHA pins. This prevents duplicate PRs and keeps action-pin review separate from Rust dependency upgrades.

Every third-party action in the security workflows is pinned to a full commit SHA. The pins are listed in ci-action-pins.md and enforced by rivulet-core/tests/ci_pinning.rs. The nightly checker resolves each pinned ref with git ls-remote; each request has a 30-second timeout so a GitHub/network outage cannot hang the workflow indefinitely. It reports a newer major separately from same-major staleness.

Code scanning alert management

GitHub Code Scanning (CodeQL + Scorecard SARIF) reports alerts under Security → Code scanning. The policy for new alerts: fix them on develop through a pull request, and pin the fix in rivulet-core/tests/ci_pinning.rs so the pattern cannot regress. Dismiss only when the alert is not a real vulnerability, using one of the canonical GitHub reasons.

Current state:

  • Workflow hardening (#79/#80, DangerousWorkflowID / "untrusted code checkout"): .github/workflows/release.yml previously checked out github.event.workflow_run.head_sha in a workflow_run-triggered workflow with contents: write. A fork whose PR source branch is named develop would pass the branches: [develop] filter, and the release job would then execute fork code with the repository token and secrets (pwn request). Both release jobs now check out the protected default branch by default instead, and gate on the triggering run's repository ownership (head_repository.full_name == github.repository). Pinned by the ci_pinning guard code_scanning_alerts_are_resolved_and_pinned.
  • Test fixtures (#70–#78, rust/hard-coded-cryptographic-value): all nine alerts point at deterministic OBS auth handshake test vectors (scripted password/salt/challenge pairs feeding compute_secret/compute_auth_response in rivulet-obs-websocket). They are confined to #[cfg(test)] modules and integration tests and are dismissed with the canonical used in tests reason so the dismissed state stays auditable. The vectors are not credentials and are never installed as secrets.
  • Alert #85 (TokenPermissionsID, high, dismissed as won't fix): the finding comes from the OpenSSF Scorecard Token-Permissions check, not from CodeQL. .github/workflows/weekly-promotion.yml declares permissions: contents: write at the top level while only its promote job pushes a tag; prepare-winget, prepare-chocolatey and prepare-aur only read the repository. Narrowing the write scope to the job was tried and rejected: Scorecard's Token-Permissions rule reports every contents: write, at the job level just as much as at the top level, so moving the scope does not clear the alert — it only moves it (see checks/evaluation/permissions.go: top-level write drives the score to 0, job-level write is logged as a warning but still emits a SARIF result at level: error). The experiment produced two new high alerts (dependabot-auto-merge.yml and weekly-promotion.yml) and failed the Scorecard code-scanning check, which blocked the merge. .github/workflows/dependabot-auto-merge.yml carries the same pattern. This is accepted deliberately: the promote job genuinely has to move the weekly-latest tag, so no narrower correct configuration exists, and Scorecard's heuristic cannot tell a needed write from an unneeded one.
  • Alert #92 (TokenPermissionsID, high, fixed): Scorecard scored .github/workflows/scorecard.yml 0 for a top-level security-events: write on 2026-10-30 and the blocking triage step failed the Security check on develop. Unlike #85 this one was redundancy, not a requirement: the scorecard job already declares security-events: write (SARIF upload) and id-token: write (publish_results), and job-level permissions replace the workflow default rather than add to it, so the top-level copies granted nothing. They are gone; the workflow default is contents: read and every write scope lives on the job that needs it. This also supersedes the reason recorded for the earlier dismissal (#62): the upload is satisfied by the job scope, not by a workflow-level grant. Pinned by scorecard_workflow_is_wired_for_sarif_and_provenance, which fails if a workflow-level write scope comes back or if the job loses either scope.
  • Alert #93 (rust/cleartext-logging, high, dismissed as false positive): CodeQL reports self.chat_accounts on its way to a log sink and pins the instance to the remove-account confirmation in rivulet-gui/src/app.rs. The roster cannot carry a secret: ChatAccount is platform + channel only, the OAuth/session tokens live in the OS credential vault (ChatTokenStore) and are read there at connect time, and every chat log line in the tree is a static string (MultiChat documents why). The first attempt was a root fix, not a dismissal: the roster round-trip test still had a leftover eprintln! of the persisted state from September - exactly the pattern the rule describes - so it was removed (#375) and chat_dock_surfaces_are_pinned now fails if a DEBUG print of app state returns. The alert survived that analysis and came back unchanged at the same line, so what is left is CodeQL's modelling of the account list, not a reachable leak. The canonical reason is false positive. The type that does carry the secret - ChatConfig, with the token and the YouTube API key - now implements Debug by hand and renders both as <redacted>, as do TwitchChatConfig and KickChatConfig, so a future debug line cannot reopen the same finding through the config path; rivulet-core/tests/chat_secret_redaction.rs pins all three. Revisit when: a token-bearing type reaches the roster, or a chat-derived value is interpolated into a log line - then the finding is real and has to be fixed, not re-dismissed.

Alert triage gate

Up to now the only enforcement of this policy lived in a human reading the Security tab: the ci_pinning guard code_scanning_alerts_are_resolved_and_pinned is purely static and never talks to the alerts API, so a new finding could sit open on develop while CI stayed green — exactly how #85 survived.

scripts/check-code-scanning-alerts.py closes that gap. It reads the open alerts through gh api, applies an ALLOWED_OPEN_ALERTS allow-list that is empty by default, and can be limited to the alerts at or above a severity threshold (--fail-on). Without allow-list entries every open alert fails the gate, so a dismissal has to be a deliberate edit plus a written reason in this file. The security_gate job in .github/workflows/security.yml runs it with security-events: read and --comment; the report is posted as a job summary and written as a pull-request comment. On pull requests the step is advisory (continue-on-error), because a pull request cannot resolve the repository's alert list; on push to develop and on schedule it is blocking. python3 scripts/check-code-scanning-alerts.py --self-test runs as part of the pre-push hook.

Note that dismissing an alert on GitHub also removes it from the gate's input: the script only sees open alerts. A dismissal therefore has to be justified in this section, not just clicked in the UI.

Dismissing an alert (checklist)

A dismissal is the exception, never the first move - and clicking the button is not the record; the written reason is. Work through these steps in order, and stop at the first one that turns the alert into a real finding:

  1. Try the root fix first. The alert names a line, so look at what actually reaches it. #93 is the cautionary case in both directions: a leftover debug print of app state really was in the tree and had to go (#375), but removing it did not clear the finding, because the flagged sink was the token-free roster.
  2. Follow the data, not the message. Ask which type carries the secret and where it lives: a value that is token-free by construction (like ChatAccount) cannot leak one, however the line is read.
  3. Check whether the finding is reachable. Deterministic test vectors, a #[cfg(test)]-only path or a helper without a caller are not a vulnerability; a token that is written to a log, a panic message or an error chain is, even if nothing logs it today.
  4. Use a canonical GitHub reason - false positive, used in tests or won't fix. The free-text note is capped at 280 characters, so the full argument goes here, not into the note.
  5. Record the evidence in this file under Current state: what you changed, why the alert survived the fix attempt, and what would make it real.
  6. Write a Revisit when: condition. An alert dismissed for a reason that expires silently is a dismissal nobody can audit. If the condition can no longer be expressed, the dismissal is wrong.

security_docs_pin_the_dismissal_checklist fails if this checklist, the three canonical reasons or the Revisit when rule disappear from this section.

Dismissed alert register

check-code-scanning-alerts.py fails on open alerts only, which is exactly why a dismissal needs its own record: from the moment the button is clicked, nothing looks at that finding again. This table is that record, and scripts/check-dismissed-alerts.py enforces it - it runs in the Security workflow (weekly, after every push to develop, and non-blocking on pull requests for the same reason the open-alert gate is):

  • a dismissed alert without a row here fails, unless it predates the register,
  • a row whose alert is no longer dismissed (fixed or re-opened) fails, because the register has to shrink when its reason disappears,
  • a row whose documented reason disagrees with the dismissal GitHub recorded fails, and
  • a row whose machine check is true again fails: the condition under which the alert must be re-triaged has returned.

Dismissals made before 2026-11-03 are grandfathered: they were dismissed before there was a place to write a revisit condition down, and demanding a row for each of them would fail the gate on day one for reasons nobody can still reconstruct. The checker counts them (grandfathered in --json) instead of failing. Every dismissal from that date on needs a row - a new alert dismissed with a free-text note alone is exactly what this register exists to prevent.

Alert Rule Reason Revisit when Machine check
#93 rust/cleartext-logging false positive a credential-bearing config derives Debug again, or a new secret field is added to one without redaction derive-debug:rivulet-core/src/chat.rs:ChatConfig; derive-debug:rivulet-core/src/twitch_chat.rs:TwitchChatConfig; derive-debug:rivulet-core/src/kick_chat.rs:KickChatConfig; derive-debug:rivulet-core/src/stream.rs:StreamSettings; derive-debug:rivulet-core/src/stream.rs:WhipSettings; derive-debug:rivulet-core/src/stream_info.rs:InfoCredentials; derive-debug:rivulet-core/src/alerts_webhook.rs:AlertsReceiverConfig

The machine check is a deliberately small DSL - a register nobody can evaluate mechanically is prose with a table around it. Cells separate several checks with ;, and the only kind is derive-debug:<path>:<Type>: it is true when <Type> in <path> carries #[derive(.., Debug, ..)] again. Add a kind only together with its parser and a --self-test case in the checker.

Run it locally:

python3 scripts/check-dismissed-alerts.py --self-test   # offline logic test
python3 scripts/check-dismissed-alerts.py               # needs gh + security-events:read

Dependabot alert management

GitHub Dependabot reports vulnerable dependencies under Security → Dependabot alerts. The policy matches code scanning: upgrade the dependency on develop through a pull request and pin the fix in rivulet-core/tests/ci_pinning.rs. Dismiss only when the alert does not describe a reachable vulnerability, using one of the canonical GitHub reasons and recording the evidence here.

Current state:

  • Alert #3 (glib unsoundness, RUSTSEC-2024-0429 / GHSA-wrw7-89jp-8q8g, medium): glib::VariantStrIter's Iterator / DoubleEndedIterator impls (next, last, nth, next_back, nth_back) handed an out-parameter to C through & instead of &mut. Recent compilers discard that unsound write under optimization, so every call then violates the CStr::from_ptr safety contract and crashes. Fixed in glib >= 0.20.0. RustSec classifies the advisory as INFO, which is why the Cargo Audit and Cargo Deny gates stay green with an empty ignore list in deny.toml.

    The lockfile does resolve a vulnerable glib 0.18.5 next to the patched glib 0.22.8 (which comes in through gstreamer 0.25), but the crate is unreachable on every supported target:

    • rivulet-browser keeps wry = "0.57" behind [target.'cfg(windows)'.dependencies], so the Linux and macOS builds never resolve the wry tree at all — cargo tree -i glib@0.18.5 --target x86_64-unknown-linux-gnu prints nothing. On Windows, wry renders through WebView2 and its gtk-rs dependencies are Linux-only, so gtk 0.18 and webkit2gtk 2.0 are never compiled either.

    • There is also no upgrade to pull in: wry 0.57.0 is the newest release on crates.io and still pins the gtk-rs 0.18 core. The lockfile entry can only disappear when upstream wry moves to the glib >= 0.20 core (or if the browser backend is dropped).

    Dismissed with the canonical not_used reason. Revisit when either of those happens: the dependabot_unreachable_glib_advisory_is_documented_and_pinned guard fails as soon as wry is no longer Windows-gated, which is precisely when this entry has to be re-evaluated.

Cargo dependency policy

The lockfile is checked for yanked crates in CI. When RustSec reports a yanked transitive dependency, update the affected package explicitly (for example cargo update -p chacha20) and commit the resulting lockfile and checksum change. Do not silence the advisory or pin back to the yanked release.

deny.toml is the reviewed policy for dependency hygiene:

  • RustSec advisories and yanked releases fail the gate.
  • The fixed releases for the advisories detected on the previous CI run are anyhow >= 1.0.103 (RUSTSEC-2026-0190), bytes >= 1.11.1 (RUSTSEC-2026-0007), and crossbeam-epoch >= 0.9.20 (RUSTSEC-2026-0204), and wasmtime/wasmtime-wasi >= 48.0.5 (RUSTSEC-2026-0321 through RUSTSEC-2026-0327). RUSTSEC-2026-0321 is the one that matters here: a WASI preview 0 poll_oneoff that circumvents fuel consumption defeats the per-call fuel budget the plugin sandbox relies on. These minimums are regression-tested against the committed lockfile; advisories are not silenced with an ignore entry.
  • On 2026-10-29 the RustSec database published four more wasmtime advisories: RUSTSEC-2026-0422 (a missing i31/nullref argument type check that can panic), RUSTSEC-2026-0423 (cross-engine type confusion with host-created struct types that carry supertypes), RUSTSEC-2026-0424 (unchecked memory use when transferring borrowed resources between components) and, in wasmtime-wasi, RUSTSEC-2026-0420 (a wasip3 UDP send can deliver a datagram to an address that is only allowed for receiving). All four describe wasmtime/wasmtime-wasi 49.0.4, the version the lockfile carried, so Cargo Audit failed the required Security check on every commit from 2026-10-30 onwards. The patched bands are 49.0.5 and 50.0.1; the workspace now resolves 49.0.5, which keeps the change a patch update inside the existing 49.0 requirement — no plugin ABI change, and the feature set above (still no cache) is untouched. fuzz/Cargo.lock and the Flatpak offline crate pin (packaging/flatpak/cargo/cargo-sources.json, regenerated with packaging/flatpak/generate-cargo-sources.sh) were updated in the same change. The requirement is a caret range, so later 49.x patches resolve automatically; no ignore entry was added. wasmtime_stays_on_a_patched_release_for_the_2026_advisories regression-tests both lockfiles against those bands. It encodes the bands instead of a single minimum, because 50.0.0 sits above 49.0.5 and is still vulnerable.
  • The zstd family is removed rather than ignored, because no upgrade can reach a fixed release. RUSTSEC-2026-0391 (zstd >= 0.14.3), RUSTSEC-2026-0398 and RUSTSEC-2026-0403 (zstd-safe >= 8.2.0) and RUSTSEC-2026-0410 (zstd-sys >= 2.1.2) all describe the zstd 0.13.3 the lockfile carried, but that version arrived only through wasmtime-internal-cache, which pins zstd = "^0.13.0" in every published release (49.0.0 through 50.0.0). Cargo therefore cannot select a fixed zstd, and cargo update fails with ``failed to select a version for the requirement zstd = "^0.13.0"```. The plugin sandbox never uses the on-disk module cache, and the cache is the only thing wasmtime's cache` feature adds, so the root manifest declares `wasmtime` with `default-features = false` and wasmtime 49's default feature set minus `cache`. That drops `wasmtime-internal-cache`, `zstd`, `zstd-safe`, `zstd-sys`, `directories-next`, `dirs-sys-next` and `redox_users` from both `Cargo.lock` and `fuzz/Cargo.lock`. The Flatpak offline crate pin (`packaging/flatpak/cargo/cargo-sources.json`) is regenerated from the same lockfile with `packaging/flatpak/generate-cargo-sources.sh`, so the vendored build cannot keep shipping the removed crates either. Plugin behaviour is unchanged — every other default feature (including `cranelift`, `wat` and `runtime`) stays enabled, and the 87 plugin-runtime library tests (WAT loading, fuel budget, epoch interruption, lifecycle) pass as before. `wasmtime_cache_feature_stays_disabled_so_the_zstd_advisories_stay_gone` fails if the feature comes back, if either lockfile grows one of the removed crates again, or if this rationale disappears.
  • Unmaintained advisories are evaluated for workspace dependencies; duplicate versions are warnings until explicitly triaged.
  • Only the crates.io registry is allowed; Git dependencies are not allowed.
  • Dependencies must use one of the explicitly allowed SPDX licenses. Every first-party workspace crate inherits the MIT license from the root manifest via license.workspace = true, so cargo-deny does not treat local packages as unlicensed.

Run the same checks locally when the tools are installed:

cargo audit
cargo deny check --all-features

The CI actions install and run these tools in isolated Ubuntu jobs. The lockfile is committed so CI and local audits resolve the same dependency versions.The GUI and capture crates disable unused optional image codecs so the yanked

core2 crate is not pulled in through AVIF support. No security advisory is ignored in the repository at present. If a future audit reports a new advisory, upgrade the dependency or document a bounded, reviewed mitigation before considering any temporary exception.

Scorecard requires id-token: write for signed, repository-bound publication. The workflow sets persist-credentials: false during checkout and does not request write access to repository contents. Review the first score regularly; Scorecard findings are improvement signals, not a substitute for threat modeling or maintainer review.

GitHub App & External Check Enablement

Rivulet uses a mix of GitHub-native automations and repository-owned workflows. This section records which GitHub Apps / external checks are active and which are recommended but not yet enabled, so the decision is traceable.

Active

Automation Status Notes
Dependabot Enabled GitHub Actions SHA pins (see the ownership split above).
Renovate Enabled Grouped Cargo updates + dependency dashboard.
CodeQL Enabled Run by security.yml (workflow, not the GitHub App); SARIF uploaded to Code Scanning.
OpenSSF Scorecard Enabled Run by security.yml; result published to the OpenSSF API.
Secret Scanning + Push Protection Enabled Repository settings, not a workflow.

Recommended but not yet enabled

These are considered for future enablement. Enable each only after reviewing its permissions and data handling; GitHub Apps can request broad repository scopes.

App Purpose Decision / risk
github/accessibility-scanner (or AccessLint) Post automated accessibility findings on pull requests, complementing the in-process ui_accessibility report. Recommended. Needs a deployed/reachable app URL for full function; the in-process contract in rivulet-gui/tests/ui_accessibility.rs already covers the deterministic baseline while the app is not installed. Tracked as finding ui-001 in docs/ui-audit.md.
rust-doctor Review Rust change risk and suggest targeted fixes on PRs. Optional; Clippy + the workspace lints already run in CI (-D warnings), so value is advisory feedback, not a gate.
Conventional Commits checkout bot Enforce the Conventional Commits format used by release.yml versioning. Nice-to-have; the release workflow already skips non-releasable commits (feat/fix/build/chore/ci release, everything else is ignored), so a breaking/malformed commit only delays a release rather than corrupting it. Prefer tightening the release check before adding a bot.
Clippy Review bot Surface clippy suggestions inline on PRs. Redundant with the CI Lints (Fmt & Clippy) job; only adds ergonomics for reviewers.

Enablement policy

  • An app must request the minimum permission scope and be documented here before installation.
  • Any app that posts on pull requests must remain advisory and must never become a blocking required check until its false-positive rate is reviewed across several PRs.
  • Apps that require a deployed service (AccessLint/accessibility-scanner) are deferred until there is an always-available deployment to point them at.

Removed / deactivated (2026-08-31)

Several GitHub Apps were installed on the repository without ever being configured. Because the apps subscribe to push events and request the checks: write permission, every push created a check suite that stayed queued forever (no workflow/config in the repo ever completed it). These queued suites kept the combined commit status of every commit in pending indefinitely, even though all six required checks were green. The apps were deactivated via Settings -> Applications -> GitHub Apps -> Configure -> Uninstall:

App Why it was removed
codecov No codecov.yml/codecov.yaml, no coverage upload in any workflow.
deepsource.io No deepsource.toml or analyzer configuration.
nx-cloud No nx.json or Nx configuration anywhere in the repo.
cypress No cypress.config.* or cypress/ directory; UI tests use ui_smoke/ui_accessibility instead.
docker-scout No Docker Scout configuration or workflow reference.
mistralai No Mistral configuration or workflow reference.

Kept on purpose:

  • Renovate stays enabled — it is actively configured (renovate.json) and drives grouped Cargo updates next to Dependabot.
  • freebuff-web stays enabled — it is the GitHub integration used by the Freebuff development session (commits/issues) and is not part of this cleanup.

Operational note: uninstalling an app does not retroactively remove check suites that were already created. Historical commits (e.g. 0abb497) keep their queued suites and their combined status therefore stays pending; only commits pushed after the uninstall are free of the removed apps' suites. When verifying an uninstall, push a throwaway commit and check its check suites rather than re-reading an old commit's status.

Required Repository Settings

The active develop branch ruleset requires pull requests and these exact status checks before merging:

  • CI — aggregate of the lint, beta-gate, three-platform build/test, and pinning jobs.
  • Security — aggregate of CodeQL, Dependency Review, Cargo Audit, and Cargo Deny.
  • OpenSSF Scorecard — repository supply-chain analysis.
  • CodeQL (rust) — the repository-owned Rust CodeQL analysis.
  • Dependency Review — pull-request dependency security review.
  • Pinning-Tests — dedicated SHA-pinning regression tests.

Direct updates to develop are blocked for every actor: the active ruleset lists no bypass actors (no RepositoryRole administrator exception), so even repository administrators cannot push to develop directly — every change lands as a pull request whose required status checks must pass. Because this is a single-maintainer repository the ruleset does not require a second human approval (count 0); the automated checks are treated as the review (see the Code Review Policy in CONTRIBUTING.md). The ruleset also prevents deletions and non-fast-forward updates.

The review requirement is staged: when a second maintainer gets access, the ruleset flips to required_approving_review_count: 1 and the ruleset checker switches to --team-mode (it then fails unless the live ruleset requires a human approval). The exact, ordered procedure — including why the flip must never happen while the repo is still single-maintainer — is the runbook in docs/team-onboarding-runbook.md; the account/workspace hygiene behind it is docs/clean-desk.md.

The release workflow still uses GITHUB_TOKEN to push generated version commits and tags — but only to release/<tag> branches and tags, never to develop, so the ruleset does not affect it. Verify each release run after a ruleset change; if GitHub ever rejects that push, move versioning/tagging to an administrator-approved pull request or use a separately scoped release credential rather than weakening the ruleset.

Inspect the live ruleset with:

gh api repos/thoser666/rivulet/rulesets \
  --jq '.[] | select(.name == "develop") | {id, name, enforcement, bypass_actors, conditions, rules}'

A Ruleset Guard continuously verifies this configuration instead of trusting it: the workflow .github/workflows/ruleset-guard.yml runs on every pull request, after every push to develop, weekly as a backstop, and on demand, executing scripts/check-develop-ruleset.py against the live rulesets API (read-only, contents: read — safe on fork PRs). The checker asserts that the develop ruleset is active, covers refs/heads/develop, lists no bypass actors, and still carries the deletion, non_fast_forward, pull_request (automated-review) and required_status_checks rules with the merge-gate contexts. The merge-gate contexts are verified in-repo against the structured job names of ci.yml/security.yml/scorecard.yml (PyYAML parse with matrix expansion) instead of a proxy literal — a rename on either side fails loudly. Run it locally — no credentials are needed for the public repo:

python3 scripts/check-develop-ruleset.py           # live check
python3 scripts/check-develop-ruleset.py --self-test  # offline logic test

A real push probe would be stronger but is deliberately avoided: a probe lands on develop exactly when the ruleset is broken, and git push --dry-run never reaches GitHub's server-side ruleset evaluation.

Configure these settings in Settings -> Rules -> Rulesets or the equivalent branch protection UI for develop:

  • Require the checks listed above before merging.
  • Require CodeQL alerts to be resolved when CodeQL is made a blocking check.
  • Keep pull-request approvals enabled for changes to workflows and packaging.
  • Keep Actions permissions restricted to the minimum required by each workflow.

The first CodeQL runs should be reviewed before making alerts a merge blocker. False positives and generated/native integration code should be triaged and tracked rather than silently dismissed.

Secret Handling

  • Keep signing certificates, Apple credentials, and stream keys in GitHub Actions Secrets or environment-specific secret stores.
  • Never place secrets in README.md, fixtures, test output, release assets, or issue comments.
  • Remove personal paths and tokens from logs before attaching them to issues.
  • Rotate a credential immediately if Push Protection reports it or if it appears in a commit, even when the commit is later removed.
  • Credential-bearing config types render their secrets as <redacted> in their own Debug output (ChatConfig, TwitchChatConfig, KickChatConfig, StreamSettings - stream key and ingest URL - WhipSettings, InfoCredentials, AlertsReceiverConfig). A {:?} must never be a way to write a token to a log line, a panic message or an error chain; chat_secret_redaction.rs and credential_debug_redaction.rs pin the behaviour and a ci_pinning guard keeps the hand-written impls in place.

Push Protection does not replace credential rotation, least-privilege tokens, or review of external release assets. It also cannot detect every proprietary or newly issued credential format.

Release asset integrity (updater)

The release workflow generates a SHA256SUMS manifest over every attached release asset and attaches it to the release. The updater downloads the manifest for the release tag and verifies the installer's SHA-256 digest before the install can start; missing entries, unreachable manifests, and digest mismatches all fail closed (see docs/update-troubleshooting.md). A ci_pinning guard keeps the manifest generation, the attachment, and the verification wiring in place. Transport security (HTTPS) plus this digest check protect the update path; artifact signing (Authenticode/codesign) is exercised by the signing E2E workflow and can be layered on top later.

Shared-memory capture channel hardening

The game-capture shared-memory channel (rivulet_capture_shm) transfers frames from the injected Vulkan layer / OpenGL hook (inside the captured game — an untrusted host) to the Rivulet reader. Any process in the session can also write the region directly, so the readers treat the header as untrusted input:

  • FrameHeader::is_plausible validates, beyond the magic: non-zero geometry (≤ 16384 px per axis), the single defined pixel format (RGBA8), data_size == width × height × 4 with checked math (no overflow), an allocation cap (MAX_PIXEL_BYTES, 16 MiB), and that the data fits inside the actual mapping.
  • Readers query the true mapping size (Windows VirtualQuery, Linux fstat on the shm object clamped to the default size) instead of trusting a compile-time constant; frames are copied out of shared memory immediately so later writes cannot race the validated snapshot.
  • The writers compute data_size with checked multiplication and refuse frames whose geometry and byte length disagree, so a broken swapchain cannot produce a header the reader must reject.

A ci_pinning guard keeps the validation, the mapping-size queries, and the writer-side checked math in place.

Fuzzing the untrusted-input parsers

Every parser that consumes remote-controlled bytes has a libFuzzer target in fuzz/ (see fuzz/README.md): Twitch IRC lines, the WHIP endpoint feeding the SDP generator, the GitHub Releases JSON behind the update check, and the SHA256SUMS manifest itself. The CI job Fuzz smoke (regression corpus) builds the targets with cargo-fuzz (nightly toolchain) and runs a fixed, short number of executions each; a crash fails the build and the offending input is uploaded as an artifact. The smoke guards against parser regressions (panics on crafted input). Beyond it, the scheduled Deep fuzz (weekly) workflow runs every Monday with a 10-minute budget per target (FUZZ_MAX_TOTAL_TIME=600, also triggerable manually) and persists the grown corpus between runs via the actions cache, so coverage accumulates instead of restarting from zero. A ci_pinning guard keeps the targets, the workspace exclusion, and the CI wiring (including the deep campaign) in place.

Telemetry policy

The M5 opt-in telemetry (rivulet-core/src/telemetry.rs, see docs/telemetry.md) is designed so that a future collector cannot accidentally leak user data:

  • Collecting is off by default; the persisted Settings toggle is the only way to start capturing, and opting back out clears everything pending.
  • The event model carries no free-form text: only enums, numeric codes and booleans. Window titles, paths, URLs, stream keys and usernames are not part of the model, and a ci_pinning guard pins the serialized payload against free-form content.
  • The shipped build wires no transport sink — nothing leaves the device. Any future transport must be reviewed separately before it is allowed to call into TelemetrySink.

Code signing policy

The project's code signing policy is documented in docs/code-signing.md. It is published on the project's homepage and download page so users and administrators can verify where signed artifacts come from and how they are built.

Incident Response

  1. Stop the affected workflow or release if a credential may be exposed.
  2. Revoke and rotate the credential at its provider.
  3. Inspect the alert under Security -> Secret scanning and identify every affected commit, branch, artifact, and log.
  4. Remove the value from the repository history where appropriate, but do not treat history rewriting as a substitute for rotation.
  5. Record the remediation in the related issue without copying the secret.

For dependency findings, review the advisory, affected transitive path, and available upgrade. If an upgrade is not immediately possible, document the scope, mitigation, and owner before accepting a temporary exception.