diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 788cc46..665cd41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,30 @@ jobs: - run: go vet ./... - run: go test ./... - run: go test -race ./... + + semgrep-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.26.x" + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install pinned Semgrep CE + run: | + python -m pip install pipx==1.8.0 + python -m pipx install --python python semgrep==1.139.0 + - name: Locate semgrep-core + id: semgrep + run: | + venvs="$(python -m pipx environment --value PIPX_LOCAL_VENVS)" + core="$(find "$venvs/semgrep" -type f -path '*/semgrep/bin/semgrep-core' -print -quit)" + test -x "$core" + test "$("$core" -version)" = "semgrep-core version: 1.139.0" + echo "core=$core" >> "$GITHUB_OUTPUT" + - name: Run real semgrep-core tests + env: + ROOM_SEMGREP_CORE: ${{ steps.semgrep.outputs.core }} + run: go test -tags=semgrep_integration ./cmd/room-semgrep diff --git a/README.md b/README.md index e31f48a..3c74b70 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ listener with `ROOM_AUTH_MODE=disabled`. ### Semgrep analyzer The Linux Semgrep adapter scans source snapshots with the OSS `semgrep-core` -binary. The included rule detects Go HTTP input that reaches SQL query text. +1.139.0 binary. Bundled rules cover recognized secret string literals in Go +and Rust, selected Go SQL injection and outbound-destination flows, and +untrusted Rust process arguments. ```bash go build -o ~/.local/bin/room-semgrep ./cmd/room-semgrep diff --git a/analyzers/semgrep/room.yml b/analyzers/semgrep/room.yml index f46091d..955974f 100644 --- a/analyzers/semgrep/room.yml +++ b/analyzers/semgrep/room.yml @@ -1,4 +1,17 @@ rules: + - id: room.source.high-confidence-secret-literal + message: A string literal with a recognized credential token format is embedded in source. + severity: ERROR + languages: [go, rust] + metadata: + room_signal: SIGNAL_KIND_SECRET_LITERAL + room_confidence_basis_points: 9500 + patterns: + - pattern: $SECRET + - metavariable-regex: + metavariable: $SECRET + regex: '^"(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{40,255}|sk-(?:proj-)?[A-Za-z0-9_-]{32,255}|xox[baprs]-[A-Za-z0-9-]{20,255})"$' + - id: room.go.dynamic-sql-with-untrusted-input message: Untrusted HTTP input reaches the SQL query text. severity: ERROR @@ -12,11 +25,66 @@ rules: - pattern: $REQ.URL.Query().Get(...) - pattern: $REQ.FormValue(...) - pattern: $REQ.PostFormValue(...) + - pattern: $REQ.Header.Get(...) + - pattern: $REQ.PathValue(...) pattern-sinks: - patterns: - pattern-either: - pattern: $DB.Query($QUERY, ...) - pattern: $DB.QueryContext($CTX, $QUERY, ...) + - pattern: $DB.QueryRow($QUERY, ...) + - pattern: $DB.QueryRowContext($CTX, $QUERY, ...) - pattern: $DB.Exec($QUERY, ...) - pattern: $DB.ExecContext($CTX, $QUERY, ...) + - pattern: $DB.Prepare($QUERY) + - pattern: $DB.PrepareContext($CTX, $QUERY) - focus-metavariable: $QUERY + + - id: room.go.untrusted-outbound-destination + message: Untrusted HTTP input reaches an outbound request destination. + severity: ERROR + languages: [go] + mode: taint + metadata: + room_signal: SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION + room_confidence_basis_points: 9000 + pattern-sources: + - pattern-either: + - pattern: $REQ.URL.Query().Get(...) + - pattern: $REQ.FormValue(...) + - pattern: $REQ.PostFormValue(...) + - pattern: $REQ.Header.Get(...) + - pattern: $REQ.PathValue(...) + pattern-sinks: + - patterns: + - pattern-either: + - pattern: http.Get($URL) + - pattern: http.Post($URL, ...) + - pattern: http.PostForm($URL, ...) + - pattern: http.NewRequest($METHOD, $URL, ...) + - pattern: http.NewRequestWithContext($CTX, $METHOD, $URL, ...) + - focus-metavariable: $URL + + - id: room.rust.command-with-untrusted-argument + message: Untrusted process or request input reaches a process command or argument. + severity: ERROR + languages: [rust] + mode: taint + metadata: + room_signal: SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT + room_confidence_basis_points: 9000 + pattern-sources: + - pattern-either: + - pattern: std::env::args().nth(...) + - pattern: std::env::var(...) + - pattern: $REQUEST.uri().query() + - pattern: $REQUEST.uri().path() + - pattern: $REQUEST.headers().get(...) + pattern-sinks: + - patterns: + - pattern-either: + - pattern: std::process::Command::new($VALUE) + - pattern: Command::new($VALUE) + - pattern: $COMMAND.arg($VALUE) + - pattern: $COMMAND.args($VALUE) + - focus-metavariable: $VALUE diff --git a/cmd/room-semgrep/main_integration_test.go b/cmd/room-semgrep/main_integration_test.go new file mode 100644 index 0000000..291314d --- /dev/null +++ b/cmd/room-semgrep/main_integration_test.go @@ -0,0 +1,271 @@ +//go:build semgrep_integration + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var integrationSignals = []string{ + "SIGNAL_KIND_SECRET_LITERAL", + "SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT", + "SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION", + "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", +} + +const semgrepCoreVersion = "1.139.0" + +func TestSemgrepCoreIntegration(t *testing.T) { + core, config := integrationPaths(t) + tests := []struct { + name, path, source, signal string + }{ + { + name: "secret literal", + path: "credentials.go", + source: "package demo\nconst token = \"ghp_" + strings.Repeat("a", 36) + "\"\n", + signal: "SIGNAL_KIND_SECRET_LITERAL", + }, + { + name: "Rust secret literal", + path: "credentials.rs", + source: "const TOKEN: &str = \"xoxb-" + strings.Repeat("a", 24) + "\";\n", + signal: "SIGNAL_KIND_SECRET_LITERAL", + }, + { + name: "dynamic SQL ignores nosem", + path: "query.go", + source: `package demo +import ("database/sql"; "net/http") +func handler(db *sql.DB, r *http.Request) { + query := r.FormValue("query") + db.Query(query) // nosem +} +`, + signal: "SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT", + }, + { + name: "untrusted outbound destination", + path: "fetch.go", + source: `package demo +import "net/http" +func handler(r *http.Request) { + target := r.Header.Get("X-Callback-URL") + http.Get(target) +} +`, + signal: "SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION", + }, + { + name: "Rust command argument", + path: "command.rs", + source: `use std::process::Command; +fn main() { + let arg = std::env::args().nth(1).unwrap(); + Command::new("tool").arg(arg).status(); +} +`, + signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", + }, + { + name: "Rust request header command argument", + path: "request_command.rs", + source: `use std::process::Command; +fn handler(request: Request) { + let arg = request.headers().get("x-command").unwrap(); + Command::new("tool").arg(arg).status(); +} +`, + signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository := t.TempDir() + if err := os.WriteFile(filepath.Join(repository, test.path), []byte(test.source), 0o600); err != nil { + t.Fatal(err) + } + adapter, err := newAdapter(core, config, repository, append([]string(nil), integrationSignals...)) + if err != nil { + t.Fatal(err) + } + response := adapter.analyze(t.Context(), requestFor(repository, config, newFileDiff(test.path, test.source))) + if response.Status != completeStatus { + t.Fatalf("response = %+v", response) + } + if len(response.Signals) != 1 || response.Signals[0].Kind != test.signal { + t.Fatalf("signals = %+v", response.Signals) + } + }) + } +} + +func TestSemgrepCoreIntegrationCleanScan(t *testing.T) { + core, config := integrationPaths(t) + tests := []struct { + name, path, source string + }{ + { + name: "credential-shaped comment and ordinary string", + path: "strings.go", + source: `package demo +// ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +const explanation = "this ordinary string is deliberately longer than a credential" +`, + }, + { + name: "parameterized SQL and fixed outbound destination", + path: "safe.go", + source: `package demo +import ("database/sql"; "net/http") +func safe(db *sql.DB, r *http.Request) { + value := r.FormValue("value") + db.Query("SELECT * FROM records WHERE value = ?", value) + http.Get("https://example.com/health") +} +`, + }, + { + name: "fixed Rust command argument", + path: "safe.rs", + source: `use std::process::Command; +fn main() { + Command::new("tool").arg("status").status(); +} +`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository := t.TempDir() + if err := os.WriteFile(filepath.Join(repository, test.path), []byte(test.source), 0o600); err != nil { + t.Fatal(err) + } + adapter, err := newAdapter(core, config, repository, append([]string(nil), integrationSignals...)) + if err != nil { + t.Fatal(err) + } + response := adapter.analyze(t.Context(), requestFor(repository, config, newFileDiff(test.path, test.source))) + if response.Status != completeStatus || len(response.Signals) != 0 { + t.Fatalf("response = %+v", response) + } + }) + } +} + +func TestSemgrepCoreIntegrationFiltersUnchangedFindings(t *testing.T) { + core, config := integrationPaths(t) + repository := t.TempDir() + source := `package demo +import ("database/sql"; "net/http") +func handler(db *sql.DB, r *http.Request) { + first := r.FormValue("first") + db.Query(first) + second := r.FormValue("second") + db.Query(second) +} +` + if err := os.WriteFile(filepath.Join(repository, "query.go"), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + adapter, err := newAdapter(core, config, repository, append([]string(nil), integrationSignals...)) + if err != nil { + t.Fatal(err) + } + diff := []byte("diff --git a/query.go b/query.go\n--- a/query.go\n+++ b/query.go\n@@ -6,0 +7 @@\n+\tdb.Query(second)\n") + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.Status != completeStatus || len(response.Signals) != 1 { + t.Fatalf("response = %+v", response) + } + if response.Signals[0].Kind != "SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT" || response.Signals[0].Location.StartLine != 7 { + t.Fatalf("signals = %+v", response.Signals) + } +} + +func TestSemgrepCoreIntegrationRejectsInvalidRule(t *testing.T) { + core, _ := integrationPaths(t) + repository := t.TempDir() + source := "package demo\nconst value = 1\n" + if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + config := filepath.Join(t.TempDir(), "invalid.yml") + rules := `rules: + - id: room.invalid + message: Invalid test rule. + severity: ERROR + languages: [go] + metadata: + room_signal: SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT + room_confidence_basis_points: 9000 + pattern: "(" +` + if err := os.WriteFile(config, []byte(rules), 0o600); err != nil { + t.Fatal(err) + } + adapter, err := newAdapter(core, config, repository, []string{"SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT"}) + if err != nil { + t.Fatal(err) + } + response := adapter.analyze(t.Context(), requestFor(repository, config, newFileDiff("main.go", source))) + if response.Status != failedStatus || response.FailureCode != "semgrep_report_invalid" { + t.Fatalf("response = %+v", response) + } +} + +func TestSemgrepCoreIntegrationFailsClosedForUnsupportedLanguage(t *testing.T) { + core, config := integrationPaths(t) + repository := t.TempDir() + source := "ghp_" + strings.Repeat("a", 36) + "\n" + if err := os.WriteFile(filepath.Join(repository, "credentials.txt"), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + adapter, err := newAdapter(core, config, repository, append([]string(nil), integrationSignals...)) + if err != nil { + t.Fatal(err) + } + response := adapter.analyze(t.Context(), requestFor(repository, config, newFileDiff("credentials.txt", source))) + if response.Status != failedStatus || response.FailureCode != "semgrep_targets_incomplete" { + t.Fatalf("response = %+v", response) + } +} + +func integrationPaths(t *testing.T) (string, string) { + t.Helper() + core := os.Getenv("ROOM_SEMGREP_CORE") + if core == "" { + t.Fatal("ROOM_SEMGREP_CORE is required") + } + core, err := filepath.Abs(core) + if err != nil { + t.Fatal(err) + } + version, err := exec.Command(core, "-version").CombinedOutput() + if err != nil || strings.TrimSpace(string(version)) != "semgrep-core version: "+semgrepCoreVersion { + t.Fatalf("semgrep-core version = %q, error = %v", version, err) + } + config, err := filepath.Abs(filepath.Join("..", "..", "analyzers", "semgrep", "room.yml")) + if err != nil { + t.Fatal(err) + } + return core, config +} + +func newFileDiff(path, source string) []byte { + lines := strings.Split(strings.TrimSuffix(source, "\n"), "\n") + var diff strings.Builder + fmt.Fprintf(&diff, "diff --git a/%s b/%s\n--- /dev/null\n+++ b/%s\n@@ -0,0 +1,%d @@\n", path, path, path, len(lines)) + for _, line := range lines { + diff.WriteByte('+') + diff.WriteString(line) + diff.WriteByte('\n') + } + return []byte(diff.String()) +} diff --git a/docs/analyzer.md b/docs/analyzer.md index 35059aa..5e851fd 100644 --- a/docs/analyzer.md +++ b/docs/analyzer.md @@ -77,13 +77,26 @@ list the same signals in `ROOM_ANALYZER_COVERED_SIGNALS`. A finding that names a undeclared signal or an invalid confidence produces a failed receipt. Findings without `room_signal` metadata are ignored. +The bundled rules provide these coverage claims: + +| Signal | Language | Detection | +| --- | --- | --- | +| `SIGNAL_KIND_SECRET_LITERAL` | Go, Rust | String literals matching GitHub, OpenAI, or Slack token formats | +| `SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT` | Go | Query, form, header, or path input reaching `database/sql` query text | +| `SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION` | Go | Query, form, header, or path input reaching a package-level `net/http` request URL | +| `SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT` | Rust | Process environment, argument, URI, or header input reaching `Command::new`, `arg`, or `args` | + +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. + ```bash go build -o ~/.local/bin/room-semgrep ./cmd/room-semgrep ROOM_ANALYZER_EXECUTABLE="$HOME/.local/bin/room-semgrep" -ROOM_ANALYZER_ARGS='["--semgrep-core","/absolute/path/to/semgrep-core","--config","/absolute/path/to/room/analyzers/semgrep/room.yml","--repository-root","/srv/repos/my-repository","--covered-signal","SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT"]' +ROOM_ANALYZER_ARGS='["--semgrep-core","/absolute/path/to/semgrep-core","--config","/absolute/path/to/room/analyzers/semgrep/room.yml","--repository-root","/srv/repos/my-repository","--covered-signal","SIGNAL_KIND_SECRET_LITERAL","--covered-signal","SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT","--covered-signal","SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION","--covered-signal","SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT"]' ROOM_ANALYZER_CONFIG_FILE=/absolute/path/to/room/analyzers/semgrep/room.yml -ROOM_ANALYZER_COVERED_SIGNALS='["SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT"]' +ROOM_ANALYZER_COVERED_SIGNALS='["SIGNAL_KIND_SECRET_LITERAL","SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT","SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION","SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT"]' ROOM_ANALYZER_ID=room.semgrep ROOM_ANALYZER_VERSION=1 ```