Skip to content
trailofbitsPublic

About

Build and query a graph database representation of source code

Resources

Contributing

Security policy

Stars

493 stars

Watchers

3 watching

Forks

Repository files navigation

Trailmark

CI Mutation Testing

Parse source code into queryable graphs of functions, classes, calls, and semantic annotations for security analysis.

Trailmark uses tree-sitter for language-agnostic AST parsing and rustworkx for high-performance graph traversal. The long-term vision is to combine this graph with mutation testing and coverage-guided fuzzing to identify gaps between assumptions and test coverage that are reachable from user input.

How It Works

Trailmark operates in three phases: parse, index, and query.

flowchart TD
    A["Source Files"] --> B["tree-sitter Parser"]
    B --> C["CodeGraph (nodes + edges)"]
    C --> D["rustworkx GraphStore"]
    D --> E["QueryEngine"]
    E --> F["JSON / Summary / Hotspots"]

    classDef src fill:#007bff26,stroke:#007bff,color:#007bff
    classDef parse fill:#28a74526,stroke:#28a745,color:#28a745
    classDef data fill:#6f42c126,stroke:#6f42c1,color:#6f42c1
    classDef query fill:#ffc10726,stroke:#e6a817,color:#e6a817

    class A src
    class B parse
    class C,D data
    class E,F query
Loading

1. Parse

A language-specific parser walks the directory, parses each file into a tree-sitter AST, and extracts:

  • Nodes — functions, methods, classes, structs, interfaces, traits, enums, modules, namespaces
  • Edges — calls, inheritance, implementation, containment, imports
  • Metadata — type annotations, cyclomatic complexity, branches, docstrings, exception types

Supported Languages

Language Extensions Key constructs
Python .py functions, classes, methods
JavaScript .js, .jsx, .mjs, .cjs functions, classes, arrow functions
TypeScript .ts, .tsx functions, classes, interfaces, enums
PHP .php functions, classes, interfaces, traits
Ruby .rb methods, classes, modules
C .c, .h functions, structs, enums
C++ .cpp, .hpp, .cc, .hh, .cxx, .hxx functions, classes, structs, namespaces
C# .cs methods, classes, interfaces, structs, enums, namespaces
Java .java methods, classes, interfaces, enums
Go .go functions, methods, structs, interfaces
Rust .rs functions, structs, traits, enums, impl blocks, verus! blocks
Solidity .sol contracts, interfaces, libraries, functions, modifiers, structs, enums
Cairo .cairo functions, traits, structs, enums, impl blocks, StarkNet contracts
Circom .circom templates, functions, signals, components
Haskell .hs functions, data types, type classes, instances
Erlang .erl functions, records, behaviours, modules
Miden Assembly .masm procedures, entrypoints, constants, invocations
Swift .swift functions, classes, structs, enums, protocols, extensions
Objective-C .m, .mm, .h C functions, classes, methods (selector-based naming)
Kotlin .kt, .kts functions, classes, interfaces, data classes, objects, methods
Dart .dart functions, classes, abstract classes, methods, constructors
Move .move modules, functions, imports, direct calls
Tact .tact contracts, structs, receivers, functions
Func .fc, .func functions, includes, direct calls
Sway .sw ABI interfaces, structs, impl methods, functions
Rego .rego packages, imports, policy rules, rule calls
Proto .proto services, RPCs, messages, fields, enums
Thrift .thrift services, functions, structs, fields, enums
GraphQL .graphql, .gql object types, root operations, fields, enums
SQL .sql schemas, tables, views, functions, procedures

