Add gosec to CI; fix the one real finding it surfaced
Part of the same security-hardening pass as the last two commits. make
lint was go vet only; govulncheck and gitleaks already scanned deps and
secrets on every push, but nothing read this repo's own source for
risky patterns (weak crypto, injection shapes, insecure cookies, ...).
New `make security-code` runs gosec, wired into ci.yaml's security job
alongside the other two. G104 (unchecked error) is excluded at the
Makefile level: every one of its 41 initial hits was this codebase's
existing, deliberate idiom for a best-effort write or an already-
reviewed json.Unmarshal of its own JSONB, predating gosec, and the rule
cannot tell that apart from a mistake -- seventeen individual #nosec
comments would hide a future real G104 regression in the suppression
noise rather than surface it. Reasoning is on the Makefile target.
Of the 12 remaining hits:
- Genuinely real: oidc.go's callback logged error_description (and,
two call sites down, identity.Subject) via %s before the request's
state was even checked against its cookie -- an attacker-reachable
value going into the log unquoted. Switched to %q, matching
identity.Username's existing treatment, so a value holding a
newline can't forge a second log line.
- False positives, annotated inline rather than globally suppressed:
4x G124 on cookies that already set Secure via cookieSecure(...)
(a function call, not the literal `true` the rule wants), 3x G202
on sqlArgs-built queries that only ever splice in a "$N"
placeholder, never a value, and the remaining 5x G706 on log lines
that were already %q-quoted -- gosec's taint analysis doesn't
model format verbs, so it flags the tainted argument regardless.
Also fixed handleMe's swallowed Scan error (gosec's catch, pre-fix):
a transient DB error left hash/dismissed at their zero values and the
response claimed no password and no onboarding dismissal regardless
of the truth, rather than surfacing a 500.
Checked both workflow files for the injection class letsvisit found
there (a `${{ }}` expression spliced straight into a `run:` block):
every one here already goes through `env:` as a quoted shell variable,
documented in ci.yaml's own header comment. Nothing to fix.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+16
-3
@@ -99,7 +99,7 @@ func (l *loginLimiter) fail(ctx context.Context, keys ...string) {
|
||||
THEN 1 ELSE rate_limit_counters.count + 1 END`,
|
||||
key, now, windowSecs,
|
||||
); err != nil {
|
||||
log.Printf("rate limiter: record failure for %q: %v", key, err)
|
||||
log.Printf("rate limiter: record failure for %q: %v", key, err) // #nosec G706 -- %q
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,9 @@ func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, user
|
||||
return err
|
||||
}
|
||||
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what trips
|
||||
// this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: raw,
|
||||
@@ -280,6 +283,9 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
|
||||
}
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
@@ -319,9 +325,16 @@ func handleMe(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
var hash sql.NullString
|
||||
var dismissed *int64
|
||||
db.QueryRowContext(r.Context(),
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
|
||||
caller.ID).Scan(&hash, &dismissed)
|
||||
caller.ID).Scan(&hash, &dismissed); err != nil {
|
||||
// fetchUser above already found this row, so an error here is a
|
||||
// transient database problem, not a missing user — worth a 500
|
||||
// rather than silently answering "no password, not dismissed",
|
||||
// which a client would otherwise take at face value.
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, meResponse{
|
||||
User: user,
|
||||
HasPassword: hash.Valid,
|
||||
|
||||
Reference in New Issue
Block a user