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
381 lines
13 KiB
Go
381 lines
13 KiB
Go
package indexer
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// processExecAPIs maps a callee — the dotted path as it appears in an
|
|
// unresolved call target — to the canonical process-execution mechanism
|
|
// it represents. Covers the os/exec, subprocess, child_process, and
|
|
// process-builder families across Go, Python, JS/TS, Rust, and Java.
|
|
var processExecAPIs = map[string]string{
|
|
// Go
|
|
"exec.Command": "exec.Command", "exec.CommandContext": "exec.CommandContext",
|
|
"os.StartProcess": "os.StartProcess", "syscall.Exec": "syscall.Exec",
|
|
"syscall.ForkExec": "syscall.ForkExec",
|
|
// Python
|
|
"subprocess.run": "subprocess.run", "subprocess.Popen": "subprocess.Popen",
|
|
"subprocess.call": "subprocess.call", "subprocess.check_call": "subprocess.check_call",
|
|
"subprocess.check_output": "subprocess.check_output", "subprocess.getoutput": "subprocess.getoutput",
|
|
"os.system": "os.system", "os.popen": "os.popen",
|
|
// JS / TS
|
|
"child_process.exec": "child_process.exec", "child_process.execSync": "child_process.execSync",
|
|
"child_process.spawn": "child_process.spawn", "child_process.spawnSync": "child_process.spawnSync",
|
|
"child_process.execFile": "child_process.execFile", "child_process.execFileSync": "child_process.execFileSync",
|
|
"child_process.fork": "child_process.fork",
|
|
// Rust
|
|
"Command::new": "Command::new", "process::Command::new": "Command::new",
|
|
// Java
|
|
"Runtime.exec": "Runtime.exec",
|
|
}
|
|
|
|
// processExecBareNames are unqualified callee names that strongly imply
|
|
// process execution regardless of receiver — PHP shell builtins and the
|
|
// destructured-import JS forms (const {execSync} = require('child_process')).
|
|
// Generic words (system, exec, spawn, popen) are intentionally excluded to
|
|
// avoid false positives on unrelated same-named calls.
|
|
var processExecBareNames = map[string]string{
|
|
"shell_exec": "shell_exec", "passthru": "passthru", "proc_open": "proc_open",
|
|
"execSync": "child_process.execSync", "spawnSync": "child_process.spawnSync",
|
|
"execFileSync": "child_process.execFileSync",
|
|
}
|
|
|
|
// knownExecSchemes are the resolver's external-symbol ID scheme tokens.
|
|
// A resolved call target carries one (optionally behind a <repo>:: prefix)
|
|
// as in stdlib::os/exec::Command — see external_call_attribution.go.
|
|
var knownExecSchemes = map[string]bool{
|
|
"stdlib": true, "dep": true, "module": true, "external": true,
|
|
}
|
|
|
|
// execCalleeCandidates yields the spellings of a call target to test
|
|
// against the exec tables. The resolver may leave a call unresolved
|
|
// (unresolved::exec.Command), resolve it onto a fully-qualified external
|
|
// node ID (stdlib::os/exec::Command, optionally <repo>::-prefixed), or
|
|
// keep a Rust-style path spelling (Command::new). Returning both the
|
|
// as-written form and the collapsed pkg.Symbol form lets one matcher
|
|
// cover all of them — so Go os/exec calls (the common case) produce an
|
|
// executes_process edge whether or not the import resolved.
|
|
func execCalleeCandidates(to string) []string {
|
|
to = strings.TrimPrefix(to, "unresolved::")
|
|
cands := []string{to}
|
|
segs := strings.Split(to, "::")
|
|
for i, s := range segs {
|
|
if !knownExecSchemes[s] {
|
|
continue
|
|
}
|
|
rest := segs[i+1:]
|
|
switch {
|
|
case len(rest) >= 2:
|
|
// <scheme>::<import/path>::<Symbol> -> pkg.Symbol, keeping the
|
|
// last path segment as the import alias the source wrote
|
|
// (os/exec -> exec). Rust's Command::new has no scheme token,
|
|
// so it never reaches here and keeps its as-written spelling.
|
|
sym := rest[len(rest)-1]
|
|
pkg := rest[len(rest)-2]
|
|
if k := strings.LastIndex(pkg, "/"); k >= 0 {
|
|
pkg = pkg[k+1:]
|
|
}
|
|
if pkg != "" && sym != "" {
|
|
cands = append(cands, pkg+"."+sym)
|
|
}
|
|
case len(rest) == 1 && rest[0] != "":
|
|
cands = append(cands, rest[0])
|
|
}
|
|
break
|
|
}
|
|
return cands
|
|
}
|
|
|
|
// processExecMechanism returns the canonical process-execution mechanism
|
|
// for a callee, or "" when the callee is not a recognised exec API. It
|
|
// accepts either an as-written callee or a resolved external node ID.
|
|
func processExecMechanism(callee string) string {
|
|
for _, c := range execCalleeCandidates(callee) {
|
|
if m := processExecAPIs[c]; m != "" {
|
|
return m
|
|
}
|
|
last := c
|
|
if i := strings.LastIndexAny(c, "."); i >= 0 {
|
|
last = c[i+1:]
|
|
}
|
|
if m := processExecBareNames[last]; m != "" {
|
|
return m
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// synthesizeCapabilityEdges materialises the three first-class capability
|
|
// edge kinds (NEW-KNW-3) from edges the language extractors already emit,
|
|
// so a supply-chain / least-privilege audit can traverse one edge kind
|
|
// instead of joining through the config, dataflow, and call layers:
|
|
//
|
|
// - EdgeReadsEnv: every reads_config edge whose target is a cfg::env::
|
|
// node, re-pointed at the same typed env-var node.
|
|
// - EdgeAccessesField: every reads / writes edge that lands on a
|
|
// KindField node, with Meta["access"] = read|write.
|
|
// - EdgeExecutesProcess: every calls edge whose callee is a known
|
|
// process-exec API, pointed at a synthetic typed process node
|
|
// (string::process::<mechanism>).
|
|
//
|
|
// It runs after the resolver (so call/field targets are settled) and is
|
|
// idempotent — AddEdge dedupes by edge key and a reindex re-derives from
|
|
// the current base edges. Returns per-kind counts for telemetry.
|
|
func synthesizeCapabilityEdges(g graph.Store) (readsEnv, execProc, fieldAccess int) {
|
|
if g == nil {
|
|
return 0, 0, 0
|
|
}
|
|
g.ResolveMutex().Lock()
|
|
defer g.ResolveMutex().Unlock()
|
|
|
|
type edgeSpec struct {
|
|
from, to, origin, file string
|
|
line int
|
|
kind graph.EdgeKind
|
|
meta map[string]any
|
|
}
|
|
var pending []edgeSpec
|
|
seen := map[string]bool{}
|
|
add := func(from, to string, kind graph.EdgeKind, origin, file string, line int, meta map[string]any) bool {
|
|
key := string(kind) + "\x00" + from + "\x00" + to
|
|
// Indirect mutations carry a `via`; key on it so a direct and an
|
|
// indirect write to the same field from the same method coexist as
|
|
// distinct-provenance edges.
|
|
if v, _ := meta["via"].(string); v != "" {
|
|
key += "\x00" + v
|
|
}
|
|
if seen[key] {
|
|
return false
|
|
}
|
|
seen[key] = true
|
|
pending = append(pending, edgeSpec{from, to, origin, file, line, kind, meta})
|
|
return true
|
|
}
|
|
|
|
// reads_env — parallel to reads_config edges that target an env key.
|
|
for e := range g.EdgesByKind(graph.EdgeReadsConfig) {
|
|
if e == nil || !strings.Contains(e.To, "cfg::env::") {
|
|
continue
|
|
}
|
|
if add(e.From, e.To, graph.EdgeReadsEnv, graph.OriginASTResolved, e.FilePath, e.Line, nil) {
|
|
readsEnv++
|
|
}
|
|
}
|
|
|
|
// accesses_field — reads / writes that land on a struct field. Build
|
|
// the KindField id set once instead of a GetNode per edge (cheap on
|
|
// the disk-backed store).
|
|
fieldIDs := map[string]bool{}
|
|
for n := range g.NodesByKind(graph.KindField) {
|
|
if n != nil {
|
|
fieldIDs[n.ID] = true
|
|
}
|
|
}
|
|
for _, base := range []graph.EdgeKind{graph.EdgeReads, graph.EdgeWrites} {
|
|
mode := "read"
|
|
if base == graph.EdgeWrites {
|
|
mode = "write"
|
|
}
|
|
for e := range g.EdgesByKind(base) {
|
|
if e == nil || !fieldIDs[e.To] {
|
|
continue
|
|
}
|
|
if add(e.From, e.To, graph.EdgeAccessesField, graph.OriginASTResolved, e.FilePath, e.Line, map[string]any{"access": mode}) {
|
|
fieldAccess++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Indirect field mutations: `s.counter.Increment()` mutates counter, and
|
|
// `s.helper()` mutates whatever helper mutates — attributed transitively.
|
|
// Lower (ast_inferred) tier than the direct writes above; tagged indirect
|
|
// + via so it's distinguishable and downgradeable.
|
|
for _, s := range indirectMutationEdges(g) {
|
|
if add(s.from, s.to, graph.EdgeAccessesField, graph.OriginASTInferred, s.file, s.line,
|
|
map[string]any{"access": "write", "indirect": true, "via": s.via}) {
|
|
fieldAccess++
|
|
}
|
|
}
|
|
|
|
// executes_process — calls to a known process-exec API, pointed at a
|
|
// shared synthetic process node per mechanism.
|
|
procNodes := map[string]*graph.Node{}
|
|
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
mech := processExecMechanism(e.To)
|
|
if mech == "" {
|
|
continue
|
|
}
|
|
procID := "string::process::" + mech
|
|
if procNodes[procID] == nil {
|
|
procNodes[procID] = &graph.Node{
|
|
ID: procID, Kind: graph.KindString, Name: mech,
|
|
Meta: map[string]any{"context": "process", "mechanism": mech},
|
|
}
|
|
}
|
|
if add(e.From, procID, graph.EdgeExecutesProcess, graph.OriginASTInferred, e.FilePath, e.Line, nil) {
|
|
execProc++
|
|
}
|
|
}
|
|
|
|
for _, n := range procNodes {
|
|
g.AddNode(n)
|
|
}
|
|
for _, s := range pending {
|
|
g.AddEdge(&graph.Edge{
|
|
From: s.from, To: s.to, Kind: s.kind,
|
|
FilePath: s.file, Line: s.line, Origin: s.origin, Meta: s.meta,
|
|
})
|
|
}
|
|
return readsEnv, execProc, fieldAccess
|
|
}
|
|
|
|
// synthesizeCapabilityEdgesScoped is synthesizeCapabilityEdges restricted to the
|
|
// changed repos in an end-of-batch pass. A nil scope runs the whole-graph pass,
|
|
// so the fresh-index / single-repo path is byte-identical.
|
|
//
|
|
// Correctness: every capability edge is FROM the code symbol that performs the
|
|
// read/write/call, so a changed repo owns exactly the capability edges its
|
|
// reindex dropped; an unchanged repo's are already on disk and are not
|
|
// re-derived. The driving scan therefore walks only the changed repos'
|
|
// out-edges (GetRepoEdges — one backend query per repo) instead of four
|
|
// whole-graph EdgesByKind sweeps. Edge TARGETS may live in any repo (a field or
|
|
// env node an unchanged sibling defines), so target identity is never scoped: a
|
|
// field target outside the changed repos is confirmed by a cached per-id lookup
|
|
// rather than a whole-graph KindField map. Indirect field mutations stay
|
|
// whole-graph — their transitive fixpoint lives in indirectMutationEdges (a
|
|
// file this pass does not own) and re-affirming an unchanged repo's indirect
|
|
// edges is idempotent via the add() dedup.
|
|
func synthesizeCapabilityEdgesScoped(g graph.Store, changedPrefixes map[string]bool) (readsEnv, execProc, fieldAccess int) {
|
|
if g == nil {
|
|
return 0, 0, 0
|
|
}
|
|
if changedPrefixes == nil {
|
|
return synthesizeCapabilityEdges(g)
|
|
}
|
|
g.ResolveMutex().Lock()
|
|
defer g.ResolveMutex().Unlock()
|
|
|
|
type edgeSpec struct {
|
|
from, to, origin, file string
|
|
line int
|
|
kind graph.EdgeKind
|
|
meta map[string]any
|
|
}
|
|
var pending []edgeSpec
|
|
seen := map[string]bool{}
|
|
add := func(from, to string, kind graph.EdgeKind, origin, file string, line int, meta map[string]any) bool {
|
|
key := string(kind) + "\x00" + from + "\x00" + to
|
|
if v, _ := meta["via"].(string); v != "" {
|
|
key += "\x00" + v
|
|
}
|
|
if seen[key] {
|
|
return false
|
|
}
|
|
seen[key] = true
|
|
pending = append(pending, edgeSpec{from, to, origin, file, line, kind, meta})
|
|
return true
|
|
}
|
|
|
|
// Field targets: the common case is a symbol writing a field of its own
|
|
// (changed) repo, so seed the id set from the changed repos' fields via the
|
|
// meta-less light reader. A cross-repo field target is resolved lazily and
|
|
// cached, so the pass never materialises the whole-graph KindField map.
|
|
fieldIDs := map[string]bool{}
|
|
for prefix := range changedPrefixes {
|
|
if prefix == "" {
|
|
continue
|
|
}
|
|
for _, n := range repoNodesLightOrFull(g, prefix) {
|
|
if n != nil && n.Kind == graph.KindField {
|
|
fieldIDs[n.ID] = true
|
|
}
|
|
}
|
|
}
|
|
fieldCache := map[string]bool{}
|
|
isField := func(id string) bool {
|
|
if fieldIDs[id] {
|
|
return true
|
|
}
|
|
if v, ok := fieldCache[id]; ok {
|
|
return v
|
|
}
|
|
n := g.GetNode(id)
|
|
f := n != nil && n.Kind == graph.KindField
|
|
fieldCache[id] = f
|
|
return f
|
|
}
|
|
|
|
procNodes := map[string]*graph.Node{}
|
|
for prefix := range changedPrefixes {
|
|
if prefix == "" {
|
|
continue
|
|
}
|
|
for _, e := range g.GetRepoEdges(prefix) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
switch e.Kind {
|
|
case graph.EdgeReadsConfig:
|
|
if !strings.Contains(e.To, "cfg::env::") {
|
|
continue
|
|
}
|
|
if add(e.From, e.To, graph.EdgeReadsEnv, graph.OriginASTResolved, e.FilePath, e.Line, nil) {
|
|
readsEnv++
|
|
}
|
|
case graph.EdgeReads:
|
|
if !isField(e.To) {
|
|
continue
|
|
}
|
|
if add(e.From, e.To, graph.EdgeAccessesField, graph.OriginASTResolved, e.FilePath, e.Line, map[string]any{"access": "read"}) {
|
|
fieldAccess++
|
|
}
|
|
case graph.EdgeWrites:
|
|
if !isField(e.To) {
|
|
continue
|
|
}
|
|
if add(e.From, e.To, graph.EdgeAccessesField, graph.OriginASTResolved, e.FilePath, e.Line, map[string]any{"access": "write"}) {
|
|
fieldAccess++
|
|
}
|
|
case graph.EdgeCalls:
|
|
mech := processExecMechanism(e.To)
|
|
if mech == "" {
|
|
continue
|
|
}
|
|
procID := "string::process::" + mech
|
|
if procNodes[procID] == nil {
|
|
procNodes[procID] = &graph.Node{
|
|
ID: procID, Kind: graph.KindString, Name: mech,
|
|
Meta: map[string]any{"context": "process", "mechanism": mech},
|
|
}
|
|
}
|
|
if add(e.From, procID, graph.EdgeExecutesProcess, graph.OriginASTInferred, e.FilePath, e.Line, nil) {
|
|
execProc++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, s := range indirectMutationEdges(g) {
|
|
if add(s.from, s.to, graph.EdgeAccessesField, graph.OriginASTInferred, s.file, s.line,
|
|
map[string]any{"access": "write", "indirect": true, "via": s.via}) {
|
|
fieldAccess++
|
|
}
|
|
}
|
|
|
|
for _, n := range procNodes {
|
|
g.AddNode(n)
|
|
}
|
|
for _, s := range pending {
|
|
g.AddEdge(&graph.Edge{
|
|
From: s.from, To: s.to, Kind: s.kind,
|
|
FilePath: s.file, Line: s.line, Origin: s.origin, Meta: s.meta,
|
|
})
|
|
}
|
|
return readsEnv, execProc, fieldAccess
|
|
}
|