The Rust parser extracts ordinary, spec, and proof functions with bodies from verus! { ... } blocks. The Verus grammar currently supports only brace-delimited blocks; verus!(...) and verus![...] are skipped with a warning. Inline modules retain qualified IDs (for example, file.inner:function), and methods retain their impl or trait owner. Each block is parsed independently; syntax errors produce a warning with the file and block location. Qualified invocations such as vstd::prelude::verus! are supported too. Local helper functions retain their enclosing function in their IDs (for example, file:outer.inner). Calls to these helpers and relative imports such as use super::* resolve to declarations present in the file. Repeated local names in different blocks receive a byte-offset suffix to keep their nodes distinct, and calls respect block scope. Local structs, enums, traits, and inline modules retain the enclosing function too: for example, file:outer.Guard.drop and file:outer.local.helper. Their bodies have separate calls and complexity. Explicit calls to known local types and modules resolve within the file. Bare calls search function and module scopes; associated items use paths such as Type::method. Dotted calls resolve only when the receiver names a known struct or enum. Other value receivers, including self.method(), remain unresolved. Implicit destructor calls and receiver type inference are not modeled. These rules apply to ordinary Rust as well as Verus blocks. Editing code before a declaration with an offset suffix changes its ID, so trailmark diff can report that declaration as removed and added.

Trailmark does not expand arbitrary macros. Literal Verus invocations inside opaque macros or function bodies are skipped with a warning. Uninvoked macro_rules! definitions are not expanded. Function signatures without bodies and calls in requires/ensures clauses are not extracted.

flowchart TD
    subgraph "Per-File Parsing"
        F["Source file"] --> TS["tree-sitter AST"]
        TS --> EX["Extract nodes"]
        TS --> EC["Extract call edges"]
        TS --> EB["Count branches"]
        TS --> ET["Resolve types"]
    end

    EX --> CG["CodeGraph"]
    EC --> CG
    EB --> CG
    ET --> CG

    classDef src fill:#007bff26,stroke:#007bff,color:#007bff
    classDef parse fill:#28a74526,stroke:#28a745,color:#28a745
    classDef extract fill:#ffc10726,stroke:#e6a817,color:#e6a817
    classDef data fill:#6f42c126,stroke:#6f42c1,color:#6f42c1

    class F src
    class TS parse
    class EX,EC,EB,ET extract
    class CG data
Loading

Node IDs follow the scheme module:function, module:Class, or module:Class.method for unambiguous lookup. Directory parsing resolves bare cross-file calls when a unique definition exists; ambiguous cross-file calls are left at their original best-effort target and marked uncertain. Edge confidence is tagged as certain (direct calls, self.method()), inferred (attribute access on non-self objects), or uncertain (dynamic dispatch or ambiguous resolution).

2. Index

The GraphStore loads the CodeGraph into a rustworkx PyDiGraph and builds bidirectional ID/index mappings for fast traversal.

3. Query

The QueryEngine provides a high-level API over the indexed graph:

Method Description
callers_of(name) Direct callers of the named target
callees_of(name) Direct callees of the named source
ancestors_of(name) Every function that can transitively reach the target (upward slice)
reachable_from(name) Every function transitively reachable from the source
paths_between(src, dst) All simple call paths between two nodes
connect_subgraphs(source, target) Paths connecting two named subgraphs
entrypoint_paths_to(name) Paths from any detected entrypoint to the target
attack_surface() Entrypoints tagged with trust level, asset value, and parser attributes when present
complexity_hotspots(n) Functions with cyclomatic complexity ≥ n
functions_that_raise(exc) Functions whose parser-detected exception list includes exc
generic_parameters(name) Generic type parameters declared by a node
type_references(name) Parameter, return, exception, and generic-bound type references
annotate(name, kind, description, source) Add a semantic annotation to a node
annotations_of(name, kind=None) Get annotations for a node, optionally filtered by kind
nodes_with_annotation(kind) Every node tagged with the given annotation kind
clear_annotations(name, kind=None) Remove annotations from a node
diff_against(other) Structural diff of this engine's graph vs. another
preanalysis() Run the built-in pre-analysis passes and store annotations/subgraphs
augment_sarif(path) Merge SARIF findings into the graph
augment_weaudit(path) Merge weAudit findings into the graph
augment_binary(path) Merge an external binary-analysis graph JSON file
findings(kind=None) Return nodes carrying finding-style annotations
subgraph(name) Return the nodes in a named subgraph
subgraph_edges(name) Return induced edges inside a named subgraph
subgraph_names() List every named subgraph currently on the graph
summary() Node counts, edge counts, dependencies
to_json() Full graph export

Data Model

