Skip to main content

Language-agnostic code graph: query symbols, entrypoints, and source-to-sink call paths from a SQLite index

Project description

entrygraph

Query your codebase like a graph. entrygraph indexes a repository into a SQLite database (through the SQLAlchemy ORM) and answers questions about symbols, classes/methods, entrypoints (HTTP routes, CLI commands, main functions, tasks, lambda handlers), and source → sink call-graph reachability ("can any HTTP route reach subprocess.run?").

Language-agnostic via tree-sitter; first-class support for Python, JavaScript/TypeScript, Go, Java, Ruby, C#, PHP, and Rust, with language and framework detection.

Reachability is a heuristic taint tier, not just call-edge closure: paths are risk-ranked, sanitizers prune or discount them, class-hierarchy analysis recovers virtual dispatch, and confidence flags trade recall for precision. Entrypoints include decorator/attribute routes, call-based route registration, middleware, and config-file handlers (serverless, SAM, Procfile, Dockerfile).

On top of the graph, entrygraph ships a diff-aware reachability gate for CI (baseline the accepted paths, then fail only on ones a change introduces) and Sentinel — an optional self-hostable GitHub App that runs the gate on PR webhooks with a Check Run, SARIF, a REST API, and a dashboard.

Install

pip install entrygraph        # or: uv pip install entrygraph

Requires Python ≥ 3.13. Installs the entrygraph command (you can also run it as uv run entrygraph … or python -m entrygraph …).

Quick start (CLI)

Index a repo once, then query the resulting .entrygraph.db as often as you like. Every query command takes --db PATH (defaults to discovering .entrygraph.db) and --json for machine-readable output.

index — build the graph

Walk the tree and extract symbols, imports, and calls into .entrygraph.db. Incremental by default (only changed files are reparsed); --full rebuilds.

entrygraph index .
╭───────────────── ✓ indexed acme-api ──────────────────╮
│ files    5 indexed, 0 skipped, 0 deleted of 5 scanned │
│ graph    32 symbols  34 edges  5 entrypoints          │
│ db       /path/to/acme-api/.entrygraph.db             │
╰─────────────────────── 0.137s ────────────────────────╯

The positional argument may also be a git URL — entrygraph clones it and indexes the checkout:

entrygraph index https://github.com/semgrep/semgrep     # or git@github.com:org/repo.git

The clone lands in a reused workspace (./.entrygraph/clones/<host>/<org>/<repo>) and the index database in the current directory (./<repo>.entrygraph.db), so follow-up queries work with --db <repo>.entrygraph.db. Re-running index <url> fetches and updates the existing checkout instead of re-cloning. The clone is hardened — shallow, repo hooks disabled, no interactive credential prompt, and a wall-clock timeout — and the indexed code is never executed.

URL flag Meaning
--ref REF branch, tag, or commit to check out (default: remote HEAD)
--depth N / --full-clone clone depth (default 1; --full-clone = full history)
--clone-dir DIR where to place the checkout
--ephemeral clone to a temp dir and delete it after indexing (no paths snippets afterward)
--timeout SECONDS max clone/fetch wall-time (default 600)

Private repos work when the ambient git environment already authenticates (SSH agent, credential helper, or a token in the URL); entrygraph never prompts for or stores secrets.

detect — languages & frameworks

Byte-share per language plus framework detections scored from manifest dependencies and code signals.

entrygraph detect
Languages
┏━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃LANGUAGE ┃ FILES ┃ SHARE              ┃
┡━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│python   │     5 │ ████████████ 100.0%│
└─────────┴───────┴────────────────────┘
Frameworks
┏━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓
┃FRAMEWORK ┃ LANGUAGE ┃ CONFIDENCE     ┃
┡━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩
│flask     │ python   │ █████████░ 0.94│
│click     │ python   │ █████████░ 0.94│
└──────────┴──────────┴────────────────┘

