Skip to content

Commit d7c8064

Browse files
aksOpsclaude
andauthored
fix(security,test): clear 3 SonarCloud vulnerabilities + flaky pipeline test (#74)
* fix(security,test): clear 3 SonarCloud vulnerabilities + flaky pipeline test Three real findings flagged by SonarCloud's analysis on main, plus a race-detector flake that surfaced post-merge. Vulnerabilities * BLOCKER gosecurity:S2083 — internal/ui/ui.go:58 path-injection. The SPA fallback handler passed r.URL.Path straight to distFS.Open. embed.FS already rejects ".." segments, but Sonar's taint tracker can't see that. Sanitize at the boundary: path.Clean + reject any residual ".." + default empty path to "index.html". Same behavior, explicit gate. * MAJOR githubactions:S8234 ×2 — .github/workflows/{scorecard,security}.yml. Top-level "permissions: read-all" replaced with the minimum "permissions: { contents: read }" needed for actions/checkout. Each job already declares the narrower scopes it actually needs (security-events: write, id-token: write, etc.), so the overly permissive default was redundant and tripped Scorecard's Token-Permissions guidance. Flaky test * internal/ingest/pipeline_test.go:497,504 — TestPipeline_PerTenantCap_ReleasedAfterProcess used 2s waitFor deadlines that fail under the race detector on busy CI runners. Bumped both to 5s. Test passes locally in milliseconds; the timeout is purely a CI-flake margin. Confirmed reproducible-pass locally with -race -count=5. Verification * go vet ./... clean * go test ./... — 516 pass / 27 packages * go build ./... clean Out of scope (next pass) * 147 code-smell issues (cognitive complexity ×55, string-literal duplication ×31, TS-specific ×24, etc.). Tracked separately — fixing them in this PR would muddle the security-fix scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ui): replace manual SPA dispatch with spaFS wrapper Sonar's go-security taint engine kept flagging the explicit distFS.Open(rel) call in the SPA-fallback handler even after path.Clean + ".." check. The pattern is structurally safe (embed.FS rejects ".." on its own) but the engine can't model it. Restructure: wrap the dist sub-FS with spaFS — when http.FileServer hits ErrNotExist on an extensionless path, the wrapper transparently serves index.html so the React router can claim the URL. Asset-shaped paths (anything with ".") still 404 normally, so a missing favicon doesn't surprise the browser with an HTML body. Net result: the user-controlled URL never crosses our own Open() call — http.FileServer is the only caller, and Sonar trusts that boundary. Verification * go vet ./... clean * go test ./... — 516 pass / 27 packages * go build ./... clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ingest): bump remaining waitFor timeouts + sync on actual condition The PR #74 amendment surfaced a second flaky test (TestPipeline_PreservesInsertionOrder) that fails on CI under the race detector for the same reason as the first: 2-second deadlines too tight. Two fixes * Bulk-bump all remaining waitFor(t, 2*time.Second, ...) calls in pipeline_test.go to 5 seconds (5 sites). Tolerates race-detector overhead on slow CI runners; locally these all complete in milliseconds. * TestPipeline_PreservesInsertionOrder synced on Stats().Processed == 1 but asserted on snapshotOrder() length. Stats() can bump between BatchCreate calls under the race detector, leaving the order snapshot partial. Switched the wait to len(w.snapshotOrder()) >= 3 — the actual condition the assertion cares about. Verification * go test -race -count=3 ./internal/ingest/... — 114 pass × 3 runs * go vet ./... clean * locally reproducible-pass under -race Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ingest): extract shared helper for failure-skip pipeline tests SonarCloud quality gate failed PR #74 on new_duplicated_lines_density=3.5% (threshold 3%). The duplication is between TestPipeline_FailedSpansSkipsLogs and TestPipeline_FailedTracesAbortsBatch — same boilerplate, same waitFor, same assertion shape. The waitFor 2s→5s bump made those lines "new code", so Sonar recounted the existing duplication against the PR's new-code budget. Extract runFailureSkipsCheck(t, writer, forbidden...) — single helper takes the configured fakeWriter and the BatchCreate* names that must not fire after the seeded upstream failure. Each failing-path test collapses to a single line. Verification * go test -race -count=3 -run 'TestPipeline_Failed*' — pass × 3 * go vet ./... clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6edfbf1 commit d7c8064

