Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

580 lines
20 KiB
Go

package astquery
import (
"strings"
"github.com/zzet/gortex/internal/parser"
)
// Bundled detectors. Each rule:
// - Has a stable kebab-case Name (the agent-visible handle).
// - Sets `match` as the row's anchor capture so engine.pickAnchor
// lands on the meaningful span rather than the whole subtree.
// - Defaults to ExcludeTests=true so test fixtures don't drown
// real findings; the few rules that should also flag tests
// opt out.
//
// Pattern style: every pattern is wrapped in `((…) @match (#…?))`
// when predicates apply to the rule as a whole. Capture names are
// short, lowercase identifiers documented at the rule.
//
// Adding a detector: write the pattern, register it from init(),
// add a golden test in detectors_test.go. Keep the count tight —
// ten high-signal rules age better than fifty noisy ones.
func init() {
RegisterDetector(detectorErrorNotWrapped())
RegisterDetector(detectorSQLStringConcat())
RegisterDetector(detectorWeakCrypto())
RegisterDetector(detectorPanicInLibrary())
RegisterDetector(detectorGoroutineWithoutRecover())
RegisterDetector(detectorHTTPClientNoTimeout())
RegisterDetector(detectorHardcodedSecret())
RegisterDetector(detectorEmptyCatch())
RegisterDetector(detectorJavaStringEquality())
RegisterDetector(detectorPythonMutableDefault())
RegisterDetector(detectorRustUnwrap())
RegisterDetector(detectorRustPanicMacro())
RegisterDetector(detectorRustAssertMacro())
RegisterDetector(detectorRustUnsafeBlock())
RegisterDetector(detectorPythonAssert())
RegisterDetector(detectorJSThrowInProd())
}
// UnsafePatternDetectors lists the detector names bundled by
// `analyze kind=unsafe_patterns`. The set is authoritative —
// `handleAnalyzeUnsafePatterns` iterates this slice to fan out the
// engine. Keeping the list here (next to the registrations) means a
// single edit adds a rule to both the bundle and the search_ast
// surface.
var UnsafePatternDetectors = []string{
// Go — already in the panic-in-library detector.
"panic-in-library",
// Rust.
"unsafe-rust-unwrap",
"unsafe-rust-panic-macro",
"unsafe-rust-assert-macro",
"unsafe-rust-block",
// Python.
"unsafe-python-assert",
// JavaScript / TypeScript.
"unsafe-js-throw",
}
// 1. error-not-wrapped (Go) -------------------------------------------------
//
// Matches `if err != nil { return err }` (or any single-arg
// pass-through return) without a `fmt.Errorf(..., %w, err)` wrap.
// Captures @errvar from the condition and @retvar from the return,
// then asserts they're identical so we don't false-positive on
// unrelated err handling.
func detectorErrorNotWrapped() *Detector {
return &Detector{
Name: "error-not-wrapped",
Description: "Returning a Go error verbatim from `if err != nil` instead of wrapping with `fmt.Errorf(\"…: %w\", err)` — strips the call-site context that makes errors debuggable.",
Severity: "warning",
Languages: map[string]string{
"go": `
((if_statement
condition: (binary_expression
left: (identifier) @errvar
operator: "!="
right: (nil))
consequence: (block
(statement_list
(return_statement
(expression_list
(identifier) @retvar))))) @match
(#eq? @errvar @retvar))
`,
},
}
}
// 2. sql-string-concat (Go / Python / JS / TS / Ruby) -----------------------
//
// Flags a SQL-shaped call site that builds the query via string
// concatenation. The detector is conservative — it only fires on
// well-known method names (`Query`, `Exec`, `execute`, `query`,
// `find_by_sql`) so a generic `+` over strings doesn't spam the
// audit. Cross-language by definition.
func detectorSQLStringConcat() *Detector {
return &Detector{
Name: "sql-string-concat",
Description: "SQL-shaped database call whose query argument is built with string concatenation — strong indicator of SQL injection in any language.",
Severity: "error",
Languages: map[string]string{
"go": `
((call_expression
function: (selector_expression
field: (field_identifier) @fn)
arguments: (argument_list
(binary_expression operator: "+") @concat)) @match
(#match? @fn "^(Query|QueryRow|Exec|QueryContext|ExecContext|QueryRowContext|Prepare|PrepareContext|Raw)$"))
`,
"python": `
((call
function: (attribute
attribute: (identifier) @fn)
arguments: (argument_list
(binary_operator operator: "+") @concat)) @match
(#match? @fn "^(execute|executemany|raw|fetch|fetchall|fetchone)$"))
`,
"javascript": `
((call_expression
function: (member_expression
property: (property_identifier) @fn)
arguments: (arguments
(binary_expression operator: "+") @concat)) @match
(#match? @fn "^(query|execute|exec|run|raw)$"))
`,
"typescript": `
((call_expression
function: (member_expression
property: (property_identifier) @fn)
arguments: (arguments
(binary_expression operator: "+") @concat)) @match
(#match? @fn "^(query|execute|exec|run|raw)$"))
`,
"ruby": `
((call
method: (identifier) @fn
arguments: (argument_list
(binary operator: "+") @concat)) @match
(#match? @fn "^(execute|exec_query|find_by_sql|where|select_all)$"))
`,
},
}
}
// 3. weak-crypto (Go / Python) ---------------------------------------------
//
// Flags hashing or symmetric-cipher constructors known to be
// cryptographically weak: MD5, SHA-1, DES, RC4. Both for password
// hashing and for HMAC keys these are deprecated; the only
// legitimate use is checksumming non-security-relevant data.
func detectorWeakCrypto() *Detector {
return &Detector{
Name: "weak-crypto",
Description: "Use of MD5 / SHA-1 / DES / RC4 — cryptographically broken for any security-sensitive purpose. Use SHA-256+, AES-GCM, or ChaCha20-Poly1305 instead.",
Severity: "error",
Languages: map[string]string{
"go": `
((call_expression
function: (selector_expression
operand: (identifier) @pkg
field: (field_identifier) @fn)) @match
(#match? @pkg "^(md5|sha1|des|rc4)$")
(#match? @fn "^(New|Sum|Sum256|NewCipher|NewTripleDESCipher)$"))
`,
"python": `
((call
function: (attribute
object: (identifier) @lib
attribute: (identifier) @fn)) @match
(#eq? @lib "hashlib")
(#match? @fn "^(md5|sha1|new)$"))
`,
},
}
}
// 4. panic-in-library (Go) -------------------------------------------------
//
// A direct `panic(...)` call. Excludes `_test.go` automatically; in
// tests panic is the right primitive. In library / production code
// panic should be reserved for "unreachable" invariants — return an
// error instead.
func detectorPanicInLibrary() *Detector {
return &Detector{
Name: "panic-in-library",
Description: "`panic(...)` call in non-test Go source. Library code should propagate errors; reserve panic for genuinely unreachable invariants.",
Severity: "warning",
Languages: map[string]string{
"go": `
((call_expression
function: (identifier) @fn) @match
(#eq? @fn "panic"))
`,
},
ExcludeTests: true,
}
}
// 5. goroutine-without-recover (Go) ----------------------------------------
//
// A `go func() { … }()` whose body never calls `recover()`. A panic
// inside the goroutine's body crashes the process; the canonical
// fix is `defer func() { _ = recover() }()` at the top of the
// goroutine. Pure-AST predicates can't express "absence" of a node,
// so the post-filter reads the body text and looks for a recover
// call.
func detectorGoroutineWithoutRecover() *Detector {
return &Detector{
Name: "goroutine-without-recover",
Description: "`go func() { … }()` whose body never calls `recover()` — a panic anywhere in that goroutine crashes the whole process.",
Severity: "warning",
Languages: map[string]string{
"go": `
(go_statement
(call_expression
function: (func_literal
body: (block) @body))) @match
`,
},
PostFilter: func(qr parser.QueryResult, _ []byte) bool {
body, ok := qr.Captures["body"]
if !ok {
return false
}
// Conservative containment check — false negatives
// (recover() inside a string literal would suppress
// the warning) are acceptable here; false positives
// would erode trust.
return !strings.Contains(body.Text, "recover()")
},
}
}
// 6. http-client-no-timeout (Go) -------------------------------------------
//
// `&http.Client{}` or `http.Client{}` literal that doesn't set
// `Timeout`. The default zero-value timeout means an upstream that
// never responds will wedge the goroutine forever — a classic
// production-grade outage trigger.
func detectorHTTPClientNoTimeout() *Detector {
return &Detector{
Name: "http-client-no-timeout",
Description: "`http.Client{}` literal without a `Timeout` field — defaults to no timeout, which lets a slow upstream wedge the goroutine indefinitely.",
Severity: "warning",
Languages: map[string]string{
"go": `
((composite_literal
type: (qualified_type
package: (package_identifier) @pkg
name: (type_identifier) @typ)
body: (literal_value) @body) @match
(#eq? @pkg "http")
(#eq? @typ "Client"))
`,
},
PostFilter: func(qr parser.QueryResult, _ []byte) bool {
body, ok := qr.Captures["body"]
if !ok {
return false
}
return !strings.Contains(body.Text, "Timeout")
},
}
}
// 7. hardcoded-secret (Go / Python / JS / TS / Ruby) ------------------------
//
// Any assignment whose left-hand identifier name looks like a
// credential (password / secret / api_key / apiKey / token) and
// whose right-hand side is a string literal of meaningful length.
// The post-filter rejects placeholder strings (length < 12, or
// purely punctuation) so the detector doesn't spam every
// `password = ""` empty-default.
func detectorHardcodedSecret() *Detector {
// (?i) makes the regex case-insensitive so apiKey, ApiKey,
// APIKey, and api_key all match.
const cred = "(?i)^(password|passwd|secret|api_?key|token|aws_?secret(_?key)?|access_?key|private_?key)$"
return &Detector{
Name: "hardcoded-secret",
Description: "Variable named like a credential (`password`, `secret`, `api_key`, `token`, …) assigned a non-trivial string literal. Move to env vars or a secret manager.",
Severity: "error",
Languages: map[string]string{
"go": `
((short_var_declaration
left: (expression_list (identifier) @name)
right: (expression_list (interpreted_string_literal) @val)) @match
(#match? @name "` + cred + `"))
`,
"python": `
((assignment
left: (identifier) @name
right: (string) @val) @match
(#match? @name "` + cred + `"))
`,
"javascript": `
((variable_declarator
name: (identifier) @name
value: (string) @val) @match
(#match? @name "` + cred + `"))
`,
"typescript": `
((variable_declarator
name: (identifier) @name
value: (string) @val) @match
(#match? @name "` + cred + `"))
`,
"ruby": `
((assignment
left: (identifier) @name
right: (string) @val) @match
(#match? @name "` + cred + `"))
`,
},
PostFilter: func(qr parser.QueryResult, _ []byte) bool {
val, ok := qr.Captures["val"]
if !ok {
return false
}
text := strings.Trim(val.Text, "\"'`")
if len(text) < 12 {
return false
}
// Reject obvious placeholders.
lower := strings.ToLower(text)
for _, marker := range []string{"todo", "fixme", "changeme", "placeholder", "example", "your-", "xxx"} {
if strings.Contains(lower, marker) {
return false
}
}
return true
},
}
}
// 8. empty-catch (Java / JavaScript / TypeScript / Python) -----------------
//
// A try/except|catch whose body is empty (or only `pass` in
// Python). Silently swallowing an exception is a near-universal
// bug pattern — we want at least a log call or a comment that
// explains why.
func detectorEmptyCatch() *Detector {
return &Detector{
Name: "empty-catch",
Description: "Catch / except clause with an empty body — silently swallowing exceptions hides production bugs and breaks observability.",
Severity: "warning",
Languages: map[string]string{
"java": `
((catch_clause body: (block) @body) @match)
`,
"javascript": `
((catch_clause body: (statement_block) @body) @match)
`,
"typescript": `
((catch_clause body: (statement_block) @body) @match)
`,
"python": `
((except_clause (block) @body) @match)
`,
},
PostFilter: func(qr parser.QueryResult, _ []byte) bool {
body, ok := qr.Captures["body"]
if !ok {
return false
}
text := strings.TrimSpace(body.Text)
text = strings.TrimPrefix(text, "{")
text = strings.TrimSuffix(text, "}")
text = strings.TrimSpace(text)
// Strip trivial bodies — empty, pass, ellipsis,
// comment-only.
lines := strings.Split(text, "\n")
meaningful := 0
for _, ln := range lines {
s := strings.TrimSpace(ln)
if s == "" || s == "pass" || s == "..." {
continue
}
if strings.HasPrefix(s, "//") || strings.HasPrefix(s, "#") || strings.HasPrefix(s, "*") {
continue
}
meaningful++
}
return meaningful == 0
},
}
}
// 9. java-string-equality (Java) -------------------------------------------
//
// `s == "foo"` or `"foo" == s` — Java string comparison via `==`
// compares object identity, not content. The bug is famous and
// still common in code that came from C# / Python / JS.
func detectorJavaStringEquality() *Detector {
return &Detector{
Name: "java-string-equality",
Description: "Java string comparison via `==` (compares object identity, not content). Use `.equals()` or `Objects.equals()`.",
Severity: "warning",
Languages: map[string]string{
"java": `
[
((binary_expression
left: (identifier)
operator: "=="
right: (string_literal)) @match)
((binary_expression
left: (string_literal)
operator: "=="
right: (identifier)) @match)
]
`,
},
}
}
// 10. python-mutable-default-arg (Python) -----------------------------------
//
// `def foo(x=[])` — the list is created once at def time and
// shared across every call that omits x. One of the most-cited
// Python pitfalls; the safe form is `def foo(x=None): if x is
// None: x = []`.
func detectorPythonMutableDefault() *Detector {
return &Detector{
Name: "python-mutable-default-arg",
Description: "Python function default value is a mutable container (list / dict / set). The container is created once at def time and shared across every call — almost certainly a bug.",
Severity: "warning",
Languages: map[string]string{
"python": `
((default_parameter
value: [(list) (dictionary) (set)]) @match)
`,
},
}
}
// 11. unsafe-rust-unwrap (Rust) ---------------------------------------------
//
// A `.unwrap()` / `.expect()` (or `_err` / `_or_else` variant) call on
// a `Result` / `Option`. Reaches for panic on the failure path —
// production code should propagate the error with `?` or handle the
// `None` / `Err` branch explicitly. Test code legitimately uses
// `.unwrap()` to assert preconditions, so the detector defaults to
// ExcludeTests.
func detectorRustUnwrap() *Detector {
return &Detector{
Name: "unsafe-rust-unwrap",
Description: "Rust `.unwrap()` / `.expect()` / `.unwrap_or_else()` / `.unwrap_err()` / `.expect_err()` in non-test source — panics on the failure path. Propagate with `?` or handle the `None` / `Err` branch explicitly.",
Severity: "warning",
Languages: map[string]string{
"rust": `
((call_expression
function: (field_expression
field: (field_identifier) @method)) @match
(#match? @method "^(unwrap|expect|unwrap_or_else|unwrap_err|expect_err)$"))
`,
},
ExcludeTests: true,
}
}
// 12. unsafe-rust-panic-macro (Rust) ----------------------------------------
//
// `panic!()`, `todo!()`, `unimplemented!()`, `unreachable!()`. All
// macro-invocation forms that abort on hit. `todo!` and
// `unimplemented!` are the strongest signal of incomplete code
// leaking into a non-test build.
func detectorRustPanicMacro() *Detector {
return &Detector{
Name: "unsafe-rust-panic-macro",
Description: "Rust `panic!` / `todo!` / `unimplemented!` / `unreachable!` macro invocation outside tests. `todo!` and `unimplemented!` mark incomplete code paths; `panic!` aborts the process on hit.",
Severity: "warning",
Languages: map[string]string{
"rust": `
((macro_invocation
macro: (identifier) @name) @match
(#match? @name "^(panic|todo|unimplemented|unreachable)$"))
`,
},
ExcludeTests: true,
}
}
// 13. unsafe-rust-assert-macro (Rust) ---------------------------------------
//
// `assert!`, `assert_eq!`, `assert_ne!`, `debug_assert!`,
// `debug_assert_eq!`, `debug_assert_ne!`. `assert!` family panics
// in release builds; `debug_assert!` family is compiled out under
// `--release` — both are surprising to find in production code.
// Listed separately from `panic!` so an agent can keep `panic!`
// noise tight while still surfacing the `assert!` discussion.
func detectorRustAssertMacro() *Detector {
return &Detector{
Name: "unsafe-rust-assert-macro",
Description: "Rust `assert!` / `assert_eq!` / `assert_ne!` (and `debug_assert*` variants) outside tests. Plain `assert!` panics in release; `debug_assert!` is silently compiled out — both are usually a sign that an invariant should be a proper `Result` / typed error instead.",
Severity: "info",
Languages: map[string]string{
"rust": `
((macro_invocation
macro: (identifier) @name) @match
(#match? @name "^(assert|assert_eq|assert_ne|debug_assert|debug_assert_eq|debug_assert_ne)$"))
`,
},
ExcludeTests: true,
}
}
// 14. unsafe-rust-block (Rust) ----------------------------------------------
//
// `unsafe { … }` block or an `unsafe fn` declaration. Every
// `unsafe` site is a hand-audit boundary; surfacing them lets a
// reviewer enumerate the full set without a manual grep that
// false-positives on `unsafe` substrings in comments / strings.
func detectorRustUnsafeBlock() *Detector {
return &Detector{
Name: "unsafe-rust-block",
Description: "Rust `unsafe { … }` block or `unsafe fn` declaration. Every `unsafe` site is a hand-audit boundary — soundness obligations cannot be checked by the compiler.",
Severity: "warning",
Languages: map[string]string{
"rust": `
[
(unsafe_block) @match
((function_item
(function_modifiers) @mods) @match
(#match? @mods "unsafe"))
]
`,
},
ExcludeTests: true,
}
}
// 15. unsafe-python-assert (Python) -----------------------------------------
//
// A Python `assert` statement in non-test code. Python's `-O` /
// `PYTHONOPTIMIZE` flag strips every `assert` at bytecode-compile
// time — so a production invariant guarded by `assert` silently
// disappears under optimised deployment. The fix is an explicit
// `if not cond: raise <ConcreteError>`.
func detectorPythonAssert() *Detector {
return &Detector{
Name: "unsafe-python-assert",
Description: "Python `assert` statement in non-test source. The `-O` / `PYTHONOPTIMIZE` flag strips every assert at bytecode-compile time, so production invariants guarded by assert silently disappear. Use `if not cond: raise <ConcreteError>` instead.",
Severity: "warning",
Languages: map[string]string{
"python": `
(assert_statement) @match
`,
},
ExcludeTests: true,
}
}
// 16. unsafe-js-throw (JavaScript / TypeScript) -----------------------------
//
// `throw <expr>` in non-test source. Throws inside async / Promise
// chains skip every synchronous handler; throwing non-Error values
// breaks every consumer relying on `.message` / `.stack`. The
// detector flags every `throw_statement` and leaves the
// production-vs-error-handling judgement to the reviewer.
func detectorJSThrowInProd() *Detector {
return &Detector{
Name: "unsafe-js-throw",
Description: "JavaScript / TypeScript `throw` statement in non-test source. Throwing inside async / Promise chains skips every synchronous handler; throwing non-Error values breaks consumers relying on `.message` / `.stack`. Review every site.",
Severity: "info",
Languages: map[string]string{
"javascript": `
(throw_statement) @match
`,
"typescript": `
(throw_statement) @match
`,
},
ExcludeTests: true,
}
}