Code Scanning

Supported Languages

Languages with tree-sitter AST matching, and the generic/text files matched with regex patterns.

Supported Languages

This page is the raw parser/extension matrix. For what xgrep actually detects in each language — the vulnerability classes, frameworks, and taint coverage — see SAST by language.

Tree-sitter languages (full AST matching)

LanguageExtensions
Python.py, .pyi
Go.go
Java.java
JavaScript.js, .jsx, .mjs, .cjs
TypeScript.ts
TSX.tsx
Ruby.rb
PHP.php
C.c, .h
C++.cc, .cpp, .cxx, .hpp, .hxx
C#.cs
Rust.rs
Kotlin.kt, .kts
Scala.scala, .sc
Bash.sh, .bash, .zsh
Lua.lua
Julia.jl
OCaml.ml, .mli
HTML.html, .htm, .vue
JSON.json
YAML.yaml, .yml
XML.xml, .config
HCL.tf, .hcl
DockerfileDockerfile
Swift.swift

Newer AST languages

These languages also parse to a tree-sitter AST, so structural matching is available. Their bundled rule coverage is lighter and still largely pattern-regex, but rules can be (and are being) authored as AST patterns.

LanguageExtensions
PowerShell.ps1, .psm1, .psd1
Solidity.sol
Cairo.cairo
Dart.dart
R.r, .R
Elixir.ex, .exs
Erlang.erl, .hrl
Clojure.clj, .cljs, .cljc
Scheme.scm, .ss
Lisp.lisp, .cl
GroovyJenkinsfile, .gradle, .groovy, .gvy
Perl.pl, .pm, .perl, .pod
Apex.cls, .trigger
Jsonnet.jsonnet, .libsonnet
Hack.hack, .hh, .hhi

Apex covers Salesforce classes and triggers. Taint analysis is available: the starter rules track a Visualforce page parameter, an Apex REST parameter or a remotely-callable @AuraEnabled/@RemoteAction argument into Database.query, Search.query and a PageReference redirect, and treat String.escapeSingleQuotes() and coercion via Id.valueOf()/Integer.valueOf() as sanitizers. An inline query with a bind variable ([SELECT Id FROM Account WHERE Name = :name]) is parsed as such and never reported.

Two limits are worth knowing. A local declaration whose type is a generic with a single type argument (List<Account> a = f();) is mis-parsed by the Apex grammar, so a tainted value assigned into one does not propagate further — a query call on that line is still matched. And a .cls file holding anonymous Apex (bare statements with no enclosing class) is not a class file and does not parse.

