Files
zzet--gortex/internal/dataflow/dataflow.go
T
wehub-resource-sync a06f331eb8
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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

580 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package dataflow implements the CPG-lite dataflow primitives
// surfaced by the flow_between and taint_paths MCP tools. It walks
// EdgeValueFlow / EdgeArgOf / EdgeReturnsTo edges over the live
// graph to answer two questions agents care about:
//
// 1. flow_between(source_id, sink_id, max_depth) — list every
// ranked path that connects two specific symbols. Used for
// refactor-safety questions ("if I change f's return type,
// every site that ultimately consumes that value") and bug
// investigation ("trace where this wrong value originated").
//
// 2. taint_paths(source_pattern, sink_pattern) — pattern-driven
// resolution of source / sink sets, then a flow_between for
// each candidate pair. Used for security-style queries
// ("every flow from os.Getenv to db.Query") and architectural
// audits ("every flow from request.Body to a logger").
//
// The traversal forms the smallest useful subset of Joern's CPG
// reachability primitives that the segment can ship inside an MCP
// surface — intra-procedural data dependence, captured at
// extraction time as EdgeValueFlow, plus inter-procedural binding
// at every call site, captured as EdgeArgOf / EdgeReturnsTo.
//
// Direction. flow_between always walks forward — out-edges of the
// current frontier — because every dataflow edge points in the
// direction of value movement: a value_flow goes source→consumer,
// an arg_of goes argument→callee param, and a returns_to goes
// callee→assignment. BFS over OutEdges therefore traces "where
// does this value go". Reverse flow ("where did this value come
// from") is a future addition; today the user can swap source
// and sink to walk the same edges from the other end.
package dataflow
import (
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// DefaultMaxDepth bounds how far BFS will look — each hop on a
// dataflow edge counts. Eight is empirically wide enough to cover
// most real handlers and security-relevant flows while keeping
// pathological "fully connected" graphs from blowing the response
// budget.
const DefaultMaxDepth = 8
// DefaultMaxPaths bounds how many distinct paths flow_between
// will return for a single (source, sink) pair. The handler ranks
// refinement-confirmed paths ahead of disproved (pruned) ones, then
// by length, then by edge-confidence, so the user gets the most
// plausible paths first.
const DefaultMaxPaths = 10
// EdgeStep is one hop along a flow path. It carries the edge kind,
// origin tier, and coarse tier label so the caller can distinguish a
// strong intra-procedural chain from a heuristic inter-procedural
// binding without recomputing the origin → tier mapping. Refined is
// stamped by the CFG-backed reaching-definitions refinement when the
// hop's endpoints are bindings of the same function:
// confirmed_intraprocedural (a def→use chain verifies the hop) or
// pruned (the source's definition is killed before the target on
// every path). Empty when the hop is out of refinement scope.
type EdgeStep struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
Origin string `json:"origin,omitempty"`
Tier string `json:"tier,omitempty"`
Refined string `json:"refined,omitempty"`
}
// Path is an ordered sequence of edge hops from a source node to
// a sink node. The IDs slice is the source-first node sequence and
// is always one longer than Edges. Confidence is a normalised
// 0-1 score derived from edge origin tiers — higher means a
// stronger end-to-end binding.
type Path struct {
IDs []string `json:"ids"`
Edges []EdgeStep `json:"edges"`
Confidence float64 `json:"confidence"`
}
// Length returns the number of hops in the path.
func (p Path) Length() int { return len(p.Edges) }
// Engine is the dataflow query backend. It holds a reference to
// the graph and exposes the two MCP-ready primitives. Concurrency-
// safe by virtue of relying only on graph.Store's read methods —
// unless a Refiner is attached (refiners cache per-function CFGs and
// are meant for a single query).
type Engine struct {
g graph.Store
refiner *Refiner
}
// New returns an engine backed by the given graph.
func New(g graph.Store) *Engine { return &Engine{g: g} }
// WithRefiner attaches a CFG-backed reaching-definitions refiner:
// FlowBetween / TaintPaths results get per-hop
// confirmed_intraprocedural / pruned markers where both hop
// endpoints are bindings of the same function. Returns the engine
// for chaining.
func (e *Engine) WithRefiner(r *Refiner) *Engine {
e.refiner = r
return e
}
// IsDataflowKind returns true for the three edge kinds the BFS
// traverses.
func IsDataflowKind(k graph.EdgeKind) bool {
switch k {
case graph.EdgeValueFlow, graph.EdgeArgOf, graph.EdgeReturnsTo:
return true
}
return false
}
// FlowBetween returns up to maxPaths shortest paths from sourceID
// to sinkID, walking forward over dataflow edges. Returns nil when
// no path exists within maxDepth hops.
//
// maxDepth and maxPaths are clamped to safe defaults when zero.
func (e *Engine) FlowBetween(sourceID, sinkID string, maxDepth, maxPaths int) []Path {
return e.FlowBetweenWithTier(sourceID, sinkID, maxDepth, maxPaths, "")
}
// FlowBetweenWithTier is FlowBetween with an additional provenance
// filter: edges whose backfilled Origin tier ranks below minTier are
// skipped during traversal, pruning entire branches that cannot
// produce a fully-resolved path. Empty minTier disables the filter
// (identical to FlowBetween). The per-step Tier label is stamped on
// every retained EdgeStep so callers do not need to recompute it.
func (e *Engine) FlowBetweenWithTier(sourceID, sinkID string, maxDepth, maxPaths int, minTier string) []Path {
if e == nil || e.g == nil || sourceID == "" || sinkID == "" {
return nil
}
if sourceID == sinkID {
return []Path{{IDs: []string{sourceID}, Confidence: 1}}
}
if maxDepth <= 0 {
maxDepth = DefaultMaxDepth
}
if maxPaths <= 0 {
maxPaths = DefaultMaxPaths
}
// DFS with depth bound. We track the current path's node set
// to prevent cycles. Memoisation by (node, depth) would help
// pathological graphs but the maxDepth cap is already
// effective on real codebases — the typical taint walk hits
// 4-6 hops before either landing on the sink or running out
// of frontier.
var paths []Path
visited := make(map[string]bool, 32)
stack := []string{sourceID}
steps := []EdgeStep{}
visited[sourceID] = true
var dfs func(nodeID string, depth int)
dfs = func(nodeID string, depth int) {
if len(paths) >= maxPaths {
return
}
if depth >= maxDepth {
return
}
out := e.g.GetOutEdges(nodeID)
for _, ed := range out {
if !IsDataflowKind(ed.Kind) {
continue
}
if visited[ed.To] {
continue
}
origin := edgeOrigin(ed)
if minTier != "" && !graph.MeetsMinTier(origin, minTier) {
continue
}
step := EdgeStep{
From: ed.From,
To: ed.To,
Kind: string(ed.Kind),
Origin: origin,
Tier: graph.ResolvedBy(origin),
}
if ed.To == sinkID {
ids := append([]string(nil), stack...)
ids = append(ids, ed.To)
edgeCopy := append([]EdgeStep(nil), steps...)
edgeCopy = append(edgeCopy, step)
paths = append(paths, Path{
IDs: ids,
Edges: edgeCopy,
Confidence: confidenceFromEdges(edgeCopy),
})
if len(paths) >= maxPaths {
return
}
continue
}
visited[ed.To] = true
stack = append(stack, ed.To)
steps = append(steps, step)
dfs(ed.To, depth+1)
stack = stack[:len(stack)-1]
steps = steps[:len(steps)-1]
delete(visited, ed.To)
}
}
dfs(sourceID, 0)
// CFG-backed refinement: judge same-function value_flow hops
// with reaching-definition chains before ranking, so pruned
// paths sink below confirmed ones.
if e.refiner != nil {
e.refiner.refinePaths(paths)
}
rankPaths(paths)
if len(paths) > maxPaths {
paths = paths[:maxPaths]
}
return paths
}
// edgeOrigin returns the stamped Origin on an edge, falling back to
// DefaultOriginFor when the field is empty so back-compat graphs
// (produced before Origin was a first-class field) still classify
// cleanly for filtering and tier surfacing.
func edgeOrigin(e *graph.Edge) string {
if e.Origin != "" {
return e.Origin
}
src, _ := e.Meta["semantic_source"].(string)
return graph.DefaultOriginFor(e.Kind, e.Confidence, src)
}
// rankPaths sorts in-place so the most plausible explanation comes
// first. A path the reaching-definitions refinement disproved (any
// pruned hop) always sinks below every non-pruned path, regardless of
// length — confidence demotion alone can't move a pruned hop past a
// shorter confirmed one when length is the primary key. Among paths
// of the same pruned status, shorter and then higher-confidence paths
// sort first.
func rankPaths(paths []Path) {
sort.SliceStable(paths, func(i, j int) bool {
pi, pj := hasPrunedHop(paths[i]), hasPrunedHop(paths[j])
if pi != pj {
return !pi // non-pruned paths rank ahead of pruned ones
}
if len(paths[i].Edges) != len(paths[j].Edges) {
return len(paths[i].Edges) < len(paths[j].Edges)
}
return paths[i].Confidence > paths[j].Confidence
})
}
// hasPrunedHop reports whether any hop on the path was disproved by
// the CFG-backed refinement.
func hasPrunedHop(p Path) bool {
for _, e := range p.Edges {
if e.Refined == RefinedPruned {
return true
}
}
return false
}
// confidenceFromEdges computes a normalised path confidence from
// the per-edge origin tiers. Each edge contributes a 0-1 score
// based on how well-grounded its kind / origin are; the path's
// score is the geometric mean (product) so a single weak edge
// drags the whole chain down — matching the agent intuition that
// a dataflow path is only as strong as its weakest hop.
func confidenceFromEdges(edges []EdgeStep) float64 {
if len(edges) == 0 {
return 1
}
prod := 1.0
for _, e := range edges {
prod *= confidenceFromOrigin(e.Origin, e.Kind)
}
return prod
}
func confidenceFromOrigin(origin, kind string) float64 {
// Delegates to the shared graph.EdgeTierScore so dataflow and callpath
// compute path confidence from one provenance→weight mapping.
return graph.EdgeTierScore(origin, graph.EdgeKind(kind))
}
// TaintPattern resolves a source / sink pattern against the live
// graph. Patterns are name-substrings (case-insensitive) by
// default; `exact:Name` switches to exact-name match; `path:dir/`
// scopes to nodes whose FilePath starts with that prefix combined
// with any name fragment after `::`.
type TaintPattern struct {
Raw string
NameMatch func(string) bool
PathMatch func(string) bool
KindMatch func(graph.NodeKind) bool
}
// ParsePattern compiles a string into a TaintPattern. Supported
// syntaxes:
//
// - "Name" → case-insensitive substring match on node name.
// - "exact:Name" → exact name match.
// - "path:dir/" → file path prefix match (any name).
// - "kind:function" → restrict to a particular node kind.
// - "name=… kind=…" → multi-clause AND form (space-separated).
//
// The clauses combine with AND; an empty pattern matches nothing.
func ParsePattern(raw string) TaintPattern {
p := TaintPattern{Raw: raw}
if strings.TrimSpace(raw) == "" {
return p
}
clauses := strings.Fields(raw)
for _, c := range clauses {
switch {
case strings.HasPrefix(c, "exact:"):
want := strings.TrimPrefix(c, "exact:")
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return name == want
})
case strings.HasPrefix(c, "name:"):
want := strings.ToLower(strings.TrimPrefix(c, "name:"))
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return strings.Contains(strings.ToLower(name), want)
})
case strings.HasPrefix(c, "name="):
want := strings.ToLower(strings.TrimPrefix(c, "name="))
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return strings.Contains(strings.ToLower(name), want)
})
case strings.HasPrefix(c, "path:"):
want := strings.TrimPrefix(c, "path:")
p.PathMatch = chainName(p.PathMatch, func(path string) bool {
return strings.HasPrefix(path, want)
})
case strings.HasPrefix(c, "kind:"):
wantKind := graph.NodeKind(strings.TrimPrefix(c, "kind:"))
p.KindMatch = chainKind(p.KindMatch, func(k graph.NodeKind) bool {
return k == wantKind
})
default:
// Bare token — case-insensitive substring on name.
want := strings.ToLower(c)
p.NameMatch = chainName(p.NameMatch, func(name string) bool {
return strings.Contains(strings.ToLower(name), want)
})
}
}
return p
}
func chainName(prev, next func(string) bool) func(string) bool {
if prev == nil {
return next
}
return func(s string) bool { return prev(s) && next(s) }
}
func chainKind(prev, next func(graph.NodeKind) bool) func(graph.NodeKind) bool {
if prev == nil {
return next
}
return func(k graph.NodeKind) bool { return prev(k) && next(k) }
}
// Empty reports whether the pattern matches nothing.
func (p TaintPattern) Empty() bool {
return p.Raw == "" || (p.NameMatch == nil && p.PathMatch == nil && p.KindMatch == nil)
}
// matches reports whether n satisfies the compiled clauses. All
// configured matchers must pass; absent matchers are skipped.
func (p TaintPattern) matches(n *graph.Node) bool {
if n == nil {
return false
}
if p.NameMatch != nil && !p.NameMatch(n.Name) {
return false
}
if p.PathMatch != nil && !p.PathMatch(n.FilePath) {
return false
}
if p.KindMatch != nil && !p.KindMatch(n.Kind) {
return false
}
return true
}
// ResolveCandidates walks the graph and returns up to limit
// distinct symbol IDs whose nodes match the pattern. Returns the
// caller-friendly nodes themselves so MCP responses can include
// names + paths without a second lookup.
//
// The seed set is bounded by taintEligibleKinds — the fixed 8-kind
// allowlist (function/method/param/field/variable/constant/type/
// interface) that taintEligible enforces. Iterating the per-kind
// NodesByKind bucket of each lets the backend stream only those
// kinds instead of materialising the full node table;
// on a disk backend AllNodes() pulls ~70k rows per request just to land
// at a handful of taint candidates. Pattern post-filters (name /
// path / pattern-supplied kind) still run Go-side — they compose
// AND, can't be projected onto the bucket index efficiently, and
// the per-bucket population is already small.
func (e *Engine) ResolveCandidates(p TaintPattern, limit int) []*graph.Node {
if e == nil || e.g == nil || p.Empty() {
return nil
}
if limit <= 0 {
limit = 100
}
out := make([]*graph.Node, 0, 16)
for _, k := range taintEligibleKinds {
if len(out) >= limit {
break
}
for n := range e.g.NodesByKind(k) {
if n == nil {
continue
}
if !p.matches(n) {
continue
}
out = append(out, n)
if len(out) >= limit {
break
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// taintEligibleKinds is the seed-bucket allowlist of node kinds that
// could plausibly be a dataflow source or sink. Files / imports / pkg
// markers don't carry value semantics, so excluding them up front
// keeps the candidate set focused. Kept as a slice (not a set) so
// callers can iterate the NodesByKind bucket of each kind in a stable
// order.
var taintEligibleKinds = []graph.NodeKind{
graph.KindFunction, graph.KindMethod, graph.KindParam,
graph.KindField, graph.KindVariable, graph.KindConstant,
graph.KindType, graph.KindInterface,
}
// TaintFinding is one (source, sink) hit produced by TaintPaths.
// Paths is non-empty when at least one BFS path connects the two.
type TaintFinding struct {
Source *graph.Node `json:"source"`
Sink *graph.Node `json:"sink"`
Paths []Path `json:"paths"`
}
// TaintPaths resolves both patterns, then runs flow_between for
// each (source, sink) pair. Returns up to limit findings, sorted
// by best path confidence × shortest length.
//
// Role-aware expansion. Sources and sinks expand differently
// because the dataflow edges are directional. A "source" function
// produces values via its return — flow originates at the
// function node itself, which has incoming returns_to edges
// agents will walk forward from. A "sink" function consumes
// values via its parameters — flow terminates at the param nodes
// where arg_of lands. So when the sink pattern resolves to a
// function/method, we automatically include each declared
// parameter as an additional candidate. This matches the agent
// intuition that `name:DBQuery` for a sink means "every value
// that lands in any argument of DBQuery", not the function
// itself (which has no incoming dataflow).
func (e *Engine) TaintPaths(sourcePattern, sinkPattern TaintPattern, maxDepth, limit int) []TaintFinding {
return e.TaintPathsWithTier(sourcePattern, sinkPattern, maxDepth, limit, "")
}
// TaintPathsWithTier is TaintPaths with the same per-edge provenance
// filter as FlowBetweenWithTier; empty minTier preserves the legacy
// behavior.
func (e *Engine) TaintPathsWithTier(sourcePattern, sinkPattern TaintPattern, maxDepth, limit int, minTier string) []TaintFinding {
if e == nil || e.g == nil {
return nil
}
if maxDepth <= 0 {
maxDepth = DefaultMaxDepth
}
if limit <= 0 {
limit = 20
}
sources := e.ResolveCandidates(sourcePattern, 0)
rawSinks := e.ResolveCandidates(sinkPattern, 0)
sinks := e.expandSinkCandidates(rawSinks)
if len(sources) == 0 || len(sinks) == 0 {
return nil
}
var findings []TaintFinding
for _, src := range sources {
for _, sink := range sinks {
if src.ID == sink.ID {
continue
}
paths := e.FlowBetweenWithTier(src.ID, sink.ID, maxDepth, DefaultMaxPaths, minTier)
if len(paths) == 0 {
continue
}
findings = append(findings, TaintFinding{
Source: src,
Sink: sink,
Paths: paths,
})
}
}
sort.SliceStable(findings, func(i, j int) bool {
bi, bj := bestPath(findings[i].Paths), bestPath(findings[j].Paths)
if bi.Length() != bj.Length() {
return bi.Length() < bj.Length()
}
return bi.Confidence > bj.Confidence
})
if len(findings) > limit {
findings = findings[:limit]
}
return findings
}
// expandSinkCandidates appends every parameter node of every
// matched function/method, deduplicated by ID. Originals stay in
// the candidate set — sometimes a flow really does land on the
// function symbol itself (e.g., when a callee value is passed
// straight back through another return) and excluding it would
// hide that case.
func (e *Engine) expandSinkCandidates(raw []*graph.Node) []*graph.Node {
if len(raw) == 0 || e == nil || e.g == nil {
return raw
}
seen := make(map[string]struct{}, len(raw)*2)
out := make([]*graph.Node, 0, len(raw)*2)
add := func(n *graph.Node) {
if n == nil {
return
}
if _, ok := seen[n.ID]; ok {
return
}
seen[n.ID] = struct{}{}
out = append(out, n)
}
for _, n := range raw {
add(n)
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
for _, edge := range e.g.GetInEdges(n.ID) {
if edge.Kind != graph.EdgeParamOf {
continue
}
pNode := e.g.GetNode(edge.From)
if pNode == nil || pNode.Kind != graph.KindParam {
continue
}
add(pNode)
}
}
return out
}
// bestPath returns the lowest-cost path in a finding (assumed
// already rankPaths-sorted). Falls back to a zero-value when the
// finding has none.
func bestPath(paths []Path) Path {
if len(paths) == 0 {
return Path{}
}
return paths[0]
}