Skip to content

Commit f2d23e1

Browse files
committed
ci: add vulnerability and SAST scanning to CI/CD
Add govulncheck, Trivy image scanning, CodeQL and Semgrep to the pipeline to satisfy CRA's "no known exploitable vulnerabilities" requirement. Scans block on findings a PR introduces (baseline + block-new): pre-existing/unfixable CVEs are tracked by a weekly full scan rather than red-walling unrelated PRs. - security.yml: govulncheck over all 8 module roots (build tags for agent/ssh) via a unit-tested allowlist wrapper, plus Trivy image scans for every shipped image; a single always-running security-gate aggregator is the one stable required-check name (no draft/fork pending-check trap). - codeql.yml / semgrep.yml: per-module CodeQL with non-empty-DB assertions, Semgrep with --baseline-ref; both fail only on new alerts. - docker-publish.yml: scan-before-push, digest-identical (nothing vulnerable reaches a registry). build-agent.yml gates the multi-arch push on an amd64 Trivy scan. - gosec: reduce exclusions to G104 only (errcheck-redundant); review the G301/G302/G304 sites, hardening gateway dir perms to 0o750 and annotating trusted-source/test-fixture/utmp cases. gateway is now linted (added to qa.yml with its own config). - Baseline files (.trivyignore, .govulncheck-allow.txt, .semgrepignore) + CODEOWNERS, and SECURITY-SCANNING.md documenting the required check and the non-blocking-first rollout. Fixes: shellhub-io/team#99
1 parent 706a0d8 commit f2d23e1

34 files changed

Lines changed: 1720 additions & 99 deletions

.github/CODEOWNERS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@
22
api/ @shellhub-io/backend-owners
33
ssh/ @shellhub-io/backend-owners
44
ui/ @shellhub-io/frontend-owners
5+
6+
# Security allowlist and scanner ignore files — reviewed by team lead.
7+
.trivyignore @shellhub-io/team-lead
8+
.govulncheck-allow.txt @shellhub-io/team-lead
9+
.semgrepignore @shellhub-io/team-lead

.github/go.mod

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module github.com/shellhub-io/shellhub/.github
2+
3+
go 1.25.8
4+
5+
require github.com/stretchr/testify v1.11.1
6+
7+
require (
8+
github.com/davecgh/go-spew v1.1.1 // indirect
9+
github.com/pmezard/go-difflib v1.0.0 // indirect
10+
gopkg.in/yaml.v3 v3.0.1 // indirect
11+
)

.github/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
7+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

.github/govulncheck/cmd/main.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Command govulncheck-wrapper wraps govulncheck JSON output, applies the
2+
// project allowlist, writes suppression notes to GITHUB_STEP_SUMMARY, and
3+
// exits non-zero on unsuppressed findings or stale allowlist entries.
4+
//
5+
// Usage:
6+
//
7+
// govulncheck -json ./... | govulncheck-wrapper <allowlist-path>
8+
package main
9+
10+
import (
11+
"fmt"
12+
"os"
13+
14+
"github.com/shellhub-io/shellhub/.github/govulncheck"
15+
)
16+
17+
func main() {
18+
if len(os.Args) != 2 {
19+
fmt.Fprintf(os.Stderr, "usage: govulncheck-wrapper <allowlist-path>\n")
20+
os.Exit(2)
21+
}
22+
23+
if err := govulncheck.Run(os.Stdin, os.Args[1], nil); err != nil {
24+
fmt.Fprintf(os.Stderr, "%s\n", err)
25+
os.Exit(1)
26+
}
27+
}