4 files changed

Lines changed: 76 additions & 55 deletions

File tree

.github/workflows/scorecard.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ on:
1313
- cron: "0 6 * * 1"
1414
workflow_dispatch:
1515

16-
# Restrict the default GITHUB_TOKEN to read-only; the steps below request the
17-
# narrow scopes they actually need.
18-
permissions: read-all
16+
# Workflow-level default: minimum needed for actions/checkout. The analysis
17+
# job declares the additional narrow scopes (security-events: write, id-token:
18+
# write, actions: read) it actually needs.
19+
permissions:
20+
contents: read
1921

2022
jobs:
2123
analysis:

.github/workflows/security.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ on:
1717
schedule:
1818
- cron: '21 4 * * 1' # Mondays 04:21 UTC — catch newly-disclosed CVEs
1919

20-
permissions: read-all
20+
# Workflow-level default: minimum needed for actions/checkout. Each job
21+
# declares the additional narrow scopes (security-events: write for SARIF
22+
# uploaders, contents: write for SBOM artifact upload) it actually needs.
23+
permissions:
24+
contents: read
2125

2226
jobs:
2327
osv-scanner:

internal/ingest/pipeline_test.go

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,11 @@ func TestPipeline_PreservesInsertionOrder(t *testing.T) {
239239
if err := p.Submit(healthyBatch()); err != nil {
240240
t.Fatalf("submit: %v", err)
241241
}
242-
if !waitFor(t, 2*time.Second, func() bool { return p.Stats().Processed == 1 }) {
243-
t.Fatalf("worker did not process batch within deadline")
242+
// Sync on the assertion target — the per-signal call sequence — rather
243+
// than Stats().Processed, which can bump between BatchCreate calls under
244+
// the race detector and trip the length check on a partial slice.
245+
if !waitFor(t, 5*time.Second, func() bool { return len(w.snapshotOrder()) >= 3 }) {
246+
t.Fatalf("worker did not record 3 calls within deadline (got %v)", w.snapshotOrder())
244247
}
245248

246249
got := w.snapshotOrder()
@@ -273,17 +276,18 @@ func TestPipeline_CallbacksFireAfterPersistence(t *testing.T) {
273276
if err := p.Submit(b); err != nil {
274277
t.Fatalf("submit: %v", err)
275278
}
276-
if !waitFor(t, 2*time.Second, func() bool { return spanHits.Load() == 1 && logHits.Load() == 1 }) {
279+
if !waitFor(t, 5*time.Second, func() bool { return spanHits.Load() == 1 && logHits.Load() == 1 }) {
277280
t.Fatalf("callbacks did not fire (span=%d log=%d, want 1/1)", spanHits.Load(), logHits.Load())
278281
}
279282
}
280283

281-
func TestPipeline_FailedSpansSkipsLogs(t *testing.T) {
282-
// When BatchCreateSpans fails, BatchCreateLogs must NOT run for that
283-
// batch — preserves the invariant that orphan logs aren't persisted
284-
// without their span. Mirrors the synchronous path's behavior of
285-
// returning the span error before log insert.
286-
w := &fakeWriter{spanErr: errors.New("span db down")}
284+
// runFailureSkipsCheck wires up a 1-worker pipeline with the configured
285+
// fakeWriter, submits a healthy batch, waits for the failure to surface,
286+
// then asserts that none of the forbidden BatchCreate* calls fired.
287+
// Shared by the trace-fails and span-fails skip tests so the boilerplate
288+
// (pipeline lifecycle + waitFor) lives in one place.
289+
func runFailureSkipsCheck(t *testing.T, w *fakeWriter, forbidden ...string) {
290+
t.Helper()
287291
p := NewPipeline(w, nil, PipelineConfig{Capacity: 2, Workers: 1})
288292
ctx, cancel := context.WithCancel(context.Background())
289293
t.Cleanup(cancel)
@@ -293,41 +297,33 @@ func TestPipeline_FailedSpansSkipsLogs(t *testing.T) {
293297
if err := p.Submit(healthyBatch()); err != nil {
294298
t.Fatalf("submit: %v", err)
295299
}
296-
if !waitFor(t, 2*time.Second, func() bool { return p.Stats().ProcessFailures > 0 }) {
300+
if !waitFor(t, 5*time.Second, func() bool { return p.Stats().ProcessFailures > 0 }) {
297301
t.Fatalf("expected ProcessFailures > 0, got %d", p.Stats().ProcessFailures)
298302
}
299303
calls := w.snapshotOrder()
300304
for _, c := range calls {
301-
if c == "logs" {
302-
t.Fatalf("BatchCreateLogs ran after spans failed — order=%v", calls)
305+
for _, f := range forbidden {
306+
if c == f {
307+
t.Fatalf("%s ran after upstream failure — order=%v", f, calls)
308+
}
303309
}
304310
}
305311
}
306312

313+
func TestPipeline_FailedSpansSkipsLogs(t *testing.T) {
314+
// When BatchCreateSpans fails, BatchCreateLogs must NOT run for that
315+
// batch — preserves the invariant that orphan logs aren't persisted
316+
// without their span. Mirrors the synchronous path's behavior of
317+
// returning the span error before log insert.
318+
runFailureSkipsCheck(t, &fakeWriter{spanErr: errors.New("span db down")}, "logs")
319+
}
320+
307321
func TestPipeline_FailedTracesAbortsBatch(t *testing.T) {
308322
// Trace failures roll the entire batch back — atomic batches are the
309323
// fix for orphan FK rows when a worker crashes between BatchCreate*
310324
// calls. Spans and logs must NOT be persisted when the trace insert
311325
// fails. Counterpart of TestPipeline_FailedSpansSkipsLogs.
312-
w := &fakeWriter{traceErr: errors.New("transient")}
313-
p := NewPipeline(w, nil, PipelineConfig{Capacity: 2, Workers: 1})
314-
ctx, cancel := context.WithCancel(context.Background())
315-
t.Cleanup(cancel)
316-
p.Start(ctx)
317-
t.Cleanup(p.Stop)
318-
319-
if err := p.Submit(healthyBatch()); err != nil {
320-
t.Fatalf("submit: %v", err)
321-
}
322-
if !waitFor(t, 2*time.Second, func() bool { return p.Stats().ProcessFailures > 0 }) {
323-
t.Fatalf("expected ProcessFailures > 0, got %d", p.Stats().ProcessFailures)
324-
}
325-
calls := w.snapshotOrder()
326-
for _, c := range calls {
327-
if c == "spans" || c == "logs" {
328-
t.Fatalf("spans/logs ran after trace failure — order=%v", calls)
329-
}
330-
}
326+
runFailureSkipsCheck(t, &fakeWriter{traceErr: errors.New("transient")}, "spans", "logs")
331327
}
332328

333329
func TestPipeline_DrainsOnStop(t *testing.T) {
@@ -493,15 +489,17 @@ func TestPipeline_PerTenantCap_ReleasedAfterProcess(t *testing.T) {
493489
if err := p.Submit(mk()); err != nil {
494490
t.Fatalf("submit 1: %v", err)
495491
}
496-
// Wait for the worker to drain it (and release the slot).
497-
if !waitFor(t, 2*time.Second, func() bool { return p.Stats().Processed == 1 }) {
492+
// Wait for the worker to drain it (and release the slot). 5s tolerates
493+
// the race detector's overhead on slow CI runners — the test passes
494+
// locally in milliseconds.
495+
if !waitFor(t, 5*time.Second, func() bool { return p.Stats().Processed == 1 }) {
498496
t.Fatalf("worker did not process first batch")
499497
}
500498
// Second batch must succeed because the slot was released.
501499
if err := p.Submit(mk()); err != nil {
502500
t.Fatalf("submit 2 after release: %v", err)
503501
}
504-
if !waitFor(t, 2*time.Second, func() bool { return p.Stats().Processed == 2 }) {
502+
if !waitFor(t, 5*time.Second, func() bool { return p.Stats().Processed == 2 }) {
505503
t.Fatalf("worker did not process second batch")
506504
}
507505
if got := p.TenantDropped(); got != 0 {
@@ -542,7 +540,7 @@ func TestPipeline_PanicInCallbackRecovered(t *testing.T) {
542540
if err := p.Submit(good); err != nil {
543541
t.Fatalf("submit good: %v", err)
544542
}
545-
if !waitFor(t, 2*time.Second, func() bool { return p.Stats().Processed >= 2 }) {
543+
if !waitFor(t, 5*time.Second, func() bool { return p.Stats().Processed >= 2 }) {
546544
t.Fatalf("worker did not survive callback panic — Processed=%d", p.Stats().Processed)
547545
}
548546
if p.Stats().ProcessFailures == 0 {

internal/ui/ui.go

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ui
22

33
import (
44
"embed"
5+
"errors"
56
"fmt"
67
"io/fs"
78
"net/http"
@@ -13,6 +14,33 @@ import (
1314
"github.com/RandomCodeSpace/otelcontext/internal/vectordb"
1415
)
1516

17+
// spaFS wraps an fs.FS so http.FileServer transparently serves index.html
18+
// for any extensionless path that doesn't resolve to a real file — the
19+
// usual single-page-app routing where the React router owns client-side
20+
// URLs. Asset-shaped paths (anything with a ".") still 404 normally so a
21+
// missing /favicon.ico doesn't surprise the browser with an HTML body.
22+
//
23+
// Wrapping the FS — rather than calling Open() against r.URL.Path in our
24+
// own handler — keeps the user-controlled name behind the stdlib
25+
// http.FileServer boundary, where path.Clean has already happened.
26+
type spaFS struct{ fs.FS }
27+
28+
func (s spaFS) Open(name string) (fs.File, error) {
29+
f, err := s.FS.Open(name)
30+
if err == nil {
31+
return f, nil
32+
}
33+
if !errors.Is(err, fs.ErrNotExist) {
34+
return nil, err
35+
}
36+
// SPA fallback only for extensionless paths (treated as client-side
37+
// routes); legitimate asset 404s still propagate.
38+
if strings.Contains(name, ".") {
39+
return nil, err
40+
}
41+
return s.FS.Open("index.html")
42+
}
43+
1644
//go:embed static/* dist
1745
var content embed.FS
1846

@@ -46,26 +74,15 @@ func (s *Server) SetMCPConfig(enabled bool, path string) {
4674
func (s *Server) RegisterRoutes(mux *http.ServeMux) error {
4775
mux.Handle("/static/", http.FileServer(http.FS(content)))
4876

49-
// Serve React SPA from dist/ for all non-API paths.
50-
// API routes are registered before this is called, so they take priority.
77+
// Serve React SPA from dist/ for all non-API paths. API routes are
78+
// registered before this is called, so they take priority. spaFS
79+
// converts extensionless 404s into index.html so the React router
80+
// can claim them.
5181
distFS, err := fs.Sub(content, "dist")
5282
if err != nil {
5383
return fmt.Errorf("ui: failed to create dist sub-fs: %w", err)
5484
}
55-
fileServer := http.FileServer(http.FS(distFS))
56-
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
57-
// Try the file as-is; if not found, fall back to index.html (SPA routing).
58-
f, openErr := distFS.Open(strings.TrimPrefix(r.URL.Path, "/"))
59-
if openErr == nil {
60-
_ = f.Close()
61-
fileServer.ServeHTTP(w, r)
62-
return
63-
}
64-
// SPA fallback — let the React router handle the path.
65-
r2 := r.Clone(r.Context())
66-
r2.URL.Path = "/"
67-
fileServer.ServeHTTP(w, r2)
68-
})
85+
mux.Handle("/", http.FileServer(http.FS(spaFS{distFS})))
6986

7087
return nil
7188
}

0 commit comments

Comments
 (0)