classDiagram
    class CodeGraph {
        language: str
        root_path: str
        nodes: dict[str, CodeUnit]
        edges: list[CodeEdge]
        annotations: dict[str, list[Annotation]]
        entrypoints: dict[str, EntrypointTag]
        dependencies: list[str]
        add_annotation(node_id, annotation)
        clear_annotations(node_id, kind=None)
        merge(other)
    }

    class CodeUnit {
        id: str
        name: str
        kind: NodeKind
        location: SourceLocation
        parameters: tuple[Parameter]
        return_type: TypeRef
        exception_types: tuple[TypeRef]
        cyclomatic_complexity: int
        branches: tuple[BranchInfo]
        docstring: str
    }

    class CodeEdge {
        source_id: str
        target_id: str
        kind: EdgeKind
        confidence: EdgeConfidence
    }

    class Annotation {
        kind: AnnotationKind
        description: str
        source: str
    }

    class EntrypointTag {
        kind: EntrypointKind
        trust_level: TrustLevel
        description: str
        asset_value: AssetValue
    }

    CodeGraph "1" *-- "*" CodeUnit
    CodeGraph "1" *-- "*" CodeEdge
    CodeGraph "1" *-- "*" Annotation
    CodeGraph "1" *-- "*" EntrypointTag
Loading

Node kinds: function, method, class, module, struct, interface, trait, enum, namespace, contract, library, template, proxy

Node origins: source, proxy, binary, synthetic

Edge kinds: calls, inherits, implements, contains, imports, resolves_to, type_uses, specializes, corresponds_to

Edge confidence: certain, inferred, uncertain

Unresolved calls are materialized as proxy nodes such as proxy.unresolved:<raw-symbol> so traversal results can show where source analysis lost resolution instead of silently dropping that edge. Directory parses, including single-language parses, also apply repository links and materialize TYPE_USES edges. Binary analysis support imports external JSON call graphs; Trailmark does not disassemble executables itself.

Example Graph

Given this Python code:

class Auth:
    def verify(self, token: str) -> bool:
        return self._check_sig(token)

    def _check_sig(self, token: str) -> bool:
        ...

def handle_request(req: Request) -> Response:
    auth = Auth()
    if auth.verify(req.token):
        return process(req)
    return deny()

Trailmark produces a graph like:

graph TD
    HR["handle_request"] -->|calls| AV["Auth.verify"]
    HR -->|calls| P["process"]
    HR -->|calls| D["deny"]
    AV -->|calls| CS["Auth._check_sig"]
    A["Auth"] -->|contains| AV
    A -->|contains| CS

    classDef fn fill:#007bff26,stroke:#007bff,color:#007bff
    classDef cls fill:#6f42c126,stroke:#6f42c1,color:#6f42c1

    class HR,P,D fn
    class A,AV,CS cls
Loading

Installation

The examples below track the current development branch. For the latest published package, install from PyPI. For the exact feature set described here, install from a checkout and run commands via uv run.

# Latest published release
uv pip install trailmark

# Current checkout / development branch
uv sync --all-groups

Requires Python ≥ 3.12.

Trailmark requires tree-sitter-language-pack 1.22.1 or newer for most grammars. Root-owned grammar caches are supported when they are not group- or other-writable. Current releases use the platform certificate store for grammar downloads. In TLS-inspected or offline environments, pre-populate the package cache with python -c "import tree_sitter_language_pack as p; p.download_all()" on a matching platform, then copy the resulting tree-sitter-language-pack cache directory to the target machine. HTTPS_PROXY is also honored. The SQL grammar ships as the tree-sitter-sql wheel dependency and does not use that cache.

The bundled Circom, Miden Assembly, and Verus grammars compile on first use. This requires a C compiler (cc), Python development headers, and write access to their installed grammar directories. Rust files without Verus blocks do not trigger compilation. If the Verus grammar cannot be built or loaded, Trailmark logs a warning, skips Verus blocks, and continues parsing ordinary Rust. The Verus binding cache includes the compiled source contents, Python ABI, platform, and compiler options. Changed inputs select a new binding; completed builds are published atomically. Compiler failures include the exit status and stderr in the warning.