entrypoints — your attack surface

Every HTTP route, CLI command, task, lambda, middleware, and main — with its framework, method, route, and handler symbol. Filter with --kind, --framework, or --route.

entrypoints --kind http_route      # or: --framework flask / --route '/api/*'
┏━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃KIND        ┃ FRAMEWORK ┃ METHOD   ┃ ROUTE            ┃ HANDLER                 ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━┩
│http_route  │ flask     │ GET      │ /users/<user_id> │ app.routes.get_user     │
│http_route  │ flask     │ GET      │ /health          │ app.routes.health       │
│http_route  │ flask     │ GET,POST │ /reports         │ app.routes.create_report│
│cli_command │ click     │          │                  │ cli.report              │
│main        │           │          │                  │ cli                     │
└────────────┴───────────┴──────────┴──────────────────┴─────────────────────────┘
5 entrypoint(s)

symbols, callers, callees — search & walk the call graph

symbols globs on name or qualified name (filter by --kind/--file); callers/callees walk the call graph (--depth N).

entrygraph symbols --kind class --name 'Report*'
entrygraph callers app.services.run_report        # who calls it
entrygraph callees app.services.run_report        # what it calls
┏━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━┓
┃KIND  ┃ QNAME                     ┃ FILE            ┃ LINE┃
┡━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━┩
│class │ app.services.ReportRunner │ app/services.py │   10│
└──────┴───────────────────────────┴─────────────────┴─────┘

paths — source → sink reachability

The security workhorse: can anything reach a dangerous sink? Paths are risk-ranked (highest first) and drawn as call trees — the sink node is flagged (), each hop shows its resolution confidence (exact/import/fuzzy/unresolved), and badges mark constant-argument sinks and speculative edges.

entrygraph paths --source '*' --sink-category command_exec
7 path(s)  * → category:command_exec

[1] ■ risk 0.73  app.services.ReportRunner.render_and_execute
└── → py:subprocess.run   line 22  import  ⚑ py.command-exec.subprocess
[4] ■ risk 0.50  app.services.run_report
└── → app.services.ReportRunner.start   line 27  fuzzy
    └── → app.services.ReportRunner.render_and_execute   line 17  exact
        └── → py:subprocess.run   line 22  import  ⚑ py.command-exec.subprocess
[5] ■ risk 0.49  app.routes.create_report
└── → app.services.run_report   line 20  import
    └── → app.services.ReportRunner.start   line 27  fuzzy
        └── → app.services.ReportRunner.render_and_execute   line 17  exact
            └── → py:subprocess.run   line 22  import  ⚑ py.command-exec.subprocess
  • CI gate: exits 0 when a path is found, 1 when none — entrygraph paths --source '*' --sink-category command_exec && echo reachable.
  • Catalog sources: instead of a --source glob, use --source-category to start from every call site of a registered taint source (e.g. --source-category http_input / env) — entrygraph paths --source-category http_input --sink-category sql. Combine with --source to union both.
  • Precision/recall dial: by default only high-confidence edges are traversed. Widen with --include-unresolved (wildcard py:*.execute sinks + dynamic calls), --include-fuzzy (speculative class-hierarchy edges), or --include-callbacks (function/method values passed as arguments — handler registrations like http.HandleFunc("/", handler) or this::handle).
  • Sanitizers: a registered sanitizer for the sink's category called on a path (e.g. shlex.quote) discounts its risk score — heuristically, since there is no dataflow, so it never zeroes the risk or hides the path. --prune-sanitized opts into dropping those paths entirely.
  • Source provenance: an http_input/cli_arg source is labeled · explicit when the handler demonstrably reads request input (a catalog accessor call like request.args.get("q")) or · handler when the handler is merely shaped like a source and reaches the sink without a proven read. Explicit sources rank above handler-as-source ones; --explicit-sources drops the handler-only seeds entirely (at the cost of property-read frameworks like Express req.body).
  • Flow verification: a bounded reaching-defs check runs over the candidate findings and labels each with whether a request value actually flows to the sink — flow: confirmed when it does, flow: not observed when it provably doesn't (that path is down-weighted). It follows up to --taint-hops interior call hops (default 3; 0 = same-function only) and is conservative: anything it can't analyze stays unlabeled and unchanged. --confirmed-only keeps just the confirmed paths.
  • Target an exact sink with --sink py:subprocess.run instead of a category.

