diff --git a/interp/builtins/break_continue.go b/interp/builtins/break_continue.go index a987e2fa..586cdb7d 100644 --- a/interp/builtins/break_continue.go +++ b/interp/builtins/break_continue.go @@ -10,6 +10,11 @@ import ( "strconv" ) +func init() { + register("break", builtinBreak) + register("continue", builtinContinue) +} + func builtinBreak(_ context.Context, callCtx *CallContext, args []string) Result { return loopControl(callCtx, "break", args) } diff --git a/interp/builtins/builtins.go b/interp/builtins/builtins.go index cafd6d00..6daf4897 100644 --- a/interp/builtins/builtins.go +++ b/interp/builtins/builtins.go @@ -20,7 +20,7 @@ type HandlerFunc func(ctx context.Context, callCtx *CallContext, args []string) type CallContext struct { Stdout io.Writer Stderr io.Writer - Stdin *os.File + Stdin io.Reader // InLoop is true when the builtin runs inside a for loop. InLoop bool @@ -65,14 +65,15 @@ type Result struct { ContinueN int } -var registry = map[string]HandlerFunc{ - "true": builtinTrue, - "false": builtinFalse, - "echo": builtinEcho, - "cat": builtinCat, - "exit": builtinExit, - "break": builtinBreak, - "continue": builtinContinue, +var registry = map[string]HandlerFunc{} + +// register adds a builtin command to the registry. +// It panics if name is already registered, catching duplicate registrations at startup. +func register(name string, fn HandlerFunc) { + if _, exists := registry[name]; exists { + panic("builtin already registered: " + name) + } + registry[name] = fn } // Lookup returns the handler for a builtin command. diff --git a/interp/builtins/cat.go b/interp/builtins/cat.go index ceaefbee..9cfe4552 100644 --- a/interp/builtins/cat.go +++ b/interp/builtins/cat.go @@ -11,6 +11,10 @@ import ( "os" ) +func init() { + register("cat", builtinCat) +} + func builtinCat(ctx context.Context, callCtx *CallContext, args []string) Result { if len(args) == 0 { args = []string{"-"} diff --git a/interp/builtins/echo.go b/interp/builtins/echo.go index 26b03e76..7f2211c7 100644 --- a/interp/builtins/echo.go +++ b/interp/builtins/echo.go @@ -7,6 +7,10 @@ package builtins import "context" +func init() { + register("echo", builtinEcho) +} + func builtinEcho(_ context.Context, callCtx *CallContext, args []string) Result { for i, arg := range args { if i > 0 { diff --git a/interp/builtins/exit.go b/interp/builtins/exit.go index a38de127..c36d233b 100644 --- a/interp/builtins/exit.go +++ b/interp/builtins/exit.go @@ -10,6 +10,10 @@ import ( "strconv" ) +func init() { + register("exit", builtinExit) +} + func builtinExit(_ context.Context, callCtx *CallContext, args []string) Result { var r Result if len(args) > 0 && args[0] == "--" { diff --git a/interp/builtins/true_false.go b/interp/builtins/true_false.go index 4889c7fc..c27ae736 100644 --- a/interp/builtins/true_false.go +++ b/interp/builtins/true_false.go @@ -7,6 +7,11 @@ package builtins import "context" +func init() { + register("true", builtinTrue) + register("false", builtinFalse) +} + func builtinTrue(_ context.Context, _ *CallContext, _ []string) Result { return Result{} } diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 0854a843..4c3b4870 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -203,7 +203,6 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { call := &builtins.CallContext{ Stdout: r.stdout, Stderr: r.stderr, - Stdin: r.stdin, InLoop: r.inLoop, LastExitCode: r.lastExit.code, OpenFile: func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { @@ -211,6 +210,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { }, PortableErr: portableErrMsg, } + if r.stdin != nil { // do not assign a typed nil into the io.Reader interface + call.Stdin = r.stdin + } result := fn(ctx, call, args[1:]) r.exit.code = result.Code r.exit.exiting = result.Exiting diff --git a/tests/import_allowlist_test.go b/tests/import_allowlist_test.go new file mode 100644 index 00000000..a30785be --- /dev/null +++ b/tests/import_allowlist_test.go @@ -0,0 +1,146 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package tests + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// builtinAllowedSymbols lists every "importpath.Symbol" that may be used by +// command implementation files in interp/builtins/. Each entry must be in +// "importpath.Symbol" form, where importpath is the full Go import path. +// +// To use a new symbol, add a single line here. +// +// Permanently banned (cannot be added): +// - reflect — reflection defeats static safety analysis +// - unsafe — bypasses Go's type and memory safety guarantees +// +// All packages not listed here are implicitly banned, including all +// third-party packages and other internal module packages. +var builtinAllowedSymbols = []string{ + "context.Context", + "io.Copy", + "io.NopCloser", + "io.ReadCloser", + "os.O_RDONLY", + "strconv.Atoi", +} + +// permanentlyBanned lists packages that may never be imported by builtin +// command implementations, regardless of what symbols they export. +var permanentlyBanned = map[string]string{ + "reflect": "reflection defeats static safety analysis", + "unsafe": "bypasses Go's type and memory safety guarantees", +} + +// TestBuiltinImportAllowlist enforces symbol-level import restrictions on +// command implementation files in interp/builtins/. builtins.go is exempt as +// the package framework. Every other file's imports and pkg.Symbol references +// must be explicitly listed in builtinAllowedSymbols. +func TestBuiltinImportAllowlist(t *testing.T) { + // Build lookup sets from the allowlist. + allowedSymbols := make(map[string]bool, len(builtinAllowedSymbols)) + allowedPackages := make(map[string]bool) + for _, entry := range builtinAllowedSymbols { + dot := strings.LastIndexByte(entry, '.') + if dot <= 0 { + t.Fatalf("malformed allowlist entry (no dot): %q", entry) + } + allowedSymbols[entry] = true + allowedPackages[entry[:dot]] = true + } + + root := repoRoot(t) + builtinsDir := filepath.Join(root, "interp", "builtins") + + entries, err := os.ReadDir(builtinsDir) + if err != nil { + t.Fatal(err) + } + + fset := token.NewFileSet() + checked := 0 + for _, entry := range entries { + name := entry.Name() + // builtins.go is the package framework (CallContext, Result, register, + // Lookup) and is exempt. Only command implementation files are checked. + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "builtins.go" { + continue + } + checked++ + + path := filepath.Join(builtinsDir, name) + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Errorf("%s: parse error: %v", name, err) + continue + } + + // Build a map from local package name → import path and validate each import. + localToPath := make(map[string]string) + for _, imp := range f.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + + if reason, banned := permanentlyBanned[importPath]; banned { + t.Errorf("%s: import of %q is permanently banned (%s)", name, importPath, reason) + continue + } + + // Determine the local name used to reference this package. + var localName string + if imp.Name != nil { + localName = imp.Name.Name + } else { + parts := strings.Split(importPath, "/") + localName = parts[len(parts)-1] + } + + if localName == "_" || localName == "." { + t.Errorf("%s: blank/dot import of %q is not allowed", name, importPath) + continue + } + + if !allowedPackages[importPath] { + t.Errorf("%s: import of %q is not in the allowlist", name, importPath) + continue + } + + localToPath[localName] = importPath + } + + // Walk all selector expressions and verify each pkg.Symbol is allowed. + ast.Inspect(f, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + importPath, ok := localToPath[ident.Name] + if !ok { + return true // not a package-level selector + } + key := importPath + "." + sel.Sel.Name + if !allowedSymbols[key] { + pos := fset.Position(sel.Pos()) + t.Errorf("%s:%d: %s is not in the allowlist", name, pos.Line, key) + } + return true + }) + } + if checked == 0 { + t.Fatal("no command implementation files found in interp/builtins/ — builtins.go may have been moved or the directory is empty") + } +}