Git checkouts keep the Verus grammar definition and generate its C parser during builds with Tree-sitter CLI 0.27.1. Before uv sync or uv build in a checkout, run npm ci with npm install scripts enabled; Node.js is needed for generation. The generated parser and headers are gitignored. Wheels and source distributions include these generated sources, so installing published packages needs neither Node.js nor the Tree-sitter CLI. Editable uv installs rerun the build hook to restore missing generated files; unchanged inputs reuse the existing output.

Usage

# Report the installed version
trailmark --version     # or: trailmark -V
trailmark version       # subcommand form

# Full JSON graph (Python, the default)
trailmark analyze path/to/project

# Analyze a different language
trailmark analyze --language rust path/to/project
trailmark analyze --language javascript path/to/project

# Polyglot: auto-detect and merge every supported language found in the
# tree, or pass an explicit comma-separated list.
trailmark analyze --language auto path/to/project
trailmark analyze --language python,rust,solidity path/to/project

# Summary statistics
trailmark analyze --summary path/to/project

# Complexity hotspots (threshold >= 10)
trailmark analyze --complexity 10 path/to/project

# Augment the graph with external findings (SARIF from static analyzers,
# weAudit findings from the VS Code extension). Each --sarif / --weaudit
# flag is repeatable. Add --json to print the augmented graph.
trailmark augment --sarif results.sarif path/to/project
trailmark augment --weaudit findings.json path/to/project
trailmark augment --sarif a.sarif --sarif b.sarif --json path/to/project

# List detected entrypoints (attack surface). Uses heuristic detection
# (main() functions, pyproject.toml [project.scripts]) plus an optional
# override file at .trailmark/entrypoints.toml (see below).
trailmark entrypoints path/to/project
trailmark entrypoints --json path/to/project

# Structural diff between two code graphs. Accepts directory paths or
# git refs (branches, tags, commits). Surfaces added/removed nodes,
# call-edge changes, and — most usefully — attack-surface changes.
trailmark diff before/ after/
trailmark diff --repo . main HEAD          # compare git refs
trailmark diff --json before/ after/        # machine-readable output

# Generate a Mermaid diagram from the code graph. --type is required; the
# choices are call-graph, class-hierarchy, module-deps, containment,
# complexity, and data-flow. Use --focus to scope large graphs.
trailmark diagram --target path/to/project --type call-graph
trailmark diagram -t path/to/project -T call-graph -f parse_file --depth 3
trailmark diagram -t path/to/project -T complexity --threshold 5 --direction LR

Entrypoint detection

Trailmark automatically populates graph.entrypoints so attack_surface(), taint propagation, and privilege-boundary crossing have data to work with. Detection runs in four layers, each overriding the last:

  1. Generic main heuristic. Any function named main in any language. Tagged user_input / trusted_internal / low.
  2. Framework-aware scan. Decorator, attribute, and visibility patterns per language — see the table below.
  3. pyproject.toml [project.scripts]. Explicit CLI targets get an upgraded trust/asset classification.
  4. Repo-local override file. Hand-curated entrypoints in .trailmark/entrypoints.toml always win.

Framework coverage:

Language Frameworks detected
Python Flask, FastAPI, aiohttp, Click, Typer, Celery
JavaScript / TypeScript NestJS, Next.js (App Router + Pages API), AWS Lambda
Java Spring MVC / WebFlux, JAX-RS, Kafka listeners, servlets
C# ASP.NET Core, Azure Functions
PHP Symfony #[Route] attributes + legacy annotations
Rust actix-web, rocket, FFI exports (#[no_mangle], pub extern "C"), async-main attributes
Solidity external / public visibility
Cairo / StarkNet #[external], #[view], #[l1_handler], #[constructor]
Circom component main declarations
Miden Assembly export.<name> directives
Haskell top-level main :: / main =
Erlang functions listed in -export([...])
Swift @main app attribute
Objective-C UIApplicationDelegate lifecycle selectors (e.g. application:openURL:options:)
Kotlin Spring MVC / WebFlux annotations (shared with Java), Android component lifecycle methods (onCreate, onReceive, onBind, ...)
Dart @pragma('vm:entry-point') native-callable markers
Go http.HandleFunc / http.Handle stdlib registrations, gin/chi/echo-style <router>.GET/POST/... handler registrations
Ruby Rails controller actions (classes inheriting ApplicationController / ActionController::*), Sidekiq worker perform methods
C / C++ extern "C" linkage, __attribute__((visibility("default"))), __declspec(dllexport)

For anything the heuristics miss, declare entrypoints explicitly in .trailmark/entrypoints.toml at the project root. The file supports both single-node and rule-based entries:

# Single-node entry
[[entrypoint]]
node = "my_module:handle_request"  # node id, or "module.path:function"
kind = "api"                       # user_input | api | database | file_system | third_party
trust = "untrusted_external"       # untrusted_external | semi_trusted_external | trusted_internal
asset_value = "high"               # high | medium | low
description = "HTTP POST /auth"

# Rule: every PHP script under public_html/ is a web-exposed entrypoint.
[[entrypoint]]
file_glob = "public_html/**/*.php"
kind = "user_input"
trust = "untrusted_external"
asset_value = "high"
description = "Web-exposed PHP script"

# Rule: any function that takes a PSR-7 request object.
[[entrypoint]]
param_type = "ServerRequestInterface"
kind = "api"
trust = "untrusted_external"
asset_value = "high"
description = "PSR-7 HTTP handler"

# Rule: functions named `handle_*`.
[[entrypoint]]
name_regex = "^handle_"
kind = "api"
trust = "untrusted_external"

# Rule: conditions compose with AND — web.py files AND name starts with handle_.
[[entrypoint]]
file_glob = "public/*.py"
name_regex = "^handle_"
kind = "api"
trust = "untrusted_external"

Later entries override earlier ones when two rules tag the same node, so place broad rules first and specific corrections after.

See docs/entrypoint-patterns.md for the full reference, including frameworks not yet implemented (Express / Koa / Fastify, Laravel, Cobra, axum, warp, clap, and others) with grep-ready patterns contributors can use to add new detectors.

Solidity detection uses parser metadata rather than signature regexes. Interface declarations are excluded, and derived overrides annotate matching base implementations with solidity_overridden_by instead of removing the base entrypoint. Concrete public and external functions remain entrypoints, including view and pure functions; their solidity_visibility, solidity_mutability, and override attributes are returned by attack_surface() so callers can distinguish read-only exposure and filter overridden base implementations. attack_surface() includes parser-specific entrypoint attributes when they are attached to the underlying graph node.

Cross-language and external links

Polyglot parsing merges language graphs, but many RPC, FFI, subprocess, and host/contract relationships are not visible in source syntax. Declare these deterministically in .trailmark/links.toml:

[[link]]
source = "backend:submit"
target = "contract:Verifier.verify"
kind = "calls"                 # defaults to calls
confidence = "certain"         # defaults to inferred
description = "JSON-RPC eth_call"

[[link]]
source = "backend:notify"
target = "payments-webhook"
target_external = true         # required because target is unresolved

References may be exact node IDs or unique names/suffixes. Ambiguous references, unknown internal endpoints, invalid enum values, and malformed TOML raise ValueError. Setting source_external = true or target_external = true explicitly permits that unresolved endpoint and creates a proxy node. This file is a stable public configuration interface.

Analysis limitations

  • entrypoint_paths_to() reports call-graph reachability, not attacker-controlled data flow. Use preanalysis taint results as a coarse separate signal; Trailmark does not yet perform interprocedural taint analysis.
  • TypeScript resolves direct calls and straightforward receivers assigned with new ConcreteClass(). Interface dispatch through manifests, computed property names, dependency-injection containers, and other dynamic mechanisms remains best-effort.
  • SQL support is PostgreSQL-oriented and extracts schemas, tables, views, functions, procedures, and routine/view dependencies. It is not a complete SQL dialect validator or query-semantic analyzer.

Programmatic API

from trailmark.parse import parse_directory, parse_file
from trailmark.query.api import QueryEngine

# Parse-only API: get the raw CodeGraph without building GraphStore/QueryEngine.
graph = parse_file("path/to/file.py")
graph = parse_directory("path/to/project", language="auto")

# Single-language (default) or auto-detect + merge across all languages
engine = QueryEngine.from_directory("path/to/project")
engine = QueryEngine.from_directory("path/to/project", language="auto")
engine = QueryEngine.from_directory("path/to/project", language="python,rust")

# Direct neighbors
engine.callers_of("handle_request")
engine.callees_of("handle_request")

# Transitive slicing — who could reach this sink, or what could it reach?
engine.ancestors_of("Auth._check_sig")
engine.reachable_from("handle_request")

# Attack-surface paths from any detected entrypoint
engine.entrypoint_paths_to("Auth._check_sig")

# All call paths between two nodes
engine.paths_between("handle_request", "Auth._check_sig")

# Functions with cyclomatic complexity >= 10
engine.complexity_hotspots(10)

# What functions can raise a given exception? (uses parser-detected
# exception_types; no runtime tracing required)
engine.functions_that_raise("PermissionError")

# Add and query semantic annotations
from trailmark.models.annotations import AnnotationKind

engine.annotate(
    "handle_request",
    AnnotationKind.ASSUMPTION,
    "Caller has already authenticated the session token",
    source="llm",
)
engine.annotations_of("handle_request")
engine.nodes_with_annotation(AnnotationKind.FINDING)

# Diff against an earlier snapshot of the same codebase
before = QueryEngine.from_directory("before/")
diff = engine.diff_against(before)
# diff contains: summary_delta, nodes {added/removed/modified},
# edges {added/removed}, entrypoints {added/removed/modified}

# Run the built-in audit-oriented preanalysis passes
engine.preanalysis()
engine.findings()
engine.subgraph_names()

# Programmatic augmentation hooks for external tooling
engine.augment_sarif("results.sarif")
engine.augment_weaudit("findings.json")

NodeKind.SCHEMA, TABLE, VIEW, and PROCEDURE are additive in v0.5.0; consumers that exhaustively match enum values should add cases for them.

Development

# Install package and dev dependencies
npm ci
uv sync --all-groups

# Lint and format
uv run ruff check --fix
uv run ruff format

# Type check
uv run --locked ty check

# Tests
uv run pytest -q tests/

# Mutation testing with pytest-gremlins
uv run python -m tests.run_mutations --reviewed --workers=4
uv run python -m tests.mutation_gate

# Full repository mutation campaign
uv run python -m tests.run_mutations --workers=4

Mutation reports are written to coverage/gremlins/. Pull requests mutate the Rust/Verus files named in tests/mutation_baseline.json and gate new and changed functions there. Full repository campaigns run on pushes to main and through the mutation workflow's manual trigger. Unchanged legacy functions are identified by source hashes and enter the gate automatically when edited. The gate rejects unreviewed survivors, missing tests/results, errors, and timeouts. Exceptions require a reason and a fingerprint of the function and mutation; changing that function requires reviewing its exceptions again.

The runner pins pytest-gremlins 1.9.0 and uses real pytest subprocesses for every mutant, including fixtures and parametrized tests. Each mutant retains the full selected test suite: component unit tests run first, followed by the remaining tests, stopping at the first failure. Shared-helper mutants retain tests from every language. Tests stay in their existing files; tests/mutation_order.py defines the ordering, and unknown components retain collection order. The runner skips the redundant coverage pre-scan, checks the clean baseline and each distinct instrumented test ordering, and preserves package metadata and exact parameter IDs when loading mutated code. These compatibility measures address upstream runner and selection issues; their control tests must pass before upgrading the plugin. Snapshot updates (TRAILMARK_UPDATE_SNAPSHOTS=1) are disabled during CI and mutation runs. To focus a local campaign, pass --targets with comma-separated source paths, followed by the relevant test files. --workers=4 already enables parallel execution. Use --test-order=collection to compare against pytest's original order.

License

Apache-2.0

About

Build and query a graph database representation of source code

Resources

Contributing

Security policy

Stars

493 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages