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
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

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. Five 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

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.

Assemblies have no line table, so a finding names the method and a byte offset rather than a line. What the analysis does not yet follow is a value that crosses a method boundary: a flow whose source and sink are in different methods is not reported.

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.

How a constant's boundaries are found

Two mechanisms, 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.

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