What a finding means. A path is a reachability lead to triage, not a confirmed dataflow: it says a source-bearing symbol can reach a sink-bearing symbol through the call graph. The flow: label sharpens this where the check can see the code, but an unlabeled path is still just reachability. The per-hop confidence tags (exact/fuzzy/unresolved) are edge-resolution confidence, not taint confidence. Rank, provenance, and flow labels are there to help you triage the list, highest-signal first.

stats & --json

entrygraph stats
entrygraph --help          # every command and flag
╭─────────────── index stats ────────────────╮
│ ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ │
│ ┃metric           ┃                 value┃ │
│ ┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │
│ │repo_root        │ /path/to/acme-api    │ │
│ │files            │                     5│ │
│ │symbols          │                    32│ │
│ │edges            │                    34│ │
│ │entrypoints      │                     5│ │
│ │sink_edges       │                     2│ │
│ └─────────────────┴──────────────────────┘ │
╰────────────────────────────────────────────╯

Add --json to any query command for machine-readable output (paths include risk_score and may_continue):

[
  {
    "risk_score": 0.4887,
    "may_continue": false,
    "symbols": [
      "app.routes.create_report", "app.services.run_report",
      "app.services.ReportRunner.start",
      "app.services.ReportRunner.render_and_execute", "py:subprocess.run"
    ],
    "lines": [20, 27, 17, 22]
  }
]

Colored tables, share/confidence bars, and risk-tree highlighting render in a real terminal; piped or --json output is plain text.

gate & baseline — block new reachable paths in CI

The reachability gate turns paths into a merge check: it diffs the current index's reachable dangerous paths against a stored baseline and fails only on paths a change introduced. Paths are identified by a line-independent fingerprint, so moving or reindenting code is never reported as new.

# on your default branch: record the accepted set
entrygraph index . && entrygraph baseline update

# on a PR branch: fail if the diff adds a new reachable dangerous path
entrygraph index . && entrygraph gate --sarif findings.sarif
╭──────────────────────────────────────────────────────────────╮
│ gate: FAILED (block)  new 1 · known 5 · fixed 0 · suppressed 0│
╰──────────────────────────────────────────────────────────────╯
1 new path(s) at/above threshold:
  risk 0.81  app.routes.upload → py.command-exec.subprocess  6c1566c2a29c

gate exits non-zero when a new path at/above the risk threshold appears (in block mode). Flags: --threshold (risk floor), --warn (report, never fail), --branch (baseline branch), --sarif PATH (SARIF 2.1.0 for GitHub code scanning), --head-sha. baseline show inspects the current baseline. The gate never executes the analyzed code — it is a parse-and-query operation. Baselines, scan history, findings, and per-repo policy are stored alongside the graph.

For zero-config CI, a composite GitHub Action wraps index → gate → SARIF upload and manages the baseline across runs — see docs/github-action.md:

- uses: actions/checkout@v4
- uses: brettbergin/entrygraph@main
  with:
    threshold: "0.5"
    mode: block # or "warn"

Sentinel — the gate as a self-hostable service