Jsonnet is a configuration-generation language, so its bundled rules are IaC-shaped: they read what the evaluated document will contain (a TLS switch turned off, a credential written as a literal, a container spec that drops the kernel's isolation boundary) rather than application logic. Taint analysis follows values through local bindings and function parameters, so a credential bound once and reused — the ordinary way to write Jsonnet — is reported where it reaches the generated config, not only where it is written. .libsonnet is the same language — the extension is a convention for a library meant to be imported — and is analyzed identically. Hack is Meta's PHP-derived language. .hack and .hhi are Hack-only, but .hh is shared with GNU-style C++ headers, so it is resolved by content: a .hh file that opens with Hack's <?hh marker is analyzed as Hack, and one that does not is analyzed as a C++ header. A .php file is analyzed as PHP even when it opens <?hh, since PHP's rule coverage is the larger of the two for such a file.

Groovy covers Gradle's build scripts as well as Jenkinsfile: build.gradle, settings.gradle and any *.gradle script plugin are ordinary Groovy and are analyzed as such. (build.gradle.kts is Kotlin, and has been analyzed as Kotlin all along.)

Template languages

These splice code of a host language into markup. xgrep separates the two: the embedded code is extracted and analyzed as its host language, so the host language's rules and taint analysis apply, and findings are reported at their position in the original template file.

TemplateExtensionsAnalyzed as
ERB.erbRuby
JSP.jspJava
Razor.cshtmlC#
ASP.NET.aspxC#

Razor and ASP.NET prefer the pre-compiled companion file the build emits (<name>.cshtml.g.cs) when one is present, since that is plain C#; otherwise the markup and the @-delimited code blocks are separated and each analyzed in its own layer.

Jupyter notebooks

xgrep reads .ipynb files. A notebook's code cells are reconstructed into one program and scanned exactly as a source file in the kernel's language is — the full rule corpus, the AST matching and the taint engine, not a text pass.

Cells are read as one program, not one at a time. They share an interpreter namespace, so a dataframe loaded in cell 3 and posted to a vendor in cell 9 is a single flow, and reading them separately would miss precisely the flows worth finding.

WhatHow it is read
code cellsthe program, in the kernel's language
markdown / raw cellsskipped, but their lines are preserved so positions stay true
%pip, !ls, ?objnot the kernel's language; skipped the same way
cell outputsscanned as captured text — a printed dataframe or a pasted key is data, not code

The language comes from the notebook's kernel (python, r, julia). A notebook whose kernel names a language xgrep does not parse is skipped with a warning, never read as Python — a silent misread would report nothing and look clean.

Findings are reported on the .ipynb itself, at the line the cell occupies in the file, and carry the code cell they came from.

Compiled JVM classes

xgrep also reads .class files, so a jar, war or ear can be scanned with no source anywhere near it — see Scanning a build artifact.

A .class file is not necessarily Java. Kotlin, Scala, Groovy and Clojure all compile to the same format, and a Kotlin call to HttpServletRequest.getParameter is byte-for-byte what the Java one compiles to. xgrep reads which compiler produced each class and reports the finding under that language's rules — a jar built from Kotlin gives you kotlin-command-injection, the same rule scanning the .kt sources gives you.

Class compiled fromFindings reported under
Javajava-* rules
Kotlinkotlin-* rules
Scalascala-* rules

It is decided per class, so a mixed Java/Kotlin jar reports each class under its own language. A class that says nothing about its origin — no debug information and no compiler marker — is read as Java.

Compiled analysis covers a subset of each language's rules: injection, SQL injection, path traversal, reflected XSS and code injection for Kotlin and Scala, and a wider set for Java. Rules outside that subset apply to source only.

Compiled .NET assemblies

xgrep also reads .dll and .exe assemblies, so a deployed .NET service — or a .nupkg of them — can be scanned with no source anywhere near it. See Scanning a build artifact.

The assembly is read twice: for the string literals it carries, through the same secret corpus a source scan uses, and for what it does, by interpreting the CIL of each method. These C# rules have a compiled counterpart and report under their own IDs:

RuleWhat it reports on an assembly
csharp-command-injectiona request or console value reaching Process.Start or a ProcessStartInfo property
csharp-sql-injectiona query built from untrusted data reaching a command constructor or CommandText
csharp-path-traversaluntrusted data reaching a System.IO file or path call
csharp-xssuntrusted data reaching HtmlString, MvcHtmlString or HttpResponse.Write
csharp-code-injectionuntrusted data reaching the Roslyn scripting API
csharp-regex-injectionuntrusted data used as a regex PATTERN — the subject being untrusted is the ordinary use of the API and is not reported
csharp-response-splittinguntrusted data reaching a response header's VALUE, or a redirect target; a header whose NAME is untrusted is not reported
csharp-xml-injectionuntrusted data reaching XmlWriter.WriteRaw, which is the writer method that does not escape
csharp-ssrf-requestuntrusted data reaching an outbound HTTP request
csharp-unsafe-reflectionuntrusted data naming the type or assembly to load
csharp-assembly-path-injectionuntrusted data naming the assembly file to load
csharp-ldap-injectionuntrusted data reaching an LDAP filter or path
csharp-xpath-injectionuntrusted data reaching an XPath expression
csharp-weak-randoma value from a non-cryptographic generator used where a secret is required
csharp-download-cradledownloaded content reaching a compiler, Assembly.Load or Process.Start
csharp-resource-injectionuntrusted data reaching a connection-string constructor or property
csharp-datatable-expression-injectionuntrusted data reaching a DataSet expression — a Compute argument, a RowFilter, a DataColumn.Expression or a DataTable.Select filter; the SORT column being untrusted is not reported
csharp-format-stringuntrusted data used as the FORMAT string; an untrusted format ARGUMENT is the ordinary use of the API and is not reported
csharp-inappropriate-encoding-sqla value run through an HTML/URL encoder or a hand-rolled quote-doubler, then concatenated into SQL
csharp-sensitive-data-exfiltrationa credential read from the environment or a key file leaving over the network; the read has to NAME a secret, so an ordinary configuration value is not reported
csharp-cleartext-storage-password-fieldthe text of a control marked a password field reaching a logging call

Both ASP.NET generations are recognised as request sources: System.Web for classic ASP.NET and WebForms, Microsoft.AspNetCore.Http for Core. Rules outside this set apply to source only.

For several of these, which argument carries the untrusted value is what decides the finding, so they read the position rather than the call: a regex PATTERN and not its subject, a header VALUE and not its name, a DataView row filter and not its sort column, a format string and not what is formatted into it. Each rule also reads exactly the request accessors its own rule names, so scanning an assembly and scanning the source it was built from report the same flows.

Four of these rules read no request at all, because their own flow does not start with one. csharp-download-cradle starts at a download, and csharp-inappropriate-encoding-sql starts at the encoder whose output was wrongly believed safe to concatenate. csharp-sensitive-data-exfiltration starts at a credential the process already holds, and only when the read names one — an environment variable called HOME is not a secret, and the name has to be recoverable, so a key chosen at runtime is not reported. csharp-cleartext-storage-password-field starts at the call that MARKS a control a password field, and it reports a control held in a local variable; one held in a field, or passed as a parameter, is not yet followed.

Assemblies have no line table, so a finding names the method and a byte offset rather than a line. A flow whose source and sink are in different methods is followed, up to a bounded call depth; beyond that bound, and through recursion, the analysis assumes a call handed untrusted data returns untrusted data.

Compiled Python modules

xgrep also reads .pyc files, so a deployment that shipped __pycache__ and deleted its sources — what python -m compileall followed by a source strip produces, and what a great many container images contain — is scanned as code rather than skipped as binary. A __pycache__ beside its .py files is still skipped, because those sources are already being scanned; one whose sources are gone is not.

CPython 3.10, 3.11, 3.12 and 3.13 are read. Each release changes its own bytecode — opcode numbers, instruction lengths, the sequence that performs a call, the layout of a code object — so a module compiled by a release xgrep has no table for is reported as unreadable rather than decoded with a neighbouring release's table and quietly misread.

The module is read twice: for the string literals it carries, through the same secret corpus a source scan uses, and for what it does, by interpreting the bytecode of each function. Four Python rules have a compiled counterpart and report under their own IDs:

RuleWhat it reports on a compiled module
python-flask-command-injectiona Flask or Django request value reaching os.system or os.popen
python-code-injectionuntrusted data reaching eval, exec, compile, __import__ or importlib.import_module
python-path-traversaluntrusted data reaching a file, path or archive-extraction call
python-sql-injectiona query built from untrusted data reaching execute, raw, extra or SQLAlchemy text

Rules outside this set apply to source only.

A compiled module has no lines of its own, so a finding names a byte offset in the file. It also names the source file the module was compiled from and the function it is in, which a .pyc still records even when that source is gone.

Two things the analysis does not do: it does not follow a value across a function boundary, and it cannot see a keyword argument's name, so subprocess.run(cmd, shell=True) is not reported where the shell-invoking calls that need no keyword are.

WebAssembly modules

xgrep also reads .wasm modules, so a deployed edge function, plugin or component can be scanned with no source anywhere near it. Before this a .wasm was binary, so it was skipped and the scan exited 0.

What is read is different from the .NET and Python compiled paths, because the format is different in a way that matters. A wasm call site names no target — there are no library calls to model — so there is no taint analysis here. What a module does name exactly is the host functions it imports, and its data segments, which hold its string literals.

Literals go through the same secret corpus a source scan uses. A credential compiled into a data segment is the same credential, and it is found the same way. Recovering those literals takes more than reading the printable bytes: a data segment lays every string constant end to end with no delimiter, so the literals are separated at the addresses the module's own code points to.

A module's imports bound what it can do. WebAssembly has no ambient authority — a module cannot open a file, a socket or an environment variable except through a function its host handed it, and every such function is named in the import section. So a finding in a module carries what that module can reach: filesystem-open, filesystem-read, filesystem-write, network, environment, command-line, random, clock, process-control.

The negative is the strong half. A module that does not import a socket function cannot open one, however its code is written. That is a guarantee the format provides and source-language analysis cannot.

Two things this deliberately does not claim. Writing to a file descriptor is not the same as writing files — fd_write is how a module prints to standard output, so only the path operations establish that the filesystem itself can be changed. And a module built for a custom host, rather than for WASI, imports functions xgrep has no vocabulary for; those host namespaces are reported by name, so "no capabilities" never silently means "not classified".

Native executables (ELF, Mach-O, PE)

xgrep reads the string constants compiled into a native executable — a Go, Rust, C, C++ or Swift binary, a shared library, a Windows .exe — so a deployed service with no source anywhere near it is still scanned for hardcoded credentials. Linux ELF, macOS Mach-O (including universal "fat" binaries) and Windows PE images are all read.

This is one reading, not two. A native binary is read only for what it states. What it does is not analysed: there is no taint analysis on machine code, so a native binary never produces an injection or traversal finding, only a finding about a constant it carries. For dependency identity in a Go binary, see Go binaries.

The constants go through the same secret corpus a source scan uses, so a credential compiled into a program is reported under the same rule ID that the source scan of the same program reports.

A secret validator is told that it is reading constants rather than a file, which changes what it may conclude from the text around a match. The case this matters for is a PEM header: in a file, a lone -----BEGIN ... PRIVATE KEY----- with nothing after it may be a key that was truncated or redacted in place, so xgrep reports it for review. In a program's constants every literal stands alone by construction, and a bare header with no key material beside it is what a program that parses PEM carries — so it is not reported. A header whose key material is there is still reported, in either form.

How a constant's boundaries are found

Three mechanisms. The first two are the boundary the program itself uses, and they differ because two toolchains disagree about what a string is:

  • NUL-terminated literals, which is what C and C++ compile. The literal ends where its terminator is.
  • Slice headers, which is what Go and Rust compile. Their strings are a pointer and a length, with no terminator, so every constant in the binary is laid end to end in one run. xgrep recovers each one from the (pointer, length) pair the compiler wrote beside it, which is the boundary the program itself uses. Without this, a Go or Rust binary's constants arrive as one enormous concatenation and match nothing at all.
  • Newlines inside a run nothing else can bound. Some constants have no header naming them and sit in a run too long to be a literal — a private key compiled into a Go program was measured at the end of a 38 KB run, with no (pointer, length) pair anywhere pointing at it, and so was invisible. Where such a run has newlines in it, xgrep cuts there. The byte is genuinely in the file, so this invents no boundary; but it bounds a line, which need not be one constant, so it is the weakest of the three. It is used only after the other two have taken what they need, and a finding records that its boundary came this way. A run with no newline anywhere still cannot be cut.

A finding names the byte offset of the constant in the file, quotes the constant as its excerpt rather than the surrounding machine code, and records which of the two mechanisms found its boundary, the container format and the machine.

In a universal ("fat") binary every architecture is read, because a credential compiled into only one slice is still in the file. A constant that appears in more than one slice is reported once, at the first byte offset that holds it — the file states one fact, not one per architecture. A constant repeated within one architecture is still reported once per occurrence, exactly as it would be in a single-architecture binary.

Limits

  • A run of concatenated constants with no (pointer, length) pair pointing into it cannot be cut, because the lengths live in the instruction stream. xgrep declines those runs rather than handing the corpus a concatenation that matches nothing.
  • One binary contributes at most 20,000 constants. A very large binary — dockerd and docker are both past it — has constants that are not scanned. A scan that reads only part of a binary says so, with a warning naming the file, so a clean result on one of them is never mistaken for a complete one. Raising the limit was measured on dockerd and containerd: it multiplies the time by 15 and finds nothing more, and takes a 100 MB binary past the default per-file --timeout, which turns it into a scan error instead of a partial read.
  • Reading a large binary is not free: a 100 MB executable takes roughly 16 seconds.
  • A binary is read even when it is larger than --max-target-bytes, up to 256 MB. That cap is sized for source files, whose scan cost grows with their size; a compiled file is read by its own reader, which caps its own cost. Every compiled format is treated this way, not only native binaries.

Regex-only matching

Only generic/text files (.txt, .tpl, .ejs, .mustache, .move, .generic) are matched purely with regex patterns — there is no grammar for them, so AST-specific features (precise structural matching, typed metavariables) are unavailable.

Two more languages are recognized by extension but have no grammar either, so rules for them are matched the same way: Tcl (.tcl) and assembly (.s, .asm).

On this page