diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b73a171 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: nuget + directory: /dotnet + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: nuget + directory: /scripts/CommentStripper + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c7f12a..127594a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ on: workflow_dispatch: inputs: tailcat_ref: - description: tailcat git ref to build - default: main + description: tailcat git ref override (blank uses tailcat.ref) + default: "" android_api_level: description: minimum Android API level default: "21" @@ -41,8 +41,9 @@ jobs: # checkout for the real cross-compiled build. - name: Fetch tailcat source (for go.mod's replace directive) env: - SRC_REF: ${{ inputs.tailcat_ref || 'main' }} + SRC_REF: ${{ inputs.tailcat_ref }} run: | + SRC_REF=${SRC_REF:-$(tr -d '[:space:]' < tailcat.ref)} git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src git -C .tailcat-src fetch --depth=1 origin "$SRC_REF" git -C .tailcat-src checkout --detach FETCH_HEAD @@ -56,6 +57,12 @@ jobs: go vet ./... go test ./... + - name: Scan Go call paths for known vulnerabilities + env: + GOTOOLCHAIN: auto + run: | + go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... + - name: Locate the NDK run: | ndk=${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}} @@ -72,10 +79,15 @@ jobs: # tailcat's go.mod asks for a newer Go than setup-go's stable may # be; let the toolchain fetch it rather than pinning a version here. GOTOOLCHAIN: auto - SRC_REF: ${{ inputs.tailcat_ref || 'main' }} + SRC_REF: ${{ inputs.tailcat_ref }} ANDROID_API_LEVEL: ${{ inputs.android_api_level || '21' }} run: ./build.sh + - name: Scan the patched tailcat build input for known vulnerabilities + env: + GOTOOLCHAIN: auto + run: (cd .tailcat-src && go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./cmd/tailcat) + - name: Verify each binary targets the platform it claims run: ./verify-binaries.sh @@ -119,8 +131,9 @@ jobs: - name: Build a host tailcat to act as the client env: GOTOOLCHAIN: auto - SRC_REF: ${{ inputs.tailcat_ref || 'main' }} + SRC_REF: ${{ inputs.tailcat_ref }} run: | + SRC_REF=${SRC_REF:-$(tr -d '[:space:]' < tailcat.ref)} git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src git -C .tailcat-src fetch --depth=1 origin "$SRC_REF" git -C .tailcat-src checkout --detach FETCH_HEAD @@ -165,6 +178,9 @@ jobs: needs: build strategy: fail-fast: false + # Both architectures use the same public relay service. Running them one + # at a time avoids manufacturing a relay-load race in the test harness. + max-parallel: 1 matrix: include: - runner: ubuntu-latest @@ -234,6 +250,11 @@ jobs: # until the Pack step below has run once. run: mkdir -p nupkg + - name: Audit NuGet dependencies + run: | + dotnet restore dotnet/Meowshell.sln --force --no-cache --nologo + dotnet restore scripts/CommentStripper/CommentStripper.csproj --force --no-cache --nologo + - name: Test # The E2E tests in Meowshell.Tests run against these real binaries # instead of the fake stand-ins the rest of the suite uses; they @@ -242,7 +263,18 @@ jobs: env: DOTNET_E2E_TAILCAT_BIN: ${{ github.workspace }}/dist/tailcat_linux_amd64 DOTNET_E2E_MEOWSHELL_BIN: ${{ github.workspace }}/dist/meowshell_linux_amd64 - run: dotnet test dotnet/Meowshell.sln -c Release --nologo + run: >- + dotnet test dotnet/Meowshell.sln -c Release --nologo + --logger "trx;LogFileName=meowshell-tests.trx" + --results-directory test-results + + - name: Upload .NET test diagnostics + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dotnet-test-results + path: test-results/ + if-no-files-found: warn - name: Install the Android workload # Needed to pack Meowshell's android-targeted build below. @@ -376,8 +408,9 @@ jobs: # address -- by having a real client dial in and run commands. env: GOTOOLCHAIN: auto - SRC_REF: ${{ inputs.tailcat_ref || 'main' }} + SRC_REF: ${{ inputs.tailcat_ref }} run: | + SRC_REF=${SRC_REF:-$(tr -d '[:space:]' < tailcat.ref)} git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src git -C .tailcat-src fetch --depth=1 origin "$SRC_REF" git -C .tailcat-src checkout --detach FETCH_HEAD diff --git a/README.md b/README.md index 734c452..1709742 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,13 @@ writes binaries to `dist/`. Android needs the NDK (`ANDROID_NDK_HOME`, r19+) for cgo-based DNS resolution; Linux and Windows are pure Go. See `./verify-binaries.sh` and `e2e/` for how CI checks the result. +## Security + +See [`SECURITY_REVIEW.md`](SECURITY_REVIEW.md) for the current threat model, +findings, accepted risks, and verification status. The accompanying +[`REVIEW_MAP.md`](REVIEW_MAP.md) groups every first-party file by runtime +boundary and records the order and focus of the repository review. + ## License [MIT](LICENSE) for this repo's own code. The vendored/patched diff --git a/REVIEW_MAP.md b/REVIEW_MAP.md new file mode 100644 index 0000000..5f7308a --- /dev/null +++ b/REVIEW_MAP.md @@ -0,0 +1,141 @@ +# Repository review map + +This is the ordered inventory used for the 2026-09-11 correctness and security +review. Files are grouped by the runtime boundary they implement rather than +alphabetically, so each producer is reviewed next to its consumers and tests. +Generated `bin/`, `obj/`, `dist/`, and `.tailcat-src/` content is excluded. + +## 1. Entrypoints, configuration, and documentation + +- `README.md`, `dotnet/README.md`, `LICENSE` +- `cmd/meowshell/main.go`, `cmd/meowshell/main_test.go` +- `cmd/meowshell/env.go`, `cmd/meowshell/env_test.go` +- `cmd/meowshell/shim_unix.go`, `cmd/meowshell/shim_windows.go` +- `cmd/meowshell/exec_unix.go`, `cmd/meowshell/exec_windows.go` + +Review focus: option defaults, unsafe modes, environment trust, executable +selection, argument boundaries, and platform-specific process replacement. +Result: insecure modes are explicit and local process launches avoid a shell. +Remote exec remains a documented SSH command-string interface, not argv-safe. + +## 2. Agent protocol and lifecycle + +- `cmd/meowshell/protocol.go`, `cmd/meowshell/protocol_test.go` +- `cmd/meowshell/agent.go`, `cmd/meowshell/agent_e2e_test.go` +- `dotnet/Meowshell/MeowshellAgentProtocol.cs` +- `dotnet/Meowshell/MeowshellAgentConnection.cs` +- `dotnet/Meowshell.Tests/MeowshellAgentConnectionTests.cs` +- `dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs` +- `dotnet/Meowshell/MeowshellErrorCode.cs` + +Review focus: framing bounds, message ordering, request/channel ownership, +cancellation, child cleanup, concurrency, backpressure, and error propagation. +Result: frames are bounded and .NET channel data is serialized through a pump. +This review fixed duplicate prompt responses blocking the Go frame loop, a +prompt-close send race, cancellation being misreported as timeout, and agent +process leakage on setup failure/cancellation. + +## 3. SSH authentication, host identity, and transport + +- `cmd/meowshell/connect.go` +- `cmd/meowshell/transport.go`, `cmd/meowshell/transport_test.go` +- `cmd/meowshell/hostkeys.go` +- `cmd/meowshell/agentauth.go`, `cmd/meowshell/agentauth_test.go` +- `cmd/meowshell/sshagent_unix.go`, `cmd/meowshell/sshagent_windows.go` +- `cmd/meowshell/keystage_unix.go`, `cmd/meowshell/keystage_windows.go` +- `cmd/meowshell/sftp.go`, `cmd/meowshell/sftp_test.go` +- `cmd/meowshell/agent_auth_e2e_test.go` +- `cmd/meowshell/agent_tcp_e2e_test.go` +- `cmd/meowshell/agent_security_e2e_test.go` +- `dotnet/Meowshell/TailcatSshSession.cs` + +Review focus: HTTPS/SOCKS proxy semantics, host-key verification, secret +handling, ssh-agent forwarding, temporary key permissions, authentication +prompts, and handshake timeouts. Result: HTTPS CONNECT now uses verified TLS; +ordinary TCP SSH uses TOFU and rejects changed keys. Tailcat's capability-based +identity intentionally does not use ordinary SSH host-key verification. + +## 4. Forwarding and SOCKS + +- `cmd/meowshell/forwarding.go`, `cmd/meowshell/forwarding_test.go` +- `cmd/meowshell/tailcatdial.go` +- `cmd/meowshell/agent_forward_e2e_test.go` +- `cmd/meowshell/agent_tailcat_forward_e2e_test.go` +- `dotnet/Meowshell/MeowshellPortForward.cs` +- `dotnet/Meowshell/MeowshellSocksProxy.cs` +- `dotnet/Meowshell.Tests/MeowshellPortForwardTests.cs` +- `dotnet/Meowshell.Tests/MeowshellSocksProxyTests.cs` +- `dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs` + +Review focus: bind scope, opt-in exposure, SOCKS authentication, constant-time +credential comparison, destination handling, socket permissions, and cleanup. +Result: TCP listeners default to loopback, TCP SOCKS defaults to random auth, +and Unix paths cannot replace non-socket files. + +## 5. SFTP and file copy + +- `cmd/meowshell/agentsftp.go`, `cmd/meowshell/agent_sftp_e2e_test.go` +- `cmd/meowshell/cp.go`, `cmd/meowshell/cp_test.go` +- `dotnet/Meowshell/TailcatFileEntry.cs` +- SFTP and transfer methods/sinks in `dotnet/Meowshell/MeowshellAgentConnection.cs` +- transfer cases in `dotnet/Meowshell.Tests/TailcatClientE2ETests.cs` + +Review focus: local/remote path classification, truncation, upload finalization, +metadata preservation, streaming/backpressure, errors, and cancellation. Result: +operations act with the connected user's authority; callers must enforce any +application-specific path allowlist before passing untrusted paths. + +## 6. .NET process wrappers and public models + +- `dotnet/Meowshell/MeowshellServer.cs` +- `dotnet/Meowshell/TailcatClient.cs` +- `dotnet/Meowshell/TailcatListener.cs` +- `dotnet/Meowshell/MeowshellProcessControl.cs`, `dotnet/Meowshell/JobObject.cs` +- `dotnet/Meowshell/BinaryLocator.cs`, `dotnet/Meowshell/MeowshellBinaries.cs` +- `dotnet/Meowshell/TailcatOptions.cs`, `TailcatAddress.cs`, + `TailcatParsedAddress.cs`, `TailcatPath.cs`, `TailcatPingResult.cs`, + `TailcatEnvironment.cs`, `TailcatDiagnostics.cs`, `TailcatException.cs`, and + `GoDuration.cs` under `dotnet/Meowshell/` +- `dotnet/Meowshell.Tests/MeowshellServerTests.cs` +- `dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs` +- `dotnet/Meowshell.Tests/TailcatClientTests.cs` +- `dotnet/Meowshell.Tests/TailcatClientE2ETests.cs` + +Review focus: argument injection, process-tree cleanup, timeout/cancellation, +binary permissions/discovery, diagnostic secret exposure, parsing, and lifecycle +races. Result: `ArgumentList` and `UseShellExecute=false` are used consistently; +server addresses remain bearer secrets and must not be put in ordinary logs. + +## 7. Packaging, Android integration, and demonstrations + +- `dotnet/Meowshell/Meowshell.csproj` +- `dotnet/Meowshell.Runtime/Meowshell.Runtime.csproj` +- `dotnet/Meowshell/buildTransitive/net10.0-android36.0/Meowshell.targets` +- `dotnet/Meowshell.PackageTests/*` +- `dotnet/Meowshell.AndroidProbe/*`, `dotnet/android-probe-e2e.sh` +- `dotnet/Meowshell.Demo/*` +- `dotnet/Directory.Build.props`, `dotnet/nuget.config`, `dotnet/Meowshell.sln` +- `dotnet/verify-package.sh` + +Review focus: native-asset RID mapping, executable permissions, transitive +runtime dependencies, package consumption, Android extraction, and vulnerable +NuGet dependencies. Test dependencies were refreshed and restore now audits all +direct/transitive dependencies, failing on NU1901–NU1904. + +## 8. Build, release, and external-source controls + +- `build.sh`, `tailcat.ref`, `verify-binaries.sh` +- `.github/workflows/ci.yml`, `.github/dependabot.yml` +- `go.mod`, `go.sum` +- `patches/tailcat/*` +- `e2e/host-e2e.sh`, `e2e/android-e2e.sh`, `e2e/windows-e2e.ps1` +- `scripts/stripcomments/main.go` +- `scripts/CommentStripper/CommentStripper.csproj`, `Program.cs` + +Review focus: immutable third-party inputs, action pinning, least-privilege CI, +artifact validation, dependency advisories, publish credentials, and cross-OS +execution. Result: actions and tailcat are commit-pinned, publishing uses scoped +OIDC, `govulncheck` and transitive NuGet audit run automatically, and Dependabot +covers Go, NuGet, and Actions. Updating `tailcat.ref` still requires manual +upstream diff and patch review because Dependabot does not manage arbitrary Git +source pins. diff --git a/SECURITY_REVIEW.md b/SECURITY_REVIEW.md new file mode 100644 index 0000000..368f4ee --- /dev/null +++ b/SECURITY_REVIEW.md @@ -0,0 +1,332 @@ +# Security review + +**Review date:** 2026-09-11 +**Reviewed revision:** working tree based on the `work` branch +**Scope:** the Go CLI/agent and protocol, the .NET process wrapper and agent +client, build/release automation, tests, and the locally fetched tailcat source +at the revision recorded in `tailcat.ref`. Generated native binaries, external +relay infrastructure, GitHub/NuGet account configuration, and Android platform +internals were not independently penetration-tested. + +## Executive summary + +The review found eleven actionable weaknesses and remediated them in this +revision: + +1. **High — HTTPS proxy transport was not encrypted.** The `https` proxy scheme + followed the raw TCP path used for HTTP. This made proxy credentials and the + CONNECT request visible to an on-path attacker and did not authenticate the + proxy. HTTPS proxy connections now perform a normal, certificate-verified TLS + handshake before sending HTTP. +2. **High — release builds consumed a mutable upstream branch.** Native artifacts + defaulted to the then-current tailcat `main`, so identical Meowshell source + could produce different binaries and an upstream compromise could enter a + release without a repository change. The default and CI are now pinned to the + reviewed full commit in `tailcat.ref`; explicit workflow input remains + available for deliberate upstream testing. +3. **Medium — Unix forwarding could delete a non-socket path.** Starting a Unix + listener unconditionally removed an existing caller-supplied path. It now + uses `lstat` and only removes an actual socket, refusing regular files and + symbolic links. +4. **Medium — the SSH dependency contained reachable denial-of-service flaws.** + `govulncheck` identified GO-2026-6354 and GO-2026-6355 in the SSH handshake + path in both Meowshell and the built tailcat source. Both build inputs now use + `golang.org/x/crypto` v0.56.0, which contains the fixes. +5. **Medium — prompt responses could race shutdown or block the agent.** A + duplicate response filled the one-element prompt channel and blocked the only + frame reader; shutdown could also close the channel between lookup and send. + Delivery is now synchronized with shutdown and duplicate responses are + ignored without blocking. +6. **Medium — cancelled or failed .NET connection setup leaked its child.** A + cancellation was also surfaced as a timeout. Setup now disposes the agent on + every exceptional path and preserves `OperationCanceledException` semantics. +7. **Medium — HTTP proxy setup could hang and expose malformed credentials.** + Cancellation only covered dialing, not the CONNECT response, and URL parse + diagnostics could echo embedded passwords. Cancellation now closes the live + connection, diagnostics redact or omit credentials, and default HTTP(S) + proxy ports are supported. +8. **Low — a fast listener failure could be missed.** The .NET wrapper started + a process before subscribing to its exit event, so `Completed` could hang if + the child exited in that window. Event handlers are now installed first. +9. **High — remote output could grow managed memory without bound.** The .NET + multiplexer drained the child continuously into an unbounded per-channel + queue. A hostile SSH peer could exhaust the embedding application's memory. + Queues are now bounded and the protocol reader applies backpressure. +10. **Medium — subprocess-backed SSH handshakes could ignore cancellation and + timeout.** The pipe transport's deadline methods are necessarily no-ops. + Handshake cancellation now closes the connection and `tailcat` is launched + with `CommandContext`, guaranteeing termination at the context deadline. +11. **Low — protocol frame writes assumed one full writer call.** A legal short + write could truncate a frame and desynchronize the protocol. Writes now loop + to completion, and both implementations reject oversized outbound frames. + +No hard-coded credentials, shell-based local process launch, unrestricted +local TCP bind by default, unbounded protocol frame allocation, or silent TCP +SSH host-key acceptance was found in the reviewed first-party code. The +repository already uses argument-list process launching, a 64 MiB frame cap, +TOFU `known_hosts` verification for ordinary SSH, loopback-only local forwards +by default, random SOCKS credentials by default, restrictive temporary key and +Unix-socket permissions, and commit-pinned GitHub Actions. + +## Trust boundaries and attack surface + +- **Remote peers and relays:** SSH handshakes, host keys, authentication prompts, + session output, SFTP metadata/data, TCP forwarding, and SOCKS destinations. +- **Local child-process boundary:** the .NET client exchanges framed JSON and + binary data with `meowshell agent` over redirected standard streams. A replaced + packaged binary inherits the application's secrets and authority. +- **Local filesystem:** executable discovery, `HOME`, known-hosts, temporary key + staging, download destinations, and Unix forwarding paths. +- **Caller-controlled configuration:** remote commands, proxy URLs and + credentials, bind addresses, key material, jump hosts, SFTP paths, and + deliberate insecure modes. +- **Build/release:** upstream tailcat source, Go/NuGet dependencies, downloaded + toolchains, CI actions, native artifacts, and NuGet trusted publishing. + +## Audit execution plan + +The review is repeatable in six ordered phases; `REVIEW_MAP.md` is the file-level +checklist for phases two through four. + +1. **Baseline and inventory:** freeze the PR revision and tailcat pin; enumerate + source, generated exclusions, dependencies, entrypoints, platforms, secrets, + privileges, and network/filesystem/process boundaries. +2. **Adversarial data flow:** trace every untrusted value from CLI/.NET API or + remote frame through parsing, validation, allocation, logging, persistence, + process arguments, authentication, forwarding, SFTP, and response handling. +3. **Lifecycle and concurrency:** evaluate startup, steady state, cancellation, + timeout, partial I/O, backpressure, duplicate/out-of-order messages, abrupt + EOF, process crashes, repeated disposal, and concurrent channels/prompts. +4. **Platform and supply chain:** compare Unix/Windows/Android behavior; inspect + build patches, mutable inputs, CI permissions/actions, artifacts, package RID + layout, publishing identity, and dependency advisory coverage. +5. **Exploit-oriented validation:** add deterministic regressions for confirmed + defects, exercise protocol limits and races, run unit/E2E tests, race/static + analyzers, Go call-path vulnerability scans, and transitive NuGet audits. +6. **Disposition and release gate:** record fixed, accepted, transferred, and + unverified risks with owners/conditions. A release passes only when required + CI/platform E2E jobs and dependency scans pass against the exact artifacts. + +No finite source audit can prove that every scenario is safe. This plan instead +enumerates the relevant attacker capabilities, failure modes, and trust +boundaries, states exclusions explicitly, and leaves uncertain claims as +release gates or residual risks rather than silently treating them as safe. + +## Security objectives and attacker models + +The audit protects four primary assets: shell execution authority, SSH/private +key material, bearer tailcat addresses and proxy credentials, and availability +of the embedding application/device. It considered: + +- an unauthenticated Internet peer that learns or guesses an address; +- an authenticated but malicious SSH server or shell account; +- a malicious SOCKS/HTTP proxy, DERP relay, or on-path observer; +- a local unprivileged process racing files, sockets, agents, or child startup; +- an application caller supplying malformed, oversized, or adversarial options; +- a compromised package/module source, mutable Git ref, CI action, or artifact; +- abrupt cancellation, process death, short I/O, resource exhaustion, duplicate + messages, reordered lifecycle events, and platform-specific behavior. + +The review does not claim protection from a compromised OS/kernel, a process +with the same effective account that can debug/read this process, a malicious +application intentionally invoking unsafe options, compromise of GitHub/NuGet +administrative accounts, or undiscovered defects in cryptographic primitives. + +## Findings and disposition + +### SR-01: HTTPS proxy scheme used plaintext TCP — fixed (High) + +`dialHTTPConnectProxy` accepted both HTTP and HTTPS URLs but previously dialed +both with `net.Dialer`. Basic proxy credentials were therefore sent in cleartext +for an HTTPS URL, contrary to the scheme and caller expectations. The HTTPS path +now uses `tls.Dialer`, which validates the proxy certificate and infers SNI from +the proxy host. A regression test verifies that an HTTPS URL starts with a TLS +handshake rather than a plaintext CONNECT request. + +### SR-02: mutable tailcat source in builds — fixed (High) + +`build.sh` and four CI jobs defaulted to `main`. The default is now the complete +reviewed commit ID, stored in `tailcat.ref` for local builds and mirrored in CI. +Maintainers should update this pin through a reviewed change, inspect upstream +diffs, reapply both Android patches, run the full matrix, and run dependency +vulnerability scanning before release. + +### SR-03: Unix listener removed arbitrary existing paths — fixed (Medium) + +The agent removed the requested Unix-socket path before binding. A mistaken path +could destroy an application file; a local attacker able to change a shared +parent directory could substitute a symlink or regular file. Existing paths are +now inspected without following symlinks and removal is limited to socket nodes. +The remaining check/remove race is only exploitable by a principal that can +mutate the socket's parent directory. Callers should place sockets in a private +0700 directory (such as an application-owned runtime directory). + +### SR-04: reachable SSH denial of service in x/crypto — fixed (Medium) + +The reviewed v0.55.0 dependency allowed a peer to deadlock established or +undecided SSH channels (GO-2026-6354 and GO-2026-6355). Because the agent calls +`ssh.NewClientConn`, `govulncheck` found a reachable trace through +`dialSSHClient`; scanning the nested tailcat command found server and client +traces as well. The direct dependency and patched tailcat build module are both +upgraded to the fixed v0.56.0 release, and CI scans both modules. + +### SR-05: prompt response concurrency hazards — fixed (Medium) + +Prompt channels are closed during agent shutdown. Response delivery previously +released the prompt-map lock before sending, permitting a send-on-closed-channel +panic. A duplicate response could instead block forever on the full buffered +channel and stop all subsequent frames. Delivery now holds the lifecycle lock +and uses a non-blocking send; a regression test covers duplicate responses. + +### SR-06: .NET agent leak and cancellation misclassification — fixed (Medium) + +Once the agent child was started, exceptions while sending configuration or +waiting for connection escaped without disposal. In addition, cancellation won +the same `WhenAny` branch as timeout and was reported as a `TailcatException`. +Connection setup now has one exception cleanup path and checks caller +cancellation before creating a timeout error. A process-backed test verifies +both cancellation type and child cleanup. + +### SR-07: CONNECT cancellation and credential-safe errors — fixed (Medium) + +After the proxy TCP/TLS connection completed, a server that never returned an +HTTP response could hold connection setup forever despite caller cancellation. +`context.AfterFunc` now closes the connection while setup is in progress. Proxy +URL parse failures no longer include the raw credential-bearing URL, later +errors use `URL.Redacted`, missing hosts are rejected, and omitted HTTP/HTTPS +ports resolve to 80/443. Regression tests cover cancellation and secret-safe +parse errors. + +### SR-08: listener exit-subscription race — fixed (Low) + +`TailcatListener.Start` enabled events and launched the child before registering +its `Exited` callback. A child that failed immediately could exit in between, +leaving `Completed` unresolved and lifecycle callers waiting indefinitely. Exit +and output handlers are now registered before process start; existing crash +tests exercise the fast-failure path. + +### SR-09: unbounded managed channel buffering — fixed (High) + +Every frame from the Go child was appended to an unbounded .NET channel while a +separate pump waited for the application to consume its `Pipe`. Consequently a +remote command that emitted data faster than the application read it could grow +memory until process termination. The queue now holds at most 32 frame/control +items. The main protocol loop awaits queue admission, preserving frame order and +propagating backpressure through the child pipe and SSH channel. The tradeoff is +intentional head-of-line blocking across multiplexed channels under a stalled +consumer, which is preferable to unbounded memory consumption. +Shutdown explicitly faults active sinks before awaiting the reader, so a +consumer that has stopped reading cannot turn that backpressure into a shutdown +deadlock. + +### SR-10: ineffective pipe-transport handshake deadlines — fixed (Medium) + +SSH applies network deadlines through `net.Conn`, but the subprocess pipe +adapter cannot implement kernel socket deadlines and returned success without +enforcement. A stuck or replaced tailcat executable could therefore outlive the +nominal handshake timeout. `dialSSHClient` now derives a bounded handshake +context and closes any transport when it expires; tailcat subprocesses use +`exec.CommandContext`; and `pipeConn.Close` is idempotent so cancellation and +normal cleanup may safely race. + +### SR-11: partial and oversized outbound frames — fixed (Low) + +Go's `io.Writer` contract permits a short write. The protocol writer previously +ignored the byte count, potentially emitting a truncated frame followed by an +unparseable stream. It now writes until all bytes are accepted and fails on no +progress. Both Go and .NET writers enforce the same 64 MiB outbound bound already +used by readers, preventing integer/size mismatches and fail-late allocations. + +## Accepted design risks and hardening backlog + +1. **Tailcat SSH host-key checking is intentionally disabled.** Tailcat sessions + use `ssh.InsecureIgnoreHostKey`, relying on the cryptographic capability in + the tailcat address/transport rather than OpenSSH-style host identity. This + assumption must be revalidated whenever tailcat's address or handshake design + changes. Ordinary TCP SSH correctly uses TOFU and rejects changed keys. +2. **`InsecureNoAuth` is intentionally dangerous.** It creates a shell whose + bearer address is the credential. Keep the warning prominent, prefer + `AuthorizedKeys`, combine address-only use with client-key restrictions, use + short lifetimes, and never log or persist addresses unnecessarily. +3. **Remote exec is a command string, not an argv-safe execution API.** Command + elements are joined with spaces because SSH exec transmits one command + string. The .NET API documents that it adds no quoting. Applications must not + concatenate untrusted values; use SFTP or a fixed remote helper protocol for + untrusted inputs. +4. **The agent protocol permits 64 MiB frames.** The explicit bound prevents + unlimited allocation but still permits substantial per-frame memory use. The + child is local and trusted in the normal architecture. If the protocol is + exposed to a less-trusted producer, lower the control-frame limit and stream + large data separately. +5. **Forwarding is powerful by design.** Local TCP forwarding is loopback-only by + default and SOCKS auth defaults on for TCP, but callers can opt into nonlocal + binds or unauthenticated sockets. Applications should surface those choices as + security-sensitive and apply destination allowlists when acting on untrusted + requests. +6. **Build inputs remain broader than one repository.** Go modules, NuGet + packages, the Go toolchain, Android NDK, and publishing infrastructure remain + external trust dependencies. Automated `govulncheck`, transitive NuGet audit, + and Dependabot now cover known advisories and routine upgrades. SBOM + generation, artifact provenance/attestation, and release verification against + reproducible hashes remain valuable follow-ups. +7. **Secret lifetime is not minimized everywhere.** Passwords, passphrases, and + private keys cross managed strings/arrays or Go byte slices and cannot always + be reliably zeroed. Prefer ssh-agent and Android Keystore sign callbacks over + raw private-key/password configuration. + +## Scenario coverage matrix + +| Scenario | Control or disposition | +| --- | --- | +| Address disclosure | Prefer authorized keys/client allowlists; address-only mode remains explicit risk. | +| TCP SSH MITM | TOFU known-hosts; changed keys fail closed. | +| Tailcat SSH identity | Capability-address security assumption; tracked as accepted design risk. | +| Malicious HTTPS proxy/on-path peer | Certificate-verified TLS before credentials or CONNECT. | +| Proxy stalls after accepting TCP | Context cancellation closes the live connection. | +| Proxy URL contains secrets | Raw parse errors are suppressed; parsed URLs are redacted. | +| Public local forward/proxy | Refused unless the caller opts into non-loopback bind. | +| Unauthenticated SOCKS use | TCP SOCKS generates credentials by default; constant-time comparison. | +| Unix socket path substitution | Symlinks and non-sockets are refused; private parent directory recommended. | +| Oversized/malformed IPC frame | 64 MiB inbound/outbound cap and structural header checks. | +| Short IPC write | Full-write loop or error; regression coverage included. | +| Duplicate/racing prompt response | Non-blocking delivery under the lifecycle lock. | +| Hostile high-volume command output | Bounded per-channel buffering with transport backpressure. | +| Stalled SSH handshake/subprocess | Bounded context, connection close, and command-context kill. | +| Cancellation during .NET startup | Correct cancellation type and unconditional child cleanup. | +| Child exits immediately | Exit handlers registered before start. | +| SFTP path abuse | Executes with SSH user's authority; embedding app must impose narrower allowlists. | +| Remote command injection | API explicitly transports a shell command string; untrusted concatenation forbidden. | +| Compromised upstream branch | Reviewed full tailcat commit pin; override is explicit. | +| Known vulnerable dependency | CI scans both Go modules and all restorable NuGet graphs; warnings fail builds. | +| Compromised CI action | Actions are full-commit pinned and workflow defaults to read-only contents. | +| Package publication credential theft | NuGet trusted publishing uses short-lived scoped OIDC. | +| Artifact/platform mismatch | ELF/PE architecture and Android-loader verification plus host/device E2E. | + +## Verification plan + +The security fixes are covered by focused Go tests. The standard repository +checks are `go test ./...`, `go vet ./...`, `./verify-binaries.sh`, and +`dotnet test dotnet/Meowshell.sln --configuration Release`. Release review should +also run `govulncheck ./...` after materializing the pinned tailcat checkout and +a NuGet dependency audit in an environment with the required .NET SDK. + +## Review verification results + +The review environment was subsequently provisioned with .NET SDK 8.0.425 and +the pinned Linux amd64 binaries. At that revision all 86 non-E2E .NET tests +passed. The then-current complete suite executed all 107 tests: 94 passed and +13 relay-dependent E2E cases failed +with `context deadline exceeded` or an SSH EOF after the tailcat connection +could not be established. Local real-binary E2E coverage, including listener +startup/shutdown and key/address operations, did pass. The environment routes +outbound HTTP(S) through a mandatory proxy, and the failures are consistent with +the tailcat transport being unable to reach its relay from this environment. +They are therefore recorded as an environment limitation rather than a passing +result or a demonstrated product regression. The full E2E suite still needs a +run from a network that permits the tailcat transport before release. + +After the additional PR hardening, all 87 current non-E2E .NET tests pass. Go +unit tests, focused race-detector tests, `go vet`, root and patched-tailcat +`govulncheck`, transitive NuGet audit, workflow lint, and shell syntax checks +also pass. Network/device E2E remains a required CI release gate rather than a +claim derived from the restricted review container. diff --git a/build.sh b/build.sh index a9f978e..87cac2b 100755 --- a/build.sh +++ b/build.sh @@ -12,7 +12,9 @@ set -euo pipefail REPO_DIR=$PWD SRC_URL=${SRC_URL:-https://github.com/tailscale/tailcat.git} -SRC_REF=${SRC_REF:-main} +# Keep release inputs reproducible. Override deliberately for upstream testing; +# CI uses this same reviewed revision unless a workflow_dispatch input is given. +SRC_REF=${SRC_REF:-$(tr -d '[:space:]' < "$REPO_DIR/tailcat.ref")} SRC_DIR=${SRC_DIR:-$REPO_DIR/.tailcat-src} OUT_DIR=${OUT_DIR:-$REPO_DIR/dist} API=${ANDROID_API_LEVEL:-21} @@ -28,6 +30,10 @@ git -C "$SRC_DIR" checkout --detach FETCH_HEAD # CI always starts from a fresh clone) would otherwise still carry the # patch applied below from the previous run, and re-applying it would fail. git -C "$SRC_DIR" reset --hard FETCH_HEAD +# The Android netmon patch adds a source file, so reset alone is insufficient: +# Git deliberately leaves that untracked file behind. SRC_DIR is a disposable +# build checkout; clean it as well so repeated builds start from the same tree. +git -C "$SRC_DIR" clean -fd # netmon.NewStatic() (used by pickregion.go's PickBestRegion, itself called # by ConnInfo.Expand whenever a key's RegionID is -1, the default for @@ -50,7 +56,7 @@ git -C "$SRC_DIR" apply "$REPO_DIR/patches/tailcat/pickregion-nil-ifstate.patch" # file's comments for the detail. Added via `go get`, which computes # go.mod/go.sum correctly, rather than hand-patching them. git -C "$SRC_DIR" apply "$REPO_DIR/patches/tailcat/android-netmon-interface-getter.patch" -(cd "$SRC_DIR" && go get github.com/wlynxg/anet@v0.0.5) +(cd "$SRC_DIR" && go get github.com/wlynxg/anet@v0.0.5 golang.org/x/crypto@v0.56.0) # tailcat's SSH server hardcodes /bin/sh and /usr/local/bin:/usr/bin:/bin # for the session shell and PATH, neither of which exist on Android -- diff --git a/cmd/meowshell/agent.go b/cmd/meowshell/agent.go index 9ad16af..f463b01 100644 --- a/cmd/meowshell/agent.go +++ b/cmd/meowshell/agent.go @@ -500,10 +500,16 @@ func (a *agentSession) handleControl(channelID uint32, payload []byte) { func (a *agentSession) deliverPromptResponse(msg controlMessage) { a.promptsMu.Lock() + defer a.promptsMu.Unlock() ch := a.prompts[msg.RequestID] - a.promptsMu.Unlock() if ch != nil { - ch <- msg + // Ignore a duplicate response instead of blocking the only frame reader. + // Holding promptsMu also prevents closeAllPrompts from closing ch between + // the lookup and send. + select { + case ch <- msg: + default: + } } } diff --git a/cmd/meowshell/agentauth_test.go b/cmd/meowshell/agentauth_test.go index 592806e..db5138e 100644 --- a/cmd/meowshell/agentauth_test.go +++ b/cmd/meowshell/agentauth_test.go @@ -7,10 +7,33 @@ import ( "encoding/pem" "io" "testing" + "time" "golang.org/x/crypto/ssh" ) +func TestDeliverPromptResponseDoesNotBlockOnDuplicate(t *testing.T) { + session := newAgentSession(nil, nil) + responses := make(chan controlMessage, 1) + session.prompts["p1"] = responses + + done := make(chan struct{}) + go func() { + session.deliverPromptResponse(controlMessage{RequestID: "p1", Answer: "first"}) + session.deliverPromptResponse(controlMessage{RequestID: "p1", Answer: "duplicate"}) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("a duplicate prompt response blocked the frame reader") + } + if got := (<-responses).Answer; got != "first" { + t.Fatalf("delivered answer = %q, want first", got) + } +} + func testAuthSession(t *testing.T) (session *agentSession, fromAgent io.Reader, toAgent io.Writer) { t.Helper() agentIn, clientOut := io.Pipe() diff --git a/cmd/meowshell/forwarding.go b/cmd/meowshell/forwarding.go index b8435f6..1c21bde 100644 --- a/cmd/meowshell/forwarding.go +++ b/cmd/meowshell/forwarding.go @@ -52,8 +52,15 @@ func listenUnix(path string) (net.Listener, error) { if path == "" { return nil, fmt.Errorf("a unix listen_network needs a non-empty socket path") } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("removing stale socket %s: %w", path, err) + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSocket == 0 { + return nil, fmt.Errorf("refusing to remove non-socket path %s", path) + } + if err := os.Remove(path); err != nil { + return nil, fmt.Errorf("removing stale socket %s: %w", path, err) + } + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("checking stale socket %s: %w", path, err) } ln, err := net.Listen("unix", path) if err != nil { diff --git a/cmd/meowshell/forwarding_test.go b/cmd/meowshell/forwarding_test.go new file mode 100644 index 0000000..77b40c6 --- /dev/null +++ b/cmd/meowshell/forwarding_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestListenUnixRefusesToRemoveNonSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix sockets are not supported on Windows") + } + path := filepath.Join(t.TempDir(), "important") + if err := os.WriteFile(path, []byte("keep me"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := listenUnix(path); err == nil || !strings.Contains(err.Error(), "refusing to remove non-socket") { + t.Fatalf("listenUnix over a regular file = %v, want refusal", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("regular file was removed: %v", err) + } + if string(got) != "keep me" { + t.Fatalf("regular file changed to %q", got) + } +} diff --git a/cmd/meowshell/protocol.go b/cmd/meowshell/protocol.go index 2b032c4..a40e4ff 100644 --- a/cmd/meowshell/protocol.go +++ b/cmd/meowshell/protocol.go @@ -27,13 +27,25 @@ type frame struct { } func writeFrame(w io.Writer, f frame) error { + if len(f.Payload) > maxFrameLength-frameHeaderLength { + return fmt.Errorf("frame payload length %d exceeds the %d limit", len(f.Payload), maxFrameLength-frameHeaderLength) + } buf := make([]byte, 4+frameHeaderLength+len(f.Payload)) binary.BigEndian.PutUint32(buf[0:4], uint32(frameHeaderLength+len(f.Payload))) buf[4] = f.Type binary.BigEndian.PutUint32(buf[5:9], f.ChannelID) copy(buf[9:], f.Payload) - _, err := w.Write(buf) - return err + for len(buf) > 0 { + n, err := w.Write(buf) + if err != nil { + return err + } + if n <= 0 { + return io.ErrShortWrite + } + buf = buf[n:] + } + return nil } func readFrame(r io.Reader) (frame, error) { diff --git a/cmd/meowshell/protocol_test.go b/cmd/meowshell/protocol_test.go index a824497..229894d 100644 --- a/cmd/meowshell/protocol_test.go +++ b/cmd/meowshell/protocol_test.go @@ -8,6 +8,18 @@ import ( "testing" ) +type shortWriter struct { + bytes.Buffer + max int +} + +func (w *shortWriter) Write(p []byte) (int, error) { + if len(p) > w.max { + p = p[:w.max] + } + return w.Buffer.Write(p) +} + func TestFrameRoundTrip(t *testing.T) { cases := []frame{ {Type: frameTypeControl, ChannelID: 0, Payload: []byte(`{"msg":"open_channel"}`)}, @@ -47,6 +59,27 @@ func TestReadFrameMultipleInSequence(t *testing.T) { } } +func TestWriteFrameCompletesShortWrites(t *testing.T) { + w := &shortWriter{max: 3} + want := frame{Type: frameTypeData, ChannelID: 42, Payload: []byte("payload")} + if err := writeFrame(w, want); err != nil { + t.Fatalf("writeFrame with short writer: %v", err) + } + got, err := readFrame(&w.Buffer) + if err != nil { + t.Fatalf("readFrame after short writes: %v", err) + } + if got.Type != want.Type || got.ChannelID != want.ChannelID || !bytes.Equal(got.Payload, want.Payload) { + t.Fatalf("frame after short writes = %+v, want %+v", got, want) + } +} + +func TestWriteFrameRejectsOversizedPayload(t *testing.T) { + if err := writeFrame(io.Discard, frame{Payload: make([]byte, maxFrameLength-frameHeaderLength+1)}); err == nil { + t.Fatal("writeFrame accepted an oversized payload") + } +} + func TestReadFrameRejectsOversizedLength(t *testing.T) { r := strings.NewReader(string([]byte{0xff, 0xff, 0xff, 0xff})) if _, err := readFrame(r); err == nil { diff --git a/cmd/meowshell/sftp.go b/cmd/meowshell/sftp.go index 4afa632..eac054b 100644 --- a/cmd/meowshell/sftp.go +++ b/cmd/meowshell/sftp.go @@ -8,6 +8,7 @@ import ( "net" "os/exec" "strings" + "sync" "time" "github.com/pkg/sftp" @@ -32,21 +33,24 @@ type pipeConn struct { cmd *exec.Cmd stdout io.ReadCloser stdin io.WriteCloser + once sync.Once + err error } func (c *pipeConn) Read(p []byte) (int, error) { return c.stdout.Read(p) } func (c *pipeConn) Write(p []byte) (int, error) { return c.stdin.Write(p) } func (c *pipeConn) Close() error { - c.stdin.Close() - c.stdout.Close() - err := c.cmd.Wait() - - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil - } - return err + c.once.Do(func() { + c.stdin.Close() + c.stdout.Close() + c.err = c.cmd.Wait() + var exitErr *exec.ExitError + if errors.As(c.err, &exitErr) { + c.err = nil + } + }) + return c.err } func (c *pipeConn) LocalAddr() net.Addr { return pipeAddr{} } @@ -63,20 +67,31 @@ func (pipeAddr) Network() string { return "tailcat" } func (pipeAddr) String() string { return "tailcat" } func dialSSHClient(ctx context.Context, dial dialer, remoteAddr, user string, hostKeyCallback ssh.HostKeyCallback, auth []ssh.AuthMethod) (*ssh.Client, error) { - conn, err := dial(ctx) + handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout) + defer cancel() + conn, err := dial(handshakeCtx) if err != nil { return nil, err } + stopCancellation := context.AfterFunc(handshakeCtx, func() { conn.Close() }) sshConn, chans, reqs, err := ssh.NewClientConn(conn, remoteAddr, &ssh.ClientConfig{ User: user, HostKeyCallback: hostKeyCallback, Auth: auth, Timeout: sshHandshakeTimeout, }) + stoppedCancellation := stopCancellation() if err != nil { conn.Close() + if ctxErr := handshakeCtx.Err(); ctxErr != nil { + return nil, fmt.Errorf("SSH handshake: %w", ctxErr) + } return nil, fmt.Errorf("SSH handshake: %w", err) } + if !stoppedCancellation || handshakeCtx.Err() != nil { + conn.Close() + return nil, fmt.Errorf("SSH handshake: %w", handshakeCtx.Err()) + } return ssh.NewClient(sshConn, chans, reqs), nil } diff --git a/cmd/meowshell/sftp_test.go b/cmd/meowshell/sftp_test.go new file mode 100644 index 0000000..50701fe --- /dev/null +++ b/cmd/meowshell/sftp_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +func TestDialSSHClientHonorsContextDuringHandshake(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + started := time.Now() + dial := dialer(func(context.Context) (net.Conn, error) { return client, nil }) + _, err := dialSSHClient(ctx, dial, "unresponsive.example:22", "user", ssh.InsecureIgnoreHostKey(), nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("dialSSHClient error = %v, want context deadline exceeded", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("handshake cancellation took %s, want under 1s", elapsed) + } +} diff --git a/cmd/meowshell/transport.go b/cmd/meowshell/transport.go index c01d58c..26ab255 100644 --- a/cmd/meowshell/transport.go +++ b/cmd/meowshell/transport.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "crypto/tls" "encoding/base64" "fmt" "net" @@ -20,6 +21,13 @@ type dialer func(ctx context.Context) (net.Conn, error) func tailcatDialer(tailcatBin string, argv []string) dialer { return func(ctx context.Context) (net.Conn, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // The context only bounds dialing; it does not own the connection after + // this function returns. In particular, dialSSHClient cancels its + // handshake context after a successful handshake. CommandContext would + // then kill the tailcat process backing the live SSH connection. cmd := exec.Command(tailcatBin, argv...) cmd.Stderr = os.Stderr stdin, err := cmd.StdinPipe() @@ -71,13 +79,18 @@ func splitUserHost(dest, defaultPort string) (user, hostPort string) { func proxyDialer(proxyURL, hostPort string) (dialer, error) { u, err := url.Parse(proxyURL) if err != nil { - return nil, fmt.Errorf("invalid --proxy %q: %w", proxyURL, err) + // url.ParseError includes the original URL, which can contain a proxy + // password. Do not copy it into diagnostics. + return nil, fmt.Errorf("invalid --proxy URL") + } + if u.Hostname() == "" { + return nil, fmt.Errorf("invalid --proxy URL: missing host") } switch u.Scheme { case "socks5", "socks5h": d, err := proxy.SOCKS5("tcp", u.Host, proxyAuthFromURL(u), proxy.Direct) if err != nil { - return nil, fmt.Errorf("configuring SOCKS5 proxy %q: %w", proxyURL, err) + return nil, fmt.Errorf("configuring SOCKS5 proxy %q: %w", u.Redacted(), err) } return func(ctx context.Context) (net.Conn, error) { if cd, ok := d.(proxy.ContextDialer); ok { @@ -104,10 +117,27 @@ func proxyAuthFromURL(u *url.URL) *proxy.Auth { func dialHTTPConnectProxy(ctx context.Context, proxyURL *url.URL, hostPort string) (net.Conn, error) { d := net.Dialer{Timeout: tcpDialTimeout} - conn, err := d.DialContext(ctx, "tcp", proxyURL.Host) + proxyAddr := proxyURL.Host + if proxyURL.Port() == "" { + port := "80" + if proxyURL.Scheme == "https" { + port = "443" + } + proxyAddr = net.JoinHostPort(proxyURL.Hostname(), port) + } + var conn net.Conn + var err error + if proxyURL.Scheme == "https" { + tlsDialer := tls.Dialer{NetDialer: &d} + conn, err = tlsDialer.DialContext(ctx, "tcp", proxyAddr) + } else { + conn, err = d.DialContext(ctx, "tcp", proxyAddr) + } if err != nil { return nil, err } + stopCancellation := context.AfterFunc(ctx, func() { conn.Close() }) + defer stopCancellation() var authHeader string if proxyURL.User != nil { pass, _ := proxyURL.User.Password() @@ -122,12 +152,19 @@ func dialHTTPConnectProxy(ctx context.Context, proxyURL *url.URL, hostPort strin resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"}) if err != nil { conn.Close() + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } return nil, err } if resp.StatusCode != http.StatusOK { conn.Close() return nil, fmt.Errorf("HTTP CONNECT proxy %s refused: %s", proxyURL.Host, resp.Status) } + if err := ctx.Err(); err != nil { + conn.Close() + return nil, err + } return &bufConn{Conn: conn, r: br}, nil } diff --git a/cmd/meowshell/transport_test.go b/cmd/meowshell/transport_test.go index 92992eb..e6c3925 100644 --- a/cmd/meowshell/transport_test.go +++ b/cmd/meowshell/transport_test.go @@ -3,14 +3,61 @@ package main import ( "bufio" "context" + "errors" "fmt" + "io" "net" "net/http" "net/url" + "runtime" + "strings" "testing" "time" ) +func TestTailcatDialerContextDoesNotOwnReturnedConnection(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("cat subprocess fixture is Unix-only") + } + + ctx, cancel := context.WithCancel(context.Background()) + conn, err := tailcatDialer("cat", nil)(ctx) + if err != nil { + t.Fatalf("tailcatDialer: %v", err) + } + defer conn.Close() + + // A dial context governs creation of a connection, not the lifetime of a + // successfully returned connection. dialSSHClient cancels the context it + // passes here as soon as the SSH handshake completes. + cancel() + const message = "still connected\n" + if _, err := io.WriteString(conn, message); err != nil { + t.Fatalf("write after dial context cancellation: %v", err) + } + buf := make([]byte, len(message)) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read after dial context cancellation: %v", err) + } + if got := string(buf); got != message { + t.Fatalf("echo = %q, want %q", got, message) + } +} + +func TestTailcatDialerRejectsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + conn, err := tailcatDialer("command-must-not-be-started", nil)(ctx) + if conn != nil { + conn.Close() + t.Fatal("tailcatDialer returned a connection for a canceled context") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("tailcatDialer error = %v, want context canceled", err) + } +} + func mustParseURL(t *testing.T, raw string) *url.URL { t.Helper() u, err := url.Parse(raw) @@ -127,6 +174,90 @@ func TestDialHTTPConnectProxyRejectsNon200(t *testing.T) { } } +func TestDialHTTPConnectProxyHonorsCancellationAfterDial(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + accepted := make(chan struct{}) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + close(accepted) + io.Copy(io.Discard, conn) + }() + + ctx, cancel := context.WithCancel(context.Background()) + proxyURL := mustParseURL(t, "http://"+ln.Addr().String()) + done := make(chan error, 1) + go func() { + _, err := dialHTTPConnectProxy(ctx, proxyURL, "backend.example:22") + done <- err + }() + <-accepted + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("dialHTTPConnectProxy cancellation = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("cancellation did not interrupt the proxy response read") + } +} + +func TestProxyDialerDoesNotExposeMalformedURLPassword(t *testing.T) { + const secret = "super-secret-password" + _, err := proxyDialer("http://user:"+secret+"%zz@example.com", "backend.example:22") + if err == nil { + t.Fatal("proxyDialer accepted a malformed URL") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("proxy parse error exposed the password: %v", err) + } +} + +func TestDialHTTPSConnectProxyUsesTLS(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + firstByte := make(chan byte, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + var b [1]byte + if _, err := conn.Read(b[:]); err == nil { + firstByte <- b[0] + } + }() + + proxyURL := mustParseURL(t, "https://"+ln.Addr().String()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := dialHTTPConnectProxy(ctx, proxyURL, "backend.example:22"); err == nil { + t.Fatal("dialHTTPConnectProxy against a non-TLS HTTPS proxy did not error") + } + select { + case got := <-firstByte: + if got != 0x16 { // TLS handshake record. + t.Fatalf("first HTTPS proxy byte = %#x, want TLS handshake %#x", got, byte(0x16)) + } + case <-ctx.Done(): + t.Fatal("HTTPS proxy did not receive a connection") + } +} + func readFull(conn net.Conn, buf []byte) (int, error) { total := 0 for total < len(buf) { diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 971a5fe..2dcc031 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -9,6 +9,12 @@ 0 $(MajorMinor).$(BuildNumber) $(MeowshellVersion) + + true + all + low + $(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904 diff --git a/dotnet/Meowshell.PackageTests/Meowshell.PackageTests.csproj b/dotnet/Meowshell.PackageTests/Meowshell.PackageTests.csproj index dda39b7..dd81d7a 100644 --- a/dotnet/Meowshell.PackageTests/Meowshell.PackageTests.csproj +++ b/dotnet/Meowshell.PackageTests/Meowshell.PackageTests.csproj @@ -21,13 +21,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/dotnet/Meowshell.Tests/Meowshell.Tests.csproj b/dotnet/Meowshell.Tests/Meowshell.Tests.csproj index 92826a5..a2f63de 100644 --- a/dotnet/Meowshell.Tests/Meowshell.Tests.csproj +++ b/dotnet/Meowshell.Tests/Meowshell.Tests.csproj @@ -12,13 +12,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs index ebf7e7d..19ac1cb 100644 --- a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs @@ -6,6 +6,7 @@ namespace Meowshell.Tests; +[Collection(RelayE2ECollection.Name)] public sealed class MeowshellAgentConnectionE2ETests : IDisposable { private static readonly Regex AddressPattern = new(@"\btc[A-Za-z0-9_-]{10,}", RegexOptions.Compiled); diff --git a/dotnet/Meowshell.Tests/MeowshellAgentConnectionTests.cs b/dotnet/Meowshell.Tests/MeowshellAgentConnectionTests.cs new file mode 100644 index 0000000..bfb808f --- /dev/null +++ b/dotnet/Meowshell.Tests/MeowshellAgentConnectionTests.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; +using Meowshell; + +namespace Meowshell.Tests; + +public sealed class MeowshellAgentConnectionTests : IDisposable +{ + private readonly string _dir = Directory.CreateTempSubdirectory("meowshell-agent-test-").FullName; + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + [Fact] + public async Task CancellationStopsTheAgentAndRemainsCancellation() + { + if (OperatingSystem.IsWindows()) return; + + var bin = Path.Combine(_dir, "bin"); + Directory.CreateDirectory(bin); + var pidFile = Path.Combine(_dir, "agent.pid"); + var script = $"#!/bin/sh\nprintf '%s' $$ > '{pidFile}'\nexec sleep 30\n"; + var naming = BinaryNaming.ForCurrentPlatform(); + foreach (var name in new[] { naming.FileName("meowshell"), naming.FileName("tailcat") }) + { + var path = Path.Combine(bin, name); + await File.WriteAllTextAsync(path, script); + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + using var cancellation = new CancellationTokenSource(); + var connecting = MeowshellAgentConnection.ConnectAsync(new TailcatClientOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "home"), + Timeout = TimeSpan.FromSeconds(10), + }, "example.invalid", cancellationToken: cancellation.Token); + + // Synchronize with the child instead of cancelling after an arbitrary + // delay: process startup can legitimately exceed 250 ms on a busy CI + // runner, which made this regression test test scheduler speed. + await WaitForFileAsync(pidFile, TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => connecting); + + var pid = int.Parse(await File.ReadAllTextAsync(pidFile), System.Globalization.CultureInfo.InvariantCulture); + Assert.False(IsRunning(pid), "the cancelled connection leaked its agent process"); + } + + private static async Task WaitForFileAsync(string path, TimeSpan timeout) + { + using var cancellation = new CancellationTokenSource(timeout); + while (!File.Exists(path)) + { + await Task.Delay(10, cancellation.Token); + } + } + + private static bool IsRunning(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + } +} diff --git a/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs index 0e6f2f1..4cc75a4 100644 --- a/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs @@ -5,6 +5,7 @@ namespace Meowshell.Tests; +[Collection(RelayE2ECollection.Name)] public sealed class MeowshellListenersE2ETests : IDisposable { private const string TailcatEnvVar = "DOTNET_E2E_TAILCAT_BIN"; diff --git a/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs index 0c0e35e..187b9b7 100644 --- a/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs @@ -4,6 +4,7 @@ namespace Meowshell.Tests; +[Collection(RelayE2ECollection.Name)] public sealed class MeowshellServerE2ETests : IDisposable { private static readonly Regex AddressPattern = new(@"\btc[A-Za-z0-9_-]{10,}", RegexOptions.Compiled); diff --git a/dotnet/Meowshell.Tests/MeowshellServerTests.cs b/dotnet/Meowshell.Tests/MeowshellServerTests.cs index 7fe9d6b..0c21789 100644 --- a/dotnet/Meowshell.Tests/MeowshellServerTests.cs +++ b/dotnet/Meowshell.Tests/MeowshellServerTests.cs @@ -179,7 +179,7 @@ public async Task TheServerIsToldWhereTailcatIs() await using var server = await MeowshellServer.StartAsync(options); var lines = File.ReadAllLines(seen); - Assert.Equal(Path.Combine(options.BinaryDirectory, "libtailcat.so"), lines[0]); + Assert.Equal(Path.Combine(options.BinaryDirectory!, "libtailcat.so"), lines[0]); Assert.Equal(options.HomeDirectory, lines[1]); } diff --git a/dotnet/Meowshell.Tests/RelayE2ECollection.cs b/dotnet/Meowshell.Tests/RelayE2ECollection.cs new file mode 100644 index 0000000..ffbd7e0 --- /dev/null +++ b/dotnet/Meowshell.Tests/RelayE2ECollection.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace Meowshell.Tests; + +// These classes all create real clients and ephemeral servers on the same +// public relay. Keeping them in one collection prevents xUnit from producing +// an artificial registration burst while leaving unrelated unit tests free to +// run in parallel. +[CollectionDefinition(Name)] +public sealed class RelayE2ECollection +{ + public const string Name = "Public relay E2E"; +} diff --git a/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs b/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs index 5239c61..2a0df32 100644 --- a/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs +++ b/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs @@ -5,6 +5,7 @@ namespace Meowshell.Tests; +[Collection(RelayE2ECollection.Name)] public sealed class TailcatClientE2ETests : IDisposable { private static readonly Regex AddressPattern = new(@"\btc[A-Za-z0-9_-]{10,}", RegexOptions.Compiled); diff --git a/dotnet/Meowshell/MeowshellAgentConnection.cs b/dotnet/Meowshell/MeowshellAgentConnection.cs index 12fb8f5..5bcee93 100644 --- a/dotnet/Meowshell/MeowshellAgentConnection.cs +++ b/dotnet/Meowshell/MeowshellAgentConnection.cs @@ -135,25 +135,32 @@ public static async Task ConnectAsync( var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; MeowshellProcessControl.Start(process); var connection = new MeowshellAgentConnection(process); - - process.ErrorDataReceived += (_, e) => + try { - if (e.Data is null) return; - connection._diagnostics.Add(e.Data); - connection.Log?.Invoke(e.Data); - }; - process.BeginErrorReadLine(); + process.ErrorDataReceived += (_, e) => + { + if (e.Data is null) return; + connection._diagnostics.Add(e.Data); + connection.Log?.Invoke(e.Data); + }; + process.BeginErrorReadLine(); - await connection.SendConfigureAsync(configure ?? new MeowshellAgentConfigureOptions(), proxyUrl, cancellationToken).ConfigureAwait(false); + await connection.SendConfigureAsync(configure ?? new MeowshellAgentConfigureOptions(), proxyUrl, cancellationToken).ConfigureAwait(false); - var settled = await Task.WhenAny(connection._connected.Task, Task.Delay(options.Timeout, cancellationToken)).ConfigureAwait(false); - if (settled != connection._connected.Task) + var settled = await Task.WhenAny(connection._connected.Task, Task.Delay(options.Timeout, cancellationToken)).ConfigureAwait(false); + if (settled != connection._connected.Task) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new TailcatException("meowshell agent did not connect in time", 0, connection._diagnostics.Tail(), MeowshellErrorCode.Timeout); + } + await connection._connected.Task.ConfigureAwait(false); + return connection; + } + catch { await connection.DisposeAsync().ConfigureAwait(false); - throw new TailcatException("meowshell agent did not connect in time", 0, connection._diagnostics.Tail(), MeowshellErrorCode.Timeout); + throw; } - await connection._connected.Task.ConfigureAwait(false); - return connection; } private Task SendConfigureAsync(MeowshellAgentConfigureOptions configure, string? proxyUrl, CancellationToken cancellationToken) => @@ -460,11 +467,11 @@ private async Task RunReadLoopAsync() if (frame is null) break; if (frame.Value.Type == MeowshellAgentProtocol.FrameTypeData) { - HandleData(frame.Value.ChannelId, frame.Value.Payload); + await HandleDataAsync(frame.Value.ChannelId, frame.Value.Payload).ConfigureAwait(false); continue; } var msg = System.Text.Json.JsonSerializer.Deserialize(frame.Value.Payload, MeowshellAgentProtocol.JsonOptions)!; - HandleControl(frame.Value.ChannelId, msg); + await HandleControlAsync(frame.Value.ChannelId, msg).ConfigureAwait(false); } FaultEverything(new TailcatException("meowshell agent exited unexpectedly", 0, _diagnostics.Tail())); } @@ -474,16 +481,16 @@ private async Task RunReadLoopAsync() } } - private void HandleData(uint channelId, byte[] payload) + private async Task HandleDataAsync(uint channelId, byte[] payload) { if (payload.Length == 0) return; var stream = payload[0]; var data = payload.AsMemory(1); if (_channels.TryGetValue(channelId, out var sink)) - _ = sink.OnDataAsync(stream, data); + await sink.OnDataAsync(stream, data).ConfigureAwait(false); } - private void HandleControl(uint channelId, AgentMessage msg) + private async Task HandleControlAsync(uint channelId, AgentMessage msg) { switch (msg.Msg) { @@ -528,7 +535,7 @@ private void HandleControl(uint channelId, AgentMessage msg) } if (_channels.TryGetValue(channelId, out var sink)) - sink.OnControl(msg); + await sink.OnControlAsync(msg).ConfigureAwait(false); } private async Task HandlePromptAsync(AgentMessage msg) @@ -605,6 +612,10 @@ public async Task StopAsync() try { await _process.WaitForExitAsync(grace.Token).ConfigureAwait(false); } catch (OperationCanceledException) { MeowshellProcessControl.TryKill(_process); } } + // Release bounded channel pumps before waiting for the read loop. A + // consumer that stopped reading may have backpressured that loop; faulting + // its sink completes the pipe and lets shutdown make progress. + FaultEverything(new OperationCanceledException("meowshell agent connection stopped")); try { await _readLoop.ConfigureAwait(false); } catch { } } @@ -621,7 +632,7 @@ public async ValueTask DisposeAsync() internal interface IAgentChannelSink { Task OnDataAsync(byte stream, ReadOnlyMemory data); - void OnControl(AgentMessage msg); + Task OnControlAsync(AgentMessage msg); void OnFault(Exception ex); } @@ -631,7 +642,12 @@ internal sealed class AgentChannelDataPump : IAgentChannelSink private readonly IAgentChannelSink _inner; private readonly System.Threading.Channels.Channel _queue = - System.Threading.Channels.Channel.CreateUnbounded(new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true }); + System.Threading.Channels.Channel.CreateBounded(new System.Threading.Channels.BoundedChannelOptions(32) + { + SingleReader = true, + SingleWriter = true, + FullMode = System.Threading.Channels.BoundedChannelFullMode.Wait, + }); private readonly Task _pumpTask; public AgentChannelDataPump(IAgentChannelSink inner) @@ -651,20 +667,19 @@ private async Task RunAsync() } else { - _inner.OnControl(item.Control!); + await _inner.OnControlAsync(item.Control!).ConfigureAwait(false); } } } public Task OnDataAsync(byte stream, ReadOnlyMemory data) { - _queue.Writer.TryWrite(new QueueItem(true, stream, data, null)); - return Task.CompletedTask; + return _queue.Writer.WriteAsync(new QueueItem(true, stream, data, null)).AsTask(); } - public void OnControl(AgentMessage msg) + public async Task OnControlAsync(AgentMessage msg) { - _queue.Writer.TryWrite(new QueueItem(false, 0, ReadOnlyMemory.Empty, msg)); + await _queue.Writer.WriteAsync(new QueueItem(false, 0, ReadOnlyMemory.Empty, msg)).ConfigureAwait(false); if (msg.Msg is "exit_status" or "error") _queue.Writer.TryComplete(); } @@ -679,7 +694,7 @@ public void OnFault(Exception ex) internal sealed class AgentIgnoreSink : IAgentChannelSink { public Task OnDataAsync(byte stream, ReadOnlyMemory data) => Task.CompletedTask; - public void OnControl(AgentMessage msg) { } + public Task OnControlAsync(AgentMessage msg) => Task.CompletedTask; public void OnFault(Exception ex) { } } @@ -687,10 +702,11 @@ internal sealed class AgentRequestResponseSink(TaskCompletionSource complet { public Task OnDataAsync(byte stream, ReadOnlyMemory data) => Task.CompletedTask; - public void OnControl(AgentMessage msg) + public Task OnControlAsync(AgentMessage msg) { if (msg.Msg == "exit_status") completion.TrySetResult(msg.ExitCode); else if (msg.Msg == "error") completion.TrySetException(new TailcatException("upload failed", 0, msg.Message ?? "", MeowshellErrorCodeExtensions.Parse(msg.Code))); + return Task.CompletedTask; } public void OnFault(Exception ex) => completion.TrySetException(ex); @@ -711,7 +727,7 @@ public async Task OnDataAsync(byte stream, ReadOnlyMemory data) if (result.IsCompleted) return; } - public void OnControl(AgentMessage msg) + public Task OnControlAsync(AgentMessage msg) { switch (msg.Msg) { @@ -725,6 +741,7 @@ public void OnControl(AgentMessage msg) _completed.TrySetException(ex); break; } + return Task.CompletedTask; } public void OnFault(Exception ex) @@ -776,7 +793,7 @@ Task IAgentChannelSink.OnDataAsync(byte stream, ReadOnlyMemory data) return pipe.Writer.WriteAsync(data).AsTask(); } - void IAgentChannelSink.OnControl(AgentMessage msg) + Task IAgentChannelSink.OnControlAsync(AgentMessage msg) { switch (msg.Msg) { @@ -792,6 +809,7 @@ void IAgentChannelSink.OnControl(AgentMessage msg) _exitCode.TrySetException(ex); break; } + return Task.CompletedTask; } void IAgentChannelSink.OnFault(Exception ex) diff --git a/dotnet/Meowshell/MeowshellAgentProtocol.cs b/dotnet/Meowshell/MeowshellAgentProtocol.cs index 5c1e473..3e84108 100644 --- a/dotnet/Meowshell/MeowshellAgentProtocol.cs +++ b/dotnet/Meowshell/MeowshellAgentProtocol.cs @@ -25,8 +25,11 @@ internal static class MeowshellAgentProtocol public static async Task WriteFrameAsync(Stream stream, AgentFrame frame, CancellationToken cancellationToken) { - var buf = new byte[4 + FrameHeaderLength + frame.Payload.Length]; - WriteUInt32BigEndian(buf.AsSpan(0, 4), (uint)(FrameHeaderLength + frame.Payload.Length)); + var frameLength = checked(FrameHeaderLength + frame.Payload.Length); + if (frameLength > MaxFrameLength) + throw new TailcatException("meowshell agent protocol error", 0, $"frame length {frameLength} exceeds the {MaxFrameLength} limit"); + var buf = new byte[4 + frameLength]; + WriteUInt32BigEndian(buf.AsSpan(0, 4), (uint)frameLength); buf[4] = frame.Type; WriteUInt32BigEndian(buf.AsSpan(5, 4), frame.ChannelId); frame.Payload.CopyTo(buf.AsSpan(9)); diff --git a/dotnet/Meowshell/TailcatListener.cs b/dotnet/Meowshell/TailcatListener.cs index d1dc0b9..493c13c 100644 --- a/dotnet/Meowshell/TailcatListener.cs +++ b/dotnet/Meowshell/TailcatListener.cs @@ -35,12 +35,8 @@ public static TailcatListener Start(Process process, TimeSpan gracePeriod, Actio { process.EnableRaisingEvents = true; var listener = new TailcatListener(process, gracePeriod); - MeowshellProcessControl.Start(process); - if (OperatingSystem.IsWindows()) - { - listener._job = JobObject.Wrap(process); - } - + // Subscribe before Start: a malformed command can exit quickly enough + // that registering afterwards misses Exited and leaves Completed hung. process.Exited += async (_, _) => { await process.WaitForExitAsync().ConfigureAwait(false); @@ -56,6 +52,11 @@ public static TailcatListener Start(Process process, TimeSpan gracePeriod, Actio listener.Log?.Invoke(e.Data); onLog?.Invoke(e.Data); }; + MeowshellProcessControl.Start(process); + if (OperatingSystem.IsWindows()) + { + listener._job = JobObject.Wrap(process); + } process.BeginOutputReadLine(); process.BeginErrorReadLine(); return listener; diff --git a/go.mod b/go.mod index 3f02192..adc125a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.1 require ( github.com/pkg/sftp v1.13.6 github.com/tailscale/tailcat v0.6.0 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 golang.org/x/net v0.58.0 golang.org/x/term v0.45.0 tailscale.com v1.103.0-pre.0.20260904030409-31d8badb3bfb diff --git a/go.sum b/go.sum index f4d2ca4..e4e1b0d 100644 --- a/go.sum +++ b/go.sum @@ -199,6 +199,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= diff --git a/scripts/CommentStripper/CommentStripper.csproj b/scripts/CommentStripper/CommentStripper.csproj index 707e6f5..bd7cad1 100644 --- a/scripts/CommentStripper/CommentStripper.csproj +++ b/scripts/CommentStripper/CommentStripper.csproj @@ -5,6 +5,10 @@ net8.0 enable enable + true + all + low + $(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904 diff --git a/tailcat.ref b/tailcat.ref new file mode 100644 index 0000000..cb84349 --- /dev/null +++ b/tailcat.ref @@ -0,0 +1 @@ +91dc4979bd4ae88af6ae2c8bb549616de4bcaa5a