For teams that would rather run the gate as a service than wire the Action into every repo, Sentinel is a self-hostable GitHub App + service that runs the same gate on PR webhooks:

  • Verifies the webhook, fetches the PR head, indexes it, diffs against a central baseline, and posts a GitHub Check Run plus SARIF to code scanning — the counts match the CLI gate exactly.
  • Baselines auto-refresh only from the protected default branch on merge, so a PR can't move the baseline it's measured against. Uninstalling the App hard-deletes all of an installation's data.
  • A token-guarded REST API (/api) and a React + Primer dashboard (/ui) surface scans, findings, suppressions, and per-repo policy — every route scoped by installation + repo.
  • Operate it with the CLI: entrygraph sentinel serve / worker / config / installations / purge / baseline.

It reuses the gate engine unchanged (never executes analyzed code) and ships behind the entrygraph[sentinel] extra (FastAPI + arq/Redis + Postgres), so the core CLI stays dependency-lean. Full deploy guide, security posture, and secret- rotation runbook in docs/sentinel.md.

Explorer — a web UI over the index

To browse an index visually rather than via the CLI, the explorer is a read-only web UI (behind the entrygraph[explore] extra) that walks a repo's symbols, entrypoints/routes, callers/callees, source→sink paths, and an interactive call graph:

entrygraph index . --db /tmp/graph.db
entrygraph serve --db /tmp/graph.db           # http://127.0.0.1:8100

entrygraph serve runs the unified web app — one server for exploring the graph (symbols, entrypoints, D3 call graph, source→sink reachability), registering and indexing repositories from the UI, and the reachability-gate workflow (findings, baselines, policy, suppressions). It supports Authentik SSO (EG_OIDC_*), with a zero-setup local mode (no auth) on loopback by default. Build the UI once with cd webapp && npm run build; the build lands in the package and is served at /.

Python API

from entrygraph import CodeGraph

# Index a repo (creates <repo>/.entrygraph.db by default)
graph = CodeGraph.index("/path/to/repo")

# ...or open an existing index
graph = CodeGraph.open("graph.db")

# Symbols — glob on name or qualified name, filter by kind or file
graph.symbols(kind="class", name="User*")
graph.symbol("app.services.Runner.execute")        # exact; raises if missing

# Detection
report = graph.detect()
report.languages      # -> [DetectedLanguage(name="python", percent=96.7, ...), ...]
report.frameworks     # -> [DetectedFramework(name="flask", confidence=0.94, ...), ...]

# Entrypoints
graph.entrypoints(framework="flask")
graph.entrypoints(kind="http_route", route="/api/*")

# Call graph
graph.callers("app.services.run_report")            # who calls it
graph.callees("app.services.run_report", depth=3)   # what it (transitively) calls
graph.references("app.models.CONST")                # inbound edges of any kind

# Source -> sink reachability (paths are risk-ranked, highest first)
paths = graph.paths(source="app.routes.*", sink_category="command_exec")
for p in paths:
    print(p.risk_score, p.render(), "(+may continue)" if p.may_continue else "")
    # 0.49 app.routes.create_report -> app.services.run_report (line 20)
    #   -> ...ReportRunner.render_and_execute (line 17) -> py:subprocess.run (line 22)

graph.reachable(source="app.routes.upload", sink="py:subprocess.run")   # -> bool

# Precision/recall dial. By default only EXACT/IMPORT and unique-name FUZZY
# edges are traversed. Opt into wider (noisier) traversal:
graph.paths(source="app.routes.*", sink_category="sql",
            include_unresolved=True)   # follow py:*.execute wildcard-sink guesses
graph.paths(source="app.routes.*", sink_category="command_exec",
            include_fuzzy=True)        # follow speculative class-hierarchy (CHA) edges
graph.paths(source="app.routes.*", sink_category="command_exec",
            prune_sanitized=True)      # drop paths where a shlex.quote etc. is called

# Incremental re-index (only changed/added/deleted files are reparsed)
graph.refresh()

# Escape hatches
graph.session()               # raw SQLAlchemy Session
graph.sql("SELECT ...")       # textual query -> list[dict]

Every result is a frozen, immutable dataclass detached from the DB session, so results are safe to hold and trivial to serialize.

