diff --git a/README.md b/README.md index 3c74b70..7c65bae 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,9 @@ listener with `ROOM_AUTH_MODE=disabled`. The Linux Semgrep adapter scans source snapshots with the OSS `semgrep-core` 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. +and Rust; selected Go SQL injection and outbound-destination flows; and selected +Rust flows involving request-derived panics, process arguments, filesystem +paths, non-cryptographic secret generation, and blocking locks before await. ```bash go build -o ~/.local/bin/room-semgrep ./cmd/room-semgrep diff --git a/analyzers/semgrep/room.yml b/analyzers/semgrep/room.yml index 955974f..9212549 100644 --- a/analyzers/semgrep/room.yml +++ b/analyzers/semgrep/room.yml @@ -76,15 +76,311 @@ rules: pattern-sources: - pattern-either: - pattern: std::env::args().nth(...) + - pattern: std::env::args_os().nth(...) + - pattern: std::env::args() + - pattern: std::env::args_os() - pattern: std::env::var(...) + - pattern: std::env::var_os(...) - pattern: $REQUEST.uri().query() - pattern: $REQUEST.uri().path() - pattern: $REQUEST.headers().get(...) + - pattern: $REQUEST.query_string() + - pattern: $REQUEST.match_info().get(...) + - pattern: $REQUEST.take_payload() pattern-sinks: - patterns: - pattern-either: - pattern: std::process::Command::new($VALUE) - - pattern: Command::new($VALUE) + - pattern: tokio::process::Command::new($VALUE) + - pattern: std::process::Command::new(...).arg($VALUE) + - pattern: std::process::Command::new(...).args($VALUE) + - pattern: tokio::process::Command::new(...).arg($VALUE) + - pattern: tokio::process::Command::new(...).args($VALUE) + - focus-metavariable: $VALUE + - patterns: + - pattern-either: - pattern: $COMMAND.arg($VALUE) - pattern: $COMMAND.args($VALUE) + - pattern-either: + - pattern-inside: | + let mut $COMMAND = std::process::Command::new(...); + ... + - pattern-inside: | + let $COMMAND = std::process::Command::new(...); + ... + - pattern-inside: | + let mut $COMMAND = tokio::process::Command::new(...); + ... + - pattern-inside: | + let $COMMAND = tokio::process::Command::new(...); + ... + - focus-metavariable: $VALUE + + - id: room.rust.request-input-panics + message: Request-derived input reaches unwrap, expect, or an explicit panic. + severity: ERROR + languages: [rust] + mode: taint + metadata: + room_signal: SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH + room_confidence_basis_points: 9000 + pattern-sources: + - pattern-either: + - pattern: $REQUEST.uri().query() + - pattern: $REQUEST.uri().path() + - pattern: $REQUEST.headers().get(...) + - pattern: $REQUEST.query_string() + - pattern: $REQUEST.match_info().get(...) + - pattern: $REQUEST.take_payload() + pattern-sinks: + - patterns: + - pattern-either: + - pattern: $VALUE.unwrap() + - pattern: $VALUE.expect(...) + - focus-metavariable: $VALUE + - patterns: + - pattern-either: + - pattern: panic!($VALUE) + - pattern: panic!(..., $VALUE, ...) + - focus-metavariable: $VALUE + + - id: room.rust.untrusted-filesystem-path + message: Process or request input reaches a filesystem path operation. + severity: ERROR + languages: [rust] + mode: taint + metadata: + room_signal: SIGNAL_KIND_RUST_UNTRUSTED_PATH + room_confidence_basis_points: 9000 + pattern-sources: + - pattern-either: + - pattern: std::env::args().nth(...) + - pattern: std::env::args_os().nth(...) + - pattern: std::env::args() + - pattern: std::env::args_os() + - pattern: std::env::var(...) + - pattern: std::env::var_os(...) + - pattern: $REQUEST.uri().query() + - pattern: $REQUEST.uri().path() + - pattern: $REQUEST.headers().get(...) + - pattern: $REQUEST.query_string() + - pattern: $REQUEST.match_info().get(...) + - pattern: $REQUEST.take_payload() + pattern-sinks: + - patterns: + - pattern-either: + - pattern: std::fs::read($PATH) + - pattern: std::fs::read_to_string($PATH) + - pattern: std::fs::write($PATH, ...) + - pattern: std::fs::remove_file($PATH) + - pattern: std::fs::remove_dir($PATH) + - pattern: std::fs::remove_dir_all($PATH) + - pattern: std::fs::metadata($PATH) + - pattern: std::fs::symlink_metadata($PATH) + - pattern: std::fs::create_dir($PATH) + - pattern: std::fs::create_dir_all($PATH) + - pattern: std::fs::File::open($PATH) + - pattern: std::fs::File::create($PATH) + - pattern: tokio::fs::read($PATH) + - pattern: tokio::fs::read_to_string($PATH) + - pattern: tokio::fs::write($PATH, ...) + - pattern: tokio::fs::remove_file($PATH) + - pattern: tokio::fs::remove_dir($PATH) + - pattern: tokio::fs::remove_dir_all($PATH) + - pattern: tokio::fs::metadata($PATH) + - pattern: tokio::fs::create_dir($PATH) + - pattern: tokio::fs::create_dir_all($PATH) + - pattern: tokio::fs::File::open($PATH) + - pattern: tokio::fs::File::create($PATH) + - focus-metavariable: $PATH + - patterns: + - pattern-either: + - pattern: $FS::read($PATH) + - pattern: $FS::read_to_string($PATH) + - pattern: $FS::write($PATH, ...) + - pattern: $FS::remove_file($PATH) + - pattern: $FS::remove_dir($PATH) + - pattern: $FS::remove_dir_all($PATH) + - pattern: $FS::metadata($PATH) + - pattern: $FS::create_dir($PATH) + - pattern: $FS::create_dir_all($PATH) + - pattern-either: + - pattern-inside: | + use std::fs as $FS; + ... + - pattern-inside: | + use tokio::fs as $FS; + ... + - focus-metavariable: $PATH + - patterns: + - pattern-either: + - pattern: std::fs::rename($PATH, ...) + - pattern: std::fs::copy($PATH, ...) + - pattern: tokio::fs::rename($PATH, ...) + - pattern: tokio::fs::copy($PATH, ...) + - focus-metavariable: $PATH + - patterns: + - pattern-either: + - pattern: std::fs::rename(..., $PATH) + - pattern: std::fs::copy(..., $PATH) + - pattern: tokio::fs::rename(..., $PATH) + - pattern: tokio::fs::copy(..., $PATH) + - focus-metavariable: $PATH + + - id: room.rust.blocking-lock-across-await + message: A blocking lock guard remains live across an await point. + severity: ERROR + languages: [rust] + metadata: + room_signal: SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT + room_confidence_basis_points: 9000 + patterns: + - pattern-either: + - pattern: | + let $GUARD = $LOCK.lock().unwrap(); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.read().unwrap(); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.write().unwrap(); + ... + $AWAITED.await + - pattern: | + let mut $GUARD = $LOCK.lock().unwrap(); + ... + $AWAITED.await + - pattern: | + let mut $GUARD = $LOCK.read().unwrap(); + ... + $AWAITED.await + - pattern: | + let mut $GUARD = $LOCK.write().unwrap(); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.lock().expect(...); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.read().expect(...); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.write().expect(...); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.blocking_lock(); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.blocking_read(); + ... + $AWAITED.await + - pattern: | + let $GUARD = $LOCK.blocking_write(); + ... + $AWAITED.await + - pattern: | + let mut $GUARD = $LOCK.blocking_lock(); + ... + $AWAITED.await + - pattern: | + let mut $GUARD = $LOCK.blocking_read(); + ... + $AWAITED.await + - pattern: | + let mut $GUARD = $LOCK.blocking_write(); + ... + $AWAITED.await + - pattern-not: | + let $GUARD = $ACQUIRE; + ... + drop($GUARD); + ... + $AWAITED.await + - pattern-not: | + let mut $GUARD = $ACQUIRE; + ... + drop($GUARD); + ... + $AWAITED.await + - pattern-not: | + let $GUARD = $ACQUIRE; + ... + std::mem::drop($GUARD); + ... + $AWAITED.await + - pattern-not: | + let mut $GUARD = $ACQUIRE; + ... + std::mem::drop($GUARD); + ... + $AWAITED.await + - pattern-not: | + let $GUARD = $ACQUIRE; + ... + core::mem::drop($GUARD); + ... + $AWAITED.await + - pattern-not: | + let mut $GUARD = $ACQUIRE; + ... + core::mem::drop($GUARD); + ... + $AWAITED.await + + - id: room.rust.weak-rng-for-secret + message: A secret-like value is generated with a known non-cryptographic RNG. + severity: ERROR + languages: [rust] + mode: taint + metadata: + room_signal: SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET + room_confidence_basis_points: 9000 + pattern-sources: + - pattern: fastrand::$METHOD(...) + - patterns: + - pattern: $RNG.$METHOD(...) + - pattern-inside: | + let mut $RNG = fastrand::Rng::$CONSTRUCTOR(...); + ... + - patterns: + - pattern: $RNG.$METHOD(...) + - pattern-inside: | + let mut $RNG = rand::rngs::SmallRng::$CONSTRUCTOR(...); + ... + - patterns: + - pattern: $RNG.$METHOD(...) + - pattern-inside: | + let mut $RNG = SmallRng::$CONSTRUCTOR(...); + ... + - patterns: + - pattern: $RNG.$METHOD(...) + - pattern-inside: | + let mut $RNG = oorandom::$TYPE::new(...); + ... + - metavariable-regex: + metavariable: $TYPE + regex: '^(?:Rand32|Rand64)$' + - patterns: + - pattern: $RNG.$METHOD(...) + - pattern-inside: | + let mut $RNG = nanorand::$TYPE::new(...); + ... + - metavariable-regex: + metavariable: $TYPE + regex: '^(?:WyRand|Pcg64)$' + pattern-sinks: + - patterns: + - pattern-either: + - pattern: let $SECRET = $VALUE; + - pattern: let mut $SECRET = $VALUE; + - pattern: $SECRET = $VALUE; + - metavariable-regex: + metavariable: $SECRET + regex: '(?i)^(?:.*_)?(?:secret|token|api_key|private_key|password|passwd|session_id|session_token|nonce|salt|otp)(?:_.*)?$' - focus-metavariable: $VALUE diff --git a/cmd/room-semgrep/main.go b/cmd/room-semgrep/main.go index 9850581..e811872 100644 --- a/cmd/room-semgrep/main.go +++ b/cmd/room-semgrep/main.go @@ -24,11 +24,12 @@ import ( ) const ( - completeStatus = "ANALYSIS_STATUS_COMPLETE" - partialStatus = "ANALYSIS_STATUS_PARTIAL" - failedStatus = "ANALYSIS_STATUS_FAILED" - unavailableStatus = "ANALYSIS_STATUS_UNAVAILABLE" - maxOutputBytes = 16 << 20 + completeStatus = "ANALYSIS_STATUS_COMPLETE" + partialStatus = "ANALYSIS_STATUS_PARTIAL" + failedStatus = "ANALYSIS_STATUS_FAILED" + unavailableStatus = "ANALYSIS_STATUS_UNAVAILABLE" + maxOutputBytes = 16 << 20 + semgrepCoreVersion = "1.139.0" ) var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$`) @@ -97,10 +98,16 @@ type semgrepResult struct { Line int `json:"line"` } `json:"end"` Extra struct { - Metadata map[string]any `json:"metadata"` + Metadata map[string]any `json:"metadata"` + DataflowTrace json.RawMessage `json:"dataflow_trace"` } `json:"extra"` } +type semgrepTraceRange struct { + Path string + Start, End int +} + type diffArtifact struct { added map[string]map[int]bool expected map[string]map[int]string @@ -123,6 +130,7 @@ type snapshot struct { config string targetsFile string targets []string + lineCounts map[string]int } type adapter struct { @@ -330,7 +338,30 @@ func (a *adapter) analyze(ctx context.Context, request analyzerRequest) analyzer if index := sort.SearchStrings(snapshot.targets, path); index >= len(snapshot.targets) || snapshot.targets[index] != path { return failed(response, "semgrep_result_invalid") } - if !rangeIntersects(artifact.added[path], result.Start.Line, result.End.Line) { + if result.End.Line > snapshot.lineCounts[path] { + return failed(response, "semgrep_result_invalid") + } + intersects := rangeIntersects(artifact.added[path], result.Start.Line, result.End.Line) + if len(result.Extra.DataflowTrace) != 0 { + traceRanges, err := semgrepTraceRanges(result.Extra.DataflowTrace) + if err != nil { + return failed(response, "semgrep_result_invalid") + } + for _, traceRange := range traceRanges { + tracePath, err := normalizedResultPath(snapshot.directory, traceRange.Path) + if err != nil { + return failed(response, "semgrep_result_invalid") + } + if index := sort.SearchStrings(snapshot.targets, tracePath); index >= len(snapshot.targets) || snapshot.targets[index] != tracePath { + return failed(response, "semgrep_result_invalid") + } + if traceRange.End > snapshot.lineCounts[tracePath] { + return failed(response, "semgrep_result_invalid") + } + intersects = intersects || rangeIntersects(artifact.added[tracePath], traceRange.Start, traceRange.End) + } + } + if !intersects { continue } rawSignal, present := result.Extra.Metadata["room_signal"] @@ -363,6 +394,132 @@ func (a *adapter) analyze(ctx context.Context, request analyzerRequest) analyzer return response } +func semgrepTraceRanges(data []byte) ([]semgrepTraceRange, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, errors.New("dataflow trace must contain one JSON value") + } + trace, ok := value.(map[string]any) + if !ok { + return nil, errors.New("dataflow trace must be an object") + } + source, hasSource := trace["taint_source"] + sink, hasSink := trace["taint_sink"] + if !hasSource || !hasSink { + return nil, errors.New("dataflow trace must contain source and sink") + } + ranges := make([]semgrepTraceRange, 0, 3) + if err := collectSemgrepCallTrace(source, &ranges); err != nil { + return nil, err + } + if intermediate, present := trace["intermediate_vars"]; present { + values, ok := intermediate.([]any) + if !ok { + return nil, errors.New("dataflow trace intermediates must be an array") + } + for _, value := range values { + if err := collectSemgrepIntermediate(value, &ranges); err != nil { + return nil, err + } + } + } + if err := collectSemgrepCallTrace(sink, &ranges); err != nil { + return nil, err + } + return ranges, nil +} + +func collectSemgrepCallTrace(value any, ranges *[]semgrepTraceRange) error { + variant, ok := value.([]any) + if !ok || len(variant) != 2 { + return errors.New("invalid dataflow call trace") + } + kind, ok := variant[0].(string) + if !ok { + return errors.New("invalid dataflow call trace kind") + } + switch kind { + case "CliLoc": + return collectSemgrepLocAndContent(variant[1], ranges) + case "CliCall": + call, ok := variant[1].([]any) + if !ok || len(call) != 3 { + return errors.New("invalid dataflow call") + } + if err := collectSemgrepLocAndContent(call[0], ranges); err != nil { + return err + } + intermediates, ok := call[1].([]any) + if !ok { + return errors.New("invalid call intermediates") + } + for _, intermediate := range intermediates { + if err := collectSemgrepIntermediate(intermediate, ranges); err != nil { + return err + } + } + return collectSemgrepCallTrace(call[2], ranges) + default: + return errors.New("unknown dataflow call trace kind") + } +} + +func collectSemgrepLocAndContent(value any, ranges *[]semgrepTraceRange) error { + locationAndContent, ok := value.([]any) + if !ok || len(locationAndContent) != 2 { + return errors.New("invalid dataflow location and content") + } + if _, ok := locationAndContent[1].(string); !ok { + return errors.New("invalid dataflow location content") + } + return collectSemgrepLocation(locationAndContent[0], ranges) +} + +func collectSemgrepIntermediate(value any, ranges *[]semgrepTraceRange) error { + intermediate, ok := value.(map[string]any) + if !ok { + return errors.New("invalid dataflow intermediate") + } + if _, ok := intermediate["content"].(string); !ok { + return errors.New("invalid dataflow intermediate content") + } + return collectSemgrepLocation(intermediate["location"], ranges) +} + +func collectSemgrepLocation(value any, ranges *[]semgrepTraceRange) error { + location, ok := value.(map[string]any) + if !ok { + return errors.New("invalid dataflow location") + } + path, pathOK := location["path"].(string) + start, startOK := semgrepTraceLine(location["start"]) + end, endOK := semgrepTraceLine(location["end"]) + if !pathOK || !startOK || !endOK || path == "" || start < 1 || end < start { + return errors.New("invalid dataflow trace location") + } + *ranges = append(*ranges, semgrepTraceRange{Path: path, Start: start, End: end}) + return nil +} + +func semgrepTraceLine(value any) (int, bool) { + position, ok := value.(map[string]any) + if !ok { + return 0, false + } + line, ok := position["line"].(json.Number) + if !ok { + return 0, false + } + parsed, err := strconv.Atoi(string(line)) + return parsed, err == nil +} + func failed(response analyzerResponse, code string) analyzerResponse { response.Status, response.CoveredSignals, response.Signals, response.FailureCode = failedStatus, []string{}, nil, code return response @@ -443,6 +600,7 @@ func (a *adapter) createSnapshot(request analyzerRequest, artifact diffArtifact) postimages[path] = data } targets := make([]string, 0, len(artifact.added)) + lineCounts := make(map[string]int, len(artifact.added)) for path := range artifact.added { if !validRelativePath(path) { return snapshot{}, errors.New("diff path is invalid") @@ -451,9 +609,14 @@ func (a *adapter) createSnapshot(request analyzerRequest, artifact diffArtifact) if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { return snapshot{}, err } - if err := os.WriteFile(destination, postimages[path], 0o600); err != nil { + postimage := postimages[path] + if err := os.WriteFile(destination, postimage, 0o600); err != nil { return snapshot{}, err } + lineCounts[path] = bytes.Count(postimage, []byte{'\n'}) + if len(postimage) > 0 && postimage[len(postimage)-1] != '\n' { + lineCounts[path]++ + } targets = append(targets, path) } sort.Strings(targets) @@ -475,7 +638,7 @@ func (a *adapter) createSnapshot(request analyzerRequest, artifact diffArtifact) return snapshot{}, err } cleanup = false - return snapshot{directory: repository, config: configPath, targetsFile: targetsPath, targets: targets}, nil + return snapshot{directory: repository, config: configPath, targetsFile: targetsPath, targets: targets, lineCounts: lineCounts}, nil } func readRegularBeneath(rootFD int, path string) ([]byte, error) { @@ -550,7 +713,7 @@ func (a *adapter) scan(ctx context.Context, snapshot snapshot) (semgrepReport, s } func validReportShape(report semgrepReport) bool { - return report.Version != "" && report.Results != nil && report.Errors != nil && len(*report.Errors) == 0 && + return report.Version == semgrepCoreVersion && report.Results != nil && report.Errors != nil && len(*report.Errors) == 0 && report.Paths != nil && report.Paths.Scanned != nil && (report.Paths.Skipped == nil || len(*report.Paths.Skipped) == 0) && report.SkippedRules != nil && len(*report.SkippedRules) == 0 } @@ -788,8 +951,8 @@ func rangeIntersects(lines map[int]bool, start, end int) bool { if start < 1 || end < start { return false } - for line := start; line <= end; line++ { - if lines[line] { + for line := range lines { + if line >= start && line <= end { return true } } diff --git a/cmd/room-semgrep/main_integration_test.go b/cmd/room-semgrep/main_integration_test.go index 291314d..5a5387a 100644 --- a/cmd/room-semgrep/main_integration_test.go +++ b/cmd/room-semgrep/main_integration_test.go @@ -15,11 +15,13 @@ var integrationSignals = []string{ "SIGNAL_KIND_SECRET_LITERAL", "SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT", "SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION", + "SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH", "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", + "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + "SIGNAL_KIND_RUST_UNTRUSTED_PATH", + "SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT", } -const semgrepCoreVersion = "1.139.0" - func TestSemgrepCoreIntegration(t *testing.T) { core, config := integrationPaths(t) tests := []struct { @@ -73,16 +75,213 @@ fn main() { signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", }, { - name: "Rust request header command argument", + name: "Rust request path command argument", path: "request_command.rs", source: `use std::process::Command; fn handler(request: Request) { - let arg = request.headers().get("x-command").unwrap(); + let arg = request.uri().path(); Command::new("tool").arg(arg).status(); } `, signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", }, + { + name: "Rust request input panic", + path: "request_panic.rs", + source: `fn handler(request: Request) { + let value = request.headers().get("x-value"); + value.expect("required header"); +} +`, + signal: "SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH", + }, + { + name: "Rust untrusted filesystem path", + path: "untrusted_path.rs", + source: `fn main() { + let path = std::env::args_os().nth(1).unwrap(); + std::fs::read_to_string(path).unwrap(); +} +`, + signal: "SIGNAL_KIND_RUST_UNTRUSTED_PATH", + }, + { + name: "Rust blocking lock across await", + path: "blocking_lock.rs", + source: `async fn update(lock: Lock) { + let guard = lock.lock().unwrap(); + work().await; + consume(guard); +} +`, + signal: "SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT", + }, + { + name: "Rust weak RNG for secret", + path: "weak_rng.rs", + source: `fn issue() { + let mut rng = rand::rngs::SmallRng::seed_from_u64(7); + let api_key = rng.gen::(); +} +`, + signal: "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + }, + { + name: "Rust Actix request panic", + path: "actix_panic.rs", + source: `fn handler(request: HttpRequest) { + let value = request.match_info().get("account"); + value.unwrap(); +} +`, + signal: "SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH", + }, + { + name: "Rust request path reaches Tokio filesystem", + path: "tokio_path.rs", + source: `async fn handler(request: Request) { + let path = request.uri().path(); + tokio::fs::read(path).await; +} +`, + signal: "SIGNAL_KIND_RUST_UNTRUSTED_PATH", + }, + { + name: "Rust blocking write guard across await", + path: "blocking_write.rs", + source: `async fn update(lock: Lock) { + let guard = lock.write().unwrap(); + work().await; + consume(guard); +} +`, + signal: "SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT", + }, + { + name: "Rust fastrand secret", + path: "fastrand_secret.rs", + source: `fn issue() { + let session_token = fastrand::u64(..); +} +`, + signal: "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + }, + { + name: "Rust fastrand instance secret", + path: "fastrand_instance.rs", + source: `fn issue() { + let mut rng = fastrand::Rng::new(); + let session_token = rng.u64(..); +} +`, + signal: "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + }, + { + name: "Rust imported SmallRng secret", + path: "imported_small_rng.rs", + source: `use rand::rngs::SmallRng; +fn issue() { + let mut rng = SmallRng::seed_from_u64(7); + let api_key = rng.gen::(); +} +`, + signal: "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + }, + { + name: "Rust oorandom secret", + path: "oorandom_secret.rs", + source: `fn issue() { + let mut rng = oorandom::Rand64::new(7); + let nonce = rng.rand_u64(); +} +`, + signal: "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + }, + { + name: "Rust nanorand WyRand secret", + path: "nanorand_secret.rs", + source: `fn issue() { + let mut rng = nanorand::WyRand::new(); + let session_token = rng.generate::(); +} +`, + signal: "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + }, + { + name: "Rust mutable process command builder", + path: "command_builder.rs", + source: `fn run() { + let value = std::env::var("COMMAND_ARG").unwrap(); + let mut command = std::process::Command::new("tool"); + command.arg(value); +} +`, + signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", + }, + { + name: "Rust untrusted process executable", + path: "command_program.rs", + source: `fn run() { + let program = std::env::var("PROGRAM").unwrap(); + std::process::Command::new(program); +} +`, + signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", + }, + { + name: "Rust Tokio process command builder", + path: "tokio_command.rs", + source: `fn run() { + let value = std::env::var("COMMAND_ARG").unwrap(); + let mut command = tokio::process::Command::new("tool"); + command.arg(value); +} +`, + signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", + }, + { + name: "Rust imported std File path", + path: "imported_file.rs", + source: `use std::fs::File; +fn load() { + let path = std::env::args_os().nth(1).unwrap(); + File::open(path); +} +`, + signal: "SIGNAL_KIND_RUST_UNTRUSTED_PATH", + }, + { + name: "Rust aliased std fs path", + path: "aliased_fs.rs", + source: `use std::fs as filesystem; +fn load() { + let path = std::env::args_os().nth(1).unwrap(); + filesystem::read(path); +} +`, + signal: "SIGNAL_KIND_RUST_UNTRUSTED_PATH", + }, + { + name: "Rust tainted explicit panic", + path: "explicit_panic.rs", + source: `fn handler(request: Request) { + let account = request.uri().path(); + panic!("invalid account: {}", account); +} +`, + signal: "SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH", + }, + { + name: "Rust blocking lock API across await", + path: "blocking_api.rs", + source: `async fn update(lock: Lock) { + let mut guard = lock.blocking_lock(); + work().await; + consume(&mut guard); +} +`, + signal: "SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT", + }, } for _, test := range tests { @@ -138,6 +337,131 @@ func safe(db *sql.DB, r *http.Request) { fn main() { Command::new("tool").arg("status").status(); } +`, + }, + { + name: "fallible Rust request parsing", + path: "fallible_request.rs", + source: `fn handler(request: Request) -> Result<&Header, Error> { + let value = request.headers().get("x-value").ok_or(Error::MissingHeader)?; + Ok(value) +} +`, + }, + { + name: "fixed Rust filesystem path", + path: "fixed_path.rs", + source: `fn load() { + std::fs::read_to_string("config/default.toml").unwrap(); +} +`, + }, + { + name: "Rust blocking guard dropped before await", + path: "dropped_guard.rs", + source: `async fn update(lock: Lock) { + let guard = lock.lock().unwrap(); + consume(&guard); + drop(guard); + work().await; +} +`, + }, + { + name: "Rust CSPRNG secret and non-secret fast RNG", + path: "safe_rng.rs", + source: `fn values(mut os_rng: rand::rngs::OsRng) { + let session_token = os_rng.next_u64(); + let retry_jitter = fastrand::u64(..); +} +`, + }, + { + name: "Rust clap argument is not process execution", + path: "clap.rs", + source: `fn configure(request: Request, command: clap::Command) { + let name = request.uri().path(); + command.arg(name); +} +`, + }, + { + name: "Rust custom open method is not filesystem access", + path: "custom_open.rs", + source: `fn handler(request: Request, archive: Archive) { + let member = request.uri().path(); + archive.open(member); +} +`, + }, + { + name: "Rust nanorand ChaCha is a CSPRNG", + path: "nanorand_chacha.rs", + source: `fn issue() { + let mut rng = nanorand::ChaCha::new(); + let session_token = rng.generate::(); +} +`, + }, + { + name: "Rust clap command constructor is not process execution", + path: "clap_constructor.rs", + source: `fn configure(request: Request) { + let name = request.uri().path(); + clap::Command::new(name); +} +`, + }, + { + name: "Rust local fs module is not std fs", + path: "local_fs.rs", + source: `mod fs { fn read(path: &str) {} } +fn handler(request: Request) { + let member = request.uri().path(); + fs::read(member); +} +`, + }, + { + name: "Rust custom File is not std File", + path: "custom_file.rs", + source: `struct File; +impl File { fn open(path: &str) {} } +fn handler(request: Request) { + let member = request.uri().path(); + File::open(member); +} +`, + }, + { + name: "Rust response body is not request input", + path: "response_body.rs", + source: `fn render(response: Response) { + let body = response.body(); + body.unwrap(); +} +`, + }, + { + name: "Rust blocking lock dropped with qualified drop", + path: "qualified_drop.rs", + source: `async fn update(lock: Lock) { + let mut guard = lock.blocking_lock(); + consume(&mut guard); + std::mem::drop(guard); + work().await; +} +`, + }, + { + name: "Rust blocking lock dropped with core drop", + path: "core_drop.rs", + source: `async fn update(lock: Lock) { + let guard = lock.lock().expect("lock"); + consume(&guard); + core::mem::drop(guard); + work().await; +} `, }, } @@ -189,6 +513,91 @@ func handler(db *sql.DB, r *http.Request) { } } +func TestSemgrepCoreIntegrationIncludesAddedSources(t *testing.T) { + core, config := integrationPaths(t) + tests := []struct { + name, source, signal string + addedLine, resultLine int + }{ + { + name: "command source", + source: `fn handler(request: Request) { + let value = request.uri().path(); + std::process::Command::new("tool").arg(value); +} +`, + signal: "SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT", + addedLine: 2, + resultLine: 3, + }, + { + name: "panic source", + source: `fn handler(request: Request) { + let value = request.headers().get("x-value"); + value.expect("required"); +} +`, + signal: "SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH", + addedLine: 2, + resultLine: 3, + }, + { + name: "filesystem source", + source: `fn handler(request: Request) { + let path = request.uri().path(); + std::fs::read(path); +} +`, + signal: "SIGNAL_KIND_RUST_UNTRUSTED_PATH", + addedLine: 2, + resultLine: 3, + }, + { + name: "RNG source", + source: `fn issue() { + let random = fastrand::u64(..); + let session_token = random; +} +`, + signal: "SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET", + addedLine: 2, + resultLine: 3, + }, + { + name: "blocking lock acquisition", + source: `async fn update(lock: Lock) { + let guard = lock.lock().unwrap(); + work().await; + consume(guard); +} +`, + signal: "SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT", + addedLine: 2, + resultLine: 2, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository := t.TempDir() + path := "source_only.rs" + if err := os.WriteFile(filepath.Join(repository, path), []byte(test.source), 0o600); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(test.source, "\n"), "\n") + diff := []byte(fmt.Sprintf("diff --git a/%s b/%s\n--- a/%s\n+++ b/%s\n@@ -%d,0 +%d @@\n+%s\n", path, path, path, path, test.addedLine-1, test.addedLine, lines[test.addedLine-1])) + adapter, err := newAdapter(core, config, repository, append([]string(nil), integrationSignals...)) + if err != nil { + t.Fatal(err) + } + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.Status != completeStatus || len(response.Signals) != 1 || response.Signals[0].Kind != test.signal || response.Signals[0].Location.StartLine != int32(test.resultLine) { + t.Fatalf("response = %+v", response) + } + }) + } +} + func TestSemgrepCoreIntegrationRejectsInvalidRule(t *testing.T) { core, _ := integrationPaths(t) repository := t.TempDir() diff --git a/cmd/room-semgrep/main_test.go b/cmd/room-semgrep/main_test.go index 6bc9d0e..fb114ff 100644 --- a/cmd/room-semgrep/main_test.go +++ b/cmd/room-semgrep/main_test.go @@ -56,6 +56,85 @@ func TestAdapterMapsSemgrepMetadataAndFiltersToAddedLines(t *testing.T) { } } +func TestAdapterFiltersAgainstSemgrepDataflowTrace(t *testing.T) { + _, repository := workspace(t) + source := "fn handler() {\n\tlet query = input();\n\tdb.Query(query);\n}\n" + if err := os.WriteFile(filepath.Join(repository, "handler.go"), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + report := `{ + "version":"1.139.0", + "errors":[], + "paths":{"scanned":["handler.go"],"skipped":[]}, + "skipped_rules":[], + "results":[{"check_id":"taint","path":"handler.go","start":{"line":3},"end":{"line":3},"extra":{ + "metadata":{"room_signal":"SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT","room_confidence_basis_points":9000}, + "dataflow_trace":{ + "taint_source":["CliLoc",[{"path":"handler.go","start":{"line":2},"end":{"line":2}},"input()"]], + "intermediate_vars":[], + "taint_sink":["CliLoc",[{"path":"handler.go","start":{"line":3},"end":{"line":3}},"query"]] + } + }}] +}` + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter(fakeSemgrep(t, report, 0), config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + + diff := []byte("diff --git a/handler.go b/handler.go\n--- a/handler.go\n+++ b/handler.go\n@@ -1,0 +2 @@\n+\tlet query = input();\n") + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.Status != completeStatus || len(response.Signals) != 1 || response.Signals[0].Location.StartLine != 3 { + t.Fatalf("source-intersection response = %+v", response) + } + + diff = []byte("diff --git a/handler.go b/handler.go\n--- a/handler.go\n+++ b/handler.go\n@@ -0,0 +1 @@\n+fn handler() {\n") + response = adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.Status != completeStatus || len(response.Signals) != 0 { + t.Fatalf("non-intersection response = %+v", response) + } +} + +func TestAdapterRejectsInvalidSemgrepRanges(t *testing.T) { + tests := []struct { + name, resultEnd, tracePath string + traceLine int + }{ + {name: "result beyond file", resultEnd: "9223372036854775807", tracePath: "handler.go", traceLine: 2}, + {name: "trace beyond file", resultEnd: "3", tracePath: "handler.go", traceLine: 999}, + {name: "trace outside target", resultEnd: "3", tracePath: "../handler.go", traceLine: 2}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, repository := workspace(t) + source := "fn handler() {\n\tlet query = input();\n\tdb.Query(query);\n}\n" + if err := os.WriteFile(filepath.Join(repository, "handler.go"), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + report := fmt.Sprintf(`{ + "version":"1.139.0","errors":[],"paths":{"scanned":["handler.go"],"skipped":[]},"skipped_rules":[], + "results":[{"check_id":"taint","path":"handler.go","start":{"line":3},"end":{"line":%s},"extra":{ + "metadata":{"room_signal":"SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT","room_confidence_basis_points":9000}, + "dataflow_trace":{ + "taint_source":["CliLoc",[{"path":%q,"start":{"line":%d},"end":{"line":%d}},"input()"]], + "intermediate_vars":[], + "taint_sink":["CliLoc",[{"path":"handler.go","start":{"line":3},"end":{"line":3}},"query"]] + } + }}]}`, test.resultEnd, test.tracePath, test.traceLine, test.traceLine) + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter(fakeSemgrep(t, report, 0), config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + diff := []byte("diff --git a/handler.go b/handler.go\n--- a/handler.go\n+++ b/handler.go\n@@ -1,0 +2 @@\n+\tlet query = input();\n") + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.Status != failedStatus || response.FailureCode != "semgrep_result_invalid" { + t.Fatalf("response = %+v", response) + } + }) + } +} + func TestAdapterReturnsPartialForPlansWithoutRunningSemgrep(t *testing.T) { root, repository := workspace(t) config := writeFile(t, "rules.yml", testRules) @@ -104,7 +183,7 @@ func TestAdapterRejectsMalformedSemgrepResult(t *testing.T) { if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package main\n"), 0o600); err != nil { t.Fatal(err) } - semgrep := fakeSemgrep(t, `{"version":"1","errors":[],"paths":{"scanned":["main.go"],"skipped":[]},"skipped_rules":[],"results":[{"check_id":"bad","path":"main.go","start":{"line":0},"end":{"line":0}}]}`, 0) + semgrep := fakeSemgrep(t, `{"version":"1.139.0","errors":[],"paths":{"scanned":["main.go"],"skipped":[]},"skipped_rules":[],"results":[{"check_id":"bad","path":"main.go","start":{"line":0},"end":{"line":0}}]}`, 0) config := writeFile(t, "rules.yml", testRules) adapter, err := newAdapter(semgrep, config, repository, []string{sqlSignal}) if err != nil { @@ -121,9 +200,10 @@ func TestAdapterFailsClosedForIncompleteOrSkippedScans(t *testing.T) { tests := []struct { name, report, code string }{ - {"target missing", `{"version":"1","errors":[],"paths":{"scanned":[],"skipped":[]},"skipped_rules":[],"results":[]}`, "semgrep_targets_incomplete"}, - {"target skipped", `{"version":"1","errors":[],"paths":{"scanned":["main.go"],"skipped":[{"path":"main.go"}]},"skipped_rules":[],"results":[]}`, "semgrep_report_invalid"}, - {"scan error", `{"version":"1","errors":[{"message":"parse failed"}],"paths":{"scanned":["main.go"],"skipped":[]},"skipped_rules":[],"results":[]}`, "semgrep_report_invalid"}, + {"version mismatch", `{"version":"1.139.1","errors":[],"paths":{"scanned":["main.go"],"skipped":[]},"skipped_rules":[],"results":[]}`, "semgrep_report_invalid"}, + {"target missing", `{"version":"1.139.0","errors":[],"paths":{"scanned":[],"skipped":[]},"skipped_rules":[],"results":[]}`, "semgrep_targets_incomplete"}, + {"target skipped", `{"version":"1.139.0","errors":[],"paths":{"scanned":["main.go"],"skipped":[{"path":"main.go"}]},"skipped_rules":[],"results":[]}`, "semgrep_report_invalid"}, + {"scan error", `{"version":"1.139.0","errors":[{"message":"parse failed"}],"paths":{"scanned":["main.go"],"skipped":[]},"skipped_rules":[],"results":[]}`, "semgrep_report_invalid"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -204,6 +284,40 @@ func TestAdapterRejectsUnimplementedCoverageAndTraversalDiff(t *testing.T) { } } +func TestSemgrepTraceRanges(t *testing.T) { + trace := []byte(`{"taint_source":["CliLoc",[{"path":"main.rs","start":{"line":2},"end":{"line":3}},"source"]],"intermediate_vars":[{"location":{"path":"main.rs","start":{"line":4},"end":{"line":4}},"content":"value"}],"taint_sink":["CliLoc",[{"path":"main.rs","start":{"line":5},"end":{"line":5}},"sink"]]}`) + ranges, err := semgrepTraceRanges(trace) + if err != nil { + t.Fatal(err) + } + want := []semgrepTraceRange{{Path: "main.rs", Start: 2, End: 3}, {Path: "main.rs", Start: 4, End: 4}, {Path: "main.rs", Start: 5, End: 5}} + if fmt.Sprint(ranges) != fmt.Sprint(want) { + t.Fatalf("ranges = %+v, want %+v", ranges, want) + } + nested := []byte(`{"taint_source":["CliCall",[[{"path":"main.rs","start":{"line":1},"end":{"line":1}},"call"],[],["CliLoc",[{"path":"main.rs","start":{"line":2},"end":{"line":2}},"source"]]]],"taint_sink":["CliLoc",[{"path":"main.rs","start":{"line":3},"end":{"line":3}},"sink"]]}`) + ranges, err = semgrepTraceRanges(nested) + if err != nil || len(ranges) != 3 { + t.Fatalf("nested ranges = %+v, error = %v", ranges, err) + } + + for _, invalid := range []string{ + `null`, + `"trace"`, + `{}`, + `{"taint_source":[],"taint_sink":[]}`, + `{"taint_source":["Unknown",[]],"taint_sink":["CliLoc",[{"path":"main.rs","start":{"line":1},"end":{"line":1}},"sink"]]}`, + `{"taint_source":["CliCall",[]],"taint_sink":["CliLoc",[{"path":"main.rs","start":{"line":1},"end":{"line":1}},"sink"]]}`, + `{"taint_source":["CliLoc",[{"path":"main.rs","start":{"line":0},"end":{"line":1}},"source"]],"taint_sink":["CliLoc",[{"path":"main.rs","start":{"line":1},"end":{"line":1}},"sink"]]}`, + `{"taint_source":["CliLoc",[{"path":"main.rs","start":{"line":2},"end":{"line":1}},"source"]],"taint_sink":["CliLoc",[{"path":"main.rs","start":{"line":1},"end":{"line":1}},"sink"]]}`, + `{"taint_source":["CliLoc",[{"path":"main.rs","start":{"line":"2"},"end":{"line":2}},"source"]],"taint_sink":["CliLoc",[{"path":"main.rs","start":{"line":1},"end":{"line":1}},"sink"]]}`, + `{} {}`, + } { + if _, err := semgrepTraceRanges([]byte(invalid)); err == nil { + t.Fatalf("expected invalid trace %q to fail", invalid) + } + } +} + func workspace(t *testing.T) (string, string) { t.Helper() root := t.TempDir() diff --git a/docs/analyzer.md b/docs/analyzer.md index 5e851fd..3346f90 100644 --- a/docs/analyzer.md +++ b/docs/analyzer.md @@ -84,19 +84,32 @@ The bundled rules provide these coverage claims: | `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` | +| `SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH` | Rust | Hyper-style request URI or headers and Actix request values reaching `unwrap`, `expect`, or `panic` | +| `SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT` | Rust | Process environment/arguments and Hyper- or Actix-style request values reaching `Command::new`, `arg`, or `args` | +| `SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET` | Rust | Secret-like assignments derived from `fastrand` module/`Rng`, `rand::rngs::SmallRng`, `oorandom::{Rand32,Rand64}`, or `nanorand::{WyRand,Pcg64}` | +| `SIGNAL_KIND_RUST_UNTRUSTED_PATH` | Rust | Process or request values reaching standard or Tokio filesystem reads, writes, metadata, creation, deletion, copy, or rename operations | +| `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. +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. + +The bundled Semgrep rules do not claim +`SIGNAL_KIND_RUST_UNSAFE_WITHOUT_SAFETY_CONTRACT`, +`SIGNAL_KIND_RUST_PANIC_IN_LIBRARY_API`, or +`SIGNAL_KIND_RUST_UNVALIDATED_EXTERNAL_DESERIALIZATION`. The current rules do +not model safety-comment absence, public-library API boundaries, or validation +across definitions. ```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_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_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_PANIC_IN_REQUEST_PATH","--covered-signal","SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT","--covered-signal","SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET","--covered-signal","SIGNAL_KIND_RUST_UNTRUSTED_PATH","--covered-signal","SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT"]' ROOM_ANALYZER_CONFIG_FILE=/absolute/path/to/room/analyzers/semgrep/room.yml -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_COVERED_SIGNALS='["SIGNAL_KIND_SECRET_LITERAL","SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT","SIGNAL_KIND_UNTRUSTED_OUTBOUND_DESTINATION","SIGNAL_KIND_RUST_PANIC_IN_REQUEST_PATH","SIGNAL_KIND_RUST_COMMAND_WITH_UNTRUSTED_ARGUMENT","SIGNAL_KIND_RUST_WEAK_RNG_FOR_SECRET","SIGNAL_KIND_RUST_UNTRUSTED_PATH","SIGNAL_KIND_RUST_BLOCKING_LOCK_ACROSS_AWAIT"]' ROOM_ANALYZER_ID=room.semgrep ROOM_ANALYZER_VERSION=1 ```