.github/govulncheck/govulncheck.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// Package govulncheck provides a wrapper around the govulncheck tool that
2+
// parses its JSON output, applies an allowlist to suppress known vulnerabilities,
3+
// logs each suppression to GITHUB_STEP_SUMMARY, and fails on stale allowlist entries.
4+
package govulncheck
5+
6+
import (
7+
"bufio"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"os"
12+
"strings"
13+
)
14+
15+
// Reachability describes how a vulnerable symbol is reached.
16+
type Reachability string
17+
18+
const (
19+
// ReachabilityCalled means the vulnerable function is in the call graph.
20+
ReachabilityCalled Reachability = "called"
21+
// ReachabilityImported means the vulnerable package is imported but the
22+
// vulnerable function itself is not called.
23+
ReachabilityImported Reachability = "imported"
24+
)
25+
26+
// Finding is a single vulnerability finding emitted by govulncheck.
27+
type Finding struct {
28+
// OSVID is the Go vulnerability database identifier, e.g. "GO-2024-0001".
29+
OSVID string
30+
// Called is true when the vulnerable function appears in the call graph
31+
// (i.e. the trace contains at least one frame with a function name).
32+
// False means the vulnerable package is imported but the vulnerable
33+
// function is not reachable.
34+
Called bool
35+
}
36+
37+
// AllowEntry is one line from .govulncheck-allow.txt.
38+
type AllowEntry struct {
39+
// OSVID is the Go vulnerability database identifier.
40+
OSVID string
41+
// Reachability is the kind of finding this entry suppresses.
42+
Reachability Reachability
43+
}
44+
45+
// govulncheckMessage is the top-level JSON object in govulncheck -json output.
46+
// Only the fields relevant to this wrapper are decoded.
47+
type govulncheckMessage struct {
48+
Finding *govulncheckFinding `json:"finding"`
49+
}
50+
51+
// govulncheckFinding is the "finding" field of a govulncheck JSON message.
52+
type govulncheckFinding struct {
53+
OSV string `json:"osv"`
54+
Trace []govulncheckFrame `json:"trace"`
55+
}
56+
57+
// govulncheckFrame is one entry in the trace.
58+
// When Function is non-empty the vulnerable symbol is in the call graph.
59+
type govulncheckFrame struct {
60+
Function string `json:"function"`
61+
}
62+
63+
// ParseFindings reads the newline-delimited JSON produced by
64+
// `govulncheck -json` and returns all vulnerability findings.
65+
func ParseFindings(r io.Reader) ([]Finding, error) {
66+
var findings []Finding
67+
68+
scanner := bufio.NewScanner(r)
69+
70+
for scanner.Scan() {
71+
line := strings.TrimSpace(scanner.Text())
72+
if line == "" {
73+
continue
74+
}
75+
76+
var msg govulncheckMessage
77+
78+
if err := json.Unmarshal([]byte(line), &msg); err != nil {
79+
return nil, fmt.Errorf("govulncheck: failed to parse JSON line: %w", err)
80+
}
81+
82+
if msg.Finding == nil {
83+
continue
84+
}
85+
86+
f := Finding{
87+
OSVID: msg.Finding.OSV,
88+
Called: isCalled(msg.Finding.Trace),
89+
}
90+
91+
findings = append(findings, f)
92+
}
93+
94+
if err := scanner.Err(); err != nil {
95+
return nil, fmt.Errorf("govulncheck: error reading input: %w", err)
96+
}
97+
98+
return findings, nil
99+
}
100+
101+
// isCalled returns true when any frame in trace contains a non-empty Function.
102+
func isCalled(trace []govulncheckFrame) bool {
103+
for _, frame := range trace {
104+
if frame.Function != "" {
105+
return true
106+
}
107+
}
108+
109+
return false
110+
}
111+
112+
// LoadAllowList reads an allowlist file in which each non-comment, non-blank
113+
// line has one of the following formats:
114+
//
115+
// <OSV-ID> <reachability>
116+
// <OSV-ID> reachability=<reachability> # <owner> — <justification>
117+
//
118+
// where <reachability> is either "called" or "imported".
119+
// Lines beginning with '#' and inline comments (text after ' #') are ignored.
120+
func LoadAllowList(path string) ([]AllowEntry, error) {
121+
f, err := os.Open(path) //nolint:gosec
122+
if err != nil {
123+
return nil, fmt.Errorf("govulncheck: failed to open allow list %q: %w", path, err)
124+
}
125+
126+
defer f.Close() //nolint:errcheck
127+
128+
var entries []AllowEntry
129+
130+
scanner := bufio.NewScanner(f)
131+
132+
for scanner.Scan() {
133+
line := strings.TrimSpace(scanner.Text())
134+
135+
if line == "" || strings.HasPrefix(line, "#") {
136+
continue
137+
}
138+
139+
// Strip inline comments: anything from " #" onward.
140+
if idx := strings.Index(line, " #"); idx >= 0 {
141+
line = strings.TrimSpace(line[:idx])
142+
}
143+
144+
if line == "" {
145+
continue
146+
}
147+
148+
parts := strings.Fields(line)
149+
if len(parts) < 2 {
150+
return nil, fmt.Errorf("govulncheck: invalid allow list line %q: expected \"<OSV-ID> <reachability>\"", line)
151+
}
152+
153+
// Support both "called" and "reachability=called" forms.
154+
rawReachability := strings.TrimPrefix(parts[1], "reachability=")
155+
156+
r := Reachability(rawReachability)
157+
if r != ReachabilityCalled && r != ReachabilityImported {
158+
return nil, fmt.Errorf("govulncheck: unknown reachability %q in allow list", rawReachability)
159+
}
160+
161+
entries = append(entries, AllowEntry{
162+
OSVID: parts[0],
163+
Reachability: r,
164+
})
165+
}
166+
167+
if err := scanner.Err(); err != nil {
168+
return nil, fmt.Errorf("govulncheck: error reading allow list: %w", err)
169+
}
170+
171+
return entries, nil
172+
}
173+
174+
// CheckFindings applies the allowlist to the findings:
175+
// - A finding is suppressed when the allowlist contains an entry whose OSV ID
176+
// and reachability both match.
177+
// - Each suppression is written to summaryWriter (for GITHUB_STEP_SUMMARY).
178+
// - A stale allowlist entry (one that matches no finding) causes an error.
179+
// - Any unsuppressed finding causes an error.
180+
//
181+
// summaryWriter may be nil; in that case no summary output is written.
182+
func CheckFindings(findings []Finding, allow []AllowEntry, summaryWriter io.Writer) error {
183+
// usedEntries tracks which allow entries actually matched a finding.
184+
usedEntries := make(map[int]bool)
185+
186+
var unsuppressed []Finding
187+
188+
for _, finding := range findings {
189+
suppressed := false
190+
191+
for idx, entry := range allow {
192+
if entry.OSVID != finding.OSVID {
193+
continue
194+
}
195+
196+
if entry.Reachability != reachabilityOf(finding) {
197+
continue
198+
}
199+
200+
suppressed = true
201+
usedEntries[idx] = true
202+
203+
writeSuppression(summaryWriter, finding, entry)
204+
205+
break
206+
}
207+
208+
if !suppressed {
209+
unsuppressed = append(unsuppressed, finding)
210+
}
211+
}
212+
213+
var errs []string
214+
215+
if len(unsuppressed) > 0 {
216+
for _, f := range unsuppressed {
217+
errs = append(errs, fmt.Sprintf("unsuppressed vulnerability: %s (%s)", f.OSVID, reachabilityOf(f)))
218+
}
219+
}
220+
221+
for idx, entry := range allow {
222+
if !usedEntries[idx] {
223+
errs = append(errs, fmt.Sprintf("stale allow list entry: %s %s (no matching finding)", entry.OSVID, entry.Reachability))
224+
}
225+
}
226+
227+
if len(errs) > 0 {
228+
return fmt.Errorf("govulncheck: %s", strings.Join(errs, "; "))
229+
}
230+
231+
return nil
232+
}
233+
234+
// reachabilityOf converts a Finding's Called flag into a Reachability value.
235+
func reachabilityOf(f Finding) Reachability {
236+
if f.Called {
237+
return ReachabilityCalled
238+
}
239+
240+
return ReachabilityImported
241+
}
242+
243+
// writeSuppression appends a suppression note to w.
244+
func writeSuppression(w io.Writer, f Finding, entry AllowEntry) {
245+
if w == nil {
246+
return
247+
}
248+
249+
fmt.Fprintf(w, "suppressed: %s (%s) — matched allow entry %s %s\n",
250+
f.OSVID, reachabilityOf(f), entry.OSVID, entry.Reachability)
251+
}
252+
253+
// Run is the CLI entry-point: it reads govulncheck JSON from r, loads the
254+
// allowlist from allowListPath, and reports results to summaryWriter.
255+
//
256+
// summaryWriter defaults to the file pointed at by GITHUB_STEP_SUMMARY when nil.
257+
func Run(r io.Reader, allowListPath string, summaryWriter io.Writer) error {
258+
if summaryWriter == nil {
259+
if path := os.Getenv("GITHUB_STEP_SUMMARY"); path != "" {
260+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) //nolint:gosec
261+
if err == nil {
262+
defer f.Close() //nolint:errcheck
263+
264+
summaryWriter = f
265+
}
266+
}
267+
268+
if summaryWriter == nil {
269+
summaryWriter = io.Discard
270+
}
271+
}
272+
273+
findings, err := ParseFindings(r)
274+
if err != nil {
275+
return err
276+
}
277+
278+
allow, err := LoadAllowList(allowListPath)
279+
if err != nil {
280+
return err
281+
}
282+
283+
return CheckFindings(findings, allow, summaryWriter)
284+
}

0 commit comments

Comments
 (0)