How it works

  1. Walkos.scandir with hard-pruned junk dirs (node_modules, .venv, …), .gitignore rules, and size/binary/minified gates. Every skip is recorded with a reason.
  2. Extract — tree-sitter .scm queries harvest definitions/imports/calls; small per-language "shaper" modules build qualified names, import maps, and receiver info. Parsing runs across a process pool for large repos.
  3. Resolve — a two-pass resolver binds references to symbols with a confidence level (exact / import / fuzzy / unresolved). External callees (subprocess.run, child_process.exec, …) become placeholder nodes so sinks are real graph terminals.
  4. Detect — frameworks are scored from manifest dependencies plus code signals (noisy-or); entrypoint rules map framework patterns to route/command records.
  5. Store — everything persists to SQLite via the SQLAlchemy 2.0 ORM with bulk inserts and app-assigned keys. Re-indexing is incremental and content-hash driven.
  6. Query — reachability runs over an in-memory adjacency cache (BFS/DFS with cycle handling); a recursive-CTE SQL engine is available as a fallback (engine="sql").

Extending

  • Custom sinks/sources/sanitizers — drop an entrygraph.toml in the repo root with [[sink]] / [[source]] / [[sanitizer]] tables (same schema as the built-in data/sinks/*.toml), or call entrygraph.detect.taint.register_sink(...) / register_sanitizer(...). A [[sanitizer]] called on a path discounts its risk score for that category; since reachability has no dataflow, the discount is capped (a match never zeroes risk or hides a path — use --prune-sanitized to drop them explicitly). Third-party wrapper libraries that reach a sink internally are covered by data/sinks/lib_*.toml "library summaries" (same schema, with a library = "..." tag).
  • New frameworks / entrypoints — register a FrameworkSpec and an EntrypointRule; adding a framework is usually a few lines.
  • New languages — add a <lang>/{definitions,imports,calls}.scm query set and a shaper implementing the LanguageExtractor protocol.

Releasing

Merging to main auto-bumps the patch version (via a git tag) and publishes to PyPI through Trusted Publishing — see RELEASING.md. The package version is derived from git tags by hatch-vcs, so it's never hand-edited.

License

MIT

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

entrygraph-0.1.117.tar.gz (508.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

entrygraph-0.1.117-py3-none-any.whl (309.1 kB view details)

Uploaded Python 3

File details

Details for the file entrygraph-0.1.117.tar.gz.

File metadata

  • Download URL: entrygraph-0.1.117.tar.gz
  • Upload date:
  • Size: 508.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for entrygraph-0.1.117.tar.gz
Algorithm Hash digest
SHA256 d8eb9dd4834797a162471d86ec63a3cdbcac89ebe00ab8f99bb64e5a237b5f03
MD5 90ae2914adea3f95d24aa9b00de2e6b5
BLAKE2b-256 4468e145c08d840a83e05abb0ba4c87c277848769b35bfcc29f2e401bffb2d17

See more details on using hashes here.

Provenance

The following attestation bundles were made for entrygraph-0.1.117.tar.gz:

Publisher: release.yml on brettbergin/entrygraph

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file entrygraph-0.1.117-py3-none-any.whl.

File metadata

  • Download URL: entrygraph-0.1.117-py3-none-any.whl
  • Upload date:
  • Size: 309.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for entrygraph-0.1.117-py3-none-any.whl
Algorithm Hash digest
SHA256 0354344415a8744317ab5d2fc0121f97971084c0709fbddb895f450dad67352f
MD5 a3fbb55bb5598acc43504a7e67e4e5b4
BLAKE2b-256 51ea3a053460778a1545db0b9b7d9f6ac07d3a192b770fe029ed08ceafcd232b

See more details on using hashes here.

Provenance

The following attestation bundles were made for entrygraph-0.1.117-py3-none-any.whl:

Publisher: release.yml on brettbergin/entrygraph

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page