modgraph

package
v0.8.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package modgraph answers one question about an image: starting from what the container actually runs, which source modules can be imported?

It is internal/elfgraph for languages that resolve imports at runtime rather than at link time, and the public surface is deliberately the same -- Build, Classify, Nodes, Roots, Taints, BlockingTaints, CountReachable -- so that a plugin's evaluate.go reads the same whether the closure it consults was built from DT_NEEDED entries or from import statements.

The two differ in one structural way. elfgraph indexes every ELF object in the image and then marks the reachable subset, because the index is also the resolver's input: a soname is found by looking through everything. Nothing resolves imports that way -- a specifier is resolved by probing a search path -- and a Python image can hold a hundred thousand .py files, so this package walks lazily from the roots outward and never reads a file no root can reach. Classify therefore asks the Language whether a path is a module rather than looking it up in an index that does not exist.

Language knowledge lives outside this package, behind the Language interface, because the same code that resolves a Python import needs to know where site-packages is and so does the Python inventory. Discovering it twice, in two packages, is how the two come to disagree.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type DynamicPolicy

type DynamicPolicy string

DynamicPolicy decides what a reachable computed import does to the graph.

const (
	// DynamicTaint is the default: record it and block not_affected.
	DynamicTaint DynamicPolicy = "taint"

	// DynamicAssumeNone takes the user's word that nothing meaningful is
	// imported by a computed name, recording the observation without letting
	// it block.
	DynamicAssumeNone DynamicPolicy = "assume-none"
)

func ParseDynamicPolicy

func ParseDynamicPolicy(s string) (DynamicPolicy, error)

ParseDynamicPolicy validates a --dynamic-import-policy value.

type FileSet

type FileSet struct {
	// Module are the source files the distribution installs.
	Module []string
	// Reachable are the ones the closure imports.
	Reachable []string
}

FileSet is Classify's answer.

type Graph

type Graph struct {
	// contains filtered or unexported fields
}

Graph is the reachable module closure.

func Build

func Build(fsys target.RootFS, lang Language, opts Options) (*Graph, error)

Build walks the import graph from the language's roots.

func (*Graph) BlockingTaints

func (g *Graph) BlockingTaints() []Taint

BlockingTaints returns the global taints that stop a not_affected conclusion for any distribution. Scoped ones are left out; a caller judging one distribution asks TaintsFor about the names that distribution owns.

func (*Graph) Canon

func (g *Graph) Canon(name string) string

Canon resolves a path to its tree-absolute form, following symlinks the way the runtime would.

func (*Graph) Classify

func (g *Graph) Classify(files []string) FileSet

Classify sorts a distribution's file list into the modules it owns and the subset of those the closure reaches. Data files, licences and stubs are simply absent from both.

Module comes from the Language rather than from a lookup, because an unreachable module was never visited and so has no node. That asymmetry is load-bearing: a distribution whose modules are all unreached must report "installed, nothing imports it", and it could not if unreached modules were invisible here.

func (*Graph) CountReachable

func (g *Graph) CountReachable() int

CountReachable is the size of the closure.

func (*Graph) Node

func (g *Graph) Node(name string) (*Node, bool)

Node returns the node at a path, canonicalizing it first.

func (*Graph) Nodes

func (g *Graph) Nodes() []*Node

Nodes returns every module the closure touched, in discovery order.

func (*Graph) Reachable

func (g *Graph) Reachable(name string) bool

Reachable reports whether the runtime could import this file.

func (*Graph) Roots

func (g *Graph) Roots() []string

Roots returns the paths the closure started from, in the order they were added: entrypoint first, then plugins, then explicit ones.

func (*Graph) Taints

func (g *Graph) Taints() []Taint

Taints returns everything the closure could not account for.

func (*Graph) TaintsFor

func (g *Graph) TaintsFor(importNames []string) []Taint

TaintsFor returns the taints that bear on one distribution: the global ones, plus any scoped to a specifier or import name it owns.

The scoped half is what keeps one package's plugin loader from tainting the whole image, and the global half is what stops that scoping from being a loophole.

