Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ jobs:

- name: Run tests with coverage
working-directory: rendezvous
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
# -parallel 4 caps concurrent test parallelism: unbounded parallelism
# exhausts ephemeral ports/sockets in the registry's integration tests
# and causes spurious dial timeouts (project convention).
run: go test -race -parallel 4 -coverprofile=coverage.out -covermode=atomic ./...

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: dependency-review

# Reviews a PR's dependency changes for known vulnerabilities / disallowed
# licenses. Complements .github/dependabot.yml (which proposes upgrades
# weekly) by reviewing the inbound side.
#
# NOTE: actions/dependency-review-action requires the repository's Dependency
# Graph to be enabled (Settings -> Security -> Dependency graph; on private
# org repos this needs GitHub Advanced Security). Until that one-click setting
# is on, the action errors with "Dependency review is not supported on this
# repository". The step is marked continue-on-error so this prerequisite does
# not turn CI red; once the graph is enabled, flip continue-on-error to false
# (or drop it) and add this job to the required status checks to make it gate.

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Dependency Review
continue-on-error: true
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
with:
fail-on-severity: high
128 changes: 128 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: security

# Security CI baseline for the rendezvous registry. Gating jobs:
# codeql — Go static analysis (security-and-quality)
# gosec — Go SAST (HIGH/MEDIUM severity; accepted rule classes
# excluded via .gosec.json, justified in that file)
# govulncheck — known-vuln scan of the call graph (stdlib + deps)
# gitleaks — secret scan over the diff/history
# Each runs on push to main and on every PR targeting main.

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
codeql:
name: CodeQL (Go)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
with:
go-version: '1.25.11'
cache: true

- name: Initialize CodeQL
uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
with:
languages: go
queries: security-and-quality

- name: Build
env:
GOWORK: off
run: go build ./...

- name: Analyze
uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
with:
category: /language:go

gosec:
name: gosec SAST
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
with:
go-version: '1.25.11'
cache: true

- name: Run gosec
env:
GOWORK: off
# Gate on HIGH and MEDIUM severity at MEDIUM+ confidence. Accepted
# rule classes (length-conversion overflows, operator-config file
# reads, 0644 registry persistence) are excluded in .gosec.json with
# per-rule justification; nothing is blanket-disabled.
run: |
go run github.com/securego/gosec/v2/cmd/gosec@v2.22.9 \
-conf .gosec.json \
-severity medium \
-confidence medium \
./...

govulncheck:
name: govulncheck
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
with:
go-version: '1.25.11'
cache: true

- name: Run govulncheck
env:
GOWORK: off
run: |
go run golang.org/x/vuln/cmd/govulncheck@latest ./...

gitleaks:
name: gitleaks secret scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

# The gitleaks GitHub Action (gitleaks/gitleaks-action) requires a paid
# GITLEAKS_LICENSE secret for ORGANIZATION repos. The gitleaks binary
# itself is MIT-licensed and free, so we run a version-pinned binary
# release directly — same scan, no license gate.
- name: Install gitleaks
env:
GITLEAKS_VERSION: "8.30.1"
run: |
set -euo pipefail
curl -sSL -o /tmp/gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
sudo install /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version

- name: Run gitleaks (full history)
run: |
gitleaks git --no-banner --redact --verbose .
12 changes: 12 additions & 0 deletions .gosec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"//": "gosec configuration for the rendezvous registry. The `global.exclude` list disables specific rule classes that produce only accepted findings in this codebase. Each exclusion is justified below; no finding is silenced with an inline #nosec annotation, and nothing is blanket-disabled. Genuine SAST findings (anything outside these classes) still fail the gosec job.",
"global": {
"//audit": "true",
"exclude": "G104,G115,G304,G306,G114",
"G104": "Unhandled errors. All sites are best-effort socket tuning / cleanup on already-terminating connections: conn.Close() in defers, SetReadBuffer/SetWriteBuffer, SetReadDeadline, and best-effort error frames on a connection about to be dropped. Handling these errors changes no control flow.",
"G115": "Integer overflow on conversion. Every flagged site converts a length or bounded count to a fixed-width wire field: uint32(len(body)) for frames capped at wire.MaxMessageSize (64KB) and uint16/uint32(netID) from JSON-decoded values. The source values cannot reach the overflow range.",
"G304": "File inclusion via variable. Every flagged path is an operator-supplied config/WAL/whitelist file path (registry.json, audit WAL, breaker/whitelist watcher targets, snapshot files) — these are intended to be read from operator-controlled locations, not attacker-controlled input.",
"G306": "WriteFile permission > 0600. The registry persistence file (registry.json) and the dashboard snapshot are written 0644 by design so an operator (non-root) can read state for diagnostics; they contain no secrets.",
"G114": "http.Serve without timeouts on the dashboard listener. The dashboard binds an internal/admin interface; request timeouts are enforced at the reverse proxy / breaker layer rather than the raw Serve call."
}
}
5 changes: 4 additions & 1 deletion dashboard/zz_public_stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ func TestPublicStats_BuildInfoOmittedWhenAbsent(t *testing.T) {
mux.ServeHTTP(w, r)
}))
defer srv.Close()
resp, _ := http.Get(srv.URL + "/api/public-stats")
resp, err := http.Get(srv.URL + "/api/public-stats")
if err != nil {
t.Fatalf("GET public-stats: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var payload map[string]interface{}
Expand Down
89 changes: 89 additions & 0 deletions directory/zz_binary_lookup_redaction_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package directory

import (
"net"
"testing"
"time"

"github.com/pilot-protocol/common/crypto"
"github.com/pilot-protocol/common/registry/wire"
)

// TestHandleBinaryLookup_DoesNotLeakExternalID is the privacy regression guard
// for the BINARY lookup path. The JSON HandleLookup path is covered by
// TestHandleLookupRedactsExternalIDSurfacesBadge; this asserts the same
// invariant on the binary wire path, which encodes its own LookupResp frame
// (and could regress independently if a refactor passed node.ExternalID into
// the last EncodeLookupResp argument). The decoded response must carry an
// EMPTY external_id even though the node holds a raw external identity.
func TestHandleBinaryLookup_DoesNotLeakExternalID(t *testing.T) {
t.Parallel()
st := newTestStore(t)
id, err := crypto.GenerateIdentity()
if err != nil {
t.Fatal(err)
}
const nodeID uint32 = 0x2BEEF
st.mu.Lock()
st.nodes[nodeID] = &NodeInfo{
ID: nodeID,
PublicKey: id.PublicKey,
Public: true,
RealAddr: "9.9.9.9:4000",
ExternalID: "github|secret-handle", // must NEVER be surfaced
Badge: "pilotbadge:v1:1:github:1700000000:0:bdg-v1:",
BadgeSig: "sig",
}
st.mu.Unlock()

srv, cli := net.Pipe()
defer srv.Close()
defer cli.Close()

// Read the response frame on the client side concurrently with the handler
// writing it.
type result struct {
res wire.LookupResult
err error
}
resCh := make(chan result, 1)
go func() {
_ = cli.SetReadDeadline(time.Now().Add(2 * time.Second))
msgType, payload, err := wire.ReadFrame(cli)
if err != nil {
resCh <- result{err: err}
return
}
if msgType != wire.MsgLookupOK {
resCh <- result{err: errUnexpectedFrame(msgType)}
return
}
r, derr := wire.DecodeLookupResp(payload)
resCh <- result{res: r, err: derr}
}()

st.HandleBinaryLookup(srv, wire.EncodeLookupReq(nodeID))

select {
case got := <-resCh:
if got.err != nil {
t.Fatalf("read/decode lookup response: %v", got.err)
}
if got.res.ExternalID != "" {
t.Fatalf("binary lookup leaked external_id: %q", got.res.ExternalID)
}
if got.res.NodeID != nodeID {
t.Fatalf("wrong node in response: got %d want %d", got.res.NodeID, nodeID)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for binary lookup response")
}
}

type unexpectedFrame byte

func (u unexpectedFrame) Error() string { return "unexpected frame type" }

func errUnexpectedFrame(b byte) error { return unexpectedFrame(b) }
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module github.com/pilot-protocol/rendezvous

go 1.25.10

toolchain go1.25.11

require github.com/pilot-protocol/common v0.5.6

require (
Expand Down
Loading
Loading