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
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:
@@ -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.)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)$"))`,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)$"))`,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,}[\"']"))`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"))`,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"))`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)$"))`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user