Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions analyzers/semgrep/room.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
271 changes: 271 additions & 0 deletions cmd/room-semgrep/main_integration_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading
Loading