type Language

type Language interface {
	// ID names the language, for evidence lines.
	ID() string

	// Roots turns the image config into entry modules, or explains through
	// taints why it could not. extra is the user's --roots values.
	//
	// A Language that cannot find an entrypoint is expected to escalate --
	// root every installed module -- and say so with a taint, never to return
	// an empty root set. No roots means nothing is reachable, which reads as
	// an image where no code runs.
	Roots(extra []string) ([]Root, []Taint, error)

	// Imports lists the specifiers one file references, plus any taint the
	// file itself raises (a computed import, a body that would not parse).
	Imports(file string) ([]Spec, []Taint, error)

	// Resolve maps a specifier referenced from a file onto the files it loads.
	// A specifier can resolve to more than one path -- a PEP 420 namespace
	// package merges across sys.path entries -- and ok is false when it
	// resolved to nothing, which the caller records as an unresolved-import
	// taint scoped to that specifier.
	Resolve(from string, spec Spec) ([]string, bool)

	// IsModule reports whether a path is code this language would load. It is
	// what makes a file eligible to be counted at all, and it must not read
	// the file: it is called once per file in an inventory.
	IsModule(path string) bool
}

Language is everything modgraph does not know: what the image runs, what a file imports, where a specifier points, and what counts as code.

Implementations hold their own filesystem and image config -- they are constructed by the plugin that already discovered both -- so the methods take only what varies per call.

type Node

type Node struct {
	// Path is the canonical tree-absolute path.
	Path string

	// Root, Why and Kind describe how the walk started here, if it did.
	Root bool
	Why  string
	Kind RootKind

	// Reachable says the walk arrived here. Every node in the graph is
	// reachable -- unlike elfgraph, which indexes unreachable objects too --
	// but the field is kept so evaluate.go reads the same in both.
	Reachable bool

	// Imports records what this file resolved to, keyed by specifier. A
	// specifier that resolved to nothing maps to nil and is also a taint.
	Imports map[string][]string

	// ImportedBy is every file that imported this one, in discovery order.
	// Empty for a root nothing else reaches.
	ImportedBy []string
}

Node is one module the closure touched.

type Options

type Options struct {
	// Roots are extra entry files or module names from --roots, passed to the
	// Language because resolving them is language-specific.
	Roots []string

	// Dynamic decides whether a computed import blocks a conclusion.
	Dynamic DynamicPolicy

	// Logf, if set, receives progress lines.
	Logf func(format string, args ...any)
}

Options configure Build.

type Root

type Root struct {
	Path string
	Why  string
	Kind RootKind
}

Root is one starting point for the closure.

type RootKind

type RootKind string

RootKind says why a file is a root, which is what lets output distinguish "this runs" from "this might run, because we could not tell what does".

const (
	// RootEntry is the image's actual entrypoint, or something reached from
	// it. This is the only kind that means the code demonstrably runs.
	RootEntry RootKind = "entrypoint"

	// RootPlugin is a file the runtime loads by name rather than by import:
	// sitecustomize.py, a path added by a .pth file, a declared entry point.
	RootPlugin RootKind = "plugin"

	// RootEscalated is a root added because the entrypoint could not be
	// understood. It always arrives with a taint.
	RootEscalated RootKind = "escalated"

	// RootExplicit is a --roots value.
	RootExplicit RootKind = "explicit"
)

type Spec

type Spec struct {
	// Name is the specifier verbatim: "yaml", ".loader", "@scope/pkg/sub".
	Name string

	// Line is where it appears, for evidence. Zero when unknown.
	Line int

	// Dynamic marks a specifier that came from importlib.import_module or
	// require() with a literal argument. It resolves like any other import --
	// the argument is right there -- and is only flagged so evidence can say
	// how the edge was found.
	Dynamic bool

	// Optional marks a specifier that may legitimately resolve to nothing, and
	// so raises no taint when it does. "from pkg import Thing" produces one:
	// Thing might be a submodule, and might equally be a class defined in
	// pkg/__init__.py. Without this every such line would report a missing
	// module, and a taint that fires on correct code teaches a reader to
	// ignore taints.
	Optional bool
}

