diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 665cd41..3f78467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: - run: buf format -d --exit-code - run: buf lint - run: buf generate && git diff --exit-code + - run: go generate ./analyzers/semgrep && git diff --exit-code - run: go vet ./... - run: go test ./... - run: go test -race ./... diff --git a/analyzers/semgrep/generate.go b/analyzers/semgrep/generate.go new file mode 100644 index 0000000..6d949e0 --- /dev/null +++ b/analyzers/semgrep/generate.go @@ -0,0 +1,4 @@ +// Package semgrep owns Room's generated Semgrep configuration. +package semgrep + +//go:generate go run ../../cmd/semgrep-rule-bundler -rules rules -output room.yml diff --git a/analyzers/semgrep/room.yml b/analyzers/semgrep/room.yml index 9212549..7a46fe8 100644 --- a/analyzers/semgrep/room.yml +++ b/analyzers/semgrep/room.yml @@ -1,17 +1,5 @@ +# Code generated by semgrep-rule-bundler; DO NOT EDIT. 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 @@ -39,7 +27,6 @@ rules: - 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 @@ -64,7 +51,111 @@ rules: - pattern: http.NewRequest($METHOD, $URL, ...) - pattern: http.NewRequestWithContext($CTX, $METHOD, $URL, ...) - focus-metavariable: $URL - + - 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.command-with-untrusted-argument message: Untrusted process or request input reaches a process command or argument. severity: ERROR @@ -115,7 +206,6 @@ rules: 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 @@ -143,7 +233,6 @@ rules: - 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 @@ -226,113 +315,6 @@ rules: - 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 @@ -384,3 +366,15 @@ rules: metavariable: $SECRET regex: '(?i)^(?:.*_)?(?:secret|token|api_key|private_key|password|passwd|session_id|session_token|nonce|salt|otp)(?:_.*)?$' - focus-metavariable: $VALUE + - 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})"$' diff --git a/analyzers/semgrep/rules/room.go.dynamic-sql-with-untrusted-input.yml b/analyzers/semgrep/rules/room.go.dynamic-sql-with-untrusted-input.yml new file mode 100644 index 0000000..abff8ad --- /dev/null +++ b/analyzers/semgrep/rules/room.go.dynamic-sql-with-untrusted-input.yml @@ -0,0 +1,28 @@ +rules: + - id: room.go.dynamic-sql-with-untrusted-input + message: Untrusted HTTP input reaches the SQL query text. + severity: ERROR + languages: [go] + mode: taint + metadata: + room_signal: SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT + 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: $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 diff --git a/analyzers/semgrep/rules/room.go.untrusted-outbound-destination.yml b/analyzers/semgrep/rules/room.go.untrusted-outbound-destination.yml new file mode 100644 index 0000000..34852d4 --- /dev/null +++ b/analyzers/semgrep/rules/room.go.untrusted-outbound-destination.yml @@ -0,0 +1,25 @@ +rules: + - 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 diff --git a/analyzers/semgrep/rules/room.rust.blocking-lock-across-await.yml b/analyzers/semgrep/rules/room.rust.blocking-lock-across-await.yml new file mode 100644 index 0000000..26b8e1b --- /dev/null +++ b/analyzers/semgrep/rules/room.rust.blocking-lock-across-await.yml @@ -0,0 +1,106 @@ +rules: + - 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 diff --git a/analyzers/semgrep/rules/room.rust.command-with-untrusted-argument.yml b/analyzers/semgrep/rules/room.rust.command-with-untrusted-argument.yml new file mode 100644 index 0000000..13d202b --- /dev/null +++ b/analyzers/semgrep/rules/room.rust.command-with-untrusted-argument.yml @@ -0,0 +1,51 @@ +rules: + - 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::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: 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 diff --git a/analyzers/semgrep/rules/room.rust.request-input-panics.yml b/analyzers/semgrep/rules/room.rust.request-input-panics.yml new file mode 100644 index 0000000..1822988 --- /dev/null +++ b/analyzers/semgrep/rules/room.rust.request-input-panics.yml @@ -0,0 +1,28 @@ +rules: + - 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 diff --git a/analyzers/semgrep/rules/room.rust.untrusted-filesystem-path.yml b/analyzers/semgrep/rules/room.rust.untrusted-filesystem-path.yml new file mode 100644 index 0000000..a3c76cd --- /dev/null +++ b/analyzers/semgrep/rules/room.rust.untrusted-filesystem-path.yml @@ -0,0 +1,83 @@ +rules: + - 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 diff --git a/analyzers/semgrep/rules/room.rust.weak-rng-for-secret.yml b/analyzers/semgrep/rules/room.rust.weak-rng-for-secret.yml new file mode 100644 index 0000000..8ecb67c --- /dev/null +++ b/analyzers/semgrep/rules/room.rust.weak-rng-for-secret.yml @@ -0,0 +1,52 @@ +rules: + - 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/analyzers/semgrep/rules/room.source.high-confidence-secret-literal.yml b/analyzers/semgrep/rules/room.source.high-confidence-secret-literal.yml new file mode 100644 index 0000000..b230442 --- /dev/null +++ b/analyzers/semgrep/rules/room.source.high-confidence-secret-literal.yml @@ -0,0 +1,13 @@ +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})"$' diff --git a/cmd/semgrep-rule-bundler/main.go b/cmd/semgrep-rule-bundler/main.go new file mode 100644 index 0000000..584e79b --- /dev/null +++ b/cmd/semgrep-rule-bundler/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/haasonsaas/room/internal/semgrepbundle" +) + +func main() { + rules := flag.String("rules", "", "fragment directory") + output := flag.String("output", "", "generated output file") + flag.Parse() + if *rules == "" || *output == "" { + flag.Usage() + os.Exit(2) + } + data, err := semgrepbundle.Bundle(*rules) + if err == nil { + err = semgrepbundle.WriteFile(*output, data) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/docs/analyzer.md b/docs/analyzer.md index 3346f90..7f42323 100644 --- a/docs/analyzer.md +++ b/docs/analyzer.md @@ -72,6 +72,12 @@ metadata: room_confidence_basis_points: 9000 ``` +Rules are authored as YAML fragments under `analyzers/semgrep/rules`, with one +rule per file. After editing a fragment, run `go generate ./analyzers/semgrep` +and commit the regenerated `analyzers/semgrep/room.yml`. That generated regular +file remains the production input passed to `cmd/room-semgrep`; Room SHA-256 +binds its exact bytes as `ROOM_ANALYZER_CONFIG_FILE`. + Pass every metadata signal through a repeated `--covered-signal` argument and list the same signals in `ROOM_ANALYZER_COVERED_SIGNALS`. A finding that names an undeclared signal or an invalid confidence produces a failed receipt. Findings diff --git a/go.mod b/go.mod index 3cf7365..9dd74ea 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.5.0 golang.org/x/sys v0.44.0 google.golang.org/protobuf v1.36.11 + go.yaml.in/yaml/v3 v3.0.4 modernc.org/sqlite v1.53.0 ) @@ -21,7 +22,6 @@ require ( github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/oauth2 v0.35.0 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/semgrepbundle/bundle.go b/internal/semgrepbundle/bundle.go new file mode 100644 index 0000000..40d960e --- /dev/null +++ b/internal/semgrepbundle/bundle.go @@ -0,0 +1,228 @@ +package semgrepbundle + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + roomv1 "github.com/haasonsaas/room/gen/go/room/v1" + "go.yaml.in/yaml/v3" +) + +const warning = "# Code generated by semgrep-rule-bundler; DO NOT EDIT.\n" + +type rule struct { + id string + node *yaml.Node +} + +// Bundle recursively reads rule fragments and returns a canonical document. +func Bundle(root string) ([]byte, error) { + var paths []string + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == root { + if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() { + return fmt.Errorf("fragment root is not a directory") + } + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("fragment entry %q is a symlink", path) + } + if entry.IsDir() { + return nil + } + if !entry.Type().IsRegular() { + return fmt.Errorf("fragment entry %q is not regular", path) + } + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".yml" || ext == ".yaml" { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return nil, err + } + return BundleFiles(paths) +} + +// BundleFiles bundles the given fragments. It is exposed to test ordering. +func BundleFiles(paths []string) ([]byte, error) { + var rules []rule + seen := make(map[string]string) + for _, path := range paths { + parsed, err := parse(path) + if err != nil { + return nil, err + } + for _, current := range parsed { + if previous, ok := seen[current.id]; ok { + return nil, fmt.Errorf("duplicate rule id %q in %s and %s", current.id, previous, path) + } + seen[current.id] = path + rules = append(rules, current) + } + } + if len(rules) == 0 { + return nil, errors.New("no Semgrep rules found") + } + sort.Slice(rules, func(i, j int) bool { return rules[i].id < rules[j].id }) + sequence := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, current := range rules { + sequence.Content = append(sequence.Content, current.node) + } + document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!str", Value: "rules"}, sequence}}}} + var out bytes.Buffer + out.WriteString(warning) + encoder := yaml.NewEncoder(&out) + encoder.SetIndent(2) + if err := encoder.Encode(document); err != nil { + return nil, err + } + return out.Bytes(), encoder.Close() +} + +func parse(path string) ([]rule, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(data)) == 0 { + return nil, fmt.Errorf("%s: empty fragment", path) + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var document yaml.Node + if err := decoder.Decode(&document); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + var extra yaml.Node + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return nil, fmt.Errorf("%s: multiple YAML documents", path) + } + return nil, fmt.Errorf("%s: %w", path, err) + } + if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return nil, fmt.Errorf("%s: top level must be a mapping", path) + } + if err := validateYAML(document.Content[0]); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + top := document.Content[0] + if len(top.Content) != 2 || top.Content[0].Value != "rules" || top.Content[1].Kind != yaml.SequenceNode { + return nil, fmt.Errorf("%s: top level must contain only a rules sequence", path) + } + if len(top.Content[1].Content) != 1 { + return nil, fmt.Errorf("%s: rules sequence must contain exactly one rule", path) + } + result := make([]rule, 0, len(top.Content[1].Content)) + for _, node := range top.Content[1].Content { + id, err := validateRule(node) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + result = append(result, rule{id: id, node: node}) + } + return result, nil +} + +func validateRule(node *yaml.Node) (string, error) { + if node.Kind != yaml.MappingNode { + return "", errors.New("rule must be a mapping") + } + idNode := mappingValue(node, "id") + if idNode == nil || idNode.Kind != yaml.ScalarNode || idNode.Tag != "!!str" || strings.TrimSpace(idNode.Value) == "" { + return "", errors.New("rule id must be a non-empty scalar") + } + metadata := mappingValue(node, "metadata") + if metadata == nil || metadata.Kind != yaml.MappingNode { + return "", fmt.Errorf("rule %q has missing or invalid metadata", idNode.Value) + } + signal := mappingValue(metadata, "room_signal") + if signal == nil || signal.Kind != yaml.ScalarNode || signal.Tag != "!!str" || strings.TrimSpace(signal.Value) == "" { + return "", fmt.Errorf("rule %q has missing or invalid room_signal", idNode.Value) + } + value, ok := roomv1.SignalKind_value[signal.Value] + if !ok || value == int32(roomv1.SignalKind_SIGNAL_KIND_UNSPECIFIED) { + return "", fmt.Errorf("rule %q has unknown or unspecified room_signal %q", idNode.Value, signal.Value) + } + confidence := mappingValue(metadata, "room_confidence_basis_points") + var points int + if confidence == nil || confidence.Kind != yaml.ScalarNode || confidence.Tag != "!!int" || confidence.Decode(&points) != nil || points < 1 || points > 10000 { + return "", fmt.Errorf("rule %q has room_confidence_basis_points outside 1..10000", idNode.Value) + } + return idNode.Value, nil +} + +func validateYAML(node *yaml.Node) error { + if node.Kind == yaml.AliasNode || node.Anchor != "" { + return errors.New("anchors and aliases are not allowed") + } + if node.Kind == yaml.MappingNode { + seen := make(map[string]bool, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { + return errors.New("mapping keys must be strings") + } + if key.Value == "<<" || key.Tag == "!!merge" { + return errors.New("YAML merge keys are not allowed") + } + if seen[key.Value] { + return fmt.Errorf("duplicate mapping key %q", key.Value) + } + seen[key.Value] = true + } + } + for _, child := range node.Content { + if err := validateYAML(child); err != nil { + return err + } + } + return nil +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +// WriteFile atomically updates path, leaving an identical existing file untouched. +func WriteFile(path string, data []byte) error { + if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() { + if current, readErr := os.ReadFile(path); readErr == nil && bytes.Equal(current, data) { + return nil + } + } + dir := filepath.Dir(path) + temporary, err := os.CreateTemp(dir, ".room.yml-*") + if err != nil { + return err + } + name := temporary.Name() + defer os.Remove(name) + if _, err = temporary.Write(data); err == nil { + err = temporary.Chmod(0o644) + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(name, path) +} diff --git a/internal/semgrepbundle/bundle_test.go b/internal/semgrepbundle/bundle_test.go new file mode 100644 index 0000000..611bb8c --- /dev/null +++ b/internal/semgrepbundle/bundle_test.go @@ -0,0 +1,163 @@ +package semgrepbundle + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const validRule = `rules: + - id: %s + metadata: + room_signal: SIGNAL_KIND_SECRET_LITERAL + room_confidence_basis_points: 9000 + pattern: foo +` + +func write(t *testing.T, dir, name, contents string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestBundleFilesDeterministicByID(t *testing.T) { + dir := t.TempDir() + a := write(t, dir, "a.yml", sprintf(validRule, "room.a")) + b := write(t, dir, "b.yaml", sprintf(validRule, "room.b")) + forward, err := BundleFiles([]string{a, b}) + if err != nil { + t.Fatal(err) + } + reverse, err := BundleFiles([]string{b, a}) + if err != nil { + t.Fatal(err) + } + if string(forward) != string(reverse) { + t.Fatal("output depends on discovery order") + } + if !strings.HasPrefix(string(forward), warning+"rules:\n") || strings.Index(string(forward), "room.a") > strings.Index(string(forward), "room.b") { + t.Fatalf("non-canonical output:\n%s", forward) + } +} + +func TestBundleRejectsInvalidFragments(t *testing.T) { + tests := map[string][]string{ + "empty set": {}, + "duplicate ids": {sprintf(validRule, "room.same"), sprintf(validRule, "room.same")}, + "empty fragment": {""}, + "malformed yaml": {"rules: ["}, + "multiple documents": {sprintf(validRule, "room.a") + "---\nrules: []\n"}, + "wrong shape": {"other: []\n"}, + "multiple rules": {sprintf(validRule, "room.a") + strings.TrimPrefix(sprintf(validRule, "room.b"), "rules:\n")}, + "missing id": {"rules:\n - metadata: {}\n"}, + "missing signal": {"rules:\n - id: room.a\n metadata:\n room_confidence_basis_points: 1\n"}, + "unknown signal": {strings.ReplaceAll(sprintf(validRule, "room.a"), "SIGNAL_KIND_SECRET_LITERAL", "SIGNAL_KIND_NOPE")}, + "unspecified signal": {strings.ReplaceAll(sprintf(validRule, "room.a"), "SIGNAL_KIND_SECRET_LITERAL", "SIGNAL_KIND_UNSPECIFIED")}, + "zero confidence": {strings.ReplaceAll(sprintf(validRule, "room.a"), "9000", "0")}, + "high confidence": {strings.ReplaceAll(sprintf(validRule, "room.a"), "9000", "10001")}, + "invalid confidence": {strings.ReplaceAll(sprintf(validRule, "room.a"), "9000", "nope")}, + "float confidence": {strings.ReplaceAll(sprintf(validRule, "room.a"), "9000", "9000.9")}, + "exponent confidence": {strings.ReplaceAll(sprintf(validRule, "room.a"), "9000", "1e3")}, + "quoted confidence": {strings.ReplaceAll(sprintf(validRule, "room.a"), "9000", `"9000"`)}, + "duplicate id key": {strings.Replace( + sprintf(validRule, "room.a"), " - id: room.a\n", " - id: room.a\n id: room.b\n", 1, + )}, + "duplicate metadata key": {strings.Replace( + sprintf(validRule, "room.a"), " metadata:\n", " metadata: {}\n metadata:\n", 1, + )}, + "duplicate signal key": {strings.Replace( + sprintf(validRule, "room.a"), " room_signal: SIGNAL_KIND_SECRET_LITERAL\n", " room_signal: SIGNAL_KIND_SECRET_LITERAL\n room_signal: SIGNAL_KIND_SECRET_LITERAL\n", 1, + )}, + "duplicate nested operator": {strings.Replace( + sprintf(validRule, "room.a"), " pattern: foo\n", " patterns:\n - pattern: foo\n pattern: bar\n", 1, + )}, + "anchor": {strings.Replace( + sprintf(validRule, "room.a"), " pattern: foo\n", " pattern: &shared foo\n", 1, + )}, + "alias": {strings.Replace( + sprintf(validRule, "room.a"), " pattern: foo\n", " patterns:\n - pattern: &shared foo\n - pattern: *shared\n", 1, + )}, + "merge key": {strings.Replace( + sprintf(validRule, "room.a"), " pattern: foo\n", " pattern: foo\n <<: {severity: ERROR}\n", 1, + )}, + } + for name, fragments := range tests { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + var paths []string + for i, fragment := range fragments { + paths = append(paths, write(t, dir, sprintf("%d.yml", i), fragment)) + } + if _, err := BundleFiles(paths); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestBundleRejectsSymlink(t *testing.T) { + dir := t.TempDir() + target := write(t, dir, "rule.yml", sprintf(validRule, "room.a")) + if err := os.Symlink(target, filepath.Join(dir, "linked.yml")); err != nil { + t.Fatal(err) + } + if _, err := Bundle(dir); err == nil { + t.Fatal("expected error") + } +} + +func TestWriteFileDoesNotReplaceUnchangedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "room.yml") + data := []byte("same") + if err := WriteFile(path, data); err != nil { + t.Fatal(err) + } + before, _ := os.Stat(path) + time.Sleep(10 * time.Millisecond) + if err := WriteFile(path, data); err != nil { + t.Fatal(err) + } + after, _ := os.Stat(path) + if !before.ModTime().Equal(after.ModTime()) { + t.Fatal("unchanged file was replaced") + } +} + +func TestWriteFileReplacesSymlinkWithoutChangingTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.yml") + output := filepath.Join(dir, "room.yml") + data := []byte("same") + if err := os.WriteFile(target, data, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, output); err != nil { + t.Fatal(err) + } + if err := WriteFile(output, data); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(output) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() { + t.Fatalf("output mode = %v, want regular file", info.Mode()) + } + targetData, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(targetData, data) { + t.Fatalf("target data = %q, want %q", targetData, data) + } +} + +func sprintf(format string, args ...any) string { return fmt.Sprintf(format, args...) }