diff --git a/cmd/room-semgrep/main_test.go b/cmd/room-semgrep/main_test.go index d01d0c5..47d6e74 100644 --- a/cmd/room-semgrep/main_test.go +++ b/cmd/room-semgrep/main_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "testing" "time" @@ -496,14 +497,22 @@ func workspace(t *testing.T) (string, string) { return root, repository } +// toolDigests caches tool-binary hashes so integration tests do not re-read +// the 200 MiB semgrep-core binary for every request. +var toolDigests sync.Map + func requestFor(repository, config, semgrepCore string, content []byte) analyzerRequest { request := requestForPhase(repository, content, "ANALYSIS_PHASE_DIFF") configData, _ := os.ReadFile(config) configDigest := sha256.Sum256(configData) request.ConfigSHA256 = hex.EncodeToString(configDigest[:]) - toolData, _ := os.ReadFile(semgrepCore) - toolDigest := sha256.Sum256(toolData) - request.ToolSHA256 = hex.EncodeToString(toolDigest[:]) + digest, ok := toolDigests.Load(semgrepCore) + if !ok { + toolData, _ := os.ReadFile(semgrepCore) + toolDigest := sha256.Sum256(toolData) + digest, _ = toolDigests.LoadOrStore(semgrepCore, hex.EncodeToString(toolDigest[:])) + } + request.ToolSHA256 = digest.(string) return request } diff --git a/docs/analyzer.md b/docs/analyzer.md index 301a846..16f033d 100644 --- a/docs/analyzer.md +++ b/docs/analyzer.md @@ -66,6 +66,8 @@ renames, binary patches, and malformed or incomplete unified diffs produce a failed receipt. The adapter invokes `semgrep-core` with explicit snapshotted targets, strict rule validation, disabled timeouts, and no ignore-file target discovery. Any core parser error, target skip, or rule skip fails the receipt. +The scan runs in its own process group, so canceling the analysis kills +`semgrep-core` and anything it spawned. Registry configuration URLs are not accepted. Each policy-bearing Semgrep rule must provide these metadata fields: @@ -101,11 +103,19 @@ The bundled rules provide these coverage claims: | `SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT` | Rust | `lock`/`read`/`write` bindings using `unwrap` or `expect`, and `blocking_lock`/`blocking_read`/`blocking_write` bindings, followed by await without a matched explicit drop | These rules model the listed source and sink families, not every framework API -or validation function. The adapter uses Semgrep's private core interface and -is pinned and integration-tested against `semgrep-core` 1.139.0. For taint +or validation function. Semgrep CE tracks taint within a single file, so a +flow that crosses a file boundary produces no finding. The adapter uses +Semgrep's private core interface and is pinned and integration-tested against +`semgrep-core` 1.139.0. For taint rules, a finding is retained when the added lines intersect either the reported sink or a source/intermediate location in the core's dataflow trace. +Upgrading `semgrep-core` means: bump `semgrepCoreVersion` in +`cmd/room-semgrep/main.go`, the `semgrep==` pin, and the `-version` assertion +in `.github/workflows/ci.yml`, then rerun the integration suite. A version +mismatch or a change to the core's private target-manifest or dataflow-trace +schema fails closed. + The bundled Semgrep rules do not claim `SIGNAL_KIND_RUST_UNSAFE_WITHOUT_SAFETY_CONTRACT`, `SIGNAL_KIND_RUST_PANIC_IN_LIBRARY_API`, or diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index 9549f4f..fb89ea9 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -249,6 +250,33 @@ func TestExternalAnalyzerUsesJSONStdinAndLiteralArguments(t *testing.T) { } } +func TestExternalAnalyzerOmitsToolDigestWithoutTool(t *testing.T) { + input := Input{Phase: roomv1.AnalysisPhase_ANALYSIS_PHASE_PLAN, Content: []byte("plan")} + digest := sha256.Sum256(input.Content) + temp := t.TempDir() + capture := filepath.Join(temp, "request.json") + response := fmt.Sprintf(`{"phase":"ANALYSIS_PHASE_PLAN","status":"ANALYSIS_STATUS_COMPLETE","covered_signals":["SIGNAL_KIND_SECRET_LITERAL"],"input_sha256":%q}`, hex.EncodeToString(digest[:])) + executable := filepath.Join(temp, "capture-provider") + script := fmt.Sprintf("#!/bin/sh\ncat > '%s'\nprintf '%%s' '%s'\n", capture, response) + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + a, err := NewExternal(Config{ID: "plain", Version: "1", Executable: executable, CoveredSignals: []roomv1.SignalKind{secretSignal}}) + if err != nil { + t.Fatal(err) + } + if got := a.Analyze(context.Background(), input).GetStatus(); got != roomv1.AnalysisStatus_ANALYSIS_STATUS_COMPLETE { + t.Fatalf("status = %s", got) + } + captured, err := os.ReadFile(capture) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(captured), "tool_sha256") { + t.Fatalf("request named a tool digest without a configured tool: %s", captured) + } +} + func TestNewExternalRejectsUnsafeOrIncompleteConfiguration(t *testing.T) { tests := []struct { name string