Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions interp/builtins/break_continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
19 changes: 10 additions & 9 deletions interp/builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions interp/builtins/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{"-"}
Expand Down
4 changes: 4 additions & 0 deletions interp/builtins/echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions interp/builtins/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] == "--" {
Expand Down
5 changes: 5 additions & 0 deletions interp/builtins/true_false.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
}
Expand Down
4 changes: 3 additions & 1 deletion interp/runner_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,16 @@ 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) {
return r.open(ctx, path, flags, mode, false)
},
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
Expand Down
146 changes: 146 additions & 0 deletions tests/import_allowlist_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}