Spec is one import as written in the source.

type Taint

type Taint struct {
	Kind TaintKind `json:"kind"`

	// Detail is the human-readable statement of what was observed.
	Detail string `json:"detail"`

	// Path is the file that caused it, when there is one.
	Path string `json:"path,omitempty"`

	// Spec scopes a taint to the specifier that went unresolved. Conclusions
	// about every other module are unaffected by it.
	Spec string `json:"spec,omitempty"`

	// Scope narrows a taint to the distributions it can affect, by import
	// name. A dynamic import inside one distribution says nothing about the
	// rest of the image, and scoping it is what keeps the whole scan from
	// being tainted by one plugin loader.
	Scope []string `json:"scope,omitempty"`

	// Blocking says whether this taint stops a not_affected conclusion.
	// Blocking is a field rather than a property of Kind because
	// --dynamic-import-policy=assume-none demotes a dynamic import to a note:
	// the user asserted the risk away, and the record should still show it.
	Blocking bool `json:"blocking"`

	// Global says the taint applies to every distribution rather than to the
	// scope named by Spec or Scope.
	Global bool `json:"global,omitempty"`
}

Taint is one recorded reason a not_affected conclusion is unavailable.

func (Taint) String

func (t Taint) String() string

type TaintKind

type TaintKind string

TaintKind names a reason the import graph cannot be trusted to be complete.

A taint never sets a status. It blocks the analysis from concluding that something is unaffected, and says in the output why the conclusion was not available. That direction is the whole point: the failure mode worth engineering against is a tool that reports "never imported" about an image whose imports it could not actually compute.

The kinds mirror internal/elfgraph's, because the failure modes are the same ones: something referenced that is not there, something loaded by a name computed at runtime, and an entrypoint that does not say what it runs.

const (
	// TaintUnresolvedImport is a specifier that resolved to no file. The
	// module it names might be the one holding the vulnerable code, so
	// conclusions about that specifier are blocked -- but only that specifier.
	TaintUnresolvedImport TaintKind = "unresolved-import"

	// TaintDynamicImport is a reachable file that imports a name computed at
	// runtime: importlib.import_module(x), __import__(x), require(x). What it
	// loads is chosen from strings this package cannot read, so the graph is a
	// lower bound on what runs.
	//
	// A *literal* argument is not this. importlib.import_module("foo.bar")
	// resolves exactly like a static import and is followed as an ordinary
	// edge; only a computed one taints, and only the file that computes it.
	// Without that distinction nearly every Python image taints, which is the
	// same honest-but-useless failure the shell-entrypoint rule guards against.
	TaintDynamicImport TaintKind = "dynamic-import"

	// TaintPluginDiscovery is reachable code that enumerates installed
	// distributions rather than naming one: entry_points(),
	// pkgutil.iter_modules. Where the set of discoverable plugins can be read
	// off disk the language roots them instead, and this taint is only emitted
	// for the part that could not be enumerated.
	TaintPluginDiscovery TaintKind = "plugin-discovery"

	// TaintForeignEntrypoint is an argv[0] that is not this language's
	// interpreter, so the graph has nothing to root at. Every installed module
	// is treated as a root.
	TaintForeignEntrypoint TaintKind = "foreign-entrypoint"

	// TaintNoEntrypoint is an image config with neither Entrypoint nor Cmd --
	// or, in rootfs mode, no config at all. Same escalation, different cause.
	TaintNoEntrypoint TaintKind = "no-entrypoint"

	// TaintBundled is an entrypoint whose dependencies were compiled into it
	// by a bundler, so the files that would carry the vulnerable code are not
	// on disk under the names an inventory knows.
	TaintBundled TaintKind = "bundled-entrypoint"

	// TaintUnreadable is a file that is reachable and could not be read. Its
	// imports are unknown, so everything downstream of it is missing from the
	// graph.
	TaintUnreadable TaintKind = "unreadable-module"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL