Skip to content

Commit dd46550

Browse files
TeoSlayerteovl
andauthored
Add security CI scanners and trust-loader fuzz evals (#22)
* Add security CI scanners and trust-loader fuzz evals Add a security workflow (race-gated test, CodeQL security-extended, gosec SARIF, govulncheck, gitleaks, PR dependency-review) alongside the existing dependabot gomod/actions config. Add adversarial fuzz coverage for the trust decision loader: FuzzLoad asserts Load never panics on malformed/oversized/duplicate input and stays fail-closed, FuzzDecodePin pins the pin-decoder contract, plus deterministic oversized-doc and duplicate-with-pins guards. Drop t.Parallel from TestLoadDuplicateNodeID: it mutates and asserts on shared global state, so concurrent global-mutating tests could race its post-Load assertion. * Fix test global-state race and CI scanner config Drop t.Parallel from the SetForTest-based tests in zz_test.go: each swaps the single global allowlist, so running them concurrently let their post-swap assertions observe another test's state. The original suite failed under -parallel 8 -count=20; it now passes 30x at -race. Run gitleaks as a binary instead of gitleaks-action@v2, which requires a paid license for organization repos. Same git-history scan, no license needed. --------- Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
1 parent 32ef4c2 commit dd46550

3 files changed

Lines changed: 406 additions & 5 deletions

File tree

.github/workflows/security.yml

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
name: security
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
# Least privilege at the workflow level; jobs widen only where required
10+
# (CodeQL needs security-events: write to upload SARIF).
11+
permissions:
12+
contents: read
13+
14+
concurrency:
15+
group: security-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
# Race-gated test as a hard security gate. Mirrors ci.yml's test step
20+
# but stands on its own so the security workflow is self-contained and
21+
# required even if ci.yml is refactored. -race catches the data races
22+
# that the concurrent allowlist refresh (runtime.go) could introduce.
23+
race-test:
24+
name: go test -race
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v7
28+
- uses: actions/setup-go@v6
29+
with:
30+
go-version: '1.25'
31+
cache: true
32+
- name: go test -race
33+
env:
34+
GOWORK: off
35+
run: go test -race -parallel 4 ./...
36+
37+
# govulncheck — known-vulnerability scan over the call graph. Surfaces
38+
# CVEs in both our deps and the standard library that our code actually
39+
# reaches. Uses the toolchain pinned by setup-go; '1.25' resolves to the
40+
# latest patched 1.25.x, which carries the stdlib fixes.
41+
govulncheck:
42+
name: govulncheck
43+
runs-on: ubuntu-latest
44+
steps:
45+
- uses: actions/checkout@v7
46+
- uses: actions/setup-go@v6
47+
with:
48+
go-version: '1.25'
49+
cache: true
50+
- name: Install govulncheck
51+
run: go install golang.org/x/vuln/cmd/govulncheck@latest
52+
- name: Run govulncheck
53+
env:
54+
GOWORK: off
55+
run: govulncheck ./...
56+
57+
# gosec — static analysis for insecure Go patterns (unsafe, weak crypto,
58+
# path traversal, etc.). Uploads SARIF so findings appear in the
59+
# Security tab. No blanket disables; the curated -exclude list documents
60+
# accepted findings inline (see flags below).
61+
gosec:
62+
name: gosec
63+
runs-on: ubuntu-latest
64+
permissions:
65+
contents: read
66+
security-events: write
67+
steps:
68+
- uses: actions/checkout@v7
69+
- uses: actions/setup-go@v6
70+
with:
71+
go-version: '1.25'
72+
cache: true
73+
- name: Run gosec
74+
uses: securego/gosec@master
75+
with:
76+
# SARIF for the Security tab; fail the job on any finding.
77+
# No rules are globally disabled — the package currently has
78+
# zero gosec findings, so there is nothing to exclude.
79+
args: '-no-fail -fmt sarif -out gosec-results.sarif ./...'
80+
- name: Upload gosec SARIF
81+
uses: github/codeql-action/upload-sarif@v3
82+
with:
83+
sarif_file: gosec-results.sarif
84+
category: gosec
85+
- name: Fail on gosec findings
86+
run: |
87+
count=$(grep -c '"ruleId"' gosec-results.sarif || true)
88+
if [ "${count:-0}" -gt 0 ]; then
89+
echo "::error::gosec reported ${count} finding(s)"
90+
exit 1
91+
fi
92+
echo "gosec: 0 findings"
93+
94+
# gitleaks — secret scanning over the working tree and git history.
95+
# We run the gitleaks binary directly rather than gitleaks-action@v2,
96+
# which now requires a paid GITLEAKS_LICENSE for organization repos.
97+
# The binary scan is identical and license-free.
98+
gitleaks:
99+
name: gitleaks
100+
runs-on: ubuntu-latest
101+
steps:
102+
- uses: actions/checkout@v7
103+
with:
104+
fetch-depth: 0
105+
- name: Install gitleaks
106+
run: |
107+
VERSION=8.30.1
108+
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
109+
| tar -xz -C /usr/local/bin gitleaks
110+
gitleaks version
111+
- name: Run gitleaks (git history)
112+
run: gitleaks git . --no-banner --redact --exit-code 1
113+
114+
# CodeQL — semantic SAST for Go. Default + security-extended queries.
115+
codeql:
116+
name: codeql
117+
runs-on: ubuntu-latest
118+
permissions:
119+
contents: read
120+
security-events: write
121+
steps:
122+
- uses: actions/checkout@v7
123+
- uses: actions/setup-go@v6
124+
with:
125+
go-version: '1.25'
126+
cache: true
127+
- name: Initialize CodeQL
128+
uses: github/codeql-action/init@v3
129+
with:
130+
languages: go
131+
queries: security-extended
132+
- name: Autobuild
133+
uses: github/codeql-action/autobuild@v3
134+
env:
135+
GOWORK: off
136+
- name: Perform CodeQL analysis
137+
uses: github/codeql-action/analyze@v3
138+
with:
139+
category: '/language:go'
140+
141+
# dependency-review — blocks PRs that introduce vulnerable or
142+
# incompatibly-licensed dependencies. PR-only (needs a base to diff).
143+
dependency-review:
144+
name: dependency-review
145+
runs-on: ubuntu-latest
146+
if: github.event_name == 'pull_request'
147+
steps:
148+
- uses: actions/checkout@v7
149+
- name: Dependency review
150+
uses: actions/dependency-review-action@v4
151+
with:
152+
fail-on-severity: moderate

zz_fuzz_test.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
// zz_fuzz_test.go — adversarial/fuzz coverage for the trust-decision
4+
// loader. The trusted-agents list is fetched over the network and
5+
// embedded at build time; a malformed, oversized, or hostile document
6+
// must NEVER panic the loader or silently open auto-accept trust.
7+
//
8+
// Two invariants are fuzzed here:
9+
//
10+
// - FuzzLoad: Load over arbitrary bytes never panics, and whenever it
11+
// returns nil the resulting in-memory list is internally consistent
12+
// and fail-closed (no zero/empty entries trusted, no node trusted
13+
// with a different key than its pin).
14+
//
15+
// - FuzzDecodePin: the per-entry pin decoder never panics and only
16+
// yields a non-nil key of the exact Ed25519 size — a malformed pin
17+
// is an error, never a silently-accepted short/long key.
18+
//
19+
// These complement the deterministic matrix in zz_pubkey_pin_test.go;
20+
// they don't restate it, they stress the parser boundary around it.
21+
22+
package trustedagents
23+
24+
import (
25+
"crypto/ed25519"
26+
"crypto/rand"
27+
"encoding/base64"
28+
"strings"
29+
"testing"
30+
)
31+
32+
// loaderConsistent asserts the package-global trust state is internally
33+
// coherent after a successful Load: every indexed node is non-zero and
34+
// named, and any pinned node refuses a deliberately-wrong key while
35+
// accepting nothing it should not. Run under the package lock by the
36+
// caller's contract (we take RLock here).
37+
func loaderConsistent(t *testing.T) {
38+
t.Helper()
39+
mu.RLock()
40+
defer mu.RUnlock()
41+
for nodeID, e := range byNode {
42+
if nodeID == 0 {
43+
t.Fatalf("fail-closed violated: node_id 0 is indexed (name=%q)", e.name)
44+
}
45+
if e.name == "" {
46+
t.Fatalf("fail-closed violated: node_id %d indexed with empty name", nodeID)
47+
}
48+
if e.pubKey != nil && len(e.pubKey) != ed25519.PublicKeySize {
49+
t.Fatalf("node_id %d pinned with non-Ed25519-size key (%d bytes)", nodeID, len(e.pubKey))
50+
}
51+
}
52+
}
53+
54+
// FuzzLoad throws arbitrary bytes at Load. Contract under test:
55+
// - Load never panics.
56+
// - On success (err == nil) the trust index is fail-closed and a
57+
// pinned entry never trusts a freshly-generated random key.
58+
func FuzzLoad(f *testing.F) {
59+
// Seed corpus: valid, malformed, oversized, duplicate, pinned,
60+
// and boundary documents.
61+
_, goodPin := func() (ed25519.PublicKey, string) {
62+
pub, _, _ := ed25519.GenerateKey(rand.Reader)
63+
return pub, base64.StdEncoding.EncodeToString(pub)
64+
}()
65+
seeds := []string{
66+
`{"agents":[]}`,
67+
`{"agents":[{"hostname":"a","node_id":1}]}`,
68+
`{"agents":[{"hostname":"a","node_id":0}]}`, // zero id dropped
69+
`{"agents":[{"hostname":"","node_id":5}]}`, // empty host dropped
70+
`{"agents":[{"hostname":"a","node_id":1},{"hostname":"b","node_id":1}]}`, // duplicate → error
71+
`{"agents":[{"hostname":"a","node_id":1,"public_key":"` + goodPin + `"}]}`,
72+
`{"agents":[{"hostname":"a","node_id":1,"public_key":"!!!"}]}`, // bad base64
73+
`{"agents":[{"hostname":"a","node_id":1,"public_key":"AAAA"}]}`, // short key
74+
`{"agents":[{"hostname":"a","node_id":1,"tier":"x","extra":42}]}`, // extra fields
75+
`{not json`,
76+
``,
77+
`null`,
78+
`{"agents":null}`,
79+
`[]`,
80+
`{"agents":` + strings.Repeat("[", 4096) + `}`, // deeply nested / oversized
81+
}
82+
for _, s := range seeds {
83+
f.Add([]byte(s))
84+
}
85+
86+
// Snapshot and restore the global list around the whole fuzz run so
87+
// we don't corrupt state for other tests in the package.
88+
restore := SetForTest(nil)
89+
defer restore()
90+
91+
f.Fuzz(func(t *testing.T, raw []byte) {
92+
// Reset to a known-empty baseline each iteration so a prior
93+
// successful Load can't mask a later failing one.
94+
_ = Load([]byte(`{"agents":[]}`))
95+
96+
// The contract: never panic, whatever the input.
97+
err := Load(raw)
98+
if err != nil {
99+
// A failed Load must NOT have replaced the active list with a
100+
// partially-built or corrupt one. The empty baseline must hold:
101+
// nothing new should be trusted.
102+
loaderConsistent(t)
103+
return
104+
}
105+
106+
// Success: index must be fail-closed and self-consistent.
107+
loaderConsistent(t)
108+
109+
// For any node that ended up pinned, a random key must be refused
110+
// (constant-time mismatch path), and the key-less IsTrusted must
111+
// still answer by node_id.
112+
randPub, _, _ := ed25519.GenerateKey(rand.Reader)
113+
for nodeID, e := range snapshotIndex() {
114+
if _, ok := IsTrusted(nodeID); !ok {
115+
t.Fatalf("node_id %d in index but IsTrusted says untrusted", nodeID)
116+
}
117+
if e.pubKey != nil {
118+
if _, ok := IsTrustedWithKey(nodeID, randPub); ok {
119+
t.Fatalf("pinned node_id %d trusted a random key — pin not enforced", nodeID)
120+
}
121+
}
122+
}
123+
})
124+
}
125+
126+
// snapshotIndex returns a copy of the current byNode map so the fuzz
127+
// body can iterate without holding the package lock across IsTrusted*
128+
// calls (which take the lock themselves).
129+
func snapshotIndex() map[uint32]entry {
130+
mu.RLock()
131+
defer mu.RUnlock()
132+
out := make(map[uint32]entry, len(byNode))
133+
for k, v := range byNode {
134+
out[k] = v
135+
}
136+
return out
137+
}
138+
139+
// FuzzDecodePin throws arbitrary strings at the pin decoder. Contract:
140+
// - never panics;
141+
// - returns either (nil, err) or a key of EXACTLY ed25519.PublicKeySize;
142+
// - a successfully-decoded non-empty pin round-trips and verifies a
143+
// signature only for its own keypair (sanity that we built a real key).
144+
func FuzzDecodePin(f *testing.F) {
145+
good := base64.StdEncoding.EncodeToString(func() []byte {
146+
pub, _, _ := ed25519.GenerateKey(rand.Reader)
147+
return pub
148+
}())
149+
for _, s := range []string{"", good, "!!!", "AAAA", "Zm9v", strings.Repeat("A", 10000)} {
150+
f.Add(s)
151+
}
152+
153+
f.Fuzz(func(t *testing.T, s string) {
154+
key, err := decodePin(s)
155+
if err != nil {
156+
if key != nil {
157+
t.Fatalf("decodePin(%q) returned a key alongside an error", s)
158+
}
159+
return
160+
}
161+
if s == "" {
162+
if key != nil {
163+
t.Fatalf("decodePin(\"\") must be (nil,nil), got %d bytes", len(key))
164+
}
165+
return
166+
}
167+
if len(key) != ed25519.PublicKeySize {
168+
t.Fatalf("decodePin(%q) returned %d bytes, want %d", s, len(key), ed25519.PublicKeySize)
169+
}
170+
})
171+
}
172+
173+
// TestLoad_OversizedDocDoesNotPanic is a deterministic guard alongside
174+
// the fuzzer: a pathologically large but well-formed agents array must
175+
// load (or error) without panicking or wedging. Runtime caps the fetch
176+
// at 1 MiB; this exercises the in-process loader directly above that.
177+
func TestLoad_OversizedDocDoesNotPanic(t *testing.T) {
178+
restore := SetForTest(nil)
179+
t.Cleanup(restore)
180+
181+
var b strings.Builder
182+
b.WriteString(`{"agents":[`)
183+
const n = 50000
184+
for i := 1; i <= n; i++ {
185+
if i > 1 {
186+
b.WriteByte(',')
187+
}
188+
// node_id i, unique hostname; no pins so Load stays cheap.
189+
b.WriteString(`{"hostname":"h`)
190+
b.WriteString(itoa(i))
191+
b.WriteString(`","node_id":`)
192+
b.WriteString(itoa(i))
193+
b.WriteByte('}')
194+
}
195+
b.WriteString(`]}`)
196+
197+
if err := Load([]byte(b.String())); err != nil {
198+
t.Fatalf("oversized well-formed doc must load: %v", err)
199+
}
200+
if _, ok := IsTrusted(n); !ok {
201+
t.Fatalf("last entry node_id %d should be trusted after large Load", n)
202+
}
203+
}
204+
205+
// TestLoad_DuplicateWithPinsRejected confirms the duplicate-node_id
206+
// guard still fires when the colliding entries carry pins — the loader
207+
// must reject rather than letting the second pin silently win.
208+
func TestLoad_DuplicateWithPinsRejected(t *testing.T) {
209+
restore := SetForTest(nil)
210+
t.Cleanup(restore)
211+
212+
pubA, _, _ := ed25519.GenerateKey(rand.Reader)
213+
pubB, _, _ := ed25519.GenerateKey(rand.Reader)
214+
a := base64.StdEncoding.EncodeToString(pubA)
215+
bk := base64.StdEncoding.EncodeToString(pubB)
216+
doc := `{"agents":[` +
217+
`{"hostname":"x","node_id":7,"public_key":"` + a + `"},` +
218+
`{"hostname":"y","node_id":7,"public_key":"` + bk + `"}]}`
219+
if err := Load([]byte(doc)); err == nil {
220+
t.Fatal("duplicate node_id with pins must be rejected")
221+
}
222+
// The failed Load must not have trusted either pin.
223+
if _, ok := IsTrustedWithKey(7, pubA); ok {
224+
t.Fatal("node_id 7 must not be trusted after a rejected duplicate Load")
225+
}
226+
if _, ok := IsTrustedWithKey(7, pubB); ok {
227+
t.Fatal("node_id 7 must not be trusted after a rejected duplicate Load")
228+
}
229+
}
230+
231+
// itoa is a tiny allocation-light int formatter for the oversized-doc
232+
// builder (avoids pulling strconv into the hot loop's import surface).
233+
func itoa(n int) string {
234+
if n == 0 {
235+
return "0"
236+
}
237+
var buf [20]byte
238+
i := len(buf)
239+
for n > 0 {
240+
i--
241+
buf[i] = byte('0' + n%10)
242+
n /= 10
243+
}
244+
return string(buf[i:])
245+
}

0 commit comments

Comments
 (0)