chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
package astquery
import (
"sort"
"strings"
"sync"
"github.com/zzet/gortex/internal/parser"
)
// Categories carried by SAST + adjacent rules. Free-form strings — the
// only consumers are the analyze-side dispatcher and the rule audit;
// the engine itself doesn't switch on these.
const (
CategorySAST = "sast"
CategoryHygiene = "hygiene"
CategoryCorrectness = "correctness"
CategoryPerformance = "performance"
// CategoryReview groups idiomatic / correctness rules (nil-deref,
// check-then-act, n-plus-one, logic errors) surfaced through the
// review path. Kept distinct from hygiene so these carry
// error/warning severity and feed the graph-grounding post-pass
// that runs one layer up (the detectors themselves stay pure-AST).
CategoryReview = "review"
)
// Detector is one named structural rule. The Languages map carries
// per-language tree-sitter S-expression queries; the engine compiles
// them once per run and runs the appropriate one for each target's
// language. PostFilter is an optional second-pass filter that
// receives the raw QueryResult plus the file's source bytes — used
// when a detector needs to do something beyond what tree-sitter
// query predicates support (e.g. "this regex matches the text of
// capture X" combined with structural shape).
type Detector struct {
Name string
Description string
Severity string
// Category lets the analyze layer fan rules out by purpose
// ("sast", "hygiene", "performance", "correctness"). Empty
// when the rule pre-dates the rule-library refactor — those
// inherit Category="" and surface only through the legacy
// unsafe_patterns bundle.
Category string
// CWE maps the rule to MITRE's Common Weakness Enumeration so
// SARIF / DefectDojo / GitHub Code Scanning consumers can join
// against canonical weakness IDs. Empty when the rule is pure
// hygiene with no security implication.
CWE string
// OWASP maps the rule to the OWASP Top 10 category, e.g.
// "A03:2021-Injection". Empty for non-web-app vulnerabilities.
OWASP string
// Tags are free-form taxonomy hooks: "injection", "deserialization",
// "crypto", "xxe", "ssrf", "path-traversal", "secrets",
// "deprecated", "django", "flask", etc. The analyze layer
// supports tag-based filtering (`tags:"crypto,deserialization"`).
Tags []string
// References are URLs / CWE links / Bandit plugin IDs (e.g.
// "B602", "bandit:subprocess_popen_with_shell_equals_true") so
// an agent can cross-check the rule's intent without re-deriving
// it from the description.
References []string
// Languages is keyed by the language string stored on KindFile
// nodes ("go", "python", "typescript", …). Each value is a
// tree-sitter S-expression. A capture named `match` is the
// row's anchor; absent that, the engine falls back to the
// longest captured node.
Languages map[string]string
// ExcludeTests defaults to true for detectors — a "panic in
// library" rule firing inside `_test.go` is noise. Detectors
// that intentionally inspect tests (e.g. a "test name doesn't
// match prefix" rule) can flip this to false.
ExcludeTests bool
// PostFilter is optional. Return true to keep the match.
PostFilter func(parser.QueryResult, []byte) bool
}
var (
detectorMu sync.RWMutex
detectorRegistry = map[string]*Detector{}
)
// RegisterDetector adds d to the global detector registry. Called
// from package-level init in detectors.go for each bundled rule.
// Tests may register additional detectors via RegisterDetector — the
// API is intentionally exported so a downstream consumer (e.g. a
// project-specific lint set) can layer rules without forking the
// engine.
func RegisterDetector(d *Detector) {
if d == nil || d.Name == "" {
return
}
d.normalise()
detectorMu.Lock()
detectorRegistry[d.Name] = d
detectorMu.Unlock()
}
func lookupDetector(name string) (*Detector, bool) {
detectorMu.RLock()
defer detectorMu.RUnlock()
d, ok := detectorRegistry[name]
return d, ok
}
// ListDetectors returns the names of every registered detector,
// sorted alphabetically. Used by the MCP layer to fail fast with a
// helpful error when a caller passes an unknown detector name.
func ListDetectors() []string {
detectorMu.RLock()
defer detectorMu.RUnlock()
names := make([]string, 0, len(detectorRegistry))
for n := range detectorRegistry {
names = append(names, n)
}
sort.Strings(names)
return names
}
// DescribeDetectors returns rich metadata for every registered
// detector, suitable for surfacing in the MCP tool description so
// agents can pick the right rule without an out-of-band docs lookup.
func DescribeDetectors() []DetectorInfo {
detectorMu.RLock()
defer detectorMu.RUnlock()
out := make([]DetectorInfo, 0, len(detectorRegistry))
for _, d := range detectorRegistry {
langs := make([]string, 0, len(d.Languages))
for l := range d.Languages {
langs = append(langs, l)
}
sort.Strings(langs)
tags := append([]string(nil), d.Tags...)
refs := append([]string(nil), d.References...)
out = append(out, DetectorInfo{
Name: d.Name,
Description: d.Description,
Severity: d.Severity,
Category: d.Category,
CWE: d.CWE,
OWASP: d.OWASP,
Tags: tags,
References: refs,
Languages: langs,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// DetectorsByCategory returns every registered rule whose Category
// matches one of the requested labels. Empty `cats` returns all rules
// (including legacy uncategorised ones). Used by the analyze layer
// to fan out `sast` / `hygiene` / etc. bundles.
func DetectorsByCategory(cats ...string) []*Detector {
want := make(map[string]struct{}, len(cats))
for _, c := range cats {
c = strings.ToLower(strings.TrimSpace(c))
if c == "" {
continue
}
want[c] = struct{}{}
}
detectorMu.RLock()
defer detectorMu.RUnlock()
out := make([]*Detector, 0, len(detectorRegistry))
for _, d := range detectorRegistry {
if len(want) == 0 {
out = append(out, d)
continue
}
if _, ok := want[strings.ToLower(d.Category)]; ok {
out = append(out, d)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// LookupDetector returns the detector with the given name. Used by
// the analyze layer when fanning out a curated set; returns nil when
// the name is unknown so callers can decide between skip and error.
func LookupDetector(name string) *Detector {
d, _ := lookupDetector(name)
return d
}
// DetectorInfo is the read-only projection used by the MCP layer.
type DetectorInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Severity string `json:"severity"`
Category string `json:"category,omitempty"`
CWE string `json:"cwe,omitempty"`
OWASP string `json:"owasp,omitempty"`
Tags []string `json:"tags,omitempty"`
References []string `json:"references,omitempty"`
Languages []string `json:"languages"`
}
func (d *Detector) normalise() {
d.Name = strings.TrimSpace(d.Name)
if d.Severity == "" {
d.Severity = "warning"
}
// Normalise language keys to the lowercase, hyphen-free form
// the engine and the graph use.
if len(d.Languages) > 0 {
fixed := make(map[string]string, len(d.Languages))
for k, v := range d.Languages {
fixed[strings.ToLower(strings.TrimSpace(k))] = v
}
d.Languages = fixed
}
// (Tests-exclusion default lives in the engine — see
// buildPlan; detectors don't need to flip a bit on every
// entry.)
}
+579
View File
@@ -0,0 +1,579 @@
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,
}
}
+460
View File
@@ -0,0 +1,460 @@
package astquery
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// runDetector is a small test helper that invokes RunOnSource with
// the named detector and returns the result. Tests assert on
// .Total / .Matches[*].Line so a query-level regression (a
// detector that compiles but matches the wrong shape) fails loud.
func runDetector(t *testing.T, name, lang, file, src string) Result {
t.Helper()
res, err := RunOnSource(context.Background(), Options{
Detector: name,
// Test fixtures use abstract paths like "lib.go" that
// IsTestPath flags as non-test, so detectors with
// ExcludeTests=true still fire.
}, file, lang, []byte(src))
require.NoError(t, err)
return res
}
func TestDetector_ErrorNotWrapped_FiresOnPassthrough(t *testing.T) {
src := `package x
func F() error {
if err := do(); err != nil {
return err
}
return nil
}
`
res := runDetector(t, "error-not-wrapped", "go", "lib.go", src)
require.Equal(t, 1, res.Total, "expected one match for plain pass-through")
assert.Equal(t, "error-not-wrapped", res.Matches[0].Detector)
assert.Equal(t, "warning", res.Matches[0].Severity)
}
func TestDetector_ErrorNotWrapped_SkipsWrappedReturns(t *testing.T) {
src := `package x
import "fmt"
func F() error {
if err := do(); err != nil {
return fmt.Errorf("doing: %w", err)
}
return nil
}
`
res := runDetector(t, "error-not-wrapped", "go", "lib.go", src)
assert.Equal(t, 0, res.Total, "wrapped errors must not match")
}
func TestDetector_SQLStringConcat_Go(t *testing.T) {
src := `package x
func F(db *sql.DB, name string) {
rows, _ := db.Query("SELECT * FROM users WHERE name = '" + name + "'")
_ = rows
}
`
res := runDetector(t, "sql-string-concat", "go", "lib.go", src)
require.GreaterOrEqual(t, res.Total, 1)
assert.Equal(t, "error", res.Matches[0].Severity)
}
func TestDetector_SQLStringConcat_GoParameterised(t *testing.T) {
src := `package x
func F(db *sql.DB, name string) {
rows, _ := db.Query("SELECT * FROM users WHERE name = ?", name)
_ = rows
}
`
res := runDetector(t, "sql-string-concat", "go", "lib.go", src)
assert.Equal(t, 0, res.Total, "parameterised query must not match")
}
func TestDetector_WeakCrypto_Go(t *testing.T) {
src := `package x
import (
"crypto/md5"
"crypto/sha256"
)
func F() {
_ = md5.New()
_ = sha256.New()
}
`
res := runDetector(t, "weak-crypto", "go", "lib.go", src)
require.Equal(t, 1, res.Total, "only md5.New() should match")
}
func TestDetector_WeakCrypto_Python(t *testing.T) {
src := `import hashlib
def f():
h1 = hashlib.md5(b"x")
h2 = hashlib.sha256(b"x")
return h1, h2
`
res := runDetector(t, "weak-crypto", "python", "lib.py", src)
require.Equal(t, 1, res.Total, "only hashlib.md5(...) should match")
}
func TestDetector_PanicInLibrary_Go(t *testing.T) {
src := `package x
func F() {
panic("nope")
}
`
res := runDetector(t, "panic-in-library", "go", "lib.go", src)
assert.Equal(t, 1, res.Total)
}
func TestDetector_PanicInLibrary_SkipsTestFiles(t *testing.T) {
// Path ending in _test.go should be classified as a test
// and skipped by the test-exclusion gate.
src := `package x
func F() {
panic("ok in tests")
}
`
res := runDetector(t, "panic-in-library", "go", "lib_test.go", src)
assert.Equal(t, 0, res.Total, "panic in test file must not flag")
}
func TestDetector_GoroutineWithoutRecover_Fires(t *testing.T) {
src := `package x
func F() {
go func() {
doSomething()
}()
}
`
res := runDetector(t, "goroutine-without-recover", "go", "lib.go", src)
assert.Equal(t, 1, res.Total)
}
func TestDetector_GoroutineWithoutRecover_SkipsRecoveredBody(t *testing.T) {
src := `package x
func F() {
go func() {
defer func() { _ = recover() }()
doSomething()
}()
}
`
res := runDetector(t, "goroutine-without-recover", "go", "lib.go", src)
assert.Equal(t, 0, res.Total)
}
func TestDetector_HTTPClientNoTimeout_Fires(t *testing.T) {
src := `package x
import "net/http"
func F() *http.Client {
return &http.Client{}
}
`
res := runDetector(t, "http-client-no-timeout", "go", "lib.go", src)
assert.Equal(t, 1, res.Total)
}
func TestDetector_HTTPClientNoTimeout_SkipsExplicitTimeout(t *testing.T) {
src := `package x
import (
"net/http"
"time"
)
func F() *http.Client {
return &http.Client{Timeout: 5 * time.Second}
}
`
res := runDetector(t, "http-client-no-timeout", "go", "lib.go", src)
assert.Equal(t, 0, res.Total)
}
func TestDetector_HardcodedSecret_Go(t *testing.T) {
// Camel-case `apiKey` is the canonical Go name for the
// credential — the regex must be case-insensitive to catch it.
// Values avoid markers ("example", "todo", "your-", …) so the
// placeholder-rejection post-filter doesn't drop them.
src := `package x
func F() {
password := "hunter2hunter2hunter"
apiKey := "AKIA0123456789ABCDEF"
emptyDefault := ""
_ = password
_ = apiKey
_ = emptyDefault
}
`
res := runDetector(t, "hardcoded-secret", "go", "lib.go", src)
require.Equal(t, 2, res.Total, "expect both password (snake) and apiKey (camel)")
}
func TestDetector_HardcodedSecret_Python(t *testing.T) {
src := `password = "hunter2hunter2hunter"
api_key = "AKIA0123456789ABCDEF"
empty_default = ""
placeholder = "TODO_set_me"
`
res := runDetector(t, "hardcoded-secret", "python", "lib.py", src)
require.Equal(t, 2, res.Total)
}
func TestDetector_EmptyCatch_JavaScript(t *testing.T) {
src := `function f() {
try {
risky();
} catch (e) {
}
try {
risky();
} catch (e) {
log(e);
}
}
`
res := runDetector(t, "empty-catch", "javascript", "lib.js", src)
assert.Equal(t, 1, res.Total)
}
func TestDetector_EmptyCatch_Python(t *testing.T) {
src := `def f():
try:
risky()
except Exception:
pass
try:
risky()
except Exception as e:
log(e)
`
res := runDetector(t, "empty-catch", "python", "lib.py", src)
assert.Equal(t, 1, res.Total)
}
func TestDetector_JavaStringEquality_Fires(t *testing.T) {
src := `class C {
boolean f(String s) {
return s == "foo";
}
boolean g(String s) {
return "bar" == s;
}
boolean h(String s) {
return s.equals("safe");
}
}
`
res := runDetector(t, "java-string-equality", "java", "C.java", src)
require.Equal(t, 2, res.Total, "two `==` comparisons must match; .equals() must not")
}
func TestDetector_PythonMutableDefault_Fires(t *testing.T) {
src := `def f(items=[]):
items.append(1)
return items
def g(opts={}):
return opts
def h(x=None):
return x
`
res := runDetector(t, "python-mutable-default-arg", "python", "lib.py", src)
require.Equal(t, 2, res.Total, "list and dict defaults match; None must not")
}
func TestListDetectors_TenBundled(t *testing.T) {
names := ListDetectors()
require.GreaterOrEqual(t, len(names), 10, "expected at least 10 bundled detectors")
want := map[string]bool{
"error-not-wrapped": false,
"sql-string-concat": false,
"weak-crypto": false,
"panic-in-library": false,
"goroutine-without-recover": false,
"http-client-no-timeout": false,
"hardcoded-secret": false,
"empty-catch": false,
"java-string-equality": false,
"python-mutable-default-arg": false,
"unsafe-rust-unwrap": false,
"unsafe-rust-panic-macro": false,
"unsafe-rust-assert-macro": false,
"unsafe-rust-block": false,
"unsafe-python-assert": false,
"unsafe-js-throw": false,
}
for _, n := range names {
if _, ok := want[n]; ok {
want[n] = true
}
}
for n, present := range want {
assert.True(t, present, "detector %q should be registered", n)
}
}
func TestDetector_UnsafeRustUnwrap_Fires(t *testing.T) {
src := `fn run(s: &str) {
let n: i32 = s.parse().unwrap();
let _v = std::fs::read("x").expect("missing");
let _ = s.parse::<i32>().unwrap_or_else(|_| 0);
let _ = match s.parse::<i32>() { Ok(v) => v, Err(_) => 0 };
}
`
res := runDetector(t, "unsafe-rust-unwrap", "rust", "lib.rs", src)
require.Equal(t, 3, res.Total, ".unwrap(), .expect(), .unwrap_or_else() must fire; the match arm must not")
assert.Equal(t, "warning", res.Matches[0].Severity)
}
func TestDetector_UnsafeRustUnwrap_DoesNotFireOnUnrelatedMethods(t *testing.T) {
src := `fn run() {
let v: Vec<i32> = Vec::new();
let _ = v.iter().count();
let _ = v.len();
}
`
res := runDetector(t, "unsafe-rust-unwrap", "rust", "lib.rs", src)
assert.Equal(t, 0, res.Total)
}
func TestDetector_UnsafeRustPanicMacro_Fires(t *testing.T) {
src := `fn run(n: i32) {
if n < 0 {
panic!("negative");
}
if n == 0 {
todo!("zero handling");
}
if n > 100 {
unreachable!();
}
if n == 1 {
unimplemented!();
}
println!("ok"); // must not match
}
`
res := runDetector(t, "unsafe-rust-panic-macro", "rust", "lib.rs", src)
require.Equal(t, 4, res.Total, "panic!, todo!, unreachable!, unimplemented! must fire; println! must not")
}
func TestDetector_UnsafeRustAssertMacro_Fires(t *testing.T) {
src := `fn run(a: i32, b: i32) {
assert!(a > 0);
assert_eq!(a, b);
assert_ne!(a, -1);
debug_assert!(a < 1_000);
debug_assert_eq!(a, b);
debug_assert_ne!(a, -2);
let _ = format!("{a}"); // must not match
}
`
res := runDetector(t, "unsafe-rust-assert-macro", "rust", "lib.rs", src)
require.Equal(t, 6, res.Total, "all six assert/debug_assert variants must fire")
assert.Equal(t, "info", res.Matches[0].Severity)
}
func TestDetector_UnsafeRustBlock_Fires(t *testing.T) {
src := `unsafe fn raw() {}
fn safe() {
unsafe {
let _ = 0;
}
}
fn also_safe() {
let _ = 1; // no match
}
`
res := runDetector(t, "unsafe-rust-block", "rust", "lib.rs", src)
require.Equal(t, 2, res.Total, "unsafe fn and unsafe { } must each fire once")
}
func TestDetector_UnsafePythonAssert_Fires(t *testing.T) {
src := `def run(x):
assert x > 0
if x < 0:
raise ValueError("neg")
return x + 1
`
res := runDetector(t, "unsafe-python-assert", "python", "lib.py", src)
require.Equal(t, 1, res.Total)
assert.Equal(t, "warning", res.Matches[0].Severity)
}
func TestDetector_UnsafePythonAssert_SkipsTestFiles(t *testing.T) {
src := `def test_run():
assert 1 + 1 == 2
`
res := runDetector(t, "unsafe-python-assert", "python", "test_run.py", src)
assert.Equal(t, 0, res.Total, "test_*.py must be excluded by default")
}
func TestDetector_UnsafeJSThrow_Fires(t *testing.T) {
src := `function run(x) {
if (x < 0) {
throw new Error("neg");
}
return x;
}
`
res := runDetector(t, "unsafe-js-throw", "javascript", "lib.js", src)
require.Equal(t, 1, res.Total)
assert.Equal(t, "info", res.Matches[0].Severity)
}
func TestDetector_UnsafeJSThrow_TypeScript(t *testing.T) {
src := `function run(x: number): number {
if (x < 0) {
throw new Error("neg");
}
return x;
}
`
res := runDetector(t, "unsafe-js-throw", "typescript", "lib.ts", src)
require.Equal(t, 1, res.Total)
}
func TestRawPattern_GoCallExpression(t *testing.T) {
// Sanity test for the raw-pattern path: find every panic()
// call without going through the detector registry.
src := `package x
func F() { panic("x") }
func G() { _ = "panic"; do() }
`
res, err := RunOnSource(context.Background(), Options{
Pattern: `((call_expression function: (identifier) @fn) @match (#eq? @fn "panic"))`,
}, "lib.go", "go", []byte(src))
require.NoError(t, err)
require.Equal(t, 1, res.Total, "only the real panic() call should match; the string literal must not")
}
func TestRawPattern_RejectsBadPattern(t *testing.T) {
_, err := RunOnSource(context.Background(), Options{
Pattern: `(this_node_does_not_exist) @match`,
}, "lib.go", "go", []byte(`package x`))
require.Error(t, err)
}
+552
View File
@@ -0,0 +1,552 @@
// Package astquery is the structural code-search engine behind the
// `search_ast` MCP tool. It runs tree-sitter pattern queries (raw
// S-expressions or one of the bundled named detectors) against
// already-indexed files and returns each match enriched with the
// enclosing symbol from the graph, the captured nodes, and the
// detector metadata.
//
// The package is deliberately graph-aware: results carry a
// `SymbolID` field so a caller can chain straight into
// `find_usages`, `verify_change`, or `apply_code_action` without a
// second graph walk. The pre-filter side of the same coin is on the
// caller — the MCP wrapper decides which (file, language) targets to
// hand the engine, scoping by repo / project / community / churn /
// fan-in / path-prefix before the engine spends a single tree-sitter
// parse. That two-layer split keeps astquery independently testable
// (no graph dependency) while still letting the wrapper exploit
// graph predicates ast-grep can't express.
package astquery
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"github.com/zzet/gortex/internal/parser"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
)
// Target points the engine at one indexed file. Both the on-disk path
// (for parsing) and the repo-prefixed graph path (for result
// enrichment and SymbolLookup) are needed because the graph speaks
// repo-relative IDs and the filesystem speaks absolute paths.
type Target struct {
AbsPath string
GraphPath string
Language string
}
// SymbolLookup resolves the enclosing function/method/closure at a
// 1-based line in a graph-relative file. The MCP layer wires this
// against `*graph.Graph`; tests can pass a stub. Returning empty
// strings is fine — matches simply ship without symbol enrichment.
type SymbolLookup func(graphPath string, line int) (symbolID, symbolName string)
// LanguageResolver maps a language name (as stored on KindFile nodes —
// "go", "python", "typescript", …) to its tree-sitter binding. The
// engine ships a default resolver covering the languages used by the
// bundled detectors; consumers can pass an extended resolver to
// support raw-pattern queries against any language Gortex indexes.
type LanguageResolver func(name string) *sitter.Language
// Options are the engine's run knobs. Pattern XOR Detector must be
// set; the engine does not invent a detector when both are blank.
type Options struct {
// Pattern is a raw tree-sitter S-expression query. Used when
// Detector is empty. The pattern's language is inferred from
// each Target's Language field — one compiled query per
// distinct (pattern, language) pair, cached for the run.
Pattern string
// Detector is the name of a bundled detector. When set, Pattern
// and Language are ignored; the engine picks the per-language
// pattern from the detector definition.
Detector string
// Language pre-filters Targets when Pattern is set: only
// targets whose Language matches this string are processed.
// Empty = no language filter.
Language string
// Targets is the file set to scan. The MCP wrapper builds this
// from the graph after applying scope predicates (repo /
// project / community / fan-in / churn / path-prefix); the
// engine itself does no graph walk.
Targets []Target
// SymbolLookup is consulted after a match to enrich the row
// with the enclosing function. Optional; nil leaves SymbolID
// blank.
SymbolLookup SymbolLookup
// Resolver maps language names to tree-sitter bindings.
// Required for both raw-pattern and detector runs (a detector's
// per-language patterns still need the binding to compile).
// Pass DefaultLanguageResolver for the bundled set.
Resolver LanguageResolver
// Limit caps the total returned matches. 0 means use Default.
Limit int
// MaxMatchText truncates each row's `MatchText` to this many
// bytes (utf-8 safe — we cut on rune boundaries). 0 → 200.
MaxMatchText int
// ExcludeTests drops matches whose target file path looks like
// a test (per IsTestPath). Detectors default this to true; raw
// patterns default to false because the user typed the query.
ExcludeTests bool
// Concurrency caps the worker pool. 0 → runtime.GOMAXPROCS(0).
Concurrency int
}
// Match is a single hit. The fields are deliberately flat so the MCP
// layer can hand them to the standard wire-format encoders without
// translation.
type Match struct {
File string `json:"file"`
Line int `json:"line"`
EndLine int `json:"end_line"`
Column int `json:"column"`
EndCol int `json:"end_col"`
SymbolID string `json:"symbol_id,omitempty"`
SymbolName string `json:"symbol_name,omitempty"`
Detector string `json:"detector,omitempty"`
Severity string `json:"severity,omitempty"`
Language string `json:"language"`
Text string `json:"text"`
Captures map[string]string `json:"captures,omitempty"`
}
// Result wraps the match list with summary counts. `Truncated` is set
// when Limit cut the response short — useful for the MCP layer to
// decide whether to surface a "narrow your filter" hint.
type Result struct {
Matches []Match `json:"matches"`
Total int `json:"total"`
Truncated bool `json:"truncated,omitempty"`
FilesWalked int `json:"files_walked"`
Errors []string `json:"errors,omitempty"`
}
const (
defaultLimit = 50
defaultMaxMatchText = 200
defaultMaxFileSize = 4 * 1024 * 1024
)
var errNoQuery = errors.New("astquery: pattern or detector is required")
// Run executes the configured query across all Targets and returns
// the assembled Result. The function is safe to call concurrently.
//
// Compilation strategy: each (pattern, language) pair is compiled
// once for the run via per-language `*parser.PreparedQuery` instances
// kept in a local cache. After Run returns the cache is closed so we
// don't leak C resources across runs.
func Run(ctx context.Context, opts Options) (Result, error) {
if opts.Pattern == "" && opts.Detector == "" {
return Result{}, errNoQuery
}
if opts.Resolver == nil {
opts.Resolver = DefaultLanguageResolver
}
if opts.Limit <= 0 {
opts.Limit = defaultLimit
}
if opts.MaxMatchText <= 0 {
opts.MaxMatchText = defaultMaxMatchText
}
if opts.Concurrency <= 0 {
opts.Concurrency = runtime.GOMAXPROCS(0)
}
plan, err := buildPlan(opts)
if err != nil {
return Result{}, err
}
defer plan.close()
if len(plan.targets) == 0 {
return Result{}, nil
}
jobs := make(chan Target, len(plan.targets))
results := make(chan []Match, opts.Concurrency)
errCh := make(chan string, opts.Concurrency)
var wg sync.WaitGroup
for i := 0; i < opts.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range jobs {
if ctx.Err() != nil {
return
}
m, e := plan.runTarget(ctx, t, opts)
if e != nil {
select {
case errCh <- fmt.Sprintf("%s: %v", t.GraphPath, e):
default:
}
}
if len(m) > 0 {
results <- m
}
}
}()
}
go func() {
for _, t := range plan.targets {
jobs <- t
}
close(jobs)
wg.Wait()
close(results)
close(errCh)
}()
out := Result{FilesWalked: len(plan.targets)}
for batch := range results {
out.Matches = append(out.Matches, batch...)
}
for e := range errCh {
out.Errors = append(out.Errors, e)
}
// Stable order: by file then line. Important for golden tests
// and for agent UX (consistent listings across reruns).
sort.Slice(out.Matches, func(i, j int) bool {
if out.Matches[i].File != out.Matches[j].File {
return out.Matches[i].File < out.Matches[j].File
}
return out.Matches[i].Line < out.Matches[j].Line
})
out.Total = len(out.Matches)
if out.Total > opts.Limit {
out.Matches = out.Matches[:opts.Limit]
out.Truncated = true
}
return out, ctx.Err()
}
// plan is the per-run state: one compiled query per language, the
// target list filtered by Language and ExcludeTests, and the detector
// definition (if any).
type plan struct {
queries map[string]*parser.PreparedQuery
detector *Detector
targets []Target
pattern string
}
func buildPlan(opts Options) (*plan, error) {
p := &plan{queries: make(map[string]*parser.PreparedQuery)}
// Detector mode: ignore Pattern + Language; the detector's
// per-language map drives both query compilation and target
// filtering.
if opts.Detector != "" {
d, ok := lookupDetector(opts.Detector)
if !ok {
return nil, fmt.Errorf("astquery: unknown detector %q (call ListDetectors to enumerate)", opts.Detector)
}
p.detector = d
for _, t := range opts.Targets {
if _, has := d.Languages[t.Language]; !has {
continue
}
if (opts.ExcludeTests || d.ExcludeTests) && IsTestPath(t.AbsPath) {
continue
}
p.targets = append(p.targets, t)
}
// Compile one query per language used by the detector,
// but only languages that actually have targets — saves
// CGO compile cost when the index is monolingual.
used := make(map[string]bool)
for _, t := range p.targets {
used[t.Language] = true
}
for lang := range used {
pat, ok := d.Languages[lang]
if !ok {
continue
}
tsLang := opts.Resolver(lang)
if tsLang == nil {
continue
}
q, err := parser.NewPreparedQuery(pat, tsLang)
if err != nil {
p.close()
return nil, fmt.Errorf("astquery: detector %q has invalid pattern for language %q: %w", d.Name, lang, err)
}
p.queries[lang] = q
}
return p, nil
}
// Raw-pattern mode: one query, one language.
p.pattern = opts.Pattern
for _, t := range opts.Targets {
if opts.Language != "" && t.Language != opts.Language {
continue
}
if opts.ExcludeTests && IsTestPath(t.AbsPath) {
continue
}
p.targets = append(p.targets, t)
}
used := make(map[string]bool)
for _, t := range p.targets {
used[t.Language] = true
}
for lang := range used {
tsLang := opts.Resolver(lang)
if tsLang == nil {
continue
}
q, err := parser.NewPreparedQuery(opts.Pattern, tsLang)
if err != nil {
p.close()
return nil, fmt.Errorf("astquery: pattern compile (lang=%s): %w", lang, err)
}
p.queries[lang] = q
}
if len(p.queries) == 0 {
return p, nil
}
return p, nil
}
func (p *plan) close() {
for _, q := range p.queries {
q.Close()
}
p.queries = nil
}
func (p *plan) runTarget(ctx context.Context, t Target, opts Options) ([]Match, error) {
src, err := readBoundedFile(t.AbsPath, defaultMaxFileSize)
if err != nil {
return nil, err
}
return p.runBytes(ctx, t, opts, src)
}
func (p *plan) runBytes(_ context.Context, t Target, opts Options, src []byte) ([]Match, error) {
q := p.queries[t.Language]
if q == nil {
return nil, nil
}
tsLang := opts.Resolver(t.Language)
if tsLang == nil {
return nil, fmt.Errorf("no tree-sitter binding for language %q", t.Language)
}
tree, err := parser.ParseFile(src, tsLang)
if err != nil {
return nil, err
}
defer tree.Close()
hits := parser.RunPrepared(q, tree.RootNode(), src)
if len(hits) == 0 {
return nil, nil
}
out := make([]Match, 0, len(hits))
for _, h := range hits {
// Prefer a "@match" capture as the row's anchor; fall back
// to the largest captured node when the user didn't tag
// one. This matches ast-grep's convention without making
// every detector spell @match explicitly.
anchor := pickAnchor(h.Captures)
if anchor == nil {
continue
}
text := truncateRune(anchor.Text, opts.MaxMatchText)
caps := make(map[string]string, len(h.Captures))
for name, cn := range h.Captures {
if name == "match" {
continue
}
caps[name] = truncateRune(cn.Text, opts.MaxMatchText)
}
m := Match{
File: t.GraphPath,
Line: anchor.StartLine + 1,
EndLine: anchor.EndLine + 1,
Column: anchor.StartCol,
EndCol: anchor.EndCol,
Language: t.Language,
Text: text,
Captures: caps,
}
if p.detector != nil {
m.Detector = p.detector.Name
m.Severity = p.detector.Severity
if p.detector.PostFilter != nil && !p.detector.PostFilter(h, src) {
continue
}
}
if opts.SymbolLookup != nil {
m.SymbolID, m.SymbolName = opts.SymbolLookup(t.GraphPath, m.Line)
}
out = append(out, m)
}
return out, nil
}
// pickAnchor selects the most useful capture as the result's anchor.
// Priority: explicit `@match` capture > longest captured span. The
// fallback is important for detectors that only define semantic
// captures and never an explicit `@match`.
func pickAnchor(caps map[string]*parser.CapturedNode) *parser.CapturedNode {
if m, ok := caps["match"]; ok {
return m
}
var best *parser.CapturedNode
bestLen := -1
for _, c := range caps {
l := len(c.Text)
if l > bestLen {
best = c
bestLen = l
}
}
return best
}
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
if st.Size() > maxBytes {
return nil, fmt.Errorf("file too large (%d bytes; cap %d)", st.Size(), maxBytes)
}
buf := make([]byte, st.Size())
if _, err := f.Read(buf); err != nil && st.Size() != 0 {
return nil, err
}
return buf, nil
}
// truncateRune cuts s to the first n bytes that fall on a UTF-8
// boundary. Appends "…" when truncation actually fired so consumers
// don't mistake a snipped result for a complete one.
func truncateRune(s string, n int) string {
if len(s) <= n {
return s
}
// Walk back to a rune start.
for n > 0 && (s[n]&0xC0) == 0x80 {
n--
}
return s[:n] + "…"
}
// RunOnSource executes one query against in-memory source bytes
// without touching the filesystem. Useful for unit tests, for the
// "lint this buffer before commit" UX, and for any caller that
// already has the bytes in hand. The function builds a Plan with a
// single synthetic Target whose GraphPath equals filePath; pass an
// empty filePath when the caller doesn't care.
//
// Limit / MaxMatchText / ExcludeTests are honored. SymbolLookup is
// honored too so a caller threading a graph in can still get
// enclosing-symbol enrichment for buffer-side lints.
func RunOnSource(ctx context.Context, opts Options, filePath, language string, src []byte) (Result, error) {
if opts.Pattern == "" && opts.Detector == "" {
return Result{}, errNoQuery
}
if opts.Resolver == nil {
opts.Resolver = DefaultLanguageResolver
}
if opts.Limit <= 0 {
opts.Limit = defaultLimit
}
if opts.MaxMatchText <= 0 {
opts.MaxMatchText = defaultMaxMatchText
}
target := Target{
AbsPath: filePath,
GraphPath: filePath,
Language: language,
}
opts.Targets = []Target{target}
plan, err := buildPlan(opts)
if err != nil {
return Result{}, err
}
defer plan.close()
matches, runErr := plan.runBytes(ctx, target, opts, src)
out := Result{FilesWalked: 1}
if runErr != nil {
out.Errors = []string{fmt.Sprintf("%s: %v", filePath, runErr)}
}
out.Matches = matches
out.Total = len(matches)
if out.Total > opts.Limit {
out.Matches = out.Matches[:opts.Limit]
out.Truncated = true
}
return out, nil
}
// IsTestPath returns true when the file path looks like a test under
// any of Gortex's recognised conventions. The list is deliberately
// conservative; false negatives (a test we don't recognise) ship
// matches that the agent can drop, which is much better than false
// positives (skipping production code that happens to have "test" in
// the name).
func IsTestPath(absPath string) bool {
base := filepath.Base(absPath)
dir := filepath.ToSlash(filepath.Dir(absPath))
switch {
case strings.HasSuffix(base, "_test.go"):
return true
case strings.HasSuffix(base, ".test.ts"), strings.HasSuffix(base, ".test.tsx"),
strings.HasSuffix(base, ".test.js"), strings.HasSuffix(base, ".test.jsx"),
strings.HasSuffix(base, ".spec.ts"), strings.HasSuffix(base, ".spec.tsx"),
strings.HasSuffix(base, ".spec.js"), strings.HasSuffix(base, ".spec.jsx"):
return true
case strings.HasPrefix(base, "test_") && strings.HasSuffix(base, ".py"):
return true
case strings.HasSuffix(base, "_test.py"):
return true
case strings.HasSuffix(base, "_test.rb"), strings.HasSuffix(base, "_spec.rb"):
return true
case strings.HasSuffix(base, "Test.java"), strings.HasSuffix(base, "Tests.java"),
strings.HasSuffix(base, "IT.java"):
return true
case strings.Contains(dir, "/__tests__/") || strings.HasSuffix(dir, "/__tests__"):
return true
case strings.Contains(dir, "/test/") || strings.Contains(dir, "/tests/") || strings.Contains(dir, "/spec/"):
return true
}
return false
}
// Compile-time checks: the runQuery hot path takes a *parser.PreparedQuery
// pointer; ensure we never lose the reference and force-close it under
// us. The plan owns every query and closes them in plan.close().
var _ = parser.RunPrepared
+75
View File
@@ -0,0 +1,75 @@
package astquery
import (
sitter "github.com/zzet/gortex/internal/parser/tsitter"
bashlang "github.com/zzet/gortex/internal/parser/tsitter/bash"
clang "github.com/zzet/gortex/internal/parser/tsitter/c"
cpplang "github.com/zzet/gortex/internal/parser/tsitter/cpp"
csharplang "github.com/zzet/gortex/internal/parser/tsitter/csharp"
elixirlang "github.com/zzet/gortex/internal/parser/tsitter/elixir"
golang "github.com/zzet/gortex/internal/parser/tsitter/golang"
javalang "github.com/zzet/gortex/internal/parser/tsitter/java"
jslang "github.com/zzet/gortex/internal/parser/tsitter/javascript"
kotlinlang "github.com/zzet/gortex/internal/parser/tsitter/kotlin"
phplang "github.com/zzet/gortex/internal/parser/tsitter/php"
pylang "github.com/zzet/gortex/internal/parser/tsitter/python"
rubylang "github.com/zzet/gortex/internal/parser/tsitter/ruby"
rustlang "github.com/zzet/gortex/internal/parser/tsitter/rust"
scalalang "github.com/zzet/gortex/internal/parser/tsitter/scala"
tsxlang "github.com/zzet/gortex/internal/parser/tsitter/tsx"
tslang "github.com/zzet/gortex/internal/parser/tsitter/typescript"
)
// DefaultLanguageResolver maps the language strings stored on
// KindFile nodes to their tree-sitter binding. The mapping covers
// every language the bundled detectors reference plus a handful of
// extras for raw-pattern queries. Languages that fall outside this
// list return nil — the engine then skips the matching targets
// (ast-grep behaves identically when its grammar isn't available).
//
// To extend: add the import + entry here. The detector definitions
// reference languages by string, not by binding pointer, so adding a
// language here doesn't require touching detectors.go.
func DefaultLanguageResolver(name string) *sitter.Language {
switch name {
case "go":
return golang.GetLanguage()
case "python":
return pylang.GetLanguage()
case "javascript":
return jslang.GetLanguage()
case "typescript":
// The plain .ts grammar handles JSX-free TypeScript. .tsx
// targets get retagged as "tsx" upstream so JSX-using
// detectors compile against the TSX grammar below.
return tslang.GetLanguage()
case "tsx":
// Superset of the TS grammar that exposes JSX nodes
// (jsx_element, jsx_attribute, …). Picked for .tsx targets
// so detectors that look for JSX shapes can compile cleanly.
return tsxlang.GetLanguage()
case "ruby":
return rubylang.GetLanguage()
case "java":
return javalang.GetLanguage()
case "kotlin":
return kotlinlang.GetLanguage()
case "scala":
return scalalang.GetLanguage()
case "rust":
return rustlang.GetLanguage()
case "elixir":
return elixirlang.GetLanguage()
case "php":
return phplang.GetLanguage()
case "c":
return clang.GetLanguage()
case "cpp", "c++":
return cpplang.GetLanguage()
case "csharp", "c#":
return csharplang.GetLanguage()
case "bash", "shell", "sh":
return bashlang.GetLanguage()
}
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package astquery
import (
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSASTCatalog_HitsSpecTarget asserts the rationale from the
// N54 gap-analysis row: SAST rule library reaches 190+ rules across
// the supported languages. Failing this test means new rules either
// regressed in count or lost their Category tag.
func TestSASTCatalog_HitsSpecTarget(t *testing.T) {
all := DescribeDetectors()
require.NotEmpty(t, all)
var sastCount, hygieneCount, totalCategorised int
for _, d := range all {
switch d.Category {
case CategorySAST:
sastCount++
totalCategorised++
case CategoryHygiene:
hygieneCount++
totalCategorised++
}
}
t.Logf("total detectors=%d sast=%d hygiene=%d uncategorised=%d",
len(all), sastCount, hygieneCount, len(all)-totalCategorised)
require.GreaterOrEqual(t, sastCount+hygieneCount, 175,
"sast + hygiene rules should be ≥175 (N54 target was 190+ across all categories including the 16 legacy uncategorised); got sast=%d hygiene=%d", sastCount, hygieneCount)
require.GreaterOrEqual(t, sastCount, 150,
"sast-categorised rules should be ≥150 to maintain Bandit parity")
}
// TestSASTCatalog_AllRulesHaveCWE confirms every sast-categorised rule
// carries a CWE identifier. Pure-hygiene rules don't (intentional —
// they're not security findings).
func TestSASTCatalog_AllRulesHaveCWE(t *testing.T) {
missing := []string{}
for _, d := range DescribeDetectors() {
if d.Category != CategorySAST {
continue
}
if strings.TrimSpace(d.CWE) == "" {
missing = append(missing, d.Name)
}
}
sort.Strings(missing)
assert.Empty(t, missing, "every SAST rule must carry a CWE id; missing on: %v", missing)
}
// TestSASTCatalog_LanguageCoverage spot-checks that each tracked
// language has at least the floor count of rules the gap-analysis
// row commits to.
func TestSASTCatalog_LanguageCoverage(t *testing.T) {
want := map[string]int{
"python": 90,
"go": 15,
"javascript": 10,
"typescript": 10,
"java": 8,
"ruby": 8,
"php": 8,
"rust": 4,
}
counts := map[string]int{}
for _, d := range DescribeDetectors() {
if d.Category != CategorySAST && d.Category != CategoryHygiene {
continue
}
for _, lang := range d.Languages {
counts[lang]++
}
}
t.Logf("per-language counts: %+v", counts)
for lang, floor := range want {
assert.GreaterOrEqual(t, counts[lang], floor,
"language %q has %d rules; floor is %d", lang, counts[lang], floor)
}
}
// TestSASTCatalog_NoDuplicateNames keeps the registry honest — a
// duplicate Name silently overwrites the older entry via the
// RegisterDetector map.
func TestSASTCatalog_NoDuplicateNames(t *testing.T) {
seen := make(map[string]int)
for _, d := range DescribeDetectors() {
seen[d.Name]++
}
for name, n := range seen {
require.Equal(t, 1, n, "detector name %q registered %d times", name, n)
}
}
// TestSASTCatalog_AllSeveritiesValid enforces the small severity
// taxonomy so downstream consumers (SARIF / DefectDojo / GitHub
// Code Scanning) don't see surprise labels.
func TestSASTCatalog_AllSeveritiesValid(t *testing.T) {
allowed := map[string]struct{}{"error": {}, "warning": {}, "info": {}}
for _, d := range DescribeDetectors() {
_, ok := allowed[strings.ToLower(d.Severity)]
assert.True(t, ok, "rule %q has invalid severity %q", d.Name, d.Severity)
}
}
// TestDetectorsByCategory exercises the public lookup the analyze
// dispatcher relies on. A simple round-trip: every Category we filter
// for must come back, and an unknown category returns 0.
func TestDetectorsByCategory(t *testing.T) {
sast := DetectorsByCategory(CategorySAST)
require.NotEmpty(t, sast)
for _, d := range sast {
require.Equal(t, CategorySAST, d.Category)
}
hyg := DetectorsByCategory(CategoryHygiene)
require.NotEmpty(t, hyg)
for _, d := range hyg {
require.Equal(t, CategoryHygiene, d.Category)
}
none := DetectorsByCategory("does-not-exist")
require.Empty(t, none)
all := DetectorsByCategory()
require.GreaterOrEqual(t, len(all), len(sast)+len(hyg))
}
// TestLookupDetector returns nil for unknown names and a *Detector
// for known ones — the contract analyze handlers rely on.
func TestLookupDetector(t *testing.T) {
require.NotNil(t, LookupDetector("py-eval-use"))
require.NotNil(t, LookupDetector("go-tls-insecure-skip-verify"))
require.NotNil(t, LookupDetector("js-eval-use"))
require.Nil(t, LookupDetector("xyzzy-does-not-exist"))
}
+139
View File
@@ -0,0 +1,139 @@
package astquery
// Cross-language hygiene rules. Lower severity than SAST proper —
// these are debugging-print / dev-debugger leftovers / dead asserts.
// Category is CategoryHygiene so an agent can run `analyze sast` and
// keep them out of the security bundle when they don't want noise.
func init() {
registerHygieneJSConsoleLog()
registerHygieneJSDebugger()
registerHygienePyPrint()
registerHygienePyPdbSetTrace()
registerHygienePyBreakpoint()
registerHygieneRubyByebug()
registerHygieneFmtPrintln()
registerHygienePhpVarDump()
registerHygieneJavaStackTrace()
}
func registerHygieneJSConsoleLog() {
pat := `((call_expression
function: (member_expression object: (identifier) @obj property: (property_identifier) @fn)) @match
(#eq? @obj "console") (#match? @fn "^(log|debug|info)$"))`
mustRegisterSAST(sastRule{
Name: "hygiene-js-console-log",
Description: "`console.log(...)` / `console.debug` / `console.info` in non-test source — leaks debug payloads to production. Use a structured logger.",
Severity: "info",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
func registerHygieneJSDebugger() {
pat := `(debugger_statement) @match`
mustRegisterSAST(sastRule{
Name: "hygiene-js-debugger",
Description: "`debugger;` statement in non-test source — pauses execution when devtools are open. Never ship.",
Severity: "warning",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
func registerHygienePyPrint() {
mustRegisterSAST(sastRule{
Name: "hygiene-py-print",
Description: "`print(...)` in non-test source — debug leftover. Use a logger.",
Severity: "info",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{
"python": `((call function: (identifier) @fn) @match (#eq? @fn "print"))`,
},
})
}
func registerHygienePyPdbSetTrace() {
mustRegisterSAST(sastRule{
Name: "hygiene-py-pdb-set-trace",
Description: "`pdb.set_trace()` / `ipdb.set_trace()` / `pudb.set_trace()` — debugger entrypoint left in source. Crashes production on hit.",
Severity: "error",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @fn)) @match
(#match? @mod "^(pdb|ipdb|pudb|pdbpp)$") (#eq? @fn "set_trace"))`,
},
})
}
func registerHygienePyBreakpoint() {
mustRegisterSAST(sastRule{
Name: "hygiene-py-breakpoint",
Description: "`breakpoint()` — the 3.7+ shortcut for `pdb.set_trace()`. Crashes production on hit unless `PYTHONBREAKPOINT=0`.",
Severity: "warning",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{
"python": `((call function: (identifier) @fn) @match (#eq? @fn "breakpoint"))`,
},
})
}
func registerHygieneRubyByebug() {
mustRegisterSAST(sastRule{
Name: "hygiene-ruby-byebug",
Description: "`byebug` / `binding.pry` — debugger entrypoint. Crashes production on hit (or worse, leaks an interactive shell over stderr).",
Severity: "error",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{
"ruby": `((call method: (identifier) @fn) @match (#match? @fn "^(byebug|pry)$"))
((call receiver: (call method: (identifier) @b) method: (identifier) @fn) @match
(#eq? @b "binding") (#match? @fn "^(pry|irb)$"))`,
},
})
}
func registerHygieneFmtPrintln() {
mustRegisterSAST(sastRule{
Name: "hygiene-go-fmt-println",
Description: "`fmt.Println(...)` / `fmt.Printf(...)` in non-test Go source — debug leftover. Use a structured logger (`slog`, `zap`).",
Severity: "info",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{
"go": `((call_expression function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)) @match
(#eq? @pkg "fmt") (#match? @fn "^(Println|Printf|Print)$"))`,
},
})
}
func registerHygienePhpVarDump() {
mustRegisterSAST(sastRule{
Name: "hygiene-php-var-dump",
Description: "`var_dump($x)` / `print_r($x)` / `var_export($x)` — debug-dump functions. Leaks structure / credentials to the response in production.",
Severity: "warning",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn) @match (#match? @fn "^(var_dump|print_r|var_export|debug_print_backtrace)$"))`,
},
})
}
func registerHygieneJavaStackTrace() {
mustRegisterSAST(sastRule{
Name: "hygiene-java-printstacktrace",
Description: "`e.printStackTrace()` in non-test Java source — writes to stderr only; production loggers miss the entry. Use a logger.",
Severity: "info",
Category: CategoryHygiene,
Tags: []string{"hygiene", "debug-leftover"},
Pat: map[string]string{
"java": `((method_invocation name: (identifier) @fn) @match (#eq? @fn "printStackTrace"))`,
},
})
}
+26
View File
@@ -0,0 +1,26 @@
package astquery
// Idiomatic / correctness review rulepack. These detectors register
// exactly like the SAST / hygiene rules — through mustRegisterSAST —
// but carry Category = CategoryReview so the analyze layer can fan
// them out independently of the security set.
//
// Two classes of rule live here:
//
// - Decidable rules (nil-deref-prone type assertions, inverted
// error checks) fire on a self-contained structural shape and need
// no further context. They surface as-is.
//
// - Undecidable-from-AST-alone rules (check-then-act on a map, a
// query call inside a loop body that smells of N+1) are emitted
// optimistically here and then refined by the graph-grounding
// post-pass in the review layer, where the resolved call / loop
// metadata is reachable. The detectors stay pure-AST: their
// PostFilters only ever see (parser.QueryResult, []byte).
//
// All rules cover Go and Python.
func init() {
registerGoReviewRules()
registerPyReviewRules()
}
+95
View File
@@ -0,0 +1,95 @@
package astquery
// Go idiomatic / correctness review detectors. Severity error|warning;
// Category CategoryReview. The N+1 and check-then-act rules are emitted
// optimistically and refined by the graph-grounding post-pass.
func registerGoReviewRules() {
mustRegisterSAST(
// --- NPE: single-result type assertion that panics on the
// wrong dynamic type. The comma-ok form `v, ok := x.(T)` is
// the safe idiom and does not match (it parses as an
// expression_list with two children).
sastRule{
Name: "go-unchecked-type-assertion",
Description: "Single-result type assertion `x.(T)` panics when the dynamic type doesn't match. Use the comma-ok form `v, ok := x.(T)` and handle the failure.",
Severity: "warning",
Category: CategoryReview,
Tags: []string{"npe", "panic", "correctness"},
Pat: map[string]string{
"go": `((short_var_declaration
left: (expression_list . (identifier) .)
right: (expression_list (type_assertion_expression) @assert)) @match)`,
},
},
// --- Logic error: an inverted error check that returns the
// (nil) error it just confirmed is nil. Almost always a
// flipped `!=` / `==`.
sastRule{
Name: "go-inverted-err-check",
Description: "`if err == nil { return err }` returns the error on the nil branch — an inverted check that silently swallows the real failure. The guard is almost certainly meant to be `err != nil`.",
Severity: "error",
Category: CategoryReview,
Tags: []string{"logic-error", "error-handling", "correctness"},
Pat: 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)
(#match? @errvar "(?i)err"))`,
},
},
// --- Check-then-act (TOCTOU / thread-safety): a map presence
// check immediately followed by a write to the same map under
// the !ok branch. Emitted optimistically; the grounding
// post-pass keeps it only when the enclosing scope shows a
// concurrent / mutating shape.
sastRule{
Name: "go-check-then-act-map",
Description: "`if _, ok := m[k]; !ok { m[k] = … }` reads then writes the same map without holding a lock across both — a check-then-act race when the map is shared. Hold the lock over the whole read-modify-write or use a single atomic operation.",
Severity: "warning",
Category: CategoryReview,
Tags: []string{"check-then-act", "toctou", "thread-safety", "concurrency"},
Pat: map[string]string{
"go": `((if_statement
initializer: (short_var_declaration
right: (expression_list (index_expression operand: (identifier) @check)))
condition: (unary_expression operand: (identifier))
consequence: (block
(statement_list
(assignment_statement
left: (expression_list (index_expression operand: (identifier) @act)))))) @match
(#eq? @check @act))`,
},
},
// --- N+1: a query-shaped call inside a for-loop body. Emitted
// optimistically; the grounding post-pass drops it when the
// enclosing symbol provably contains no loop (loop_depth==0).
sastRule{
Name: "go-loop-query-call",
Description: "Database / query-shaped call inside a `for` loop body — a classic N+1 query. Batch the lookups into a single round-trip (one IN-query or a join) instead of querying per iteration.",
Severity: "warning",
Category: CategoryReview,
Tags: []string{"n-plus-one", "performance", "correctness"},
Pat: map[string]string{
"go": `((for_statement
body: (block
(statement_list
(expression_statement
(call_expression
function: (selector_expression
field: (field_identifier) @fn)))))) @match
(#match? @fn "^(Query|QueryRow|QueryContext|QueryRowContext|Exec|ExecContext|Get|Select|Find|First|Scan)$"))`,
},
},
)
}
+88
View File
@@ -0,0 +1,88 @@
package astquery
// Python idiomatic / correctness review detectors. Severity
// error|warning; Category CategoryReview. The N+1 and check-then-act
// rules are emitted optimistically and refined by the graph-grounding
// post-pass.
func registerPyReviewRules() {
mustRegisterSAST(
// --- NPE-class footgun: a mutable default argument. The list /
// dict is created once at def-time and shared across every
// call, so mutating it leaks state between invocations. Use
// `None` and build the container in the body.
sastRule{
Name: "py-mutable-default-arg",
Description: "Mutable default argument (`def f(x=[])` / `def f(x={})`) — the default is created once and shared across all calls, leaking state between invocations. Default to `None` and build the container inside the function.",
Severity: "warning",
Category: CategoryReview,
Tags: []string{"npe", "logic-error", "correctness"},
Pat: map[string]string{
"python": `((default_parameter value: [(list) (dictionary) (set)] @default) @match)`,
},
},
// --- Logic error: a comparison of an identifier with itself,
// which is constant (always True for ==, always False for !=).
// Almost always a typo for a different operand.
sastRule{
Name: "py-self-comparison",
Description: "Comparison of an expression with itself (`if x == x:`) is constant — always True (or always False for `!=`). Almost certainly a typo; one side should reference a different value.",
Severity: "error",
Category: CategoryReview,
Tags: []string{"logic-error", "correctness"},
Pat: map[string]string{
"python": `((comparison_operator
. (identifier) @lhs
. ["==" "!="]
. (identifier) @rhs .) @match
(#eq? @lhs @rhs))`,
},
},
// --- Check-then-act (TOCTOU / thread-safety): a `not in`
// membership check on a dict immediately followed by a write to
// the same dict. Emitted optimistically; the grounding
// post-pass keeps it only when the enclosing scope shows a
// mutating shape.
sastRule{
Name: "py-check-then-act-dict",
Description: "`if k not in d: d[k] = …` reads then writes the same dict without holding a lock across both — a check-then-act race when the dict is shared between threads / tasks. Use `setdefault`, a single locked region, or `dict.get` with a default.",
Severity: "warning",
Category: CategoryReview,
Tags: []string{"check-then-act", "toctou", "thread-safety", "concurrency"},
Pat: map[string]string{
"python": `((if_statement
condition: (comparison_operator
(identifier)
"not in"
(identifier) @coll)
consequence: (block
(expression_statement
(assignment
left: (subscript value: (identifier) @target))))) @match
(#eq? @coll @target))`,
},
},
// --- N+1: a query-shaped call inside a for-loop body. Emitted
// optimistically; the grounding post-pass drops it when the
// enclosing symbol provably contains no loop (loop_depth==0).
sastRule{
Name: "py-loop-query-call",
Description: "Database / query-shaped call inside a `for` loop body — a classic N+1 query. Batch the lookups into a single round-trip (one IN-query, a join, or a prefetch) instead of querying per iteration.",
Severity: "warning",
Category: CategoryReview,
Tags: []string{"n-plus-one", "performance", "correctness"},
Pat: map[string]string{
"python": `((for_statement
body: (block
(expression_statement
(call
function: (attribute
attribute: (identifier) @fn))))) @match
(#match? @fn "^(execute|executemany|query|get|filter|fetch|fetchone|fetchall|find|find_one|first|all|scalar)$"))`,
},
},
)
}
+120
View File
@@ -0,0 +1,120 @@
package astquery
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
// TestReviewRulesCompile drives every registered review detector
// against a stub source per language so a tree-sitter pattern that
// compiles in isolation but not under the engine fails at unit-test
// time.
func TestReviewRulesCompile(t *testing.T) {
stubs := map[string]struct {
file string
src string
}{
"go": {"sample.go", "package x\n"},
"python": {"sample.py", "x = 1\n"},
}
for _, info := range DescribeDetectors() {
if info.Category != CategoryReview {
continue
}
for _, lang := range info.Languages {
stub, ok := stubs[lang]
if !ok {
continue
}
lang := lang
t.Run(info.Name+"/"+lang, func(t *testing.T) {
_, err := RunOnSource(context.Background(), Options{Detector: info.Name},
stub.file, lang, []byte(stub.src))
require.NoError(t, err, "review rule %s failed to compile for %s", info.Name, lang)
})
}
}
}
// TestReviewRulesFire pairs each review rule with a positive fixture
// (must fire) and a negative fixture (must be silent), for Go and
// Python.
func TestReviewRulesFire(t *testing.T) {
cases := []struct {
name string
lang string
file string
bad string
good string
}{
// --- Go ---------------------------------------------------------
{"go-unchecked-type-assertion", "go", "lib.go",
"package x\nfunc f(y any) string {\n\tv := y.(string)\n\treturn v\n}\n",
"package x\nfunc f(y any) (string, bool) {\n\tv, ok := y.(string)\n\treturn v, ok\n}\n"},
{"go-inverted-err-check", "go", "lib.go",
"package x\nfunc f() error {\n\terr := do()\n\tif err == nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n",
"package x\nfunc f() error {\n\terr := do()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"},
{"go-check-then-act-map", "go", "lib.go",
"package x\nfunc f(m map[string]int, k string) {\n\tif _, ok := m[k]; !ok {\n\t\tm[k] = 1\n\t}\n}\n",
"package x\nfunc f(m map[string]int, k string) {\n\tif _, ok := m[k]; !ok {\n\t\tcompute(k)\n\t}\n}\n"},
{"go-loop-query-call", "go", "lib.go",
"package x\nfunc f(db *DB, ids []int) {\n\tfor _, id := range ids {\n\t\tdb.Query(id)\n\t}\n}\n",
"package x\nfunc f(db *DB, id int) {\n\tdb.Query(id)\n}\n"},
// --- Python -----------------------------------------------------
{"py-mutable-default-arg", "python", "lib.py",
"def f(x=[]):\n return x\n",
"def f(x=None):\n return x\n"},
{"py-self-comparison", "python", "lib.py",
"if a == a:\n pass\n",
"if a == b:\n pass\n"},
{"py-check-then-act-dict", "python", "lib.py",
"if k not in d:\n d[k] = 1\n",
"if k not in d:\n compute(k)\n"},
{"py-loop-query-call", "python", "lib.py",
"for u in users:\n db.query(u)\n",
"db.query(u)\n"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
bad := runDetector(t, c.name, c.lang, c.file, c.bad)
require.GreaterOrEqual(t, bad.Total, 1,
"review rule %q should fire on its positive fixture; got 0", c.name)
require.Equal(t, CategoryReview, lookupCategory(c.name),
"review rule %q must carry CategoryReview", c.name)
good := runDetector(t, c.name, c.lang, c.file, c.good)
require.Equal(t, 0, good.Total,
"review rule %q should be silent on its negative fixture; got %d", c.name, good.Total)
})
}
}
// TestReviewRulesCoverBothLanguages asserts the rulepack ships at
// least one review detector for each of Go and Python (the languages
// the spec requires).
func TestReviewRulesCoverBothLanguages(t *testing.T) {
langs := map[string]int{}
for _, info := range DescribeDetectors() {
if info.Category != CategoryReview {
continue
}
for _, l := range info.Languages {
langs[l]++
}
}
require.GreaterOrEqual(t, langs["go"], 1, "expected at least one Go review rule")
require.GreaterOrEqual(t, langs["python"], 1, "expected at least one Python review rule")
}
func lookupCategory(name string) string {
d, ok := lookupDetector(name)
if !ok {
return ""
}
return d.Category
}
+79
View File
@@ -0,0 +1,79 @@
package astquery
// Package-level helpers shared by the per-language rule files
// (rules_sast_python.go, rules_sast_go.go, etc.). The split keeps
// each file under a few hundred LOC while still giving every rule a
// CWE / OWASP / category tag.
//
// All rules registered through these helpers default to:
// - ExcludeTests = true (a SAST finding inside a test fixture is
// almost always intentional bait)
// - Category = CategorySAST
// - Severity = severity arg
//
// PostFilter callers should attach the filter after the builder
// returns — keeps the registration call tight.
import (
"github.com/zzet/gortex/internal/parser"
)
// sastRule is a compact form for the per-language rule tables. The
// `Pat` map carries one tree-sitter S-expression per language; passing
// multiple languages in one entry means the same vulnerability is
// detected with the same name across them (e.g. `eval-use` across
// JS+TS or `xml-without-defusedxml` across the three xml.* submodules).
type sastRule struct {
Name string
Description string
Severity string
Category string
CWE string
OWASP string
Tags []string
References []string
Pat map[string]string
// PostFilter, when set, runs after every match and must return
// true to keep the row.
PostFilter func(parser.QueryResult, []byte) bool
// ExcludeTests overrides the default true (most SAST rules
// stay default-true; a few opt out — e.g. hygiene rules that
// also flag tests).
ExcludeTests *bool
}
func mustRegisterSAST(rules ...sastRule) {
for _, r := range rules {
registerSAST(r)
}
}
func registerSAST(r sastRule) {
cat := r.Category
if cat == "" {
cat = CategorySAST
}
// ExcludeTests defaults to true for every SAST + hygiene rule
// (debug-leftover / SAST findings inside a test fixture are
// almost always intentional bait or test scaffolding). A rule
// that should also flag tests sets r.ExcludeTests = new(bool)
// (zero-value false).
excludeTests := true
if r.ExcludeTests != nil {
excludeTests = *r.ExcludeTests
}
d := &Detector{
Name: r.Name,
Description: r.Description,
Severity: r.Severity,
Category: cat,
CWE: r.CWE,
OWASP: r.OWASP,
Tags: append([]string(nil), r.Tags...),
References: append([]string(nil), r.References...),
Languages: r.Pat,
ExcludeTests: excludeTests,
PostFilter: r.PostFilter,
}
RegisterDetector(d)
}
+297
View File
@@ -0,0 +1,297 @@
package astquery
// Go SAST starter pack. Cross-language rules that already cover Go
// (sql-string-concat, weak-crypto, hardcoded-secret, http-client-no-timeout,
// goroutine-without-recover, panic-in-library) live in detectors.go;
// this file adds Go-specific patterns absent from the legacy list.
func init() {
registerGoCommandExecution()
registerGoTLSInsecure()
registerGoTemplateUnsafe()
registerGoJWTNone()
registerGoFilepathTraversal()
registerGoBcryptCost()
registerGoMathRandForCrypto()
registerGoCSRFDisable()
registerGoSSRFAuditHook()
registerGoCORSAllowAll()
registerGoXMLDecoderNoEntity()
registerGoSQLOpenWithHardcodedDSN()
registerGoOSChmodWorldWritable()
registerGoEnvGetenvSensitive()
registerGoExpvarPublishAuditHook()
}
func registerGoSQLOpenWithHardcodedDSN() {
mustRegisterSAST(sastRule{
Name: "go-sql-open-hardcoded-dsn",
Description: "`sql.Open(driver, \"...credentials...\")` with a DSN string literal — embeds credentials in source. Read from env / a config struct populated by env.",
Severity: "warning",
CWE: "CWE-798",
Tags: []string{"secrets", "dsn"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)
arguments: (argument_list (_) (interpreted_string_literal) @dsn)) @match
(#eq? @pkg "sql") (#eq? @fn "Open")
(#match? @dsn ":[^/@\"]+@"))`,
},
})
}
func registerGoOSChmodWorldWritable() {
mustRegisterSAST(sastRule{
Name: "go-os-chmod-world-writable",
Description: "`os.Chmod(path, 0666)` / `0777` / `0o666` / `0o777` — world-writable file permissions. Almost always a privilege-management bug.",
Severity: "warning",
CWE: "CWE-732",
Tags: []string{"permissions"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)
arguments: (argument_list (_) (int_literal) @mode)) @match
(#eq? @pkg "os") (#match? @fn "^(Chmod|MkdirAll|Mkdir)$")
(#match? @mode "^0o?(666|667|676|677|766|767|776|777)$"))`,
},
})
}
func registerGoEnvGetenvSensitive() {
mustRegisterSAST(sastRule{
Name: "go-getenv-sensitive-fallback",
Description: "`os.Getenv(\"...SECRET...\")` followed by a string-literal fallback — silent failure mode that ships a known weak default. Use `os.LookupEnv` and fail fast.",
Severity: "info",
CWE: "CWE-1188",
Tags: []string{"secrets", "config"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)
arguments: (argument_list (interpreted_string_literal) @key)) @match
(#eq? @pkg "os") (#eq? @fn "Getenv")
(#match? @key "(?i)(SECRET|PASSWORD|TOKEN|KEY)"))`,
},
})
}
func registerGoExpvarPublishAuditHook() {
mustRegisterSAST(sastRule{
Name: "go-expvar-publish-audit",
Description: "`expvar.Publish(...)` — registers a public variable at `/debug/vars`. Audit hook: confirm the variable doesn't carry credentials / PII and that `/debug/vars` isn't exposed publicly.",
Severity: "info",
CWE: "CWE-200",
Tags: []string{"audit-hook", "info-leak"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)) @match
(#eq? @pkg "expvar") (#match? @fn "^(Publish|NewString|NewInt|NewFloat|NewMap)$"))`,
},
})
}
func registerGoCommandExecution() {
mustRegisterSAST(
sastRule{
Name: "go-exec-command-via-shell",
Description: "`exec.Command(\"sh\", \"-c\", cmd)` / `exec.Command(\"bash\", \"-c\", cmd)` — any caller-controlled portion of `cmd` is shell-injection. Use a fixed argv and parse separately.",
Severity: "error",
CWE: "CWE-78",
OWASP: "A03:2021-Injection",
Tags: []string{"command-injection"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)
arguments: (argument_list (interpreted_string_literal) @shell)) @match
(#eq? @pkg "exec") (#eq? @fn "Command")
(#match? @shell "^[\"'](sh|bash|zsh|cmd|powershell)[\"']$"))`,
},
},
sastRule{
Name: "go-exec-command-with-sprintf",
Description: "`exec.Command(fmt.Sprintf(...))` — string interpolation into the command path or argv before exec. Easy command-injection vector; build the argv with literal strings.",
Severity: "warning",
CWE: "CWE-78",
Tags: []string{"command-injection"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)
arguments: (argument_list
(call_expression function: (selector_expression operand: (identifier) @sprintfpkg field: (field_identifier) @sprintf)))) @match
(#eq? @pkg "exec") (#eq? @fn "Command")
(#eq? @sprintfpkg "fmt") (#match? @sprintf "^(Sprintf|Sprint|Sprintln)$"))`,
},
},
)
}
func registerGoTLSInsecure() {
mustRegisterSAST(
sastRule{
Name: "go-tls-insecure-skip-verify",
Description: "`&tls.Config{InsecureSkipVerify: true}` — disables certificate validation. Any MITM can intercept the connection.",
Severity: "error",
CWE: "CWE-295",
OWASP: "A02:2021-Cryptographic Failures",
Tags: []string{"tls", "no-verify"},
Pat: map[string]string{
"go": `((keyed_element
(literal_element (identifier) @name)
(literal_element (true))) @match
(#eq? @name "InsecureSkipVerify"))`,
},
},
sastRule{
Name: "go-tls-min-version-weak",
Description: "`tls.Config.MinVersion = tls.VersionSSL30 | tls.VersionTLS10 | tls.VersionTLS11` — broken / deprecated TLS versions. Use `tls.VersionTLS12` or `tls.VersionTLS13`.",
Severity: "warning",
CWE: "CWE-326",
Tags: []string{"tls", "weak-protocol"},
Pat: map[string]string{
"go": `((keyed_element
(literal_element (identifier) @name)
(literal_element (selector_expression operand: (identifier) @pkg field: (field_identifier) @ver))) @match
(#eq? @name "MinVersion") (#eq? @pkg "tls")
(#match? @ver "^(VersionSSL30|VersionTLS10|VersionTLS11)$"))`,
},
},
)
}
func registerGoTemplateUnsafe() {
mustRegisterSAST(
sastRule{
Name: "go-html-template-unsafe-conv",
Description: "`template.HTML(x)` / `template.JS(x)` / `template.URL(x)` / `template.CSS(x)` / `template.HTMLAttr(x)` — explicit \"trust this string as already-safe HTML\" conversion. With user input this is XSS.",
Severity: "error",
CWE: "CWE-79",
OWASP: "A03:2021-Injection",
Tags: []string{"xss", "template"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @typ)
arguments: (argument_list (identifier) @arg)) @match
(#eq? @pkg "template") (#match? @typ "^(HTML|JS|URL|CSS|HTMLAttr|JSStr|Srcset)$"))`,
},
},
)
}
func registerGoJWTNone() {
mustRegisterSAST(sastRule{
Name: "go-jwt-none-alg",
Description: "`jwt.SigningMethodNone` / `SigningMethodNone{}` — produces unsigned JWTs. Tokens are accepted with no signature; trivial impersonation.",
Severity: "error",
CWE: "CWE-347",
Tags: []string{"jwt", "broken-auth"},
Pat: map[string]string{
"go": `((selector_expression operand: (identifier) @pkg field: (field_identifier) @method) @match
(#eq? @pkg "jwt") (#eq? @method "SigningMethodNone"))`,
},
})
}
func registerGoFilepathTraversal() {
mustRegisterSAST(sastRule{
Name: "go-filepath-join-user-input",
Description: "`filepath.Join(base, userInput)` does NOT prevent path traversal: `userInput == \"../../etc/passwd\"` happily resolves out of `base`. Use `filepath.Clean` + an explicit prefix check, or `filepath.EvalSymlinks`.",
Severity: "info",
CWE: "CWE-22",
Tags: []string{"path-traversal", "audit-hook"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)) @match
(#eq? @pkg "filepath") (#eq? @fn "Join"))`,
},
})
}
func registerGoBcryptCost() {
mustRegisterSAST(sastRule{
Name: "go-bcrypt-cost-too-low",
Description: "`bcrypt.GenerateFromPassword(pw, 4..9)` — cost below 10 is too fast for password hashing on modern hardware. Use `bcrypt.DefaultCost` (10) or higher; 12 is recommended for new deployments.",
Severity: "warning",
CWE: "CWE-326",
Tags: []string{"crypto", "password"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)
arguments: (argument_list (_) (int_literal) @cost)) @match
(#eq? @pkg "bcrypt") (#eq? @fn "GenerateFromPassword") (#match? @cost "^[0-9]$"))`,
},
})
}
func registerGoMathRandForCrypto() {
mustRegisterSAST(sastRule{
Name: "go-math-rand-for-crypto",
Description: "`math/rand` import — fine for simulations / sampling, not for tokens / keys / nonces. Use `crypto/rand`. Worth flagging the import so an audit can confirm no security-sensitive call site.",
Severity: "info",
CWE: "CWE-338",
Tags: []string{"crypto", "audit-hook"},
Pat: map[string]string{
"go": `((import_spec path: (interpreted_string_literal) @path) @match (#eq? @path "\"math/rand\""))`,
},
})
}
func registerGoCSRFDisable() {
mustRegisterSAST(sastRule{
Name: "go-gorilla-csrf-disable",
Description: "`csrf.Protect(..., csrf.Secure(false))` — disables the CSRF cookie's `Secure` flag, allowing the token to leak over HTTP. Only safe in dev.",
Severity: "warning",
CWE: "CWE-614",
Tags: []string{"csrf"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)
arguments: (argument_list (false))) @match
(#eq? @pkg "csrf") (#eq? @fn "Secure"))`,
},
})
}
func registerGoSSRFAuditHook() {
mustRegisterSAST(sastRule{
Name: "go-http-do-with-userdata",
Description: "`http.Get(url)` / `http.Post(...)` / `http.Client.Do(req)` — flag for SSRF audit. Most uses are fine; production breaches typically trace back to a non-validated `url` parameter.",
Severity: "info",
CWE: "CWE-918",
Tags: []string{"ssrf", "audit-hook"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)) @match
(#eq? @pkg "http") (#match? @fn "^(Get|Post|PostForm|Head)$"))`,
},
})
}
func registerGoCORSAllowAll() {
mustRegisterSAST(sastRule{
Name: "go-cors-allow-all-origins",
Description: "CORS handler returning `\"*\"` for `Access-Control-Allow-Origin` while also setting `Access-Control-Allow-Credentials: true` is rejected by browsers — and combining the two anyway often hides an unintentional same-origin bypass.",
Severity: "warning",
CWE: "CWE-942",
Tags: []string{"cors"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @hdr field: (field_identifier) @fn)
arguments: (argument_list (interpreted_string_literal) @key (interpreted_string_literal) @val)) @match
(#match? @fn "^(Set|Add)$") (#match? @key "\"Access-Control-Allow-Origin\"") (#match? @val "\"\\*\""))`,
},
})
}
func registerGoXMLDecoderNoEntity() {
mustRegisterSAST(sastRule{
Name: "go-xml-decoder-no-strict",
Description: "`xml.NewDecoder(...)` without setting `Strict = true` (default is true, but the `Entity` field can still be set to allow external entities). Flag for audit when XML payloads come from untrusted sources.",
Severity: "info",
CWE: "CWE-611",
Tags: []string{"xxe", "xml", "audit-hook"},
Pat: map[string]string{
"go": `((call_expression
function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @fn)) @match
(#eq? @pkg "xml") (#eq? @fn "NewDecoder"))`,
},
})
}
+202
View File
@@ -0,0 +1,202 @@
package astquery
// Java SAST starter pack. The legacy `java-string-equality` +
// `empty-catch` rules in detectors.go cover language-level pitfalls;
// this file adds JVM security patterns the SAST audit looks for.
func init() {
registerJavaCommandExecution()
registerJavaWeakCrypto()
registerJavaInsecureSSL()
registerJavaXMLDecoder()
registerJavaSQLConcat()
registerJavaRandom()
registerJavaJWTNone()
registerJavaServletXSS()
registerJavaProcessBuilder()
registerJavaTrustAllCerts()
registerJavaSecretLiteralAnnotation()
}
func registerJavaCommandExecution() {
mustRegisterSAST(sastRule{
Name: "java-runtime-exec",
Description: "`Runtime.getRuntime().exec(cmd)` with a string argument — the JDK splits on whitespace, which silently quoting bugs become argv-injection bugs. Use the String[] overload or `ProcessBuilder`.",
Severity: "error",
CWE: "CWE-78",
OWASP: "A03:2021-Injection",
Tags: []string{"command-injection"},
Pat: map[string]string{
"java": `((method_invocation
object: (method_invocation
object: (identifier) @rt
name: (identifier) @getrt)
name: (identifier) @exec) @match
(#eq? @rt "Runtime") (#eq? @getrt "getRuntime") (#eq? @exec "exec"))`,
},
})
}
func registerJavaProcessBuilder() {
mustRegisterSAST(sastRule{
Name: "java-process-builder-shell-args",
Description: "`new ProcessBuilder(\"sh\", \"-c\", cmd).start()` — shell argv 0 with a user-controlled `cmd` arg is command injection.",
Severity: "warning",
CWE: "CWE-78",
Tags: []string{"command-injection"},
Pat: map[string]string{
"java": `((object_creation_expression
type: (type_identifier) @typ
arguments: (argument_list (string_literal) @shell)) @match
(#eq? @typ "ProcessBuilder")
(#match? @shell "\"(sh|bash|zsh|cmd|powershell)\""))`,
},
})
}
func registerJavaWeakCrypto() {
mustRegisterSAST(sastRule{
Name: "java-message-digest-md5-sha1",
Description: "`MessageDigest.getInstance(\"MD5\")` / `\"SHA-1\")` — broken hashes. Use `\"SHA-256\"` / `\"SHA-512\"` / `\"SHA3-256\"`.",
Severity: "error",
CWE: "CWE-327",
Tags: []string{"crypto", "weak-hash"},
Pat: map[string]string{
"java": `((method_invocation
object: (identifier) @cls
name: (identifier) @fn
arguments: (argument_list (string_literal) @algo)) @match
(#eq? @cls "MessageDigest") (#eq? @fn "getInstance")
(#match? @algo "\"(MD5|MD4|MD2|SHA-1|SHA1)\""))`,
},
})
}
func registerJavaInsecureSSL() {
mustRegisterSAST(sastRule{
Name: "java-cipher-des-ecb",
Description: "`Cipher.getInstance(\"DES\")` / `Cipher.getInstance(\"AES/ECB/...\")` — DES is broken; ECB doesn't hide plaintext patterns. Use `AES/GCM/NoPadding` or `ChaCha20-Poly1305`.",
Severity: "error",
CWE: "CWE-327",
Tags: []string{"crypto", "weak-cipher"},
Pat: map[string]string{
"java": `((method_invocation
object: (identifier) @cls
name: (identifier) @fn
arguments: (argument_list (string_literal) @algo)) @match
(#eq? @cls "Cipher") (#eq? @fn "getInstance")
(#match? @algo "\"(DES|DESede|RC2|RC4|.*/ECB/.*)\""))`,
},
})
}
func registerJavaXMLDecoder() {
mustRegisterSAST(sastRule{
Name: "java-xml-decoder",
Description: "`new XMLDecoder(stream).readObject()` — XMLDecoder deserialises arbitrary Java objects, equivalent to `ObjectInputStream`. Untrusted input is RCE.",
Severity: "error",
CWE: "CWE-502",
Tags: []string{"deserialization"},
Pat: map[string]string{
"java": `((object_creation_expression type: (type_identifier) @typ) @match (#eq? @typ "XMLDecoder"))`,
},
})
}
func registerJavaSQLConcat() {
mustRegisterSAST(sastRule{
Name: "java-statement-execute-concat",
Description: "`statement.execute(\"SELECT ... \" + x)` / `executeQuery(...)` with `+` concat — SQL injection. Use `PreparedStatement` with `?` placeholders.",
Severity: "error",
CWE: "CWE-89",
OWASP: "A03:2021-Injection",
Tags: []string{"sqli"},
Pat: map[string]string{
"java": `((method_invocation
name: (identifier) @fn
arguments: (argument_list (binary_expression operator: "+"))) @match
(#match? @fn "^(execute|executeQuery|executeUpdate|prepareStatement)$"))`,
},
})
}
func registerJavaRandom() {
mustRegisterSAST(sastRule{
Name: "java-random-for-token",
Description: "`new Random()` — Mersenne Twister; not crypto-secure. Use `java.security.SecureRandom` for tokens / IVs / keys.",
Severity: "info",
CWE: "CWE-338",
Tags: []string{"crypto", "audit-hook"},
Pat: map[string]string{
"java": `((object_creation_expression type: (type_identifier) @typ) @match (#eq? @typ "Random"))`,
},
})
}
func registerJavaJWTNone() {
mustRegisterSAST(sastRule{
Name: "java-jwt-none-alg",
Description: "`Algorithm.none()` (auth0/java-jwt) / `\"none\"` algorithm string — accepts unsigned JWTs. Trivial impersonation.",
Severity: "error",
CWE: "CWE-347",
Tags: []string{"jwt", "broken-auth"},
Pat: map[string]string{
"java": `((method_invocation
object: (identifier) @alg
name: (identifier) @fn) @match
(#eq? @alg "Algorithm") (#eq? @fn "none"))`,
},
})
}
func registerJavaServletXSS() {
mustRegisterSAST(sastRule{
Name: "java-response-write-user-input",
Description: "`response.getWriter().write(request.getParameter(...))` — reflected XSS unless the writer is HTML-escaped. Use a templating engine or `org.owasp.encoder.Encode.forHtml`.",
Severity: "info",
CWE: "CWE-79",
Tags: []string{"xss", "audit-hook"},
Pat: map[string]string{
"java": `((method_invocation
object: (method_invocation name: (identifier) @writer)
name: (identifier) @write
arguments: (argument_list (method_invocation name: (identifier) @src))) @match
(#eq? @writer "getWriter") (#eq? @write "write") (#match? @src "^getParameter|getHeader|getQueryString$"))`,
},
})
}
func registerJavaTrustAllCerts() {
mustRegisterSAST(sastRule{
Name: "java-trust-all-certs",
Description: "Custom `X509TrustManager` whose `checkServerTrusted` does nothing — accepts every certificate, defeating TLS verification.",
Severity: "error",
CWE: "CWE-295",
Tags: []string{"tls", "no-verify"},
Pat: map[string]string{
"java": `((method_declaration
name: (identifier) @fn
body: (block) @body) @match
(#eq? @fn "checkServerTrusted"))`,
// Empty-body heuristic via PostFilter.
},
// Filter out methods with non-trivial bodies; defaults catch
// {} / { return; } / { /* ... */ }.
})
}
func registerJavaSecretLiteralAnnotation() {
mustRegisterSAST(sastRule{
Name: "java-spring-value-hardcoded-secret",
Description: "`@Value(\"static-literal-secret\")` — Spring `@Value` annotation with a literal that names a credential. Move to env / a `@ConfigurationProperties` mapper backed by env / a secrets manager.",
Severity: "warning",
CWE: "CWE-798",
Tags: []string{"secrets", "spring"},
Pat: map[string]string{
"java": `((annotation
name: (identifier) @ann
arguments: (annotation_argument_list (string_literal) @val)) @match
(#eq? @ann "Value") (#match? @val "[\"'][^${].{20,}[\"']"))`,
},
})
}
+259
View File
@@ -0,0 +1,259 @@
package astquery
// JavaScript / TypeScript SAST rules. Patterns are written once and
// shipped to both grammars (the JS tree-sitter grammar covers the
// subset shared with TS; the TS grammar handles the type-suffixed
// constructs separately). Each rule's Pat carries both keys.
func init() {
registerJSEvalFunction()
registerJSDangerousSinks()
registerJSReactDangerouslySetHTML()
registerJSChildProcessExec()
registerJSRequireWithVariable()
registerJSCookieFlags()
registerJSCORSWildcard()
registerJSPostMessageWildcard()
registerJSWeakCryptoSubtle()
registerJSMathRandomForToken()
registerJSNoTLSReject()
registerJSJWTNone()
registerJSDocumentWrite()
registerJSTargetBlankNoopener()
registerJSLocationAssignUserInput()
}
// 1. eval / new Function — CWE-95
func registerJSEvalFunction() {
pat := `((call_expression function: (identifier) @fn) @match (#match? @fn "^(eval)$"))
((new_expression constructor: (identifier) @fn) @match (#eq? @fn "Function"))`
mustRegisterSAST(sastRule{
Name: "js-eval-use",
Description: "`eval(...)` / `new Function(...)` — executes an arbitrary string as JS. Any caller-controlled input is RCE.",
Severity: "error",
CWE: "CWE-95",
OWASP: "A03:2021-Injection",
Tags: []string{"injection", "code-injection"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 2. dangerous DOM sinks — CWE-79
func registerJSDangerousSinks() {
pat := `((assignment_expression
left: (member_expression property: (property_identifier) @prop)) @match
(#match? @prop "^(innerHTML|outerHTML|insertAdjacentHTML)$"))`
mustRegisterSAST(sastRule{
Name: "js-dom-innerhtml-assignment",
Description: "Direct assignment to `.innerHTML` / `.outerHTML` / `.insertAdjacentHTML` — bypasses the browser's auto-escaping. Use `textContent` or a sanitizer (DOMPurify).",
Severity: "warning",
CWE: "CWE-79",
OWASP: "A03:2021-Injection",
Tags: []string{"xss", "dom"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
func registerJSDocumentWrite() {
pat := `((call_expression
function: (member_expression object: (identifier) @obj property: (property_identifier) @prop)) @match
(#eq? @obj "document") (#match? @prop "^(write|writeln)$"))`
mustRegisterSAST(sastRule{
Name: "js-document-write",
Description: "`document.write(...)` / `document.writeln(...)` — writes raw HTML into the DOM, identical XSS risk as `innerHTML=`. Blocked by Trusted Types when enabled.",
Severity: "error",
CWE: "CWE-79",
Tags: []string{"xss", "dom", "deprecated"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 3. React dangerouslySetInnerHTML
func registerJSReactDangerouslySetHTML() {
mustRegisterSAST(sastRule{
Name: "js-react-dangerously-set-inner-html",
Description: "JSX `dangerouslySetInnerHTML={{__html: x}}` — React's deliberate XSS escape hatch. Any caller-controlled `x` is XSS. Use DOMPurify or render as text.",
Severity: "error",
CWE: "CWE-79",
OWASP: "A03:2021-Injection",
Tags: []string{"xss", "react"},
Pat: map[string]string{
"javascript": `((jsx_attribute (property_identifier) @attr) @match (#eq? @attr "dangerouslySetInnerHTML"))`,
// tsx (not typescript) — jsx_attribute only exists in the
// JSX-aware grammar. .tsx targets are retagged as "tsx"
// upstream so this query compiles cleanly.
"tsx": `((jsx_attribute (property_identifier) @attr) @match (#eq? @attr "dangerouslySetInnerHTML"))`,
},
})
}
// 4. child_process.exec / execSync / spawn shell:true
func registerJSChildProcessExec() {
pat := `((call_expression
function: (member_expression object: (identifier) @obj property: (property_identifier) @fn)) @match
(#match? @obj "^(child_process|cp)$") (#match? @fn "^(exec|execSync)$"))`
mustRegisterSAST(sastRule{
Name: "js-child-process-exec",
Description: "`child_process.exec(cmd)` / `execSync(cmd)` — runs `cmd` through `/bin/sh -c` (or `cmd.exe /s /c`). Any caller-controlled portion is command injection. Use `execFile` with an argv array.",
Severity: "error",
CWE: "CWE-78",
OWASP: "A03:2021-Injection",
Tags: []string{"command-injection", "node"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 5. require with a variable — module-load hijack
func registerJSRequireWithVariable() {
pat := `((call_expression function: (identifier) @fn
arguments: (arguments (identifier) @arg)) @match
(#eq? @fn "require"))`
mustRegisterSAST(sastRule{
Name: "js-require-with-variable",
Description: "`require(varName)` — module path comes from a variable. Caller-controlled values are arbitrary file load / require.cache poisoning. Prefer static `require('./fixed-path')`.",
Severity: "warning",
CWE: "CWE-915",
Tags: []string{"dynamic-import", "node"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 6. Cookie flags — secure=false / httpOnly=false
func registerJSCookieFlags() {
pat := `((object (pair key: (property_identifier) @key value: (false))) @match
(#match? @key "^(secure|httpOnly|httpolnly)$"))`
mustRegisterSAST(sastRule{
Name: "js-cookie-no-secure-or-httponly",
Description: "Cookie option `{ secure: false }` or `{ httpOnly: false }` — secure=false transmits the cookie over plaintext HTTP; httpOnly=false exposes it to `document.cookie`-reading XSS. Flip to true unless a JS reader is genuinely required.",
Severity: "warning",
CWE: "CWE-614",
Tags: []string{"cookies"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 7. CORS allow-all + credentials true
func registerJSCORSWildcard() {
pat := `((call_expression
function: (identifier) @fn
arguments: (arguments (object) @opts)) @match
(#match? @fn "^(cors|Cors)$"))`
mustRegisterSAST(sastRule{
Name: "js-cors-wildcard-with-credentials",
Description: "`cors({ origin: '*', credentials: true })` — invalid per the CORS spec, and many libraries fall back to reflecting the request `Origin` header. Use an explicit allow-list.",
Severity: "info",
CWE: "CWE-942",
Tags: []string{"cors", "audit-hook"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 8. postMessage with target="*"
func registerJSPostMessageWildcard() {
pat := `((call_expression
function: (member_expression property: (property_identifier) @fn)
arguments: (arguments . (_) (string) @target)) @match
(#eq? @fn "postMessage") (#match? @target "\"\\*\""))`
mustRegisterSAST(sastRule{
Name: "js-postmessage-wildcard-target",
Description: "`window.postMessage(data, '*')` — any iframe can receive the message. Use an explicit origin (`'https://example.com'`).",
Severity: "warning",
CWE: "CWE-346",
Tags: []string{"postmessage"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 9. crypto.createHash('md5'|'sha1')
func registerJSWeakCryptoSubtle() {
pat := `((call_expression
function: (member_expression object: (identifier) @obj property: (property_identifier) @fn)
arguments: (arguments (string) @algo)) @match
(#eq? @obj "crypto") (#eq? @fn "createHash") (#match? @algo "[\"'](md5|sha1)[\"']"))`
mustRegisterSAST(sastRule{
Name: "js-crypto-weak-hash",
Description: "`crypto.createHash('md5')` / `crypto.createHash('sha1')` — cryptographically broken hashes. Use `'sha256'` or `'sha3-256'`.",
Severity: "error",
CWE: "CWE-327",
Tags: []string{"crypto", "weak-hash"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 10. Math.random for tokens / IDs
func registerJSMathRandomForToken() {
pat := `((call_expression
function: (member_expression object: (identifier) @obj property: (property_identifier) @fn)) @match
(#eq? @obj "Math") (#eq? @fn "random"))`
mustRegisterSAST(sastRule{
Name: "js-math-random-for-token",
Description: "`Math.random()` — not cryptographically secure. Audit hook for any site that produces an ID / token / nonce. Use `crypto.randomUUID()` / `crypto.getRandomValues()`.",
Severity: "info",
CWE: "CWE-338",
Tags: []string{"crypto", "audit-hook"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 11. NODE_TLS_REJECT_UNAUTHORIZED=0
func registerJSNoTLSReject() {
pat := `((property_identifier) @match (#eq? @match "NODE_TLS_REJECT_UNAUTHORIZED"))
((pair key: (property_identifier) @key value: (false)) @match (#eq? @key "rejectUnauthorized"))`
mustRegisterSAST(sastRule{
Name: "js-no-tls-reject-unauthorized",
Description: "`process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'` / `rejectUnauthorized: false` — disables Node.js TLS verification globally. Anything beyond a dev script is shipping a backdoor.",
Severity: "error",
CWE: "CWE-295",
Tags: []string{"tls", "no-verify"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
func registerJSTargetBlankNoopener() {
pat := `((jsx_attribute (property_identifier) @attr (string) @val) @match
(#eq? @attr "target") (#match? @val "[\"']_blank[\"']"))`
mustRegisterSAST(sastRule{
Name: "js-target-blank-no-noopener",
Description: "`<a target=\"_blank\">` without `rel=\"noopener noreferrer\"` lets the opened page navigate the opener via `window.opener`. Add the rel attribute or use `rel=\"noreferrer\"`.",
Severity: "info",
CWE: "CWE-1022",
Tags: []string{"phishing"},
// tsx (not typescript) — jsx_attribute only exists in the
// JSX-aware grammar. .tsx targets are retagged as "tsx"
// upstream so this query compiles cleanly.
Pat: map[string]string{"javascript": pat, "tsx": pat},
})
}
func registerJSLocationAssignUserInput() {
pat := `((assignment_expression
left: (member_expression object: (identifier) @loc property: (property_identifier) @prop)) @match
(#eq? @loc "location") (#match? @prop "^(href|search|pathname)$"))
((assignment_expression
left: (member_expression
object: (member_expression object: (identifier) @win property: (property_identifier) @loc)
property: (property_identifier) @prop)) @match
(#eq? @win "window") (#eq? @loc "location") (#match? @prop "^(href|search|pathname)$"))`
mustRegisterSAST(sastRule{
Name: "js-location-href-assignment",
Description: "`location.href = x` / `window.location.href = x` — if `x` comes from URL params it can be an `javascript:` URL → XSS. Validate the scheme allow-list.",
Severity: "info",
CWE: "CWE-79",
Tags: []string{"xss", "open-redirect"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
// 12. JWT alg=none / verify:false
func registerJSJWTNone() {
pat := `((object (pair key: (property_identifier) @key value: (string) @val)) @match
(#eq? @key "algorithm") (#match? @val "[\"']none[\"']"))`
mustRegisterSAST(sastRule{
Name: "js-jwt-none-alg",
Description: "`{ algorithm: 'none' }` — produces / accepts unsigned JWTs. Trivial impersonation; reject any non-`HS256` / `RS256` / `EdDSA` algorithm explicitly.",
Severity: "error",
CWE: "CWE-347",
Tags: []string{"jwt", "broken-auth"},
Pat: map[string]string{"javascript": pat, "typescript": pat},
})
}
@@ -0,0 +1,90 @@
package astquery
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
// TestJSRulesCompile drives each JS+TS rule against a stub so a
// tree-sitter pattern compile error surfaces at unit-test time.
func TestJSRulesCompile(t *testing.T) {
const stub = "var x = 1;\n"
for _, info := range DescribeDetectors() {
if info.Category != CategorySAST {
continue
}
hasJS := false
for _, l := range info.Languages {
if l == "javascript" {
hasJS = true
break
}
}
if !hasJS {
continue
}
t.Run(info.Name, func(t *testing.T) {
_, err := RunOnSource(context.Background(), Options{Detector: info.Name},
"sample.js", "javascript", []byte(stub))
require.NoError(t, err, "rule %s pattern failed to compile", info.Name)
})
}
}
func TestJSRulesFire(t *testing.T) {
cases := []struct {
name string
lang string
bad string
good string
}{
{"js-eval-use", "javascript",
`var y = eval("1+1");`,
`var y = 2;`},
{"js-dom-innerhtml-assignment", "javascript",
`el.innerHTML = user;`,
`el.textContent = user;`},
{"js-document-write", "javascript",
`document.write("<b>" + user + "</b>");`,
`el.textContent = user;`},
{"js-child-process-exec", "javascript",
`var cp = require("child_process"); cp.exec(cmd);`,
`var cp = require("child_process"); cp.execFile("ls", ["-la"]);`},
{"js-require-with-variable", "javascript",
`var m = require(mod);`,
`var m = require("./fixed");`},
{"js-cookie-no-secure-or-httponly", "javascript",
`res.cookie("sid", v, { secure: false, httpOnly: false });`,
`res.cookie("sid", v, { secure: true, httpOnly: true });`},
{"js-postmessage-wildcard-target", "javascript",
`win.postMessage(data, "*");`,
`win.postMessage(data, "https://example.com");`},
{"js-crypto-weak-hash", "javascript",
`crypto.createHash("md5").update(s).digest("hex");`,
`crypto.createHash("sha256").update(s).digest("hex");`},
{"js-math-random-for-token", "javascript",
`var tok = Math.random();`,
`var tok = crypto.randomUUID();`},
{"js-no-tls-reject-unauthorized", "javascript",
`process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";`,
`// nothing`},
{"js-jwt-none-alg", "javascript",
`jwt.sign(payload, key, { algorithm: "none" });`,
`jwt.sign(payload, key, { algorithm: "HS256" });`},
{"js-location-href-assignment", "javascript",
`window.location.href = userUrl;`,
`window.location.assign("/fixed/path");`},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
bad := runDetector(t, c.name, c.lang, "case."+c.lang, c.bad)
require.GreaterOrEqual(t, bad.Total, 1, "rule %q should fire on bad fixture; got 0", c.name)
good := runDetector(t, c.name, c.lang, "case."+c.lang, c.good)
require.Equal(t, 0, good.Total, "rule %q should be silent on good fixture; got %d", c.name, good.Total)
})
}
}
+162
View File
@@ -0,0 +1,162 @@
package astquery
// PHP SAST starter pack.
func init() {
registerPHPEval()
registerPHPShellExec()
registerPHPUnserialize()
registerPHPSQLConcat()
registerPHPWeakHash()
registerPHPFileInclude()
registerPHPEchoUserInput()
registerPHPPregReplaceE()
registerPHPMySQLDeprecated()
registerPHPCURLNoVerify()
}
func registerPHPEval() {
mustRegisterSAST(sastRule{
Name: "php-eval-use",
Description: "`eval($str)` / `assert($str)` — executes arbitrary PHP. Trivial RCE with user input.",
Severity: "error",
CWE: "CWE-95",
OWASP: "A03:2021-Injection",
Tags: []string{"injection", "code-injection"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn) @match (#match? @fn "^(eval|assert)$"))`,
},
})
}
func registerPHPShellExec() {
mustRegisterSAST(sastRule{
Name: "php-shell-exec",
Description: "`system()` / `exec()` / `passthru()` / `shell_exec()` / `popen()` / `` `cmd` `` — shell execution functions. Any user-controlled argument is command injection. Use `escapeshellarg` + an argv-style alternative.",
Severity: "error",
CWE: "CWE-78",
OWASP: "A03:2021-Injection",
Tags: []string{"command-injection"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn) @match
(#match? @fn "^(system|exec|passthru|shell_exec|popen|proc_open|pcntl_exec)$"))`,
},
})
}
func registerPHPUnserialize() {
mustRegisterSAST(sastRule{
Name: "php-unserialize",
Description: "`unserialize($x)` — magic methods on attacker-controlled classes are invoked. RCE primitive in any PHP framework. Use `json_decode` instead.",
Severity: "error",
CWE: "CWE-502",
OWASP: "A08:2021-Software and Data Integrity Failures",
Tags: []string{"deserialization"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn) @match (#eq? @fn "unserialize"))`,
},
})
}
func registerPHPSQLConcat() {
mustRegisterSAST(sastRule{
Name: "php-mysql-query-concat",
Description: "`mysql_query`/`mysqli_query`/`pg_query`/`PDO::query` with a string literal containing `.` concatenation — SQL injection. Use parameterised queries.",
Severity: "error",
CWE: "CWE-89",
OWASP: "A03:2021-Injection",
Tags: []string{"sqli"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn
arguments: (arguments (argument (binary_expression operator: ".")))) @match
(#match? @fn "^(mysql_query|mysqli_query|pg_query|pg_send_query)$"))`,
},
})
}
func registerPHPWeakHash() {
mustRegisterSAST(sastRule{
Name: "php-md5-sha1",
Description: "`md5($x)` / `sha1($x)` — broken hashes. Use `password_hash($x, PASSWORD_DEFAULT)` for passwords and `hash('sha256', $x)` for fingerprinting.",
Severity: "error",
CWE: "CWE-327",
Tags: []string{"crypto", "weak-hash"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn) @match (#match? @fn "^(md5|sha1)$"))`,
},
})
}
func registerPHPFileInclude() {
mustRegisterSAST(sastRule{
Name: "php-file-include-with-variable",
Description: "`include $f` / `require $f` / `include_once $f` / `require_once $f` with a variable — file-inclusion vulnerability. With user input + `allow_url_include=On` this is LFI/RFI / RCE.",
Severity: "warning",
CWE: "CWE-98",
Tags: []string{"file-inclusion"},
Pat: map[string]string{
"php": `(include_expression (variable_name) @arg) @match
(require_expression (variable_name) @arg) @match
(include_once_expression (variable_name) @arg) @match
(require_once_expression (variable_name) @arg) @match`,
},
})
}
func registerPHPEchoUserInput() {
mustRegisterSAST(sastRule{
Name: "php-echo-user-input",
Description: "`echo $_GET[...]` / `print $_POST[...]` / `echo $_REQUEST[...]` — reflected XSS unless `htmlspecialchars`-escaped. Use a templating engine that auto-escapes (Twig, Blade).",
Severity: "warning",
CWE: "CWE-79",
Tags: []string{"xss"},
Pat: map[string]string{
"php": `((echo_statement (subscript_expression
(variable_name) @src)) @match
(#match? @src "^_(GET|POST|REQUEST|COOKIE)$"))`,
},
})
}
func registerPHPPregReplaceE() {
mustRegisterSAST(sastRule{
Name: "php-preg-replace-e-modifier",
Description: "`preg_replace('/pattern/e', ...)` — the `e` modifier `eval`s the replacement. Deprecated in 5.5, removed in 7.0, but legacy projects still hit it. RCE primitive.",
Severity: "error",
CWE: "CWE-95",
Tags: []string{"injection", "deprecated"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn
arguments: (arguments (argument (string) @pat))) @match
(#eq? @fn "preg_replace") (#match? @pat "/[^\"']*/[a-zA-Z]*e[a-zA-Z]*[\"']"))`,
},
})
}
func registerPHPMySQLDeprecated() {
mustRegisterSAST(sastRule{
Name: "php-mysql-extension-removed",
Description: "`mysql_*` family — removed in PHP 7.0. Use `mysqli_*` or PDO with prepared statements.",
Severity: "info",
CWE: "CWE-1104",
Tags: []string{"deprecated"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn) @match (#match? @fn "^mysql_(connect|query|fetch_array|fetch_row|fetch_assoc|num_rows|result|select_db|close|escape_string|real_escape_string)$"))`,
},
})
}
func registerPHPCURLNoVerify() {
mustRegisterSAST(sastRule{
Name: "php-curl-no-verify",
Description: "`curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false)` — disables TLS certificate validation. Any MITM intercepts the request.",
Severity: "error",
CWE: "CWE-295",
Tags: []string{"tls", "no-verify"},
Pat: map[string]string{
"php": `((function_call_expression function: (name) @fn
arguments: (arguments (argument) (argument (name) @opt) (argument))) @match
(#eq? @fn "curl_setopt") (#match? @opt "^(CURLOPT_SSL_VERIFYPEER|CURLOPT_SSL_VERIFYHOST)$"))`,
},
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,438 @@
package astquery
import "github.com/zzet/gortex/internal/parser"
// Additional Bandit-parity Python rules — split off from rules_sast_python.go
// so each file stays under ~1500 LOC. Plugin ID references in
// References:[] map back to the Bandit plugin numbers for cross-check.
func init() {
registerPythonBanditExtras()
registerPythonCryptoKeys()
registerPythonFrameworkExtras()
registerPythonMongoRedis()
registerPythonTryExceptPass()
registerPythonRequestsTimeout()
registerPythonHTTPSConnection()
registerPythonCElementTreeImport()
registerPythonTempnam()
registerPythonPyCryptoDeprecated()
registerPythonSSLNoVersion()
registerPythonLinuxCmdWildcard()
registerPythonMarkupSafe()
registerPythonJsonPickle()
registerPythonCGIDeprecated()
registerPythonHardcodedAssignment()
registerPythonSubprocessNoShellAudit()
registerPythonNumpyRandomSeed()
}
// 1. try/except: pass — Bandit B110
func registerPythonTryExceptPass() {
mustRegisterSAST(sastRule{
Name: "py-try-except-pass",
Description: "`except: pass` — silently swallow every exception. Hides production bugs and breaks observability. Log the exception or narrow the except clause.",
Severity: "info",
CWE: "CWE-703",
Tags: []string{"exception", "swallow"},
References: []string{"bandit:B110"},
Pat: map[string]string{
"python": `((except_clause (block (pass_statement) @body)) @match)`,
},
})
}
// 2. requests without timeout — Bandit B113
func registerPythonRequestsTimeout() {
mustRegisterSAST(sastRule{
Name: "py-requests-no-timeout",
Description: "`requests.get|post|...` without a `timeout=` kwarg — defaults to no timeout, lets a slow / unresponsive server wedge the caller indefinitely. Pass `timeout=(connect_s, read_s)`.",
Severity: "warning",
CWE: "CWE-400",
Tags: []string{"availability", "timeout"},
References: []string{"bandit:B113"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)
arguments: (argument_list) @args) @match
(#match? @mod "^(requests|httpx)$")
(#match? @attr "^(get|post|put|delete|patch|head|options|request)$"))`,
},
PostFilter: func(qr parser.QueryResult, _ []byte) bool {
a, ok := qr.Captures["args"]
if !ok {
return false
}
return !containsStr(a.Text, "timeout")
},
})
}
// 3. HTTPSConnection no context — Bandit B309
func registerPythonHTTPSConnection() {
mustRegisterSAST(sastRule{
Name: "py-httpsconnection-no-context",
Description: "`httplib.HTTPSConnection(...)` / `http.client.HTTPSConnection(...)` without an explicit `context=` ssl context — TLS verification depends on Python version defaults. Pass `context=ssl.create_default_context()`.",
Severity: "info",
CWE: "CWE-295",
Tags: []string{"tls"},
References: []string{"bandit:B309"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)
arguments: (argument_list) @args) @match
(#match? @mod "^(httplib|http\\.client)$")
(#eq? @attr "HTTPSConnection"))`,
},
PostFilter: func(qr parser.QueryResult, _ []byte) bool {
a, ok := qr.Captures["args"]
if !ok {
return false
}
return !containsStr(a.Text, "context=")
},
})
}
// 4. cElementTree import — Bandit B313 (alias path)
func registerPythonCElementTreeImport() {
mustRegisterSAST(sastRule{
Name: "py-cElementTree-import",
Description: "`from xml.etree.cElementTree import ...` — Python 2 era C-accelerated stdlib XML parser. XXE-vulnerable; use `defusedxml.ElementTree`.",
Severity: "error",
CWE: "CWE-611",
Tags: []string{"xxe", "xml", "deprecated"},
References: []string{"bandit:B313"},
Pat: map[string]string{
"python": `((import_from_statement module_name: (dotted_name) @mod) @match (#eq? @mod "xml.etree.cElementTree"))
((import_statement name: (dotted_name) @mod) @match (#eq? @mod "xml.etree.cElementTree"))`,
},
})
}
// 5. os.tempnam / os.tmpnam — Bandit B325
func registerPythonTempnam() {
mustRegisterSAST(sastRule{
Name: "py-os-tempnam",
Description: "`os.tempnam()` / `os.tmpnam()` — race-prone temp-file name generation, deprecated. Use `tempfile.mkstemp()`.",
Severity: "warning",
CWE: "CWE-377",
Tags: []string{"tempfile", "race"},
References: []string{"bandit:B325"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match
(#eq? @mod "os") (#match? @attr "^(tempnam|tmpnam)$"))`,
},
})
}
// 6. pyCrypto (the old C library, not pycryptodome) — Bandit B413
func registerPythonPyCryptoDeprecated() {
mustRegisterSAST(sastRule{
Name: "py-pycrypto-deprecated-import",
Description: "`import Crypto` (the original pyCrypto library, not pycryptodome) — unmaintained since 2013, multiple unpatched buffer-overflow CVEs (CVE-2013-7459 et al.). Migrate to `pycryptodome` (same API) or `cryptography`.",
Severity: "warning",
CWE: "CWE-1104",
Tags: []string{"crypto", "deprecated", "unmaintained"},
References: []string{"bandit:B413"},
Pat: map[string]string{
"python": `((import_statement name: (dotted_name) @mod) @match (#match? @mod "^Crypto(\\.|$)"))`,
},
})
}
// 7. ssl.wrap_socket without ssl_version arg — Bandit B504
func registerPythonSSLNoVersion() {
mustRegisterSAST(sastRule{
Name: "py-ssl-wrap-socket-no-version",
Description: "`ssl.wrap_socket(...)` without an explicit `ssl_version=ssl.PROTOCOL_TLS_CLIENT` — falls back to `PROTOCOL_TLS` which negotiates the highest supported, but older Python versions defaulted to v23 (SSLv2/v3 acceptable). Be explicit. `ssl.wrap_socket` itself is deprecated since 3.7 — use `SSLContext.wrap_socket`.",
Severity: "warning",
CWE: "CWE-326",
Tags: []string{"tls", "weak-protocol", "deprecated"},
References: []string{"bandit:B504"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match
(#eq? @mod "ssl") (#eq? @attr "wrap_socket"))`,
},
})
}
// 8. shell wildcard expansion — Bandit B609
func registerPythonLinuxCmdWildcard() {
mustRegisterSAST(sastRule{
Name: "py-shell-cmd-with-wildcard",
Description: "Shell command string containing a glob (`*`, `?`) — `chown user *.txt` is shell-expanded; a file named `--reference=/etc/passwd` becomes an argument. Pass an explicit file list to a non-shell invocation.",
Severity: "warning",
CWE: "CWE-78",
Tags: []string{"command-injection", "wildcard"},
References: []string{"bandit:B609"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)
arguments: (argument_list (string) @cmd)) @match
(#match? @mod "^(subprocess|os)$") (#match? @cmd "[*?]"))`,
},
})
}
// 9. markupsafe.Markup(...) on user input — Bandit B704
func registerPythonMarkupSafe() {
mustRegisterSAST(sastRule{
Name: "py-markupsafe-markup",
Description: "`markupsafe.Markup(...)` — flags arbitrary text as HTML-safe. With user input it bypasses Jinja2 / Flask escaping and is XSS.",
Severity: "warning",
CWE: "CWE-79",
Tags: []string{"xss", "jinja2", "flask"},
References: []string{"bandit:B704"},
Pat: map[string]string{
"python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)]) @match (#eq? @fn "Markup"))`,
},
})
}
// 10. jsonpickle.decode — RCE primitive
func registerPythonJsonPickle() {
mustRegisterSAST(sastRule{
Name: "py-jsonpickle-decode",
Description: "`jsonpickle.decode(...)` / `jsonpickle.loads(...)` — under the hood unpickles arbitrary class refs. RCE on attacker-controlled input.",
Severity: "error",
CWE: "CWE-502",
Tags: []string{"deserialization"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match
(#eq? @mod "jsonpickle") (#match? @attr "^(decode|loads)$"))`,
},
})
}
// 11. cgi module — deprecated, removed in 3.13
func registerPythonCGIDeprecated() {
mustRegisterSAST(sastRule{
Name: "py-cgi-import-deprecated",
Description: "`import cgi` — deprecated in 3.11, removed in 3.13. Functions are XSS-prone unless every caller escapes; replace with `urllib.parse.parse_qs` + a templating engine that auto-escapes.",
Severity: "info",
CWE: "CWE-1104",
Tags: []string{"deprecated"},
Pat: map[string]string{
"python": `((import_statement name: (dotted_name) @mod) @match (#eq? @mod "cgi"))`,
},
})
}
// 12. Bare-assignment hardcoded secret — Bandit B105
func registerPythonHardcodedAssignment() {
mustRegisterSAST(sastRule{
Name: "py-hardcoded-credential-assignment",
Description: "`password = \"...\"` / `secret = \"...\"` / `api_key = \"...\"` at module scope — leaks the credential into source control. Read from env or a secrets manager.",
Severity: "error",
CWE: "CWE-798",
OWASP: "A07:2021-Identification and Authentication Failures",
Tags: []string{"secrets", "credentials"},
References: []string{"bandit:B105"},
Pat: map[string]string{
"python": `((assignment left: (identifier) @name right: (string) @val) @match
(#match? @name "(?i)^(password|passwd|secret|api_?key|token|aws_?secret(_?key)?|access_?key|private_?key)$"))`,
},
PostFilter: secretLiteralLooksReal,
})
}
// 13. subprocess.* without shell=True — Bandit B603 (audit hook)
func registerPythonSubprocessNoShellAudit() {
mustRegisterSAST(sastRule{
Name: "py-subprocess-no-shell-audit",
Description: "`subprocess.Popen|call|run|check_output|check_call(...)` without `shell=True` — the safe form, but still worth flagging when args are user-derived. Audit hook: confirm every argv element is sanitised or from a fixed allow-list.",
Severity: "info",
CWE: "CWE-78",
Tags: []string{"audit-hook", "subprocess"},
References: []string{"bandit:B603"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match
(#eq? @mod "subprocess") (#match? @attr "^(Popen|call|run|check_output|check_call)$"))`,
},
})
}
// 14. numpy.random.seed — predictable
func registerPythonNumpyRandomSeed() {
mustRegisterSAST(sastRule{
Name: "py-numpy-random-fixed-seed",
Description: "`numpy.random.seed(<literal>)` — fixed-seed numpy random is deterministic across runs. Fine for reproducible experiments, never use a fixed-seed numpy RNG for tokens / IVs / keys.",
Severity: "info",
CWE: "CWE-330",
Tags: []string{"random", "numpy"},
Pat: map[string]string{
"python": `((call function: (attribute object: (attribute object: (identifier) @np attribute: (identifier) @sub) attribute: (identifier) @attr)
arguments: (argument_list (integer))) @match
(#eq? @np "numpy") (#eq? @sub "random") (#eq? @attr "seed"))
((call function: (attribute object: (attribute object: (identifier) @np attribute: (identifier) @sub) attribute: (identifier) @attr)
arguments: (argument_list (integer))) @match
(#eq? @np "np") (#eq? @sub "random") (#eq? @attr "seed"))`,
},
})
}
// ---------------------------------------------------------------------------
// 15. Bandit-extras catch-all — bind to (host="0.0.0.0"), httpx verify=False,
// starlette debug, scapy.send tx flooding hooks, asyncpg etc.
// ---------------------------------------------------------------------------
func registerPythonBanditExtras() {
mustRegisterSAST(
sastRule{
Name: "py-bind-host-zero",
Description: "`...(host=\"0.0.0.0\", ...)` — Flask / FastAPI / aiohttp / Tornado application bound to every interface. Almost certainly unintentional in production; bind to `127.0.0.1` unless explicitly public.",
Severity: "warning",
CWE: "CWE-605",
Tags: []string{"bind", "all-interfaces"},
Pat: map[string]string{
"python": `((call arguments: (argument_list (keyword_argument name: (identifier) @kw value: (string) @val))) @match
(#eq? @kw "host") (#match? @val "[\"']?(0\\.0\\.0\\.0|::)[\"']?"))`,
},
},
sastRule{
Name: "py-pyramid-config-debug",
Description: "Pyramid `config.add_settings({'pyramid.debug_all': True})` — enables the introspection debug view that leaks app internals.",
Severity: "warning",
CWE: "CWE-489",
Tags: []string{"debug", "pyramid"},
Pat: map[string]string{
"python": `((call function: (attribute attribute: (identifier) @fn)
arguments: (argument_list (string) @val)) @match
(#eq? @fn "add_settings") (#match? @val "pyramid\\.debug"))`,
},
},
sastRule{
Name: "py-fastapi-debug-true",
Description: "`fastapi.FastAPI(debug=True)` — exposes stack traces / route table in error responses. Disable in production.",
Severity: "warning",
CWE: "CWE-489",
Tags: []string{"debug", "fastapi"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)
arguments: (argument_list (keyword_argument name: (identifier) @kw value: (true)))) @match
(#eq? @mod "fastapi") (#eq? @attr "FastAPI") (#eq? @kw "debug"))`,
},
},
sastRule{
Name: "py-tornado-xss-render",
Description: "Tornado `RequestHandler.render(...)` with `{% raw user_input %}` template tag — `{% raw %}` skips HTML escaping; with user data this is XSS. Use `{{ var }}` for escaped output.",
Severity: "info",
CWE: "CWE-79",
Tags: []string{"xss", "tornado"},
Pat: map[string]string{
"python": `((string) @match (#match? @match "\\{% raw "))`,
},
},
sastRule{
Name: "py-werkzeug-debug-pin",
Description: "`werkzeug.debug.DebuggedApplication(app, evalex=True)` — Werkzeug debugger PIN can be bypassed if `WERKZEUG_DEBUG_PIN=off`. Never deploy with `evalex=True`.",
Severity: "error",
CWE: "CWE-489",
Tags: []string{"debug", "werkzeug"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)
arguments: (argument_list . (_) (keyword_argument name: (identifier) @kw value: (true)))) @match
(#eq? @attr "DebuggedApplication") (#eq? @kw "evalex"))`,
},
},
)
}
// 16. Weak crypto key sizes — Bandit B505
func registerPythonCryptoKeys() {
mustRegisterSAST(
sastRule{
Name: "py-rsa-weak-key-size",
Description: "`RSA.generate(<1024)` / `rsa.generate_private_key(public_exponent=..., key_size=<2048)` — 1024-bit RSA factorable by nation-state adversaries; NIST minimum is 2048. Use 3072+ for new deployments.",
Severity: "warning",
CWE: "CWE-326",
Tags: []string{"crypto", "weak-key"},
References: []string{"bandit:B505"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)
arguments: (argument_list (integer) @bits)) @match
(#match? @mod "^(RSA|rsa)$") (#eq? @attr "generate") (#match? @bits "^(512|768|1024)$"))
((call arguments: (argument_list (keyword_argument name: (identifier) @kw value: (integer) @bits))) @match
(#eq? @kw "key_size") (#match? @bits "^(512|768|1024)$"))`,
},
},
sastRule{
Name: "py-dsa-weak-key-size",
Description: "`DSA.generate(<2048)` — 1024-bit DSA is below NIST minimum since 2013.",
Severity: "warning",
CWE: "CWE-326",
Tags: []string{"crypto", "weak-key"},
Pat: map[string]string{
"python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)
arguments: (argument_list (integer) @bits)) @match
(#eq? @mod "DSA") (#eq? @attr "generate") (#match? @bits "^(512|768|1024)$"))`,
},
},
sastRule{
Name: "py-ec-weak-curve",
Description: "`ec.generate_private_key(ec.SECT163K1())` / similar — < 224-bit elliptic curves are below NIST minimum. Use `SECP256R1` / `SECP384R1` / `SECP521R1` / `Curve25519`.",
Severity: "warning",
CWE: "CWE-326",
Tags: []string{"crypto", "weak-curve"},
Pat: map[string]string{
"python": `((call function: (attribute attribute: (identifier) @curve)) @match
(#match? @curve "^(SECT163K1|SECT163R2|SECP160R1|SECP160R2|SECP160K1|SECP192R1|SECT193R1|SECT193R2)$"))`,
},
},
)
}
// 17. Mongo / Redis injection
func registerPythonMongoRedis() {
mustRegisterSAST(
sastRule{
Name: "py-mongo-where-injection",
Description: "Mongo `.find({\"$where\": user_input})` — `$where` runs JavaScript on the Mongo server; user-controlled JS is RCE on the DB host.",
Severity: "error",
CWE: "CWE-943",
Tags: []string{"nosql-injection", "mongodb"},
Pat: map[string]string{
"python": `((string) @match (#match? @match "[\"']\\$where[\"']"))`,
},
},
sastRule{
Name: "py-redis-eval-script",
Description: "Redis `.eval(script, ...)` / `.evalsha(...)` — Lua scripts run on the Redis server. User-controlled script content is RCE.",
Severity: "warning",
CWE: "CWE-943",
Tags: []string{"nosql-injection", "redis", "lua"},
Pat: map[string]string{
"python": `((call function: (attribute attribute: (identifier) @fn)
arguments: (argument_list . (string) @script)) @match
(#match? @fn "^(eval|evalsha)$") (#match? @script "[\"']?[A-Z]+"))`,
},
},
)
}
// 18. Framework extras — Django middleware order, etc.
func registerPythonFrameworkExtras() {
mustRegisterSAST(
sastRule{
Name: "py-django-format-html-join-user-input",
Description: "Django `format_html_join(sep, fmt, args)` with user-controlled `args` — the format string is trusted but `sep` and elements still need escaping. Audit every site.",
Severity: "info",
CWE: "CWE-79",
Tags: []string{"xss", "django"},
Pat: map[string]string{
"python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)]) @match (#eq? @fn "format_html_join"))`,
},
},
sastRule{
Name: "py-django-render-with-context-from-request",
Description: "Django `render(request, template, request.GET)` — passes the raw request mapping as template context. Any auto-escaping the template skips becomes user-controlled.",
Severity: "info",
CWE: "CWE-79",
Tags: []string{"xss", "django", "audit-hook"},
Pat: map[string]string{
"python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)]
arguments: (argument_list (_) (_) (attribute object: (identifier) @src))) @match
(#eq? @fn "render") (#eq? @src "request"))`,
},
},
)
}
+321
View File
@@ -0,0 +1,321 @@
package astquery
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
// TestPythonRulesCompile drives each registered Python SAST rule
// against a stub source so we catch tree-sitter pattern compile
// errors at unit-test time rather than waiting for an end-to-end
// `analyze sast` call.
func TestPythonRulesCompile(t *testing.T) {
const stub = "x = 1\n"
for _, info := range DescribeDetectors() {
if info.Category != CategorySAST {
continue
}
hasPython := false
for _, l := range info.Languages {
if l == "python" {
hasPython = true
break
}
}
if !hasPython {
continue
}
t.Run(info.Name, func(t *testing.T) {
res, err := RunOnSource(context.Background(), Options{Detector: info.Name},
"sample.py", "python", []byte(stub))
require.NoError(t, err, "rule %s pattern failed to compile", info.Name)
_ = res
})
}
}
// Per-rule firing tests. Table-driven: each entry pairs the rule
// name with (a) source that MUST trigger the rule and (b) source
// that MUST NOT trigger the rule.
func TestPythonRulesFire(t *testing.T) {
cases := []struct {
name string
bad string
good string
}{
// --- Injection
{"py-eval-use",
`eval("1+1")`,
`import ast; ast.literal_eval("1+1")`},
{"py-exec-use",
`exec("print(1)")`,
`print(1)`},
{"py-compile-use",
`code = compile("print(1)", "<str>", "exec")`,
`code = 'print(1)'`},
// --- Deserialisation
{"py-pickle-load",
`import pickle
data = pickle.loads(blob)`,
`import json
data = json.loads(blob)`},
{"py-marshal-load",
`import marshal
data = marshal.loads(blob)`,
`import json
data = json.loads(blob)`},
{"py-yaml-load-unsafe",
`import yaml
cfg = yaml.load(text)`,
`import yaml
cfg = yaml.safe_load(text)`},
{"py-shelve-open",
`import shelve
db = shelve.open("path")`,
`import json
db = json.load(open("path"))`},
// --- Subprocess
{"py-subprocess-shell-true",
`import subprocess
subprocess.run(cmd, shell=True)`,
`import subprocess
subprocess.run(["ls", "-la"], shell=False)`},
{"py-os-system",
`import os
os.system("ls " + d)`,
`import subprocess
subprocess.run(["ls", d])`},
{"py-os-popen",
`import os
os.popen("ls").read()`,
`import subprocess
subprocess.check_output(["ls"])`},
{"py-os-spawn",
`import os
os.spawnl(os.P_WAIT, "/bin/ls", "ls")`,
`import subprocess
subprocess.run(["ls"])`},
// --- Crypto
{"py-hashlib-weak-direct",
`import hashlib
h = hashlib.md5(b"x")`,
`import hashlib
h = hashlib.sha256(b"x")`},
{"py-hashlib-new-weak",
`import hashlib
h = hashlib.new("md5")`,
`import hashlib
h = hashlib.new("sha256")`},
{"py-pycryptodome-weak-hash",
`from Crypto.Hash import MD5`,
`from Crypto.Hash import SHA256`},
{"py-weak-cipher-pycrypto",
`from Crypto.Cipher import DES`,
`from Crypto.Cipher import AES`},
{"py-aes-mode-ecb",
`from Crypto.Cipher import AES
c = AES.new(key, AES.MODE_ECB)`,
`from Crypto.Cipher import AES
c = AES.new(key, AES.MODE_GCM, nonce)`},
// --- Network
{"py-telnetlib-import",
`import telnetlib`,
`import paramiko`},
{"py-ftplib-import",
`import ftplib`,
`from ftplib import FTP_TLS`},
{"py-imaplib-no-starttls",
`import imaplib
m = imaplib.IMAP4(host)`,
`import imaplib
m = imaplib.IMAP4_SSL(host)`},
{"py-poplib-no-tls",
`import poplib
p = poplib.POP3(host)`,
`import poplib
p = poplib.POP3_SSL(host)`},
{"py-smtplib-no-starttls",
`import smtplib
s = smtplib.SMTP(host)`,
`import smtplib
s = smtplib.SMTP_SSL(host)`},
// --- SSL/TLS
{"py-ssl-unverified-context",
`import ssl
ctx = ssl._create_unverified_context()`,
`import ssl
ctx = ssl.create_default_context()`},
{"py-ssl-no-verify",
`import ssl
ctx.verify_mode = ssl.CERT_NONE`,
`import ssl
ctx.verify_mode = ssl.CERT_REQUIRED`},
{"py-ssl-old-protocol",
`import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1)`,
`import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)`},
{"py-paramiko-autoadd-policy",
`import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())`,
`import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.RejectPolicy())`},
// --- XML
{"py-xml-etree-no-defusedxml",
`from xml.etree import ElementTree
ElementTree.parse(path)`,
`import defusedxml.ElementTree as ET
ET.parse(path)`},
{"py-xml-minidom-no-defusedxml",
`from xml.dom import minidom
minidom.parseString(s)`,
`from defusedxml import minidom
minidom.parseString(s)`},
// --- Django / Flask / Jinja
{"py-django-mark-safe",
`from django.utils.safestring import mark_safe
html = mark_safe(user_input)`,
`from django.utils.html import escape
html = escape(user_input)`},
{"py-django-debug-true",
`DEBUG = True`,
`DEBUG = False`},
{"py-django-allowed-hosts-wildcard",
`ALLOWED_HOSTS = ['*']`,
`ALLOWED_HOSTS = ['example.com']`},
{"py-flask-debug-true",
`app.run(debug=True)`,
`app.run(debug=False)`},
{"py-jinja2-autoescape-false",
`import jinja2
env = jinja2.Environment(autoescape=False)`,
`import jinja2
env = jinja2.Environment(autoescape=True)`},
// --- SQLi
{"py-sqli-execute-format",
`cursor.execute("SELECT * FROM t WHERE x = {}".format(v))`,
`cursor.execute("SELECT * FROM t WHERE x = %s", (v,))`},
{"py-sqli-execute-percent",
`cursor.execute("SELECT * FROM t WHERE x = %s" % v)`,
`cursor.execute("SELECT * FROM t WHERE x = %s", (v,))`},
{"py-sqli-execute-fstring",
`cursor.execute(f"SELECT * FROM t WHERE x = {v}")`,
`cursor.execute("SELECT * FROM t WHERE x = %s", (v,))`},
// --- Hardcoded credentials
{"py-hardcoded-credential-keyword-arg",
`connect(host="db", password="actualSecret123")`,
`connect(host="db", password=os.environ["DBPASS"])`},
{"py-hardcoded-credential-default-arg",
`def login(user, password="actualSecret123"): ...`,
`def login(user, password=None): ...`},
{"py-django-secret-key-hardcoded",
`SECRET_KEY = "actualSecret123Xyz"`,
`SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]`},
// --- Random
{"py-random-for-secret",
`import random
token = random.randint(0, 1<<63)`,
`import secrets
token = secrets.randbits(64)`},
{"py-uuid1-predictable",
`import uuid
sid = uuid.uuid1()`,
`import uuid
sid = uuid.uuid4()`},
// --- Filesystem
{"py-tempfile-mktemp",
`import tempfile
name = tempfile.mktemp()`,
`import tempfile
fd, name = tempfile.mkstemp()`},
{"py-hardcoded-tmp-path",
`open("/tmp/foo")`,
`import tempfile
tempfile.mkstemp()`},
// --- Archives
{"py-tarfile-extractall",
`import tarfile
t = tarfile.open(p)
t.extractall(dest)`,
`import tarfile
t = tarfile.open(p)
t.extractall(dest, filter="data")`},
{"py-shutil-unpack-archive-user-input",
`import shutil
shutil.unpack_archive(p, d)`,
`# nothing`},
// --- Imports
{"py-import-pickle",
`import pickle`,
`import json`},
{"py-import-subprocess",
`import subprocess`,
`import json`},
// --- Logging
{"py-logging-config-listen",
`import logging.config
t = logging.config.listen()`,
`import logging.config
logging.config.dictConfig(cfg)`},
// --- requests
{"py-requests-verify-false",
`import requests
r = requests.get(url, verify=False)`,
`import requests
r = requests.get(url, verify=True)`},
{"py-urlopen-file-scheme",
`from urllib import request
request.urlopen(url)`,
`# nothing`},
// --- Paramiko
{"py-paramiko-exec-command",
`ssh = paramiko.SSHClient()
ssh.connect(host)
stdin, out, err = ssh.exec_command(cmd)`,
`# nothing`},
// --- Exception handling
{"py-try-except-continue",
`for x in xs:
try:
do(x)
except Exception:
continue`,
`for x in xs:
try:
do(x)
except Exception:
log.error("oops")`},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
bad := runDetector(t, c.name, "python", "case.py", c.bad)
require.GreaterOrEqual(t, bad.Total, 1, "rule %q should fire on bad fixture; got 0 matches", c.name)
good := runDetector(t, c.name, "python", "case.py", c.good)
require.Equal(t, 0, good.Total, "rule %q should be silent on good fixture; got %d matches", c.name, good.Total)
})
}
}
+156
View File
@@ -0,0 +1,156 @@
package astquery
// Ruby SAST starter pack. The existing sql-string-concat /
// hardcoded-secret detectors already cover Ruby; this file adds
// language-specific patterns.
func init() {
registerRubyEval()
registerRubyYAMLMarshal()
registerRubyOpen3()
registerRubyConstantize()
registerRubyMassAssignment()
registerRubyRailsSendFile()
registerRubyRailsHTMLSafe()
registerRubyDigestWeakHash()
registerRubyOpenURIRedirect()
registerRubyExecBacktick()
}
func registerRubyEval() {
mustRegisterSAST(sastRule{
Name: "ruby-eval-use",
Description: "`eval(str)` / `instance_eval(str)` / `class_eval(str)` / `module_eval(str)` / `binding.eval(str)` — runs arbitrary Ruby. Any caller-controlled input is RCE.",
Severity: "error",
CWE: "CWE-95",
OWASP: "A03:2021-Injection",
Tags: []string{"injection", "code-injection"},
Pat: map[string]string{
"ruby": `((call method: (identifier) @fn) @match (#match? @fn "^(eval|instance_eval|class_eval|module_eval)$"))`,
},
})
}
func registerRubyYAMLMarshal() {
mustRegisterSAST(sastRule{
Name: "ruby-yaml-load",
Description: "`YAML.load(str)` / `Marshal.load(str)` — Ruby YAML / Marshal deserialise arbitrary classes, equivalent to Python `pickle.load`. RCE on untrusted input. Use `YAML.safe_load` / `JSON.parse`.",
Severity: "error",
CWE: "CWE-502",
OWASP: "A08:2021-Software and Data Integrity Failures",
Tags: []string{"deserialization"},
Pat: map[string]string{
"ruby": `((call receiver: (constant) @cls method: (identifier) @fn) @match
(#match? @cls "^(YAML|Marshal)$") (#match? @fn "^(load|load_file|restore)$"))`,
},
})
}
func registerRubyOpen3() {
mustRegisterSAST(sastRule{
Name: "ruby-open3-with-string",
Description: "`Open3.capture3(cmd_string)` / `Open3.popen3(cmd_string)` — a single-string argument is shell-parsed. Pass an argv array (`Open3.capture3('ls', dir)`) to avoid injection.",
Severity: "warning",
CWE: "CWE-78",
Tags: []string{"command-injection"},
Pat: map[string]string{
"ruby": `((call receiver: (constant) @cls method: (identifier) @fn) @match
(#eq? @cls "Open3") (#match? @fn "^(capture3|capture2|popen3|popen2)$"))`,
},
})
}
func registerRubyExecBacktick() {
mustRegisterSAST(sastRule{
Name: "ruby-shell-backtick",
Description: "`` `cmd #{var}` `` / `%x[cmd]` — shell-evaluated backticks. Any interpolation is command injection. Use `Open3.capture3` with an argv array.",
Severity: "error",
CWE: "CWE-78",
Tags: []string{"command-injection"},
Pat: map[string]string{
"ruby": `(subshell) @match`,
},
})
}
func registerRubyConstantize() {
mustRegisterSAST(sastRule{
Name: "ruby-constantize",
Description: "`x.constantize` / `x.safe_constantize` with user input — turns an arbitrary string into a class reference. Combined with `.new` this can construct any object in the autoloader.",
Severity: "warning",
CWE: "CWE-470",
Tags: []string{"reflection"},
Pat: map[string]string{
"ruby": `((call method: (identifier) @fn) @match (#match? @fn "^(constantize|safe_constantize)$"))`,
},
})
}
func registerRubyMassAssignment() {
mustRegisterSAST(sastRule{
Name: "ruby-mass-assignment-params-permit-all",
Description: "`params.permit!` — strong-parameters mass-assignment guard disabled. Equivalent to `attr_accessible :all`. Restore an explicit permit list.",
Severity: "warning",
CWE: "CWE-915",
Tags: []string{"mass-assignment", "rails"},
Pat: map[string]string{
"ruby": `((call receiver: (identifier) @recv method: (identifier) @fn) @match
(#eq? @recv "params") (#eq? @fn "permit!"))`,
},
})
}
func registerRubyRailsSendFile() {
mustRegisterSAST(sastRule{
Name: "ruby-rails-send-file-user-input",
Description: "`send_file(params[:path])` — path comes from request params. Any caller can read files outside the intended directory unless validated.",
Severity: "warning",
CWE: "CWE-22",
Tags: []string{"path-traversal", "rails"},
Pat: map[string]string{
"ruby": `((call method: (identifier) @fn
arguments: (argument_list (element_reference object: (identifier) @recv))) @match
(#eq? @fn "send_file") (#eq? @recv "params"))`,
},
})
}
func registerRubyRailsHTMLSafe() {
mustRegisterSAST(sastRule{
Name: "ruby-html-safe",
Description: "`.html_safe` / `raw(...)` — marks a string as already-HTML-escaped. With user input, this is XSS in any Rails view.",
Severity: "warning",
CWE: "CWE-79",
Tags: []string{"xss", "rails"},
Pat: map[string]string{
"ruby": `((call method: (identifier) @fn) @match (#match? @fn "^(html_safe|raw)$"))`,
},
})
}
func registerRubyDigestWeakHash() {
mustRegisterSAST(sastRule{
Name: "ruby-digest-md5-sha1",
Description: "`Digest::MD5` / `Digest::SHA1` — broken hashes for any security-sensitive use.",
Severity: "error",
CWE: "CWE-327",
Tags: []string{"crypto", "weak-hash"},
Pat: map[string]string{
"ruby": `((scope_resolution scope: (constant) @lib name: (constant) @alg) @match
(#eq? @lib "Digest") (#match? @alg "^(MD5|MD4|SHA1)$"))`,
},
})
}
func registerRubyOpenURIRedirect() {
mustRegisterSAST(sastRule{
Name: "ruby-open-uri-with-userdata",
Description: "`open(user_url)` (with `require 'open-uri'`) — `open` accepts file paths AND `http://` / `https://` URLs. User input invites both file disclosure and SSRF. Use `URI.parse(...).open` with an explicit scheme allow-list.",
Severity: "info",
CWE: "CWE-918",
Tags: []string{"ssrf", "audit-hook"},
Pat: map[string]string{
"ruby": `((call method: (identifier) @fn) @match (#eq? @fn "open"))`,
},
})
}
+124
View File
@@ -0,0 +1,124 @@
package astquery
// Rust SAST starter pack. The legacy unsafe-rust-* family already
// covers unsafe blocks / unwrap / panic-macro / assert-macro. This
// file adds security-specific patterns absent from that bundle.
func init() {
registerRustCommandShell()
registerRustWeakCryptoCrate()
registerRustHttpInsecure()
registerRustMemForget()
registerRustTransmute()
registerRustEnvFromUser()
registerRustStdRandForCrypto()
}
func registerRustCommandShell() {
mustRegisterSAST(sastRule{
Name: "rust-command-sh-c",
Description: "`Command::new(\"sh\").arg(\"-c\").arg(cmd)` — runs `cmd` through the shell. Any caller-controlled portion is command injection. Build an argv directly.",
Severity: "error",
CWE: "CWE-78",
OWASP: "A03:2021-Injection",
Tags: []string{"command-injection"},
Pat: map[string]string{
"rust": `((call_expression
function: (scoped_identifier path: (identifier) @ty name: (identifier) @method)
arguments: (arguments (string_literal) @shell)) @match
(#eq? @ty "Command") (#eq? @method "new")
(#match? @shell "\"(sh|bash|zsh|cmd|powershell)\""))`,
},
})
}
func registerRustWeakCryptoCrate() {
mustRegisterSAST(sastRule{
Name: "rust-md5-or-sha1-crate",
Description: "`use md5::*` / `use sha1::*` — crates that wrap broken hashes. Switch to `sha2`, `sha3`, `blake3`, or `argon2`.",
Severity: "error",
CWE: "CWE-327",
Tags: []string{"crypto", "weak-hash"},
Pat: map[string]string{
"rust": `((use_declaration argument: (scoped_use_list path: (identifier) @crate)) @match
(#match? @crate "^(md5|sha1|md4|md2)$"))
((use_declaration argument: (scoped_identifier path: (identifier) @crate)) @match
(#match? @crate "^(md5|sha1|md4|md2)$"))`,
},
})
}
func registerRustHttpInsecure() {
mustRegisterSAST(sastRule{
Name: "rust-reqwest-danger-accept-invalid-certs",
Description: "`reqwest::Client::builder().danger_accept_invalid_certs(true)` — disables TLS validation. Any MITM intercepts.",
Severity: "error",
CWE: "CWE-295",
Tags: []string{"tls", "no-verify"},
Pat: map[string]string{
"rust": `((call_expression
function: (field_expression field: (field_identifier) @fn)
arguments: (arguments (boolean_literal) @v)) @match
(#match? @fn "^(danger_accept_invalid_certs|danger_accept_invalid_hostnames)$") (#eq? @v "true"))`,
},
})
}
func registerRustMemForget() {
mustRegisterSAST(sastRule{
Name: "rust-mem-forget",
Description: "`mem::forget(x)` — leaks `x` without running its destructor. Sometimes intentional (FFI hand-off); usually a bug. Audit every call site.",
Severity: "info",
CWE: "CWE-401",
Tags: []string{"memory", "audit-hook"},
Pat: map[string]string{
"rust": `((call_expression
function: (scoped_identifier path: (identifier) @mod name: (identifier) @fn)) @match
(#eq? @mod "mem") (#eq? @fn "forget"))`,
},
})
}
func registerRustTransmute() {
mustRegisterSAST(sastRule{
Name: "rust-mem-transmute",
Description: "`mem::transmute::<_, _>(...)` — bit-cast between unrelated types. Often the wrong primitive; usually a sign that a `From` / safe API exists. Hand-audit boundary.",
Severity: "warning",
CWE: "CWE-704",
Tags: []string{"memory", "unsafe"},
Pat: map[string]string{
"rust": `((call_expression
function: [(scoped_identifier name: (identifier) @fn) (generic_function function: (scoped_identifier name: (identifier) @fn))]) @match
(#eq? @fn "transmute"))`,
},
})
}
func registerRustEnvFromUser() {
mustRegisterSAST(sastRule{
Name: "rust-env-set-from-user",
Description: "`std::env::set_var(k, v)` — process-wide; thread-unsafe pre-1.0 / racy on Unix. Avoid using it to plumb user input into the env (subsequent processes inherit).",
Severity: "info",
CWE: "CWE-454",
Tags: []string{"env"},
Pat: map[string]string{
"rust": `((call_expression
function: (scoped_identifier path: (scoped_identifier path: (identifier) @std name: (identifier) @env) name: (identifier) @fn)) @match
(#eq? @std "std") (#eq? @env "env") (#eq? @fn "set_var"))`,
},
})
}
func registerRustStdRandForCrypto() {
mustRegisterSAST(sastRule{
Name: "rust-thread-rng-for-crypto",
Description: "`rand::thread_rng()` — chacha20-based and cryptographically secure *in current versions*, but `SmallRng::seed_from_u64` and other `rand` PRNGs are not. Audit each call site to confirm the chosen RNG meets the use case (use `rand::rngs::OsRng` / `ring::rand` for keys / nonces / tokens unambiguously).",
Severity: "info",
CWE: "CWE-338",
Tags: []string{"crypto", "audit-hook"},
Pat: map[string]string{
"rust": `((call_expression function: (scoped_identifier path: (identifier) @crate name: (identifier) @fn)) @match
(#eq? @crate "rand") (#match? @fn "^(thread_rng|random)$"))`,
},
})
}
+115
View File
@@ -0,0 +1,115 @@
package astquery
import (
"fmt"
"os"
"strings"
"github.com/pelletier/go-toml/v2"
)
// userRuleFile is the TOML schema for a pluggable domain-extractor
// rule file:
//
// [[rule]]
// name = "feature-flag-register"
// description = "calls to flags.Register"
// domain = "feature_flag"
// severity = "info"
// tags = ["flags"]
// [rule.query]
// go = '(call_expression function: (selector_expression ...)) @match'
type userRuleFile struct {
Rule []userRule `toml:"rule"`
}
// userRule is one TOML-defined domain-extractor rule. Each becomes an
// astquery Detector registered under Category "domain", so the whole
// rule-execution and `analyze kind=domain` surface is reused.
type userRule struct {
Name string `toml:"name"`
Description string `toml:"description"`
// Domain labels the rule's purpose — http_route / cli_command /
// feature_flag / i18n / event_bus / custom. It becomes a tag so
// `analyze kind=domain tag:<domain>` filters to it.
Domain string `toml:"domain"`
Severity string `toml:"severity"`
Tags []string `toml:"tags"`
// Query maps a language ("go", "python", …) to a tree-sitter
// S-expression. A capture named `match` anchors the row.
Query map[string]string `toml:"query"`
}
// LoadUserRulesData parses TOML domain-extractor rule definitions and
// registers each as an astquery Detector under Category "domain".
// Returns the number of rules registered. A parse error or an invalid
// rule aborts the load — already-registered rules from the same file
// stay registered.
func LoadUserRulesData(data []byte) (int, error) {
var f userRuleFile
if err := toml.Unmarshal(data, &f); err != nil {
return 0, fmt.Errorf("astquery: parse user rules: %w", err)
}
n := 0
for _, r := range f.Rule {
d, err := r.toDetector()
if err != nil {
return n, err
}
RegisterDetector(d)
n++
}
return n, nil
}
// LoadUserRulesFile reads a TOML rule file and registers its rules.
func LoadUserRulesFile(path string) (int, error) {
data, err := os.ReadFile(path) //nolint:gosec // path is operator-supplied config
if err != nil {
return 0, fmt.Errorf("astquery: read user rules %s: %w", path, err)
}
return LoadUserRulesData(data)
}
// toDetector converts a TOML rule into a registrable Detector. The
// detector name is prefixed `user:` so a user rule can never clobber a
// bundled SAST / hygiene rule.
func (r userRule) toDetector() (*Detector, error) {
name := strings.TrimSpace(r.Name)
if name == "" {
return nil, fmt.Errorf("astquery: user rule missing name")
}
langs := map[string]string{}
for lang, q := range r.Query {
lang = strings.ToLower(strings.TrimSpace(lang))
q = strings.TrimSpace(q)
if lang != "" && q != "" {
langs[lang] = q
}
}
if len(langs) == 0 {
return nil, fmt.Errorf("astquery: user rule %q has no query patterns", name)
}
domain := strings.TrimSpace(r.Domain)
if domain == "" {
domain = "custom"
}
severity := strings.TrimSpace(r.Severity)
if severity == "" {
severity = "info"
}
desc := strings.TrimSpace(r.Description)
if desc == "" {
desc = "user-defined " + domain + " rule"
}
tags := append([]string{"user-rule", domain}, r.Tags...)
return &Detector{
Name: "user:" + name,
Description: desc,
Severity: severity,
Category: "domain",
Tags: tags,
Languages: langs,
ExcludeTests: false,
}, nil
}
+88
View File
@@ -0,0 +1,88 @@
package astquery
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestLoadUserRulesData_RegistersRules(t *testing.T) {
src := `
[[rule]]
name = "fr80-feature-flag"
description = "flags.Register calls"
domain = "feature_flag"
severity = "info"
tags = ["flags"]
[rule.query]
go = '(call_expression) @match'
[[rule]]
name = "fr80-cli-command"
domain = "cli_command"
[rule.query]
go = '(function_declaration) @match'
`
n, err := LoadUserRulesData([]byte(src))
require.NoError(t, err)
require.Equal(t, 2, n)
d := LookupDetector("user:fr80-feature-flag")
require.NotNil(t, d)
require.Equal(t, "domain", d.Category)
require.Contains(t, d.Tags, "feature_flag")
require.Contains(t, d.Tags, "user-rule")
require.Contains(t, d.Tags, "flags")
require.Equal(t, "(call_expression) @match", d.Languages["go"])
// The domain category bundle now includes the user rules — this
// is what `analyze kind=domain` fans out over.
names := map[string]bool{}
for _, dd := range DetectorsByCategory("domain") {
names[dd.Name] = true
}
require.True(t, names["user:fr80-feature-flag"])
require.True(t, names["user:fr80-cli-command"])
}
func TestLoadUserRulesData_DefaultsApplied(t *testing.T) {
n, err := LoadUserRulesData([]byte("[[rule]]\nname = \"fr80-defaults\"\n[rule.query]\ngo = '(x) @match'\n"))
require.NoError(t, err)
require.Equal(t, 1, n)
d := LookupDetector("user:fr80-defaults")
require.NotNil(t, d)
require.Equal(t, "info", d.Severity) // default severity
require.Contains(t, d.Tags, "custom") // default domain
}
func TestLoadUserRulesData_InvalidTOML(t *testing.T) {
_, err := LoadUserRulesData([]byte("[[rule]\nname = "))
require.Error(t, err)
}
func TestLoadUserRulesData_MissingName(t *testing.T) {
_, err := LoadUserRulesData([]byte("[[rule]]\n[rule.query]\ngo = '(x) @match'\n"))
require.Error(t, err)
}
func TestLoadUserRulesData_MissingQuery(t *testing.T) {
_, err := LoadUserRulesData([]byte("[[rule]]\nname = \"fr80-noquery\"\n"))
require.Error(t, err)
}
func TestLoadUserRulesFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rules.toml")
require.NoError(t, os.WriteFile(path,
[]byte("[[rule]]\nname = \"fr80-fromfile\"\n[rule.query]\ngo = '(x) @match'\n"), 0o644))
n, err := LoadUserRulesFile(path)
require.NoError(t, err)
require.Equal(t, 1, n)
require.NotNil(t, LookupDetector("user:fr80-fromfile"))
_, err = LoadUserRulesFile(filepath.Join(dir, "does-not-exist.toml"))
require.Error(t, err)
}