feat(security): harden optional tirith exec scanning
This commit is contained in:
parent
bfb2b35f74
commit
e753cff0ba
8 changed files with 1261 additions and 6 deletions
|
|
@ -391,7 +391,13 @@
|
|||
"enabled": true,
|
||||
"enable_deny_patterns": true,
|
||||
"custom_deny_patterns": null,
|
||||
"custom_allow_patterns": null
|
||||
"custom_allow_patterns": null,
|
||||
"tirith": {
|
||||
"enabled": false,
|
||||
"bin": "tirith",
|
||||
"timeout_seconds": 5,
|
||||
"fail_open": true
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ The exec tool is used to execute shell commands.
|
|||
| `enabled` | bool | true | Enable the exec tool |
|
||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||
| `tirith.enabled` | bool | false | Enable optional Tirith pre-exec scanning |
|
||||
|
||||
### Disabling the Exec Tool
|
||||
|
||||
|
|
@ -219,6 +220,96 @@ PICOCLAW_TOOLS_EXEC_ENABLED=false
|
|||
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
|
||||
- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked
|
||||
|
||||
### Optional Tirith Security Scan
|
||||
|
||||
PicoClaw can run [Tirith](https://github.com/sheeki03/tirith) before executing a command. This is disabled by default
|
||||
and has no path lookup or subprocess overhead unless explicitly enabled.
|
||||
|
||||
Tirith analyzes command text for content-level threats such as homograph and punycode URLs, pipe-to-interpreter
|
||||
chains (`curl | bash`, `wget | sh`, PowerShell `iwr | iex`, and wrapper variants with `sudo` / `env`), base64
|
||||
decode-execute chains, terminal-control injection, suspicious package or URL installs, insecure transport, shortened
|
||||
URLs, and credential or file exfiltration patterns. If the local Tirith installation has its signed threat-intelligence
|
||||
database installed, `tirith check` can also apply package, hostname, and IP reputation matches.
|
||||
|
||||
PicoClaw does not download, install, vendor, or bundle Tirith. Install Tirith separately, then enable it in config:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"exec": {
|
||||
"tirith": {
|
||||
"enabled": true,
|
||||
"bin": "tirith",
|
||||
"timeout_seconds": 5,
|
||||
"fail_open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `tirith.enabled` | bool | `false` | Run Tirith before command execution |
|
||||
| `tirith.bin` | string | `"tirith"` | Tirith binary name from `PATH`, or an explicit absolute/relative path. Empty values are treated as `"tirith"` |
|
||||
| `tirith.timeout_seconds` | int | `5` | Scanner timeout in seconds |
|
||||
| `tirith.fail_open` | bool | `true` | Allow commands if Tirith is missing or the scanner fails operationally |
|
||||
|
||||
Explicit relative `tirith.bin` paths are resolved from PicoClaw's process working directory. Directories are rejected
|
||||
on all platforms, and non-executable regular files are rejected on Unix.
|
||||
|
||||
Environment variables:
|
||||
|
||||
```bash
|
||||
PICOCLAW_TOOLS_EXEC_TIRITH_ENABLED=true
|
||||
PICOCLAW_TOOLS_EXEC_TIRITH_BIN=tirith
|
||||
PICOCLAW_TOOLS_EXEC_TIRITH_TIMEOUT_SECONDS=5
|
||||
PICOCLAW_TOOLS_EXEC_TIRITH_FAIL_OPEN=true
|
||||
```
|
||||
|
||||
For this integration, PicoClaw starts `tirith check` with a temporary local-only policy. The pre-exec check disables
|
||||
Tirith threat-DB auto-update, remote policy credentials inherited from the environment, live OSV/deps.dev enrichment,
|
||||
Google Safe Browsing enrichment, and supplemental phishing/abuse.ch feed downloads. Local threat-database files that
|
||||
are already installed remain available to Tirith, but PicoClaw does not install, update, or download them during
|
||||
command execution.
|
||||
|
||||
Install methods depend on the Tirith project release you choose. Common options include:
|
||||
|
||||
```bash
|
||||
brew install sheeki03/tap/tirith
|
||||
cargo install tirith
|
||||
tirith threat-db update # optional, enables local threat-intelligence database matches
|
||||
```
|
||||
|
||||
You can also download a release binary from the Tirith project. PicoClaw only needs the final `tirith` executable
|
||||
to be on `PATH` or configured with `tools.exec.tirith.bin`.
|
||||
|
||||
Supported prebuilt platform coverage is determined by the Tirith binary you install. Common Tirith release targets
|
||||
include macOS x86_64/aarch64, Linux x86_64/aarch64, and Windows x86_64. PicoClaw passes `--shell posix` on non-Windows
|
||||
systems and `--shell powershell` on Windows to match its command execution path.
|
||||
|
||||
PicoClaw runs:
|
||||
|
||||
```text
|
||||
tirith check --format json --non-interactive --no-daemon --shell <mode> -- <command>
|
||||
```
|
||||
|
||||
Tirith's exit code is the verdict source of truth:
|
||||
|
||||
| Exit code | PicoClaw behavior |
|
||||
|-----------|-------------------|
|
||||
| `0` | Allow |
|
||||
| `1` | Block |
|
||||
| `2` | Log a warning and allow |
|
||||
| Other scanner failure | Allow when `fail_open=true`; block when `fail_open=false` |
|
||||
|
||||
Direct non-interactive `tirith check` normally returns `0`, `1`, or `2`. PicoClaw also handles Tirith's warn-ack exit
|
||||
defensively by logging and allowing, but it is not expected in normal non-interactive use.
|
||||
|
||||
Tirith scans the top-level `exec` command before PicoClaw starts the process. It does not inspect later `write` or
|
||||
`send-keys` input sent into an already-running background or PTY session, and it does not recursively inspect child
|
||||
processes spawned by the command after it starts.
|
||||
|
||||
### Default Blocked Command Patterns
|
||||
|
||||
By default, PicoClaw blocks the following dangerous commands:
|
||||
|
|
|
|||
|
|
@ -928,13 +928,21 @@ type CronToolsConfig struct {
|
|||
AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
|
||||
}
|
||||
|
||||
type TirithConfig struct {
|
||||
Enabled bool `env:"ENABLED" json:"enabled"`
|
||||
Bin string `env:"BIN" json:"bin"`
|
||||
TimeoutSeconds int `env:"TIMEOUT_SECONDS" json:"timeout_seconds"`
|
||||
FailOpen bool `env:"FAIL_OPEN" json:"fail_open"`
|
||||
}
|
||||
|
||||
type ExecConfig struct {
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
||||
EnableDenyPatterns bool ` json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
||||
AllowRemote bool ` json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"`
|
||||
CustomDenyPatterns []string ` json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||
CustomAllowPatterns []string ` json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
||||
TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s)
|
||||
EnableDenyPatterns bool ` json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
||||
AllowRemote bool ` json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"`
|
||||
CustomDenyPatterns []string ` json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||
CustomAllowPatterns []string ` json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
||||
TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s)
|
||||
Tirith TirithConfig ` json:"tirith" envPrefix:"PICOCLAW_TOOLS_EXEC_TIRITH_"`
|
||||
}
|
||||
|
||||
type SkillsToolsConfig struct {
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,118 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_TirithDisabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Tools.Exec.Tirith.Enabled {
|
||||
t.Fatal("DefaultConfig().Tools.Exec.Tirith.Enabled should be false")
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.Bin != "tirith" {
|
||||
t.Fatalf("DefaultConfig().Tools.Exec.Tirith.Bin = %q, want tirith", cfg.Tools.Exec.Tirith.Bin)
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.TimeoutSeconds != 5 {
|
||||
t.Fatalf("DefaultConfig().Tools.Exec.Tirith.TimeoutSeconds = %d, want 5", cfg.Tools.Exec.Tirith.TimeoutSeconds)
|
||||
}
|
||||
if !cfg.Tools.Exec.Tirith.FailOpen {
|
||||
t.Fatal("DefaultConfig().Tools.Exec.Tirith.FailOpen should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_TirithNestedKeysAndEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
raw := `{
|
||||
"version": 3,
|
||||
"tools": {
|
||||
"exec": {
|
||||
"tirith": {
|
||||
"enabled": true,
|
||||
"bin": "/usr/local/bin/tirith",
|
||||
"timeout_seconds": 7,
|
||||
"fail_open": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if !cfg.Tools.Exec.Tirith.Enabled {
|
||||
t.Fatal("Tirith.Enabled = false, want true")
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.Bin != "/usr/local/bin/tirith" {
|
||||
t.Fatalf("Tirith.Bin = %q", cfg.Tools.Exec.Tirith.Bin)
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.TimeoutSeconds != 7 {
|
||||
t.Fatalf("Tirith.TimeoutSeconds = %d, want 7", cfg.Tools.Exec.Tirith.TimeoutSeconds)
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.FailOpen {
|
||||
t.Fatal("Tirith.FailOpen = true, want false")
|
||||
}
|
||||
|
||||
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_ENABLED", "false")
|
||||
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_BIN", "/opt/tirith")
|
||||
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_TIMEOUT_SECONDS", "11")
|
||||
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_FAIL_OPEN", "true")
|
||||
|
||||
cfg, err = LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() with env error: %v", err)
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.Enabled {
|
||||
t.Fatal("env Tirith.Enabled = true, want false")
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.Bin != "/opt/tirith" {
|
||||
t.Fatalf("env Tirith.Bin = %q, want /opt/tirith", cfg.Tools.Exec.Tirith.Bin)
|
||||
}
|
||||
if cfg.Tools.Exec.Tirith.TimeoutSeconds != 11 {
|
||||
t.Fatalf("env Tirith.TimeoutSeconds = %d, want 11", cfg.Tools.Exec.Tirith.TimeoutSeconds)
|
||||
}
|
||||
if !cfg.Tools.Exec.Tirith.FailOpen {
|
||||
t.Fatal("env Tirith.FailOpen = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_TirithOldPRKeysAreUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
raw := `{
|
||||
"version": 3,
|
||||
"tools": {
|
||||
"exec": {
|
||||
"tirith": {
|
||||
"tirith_enabled": true,
|
||||
"tirith_bin": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
"tirith_fail_open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(configPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected old Tirith PR keys to be rejected as unknown")
|
||||
}
|
||||
for _, field := range []string{
|
||||
"tools.exec.tirith.tirith_enabled",
|
||||
"tools.exec.tirith.tirith_bin",
|
||||
"tools.exec.tirith.tirith_timeout",
|
||||
"tools.exec.tirith.tirith_fail_open",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), field) {
|
||||
t.Fatalf("expected unknown field %s in error, got %q", field, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.FilterSensitiveData {
|
||||
|
|
|
|||
|
|
@ -381,6 +381,12 @@ func DefaultConfig() *Config {
|
|||
EnableDenyPatterns: true,
|
||||
AllowRemote: true,
|
||||
TimeoutSeconds: 60,
|
||||
Tirith: TirithConfig{
|
||||
Enabled: false,
|
||||
Bin: "tirith",
|
||||
TimeoutSeconds: 5,
|
||||
FailOpen: true,
|
||||
},
|
||||
},
|
||||
Skills: SkillsToolsConfig{
|
||||
ToolConfig: ToolConfig{
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type ExecTool struct {
|
|||
restrictToWorkspace bool
|
||||
allowRemote bool
|
||||
sessionManager *SessionManager
|
||||
tirithConfig TirithConfig
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -168,6 +169,22 @@ func NewExecToolWithConfig(
|
|||
timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
tirithCfg := TirithConfig{
|
||||
Enabled: false,
|
||||
BinPath: "tirith",
|
||||
Timeout: 5,
|
||||
FailOpen: true,
|
||||
}
|
||||
if cfg != nil {
|
||||
tc := cfg.Tools.Exec.Tirith
|
||||
tirithCfg = TirithConfig{
|
||||
Enabled: tc.Enabled,
|
||||
BinPath: tc.Bin,
|
||||
Timeout: tc.TimeoutSeconds,
|
||||
FailOpen: tc.FailOpen,
|
||||
}
|
||||
}
|
||||
|
||||
return &ExecTool{
|
||||
workingDir: workingDir,
|
||||
timeout: timeout,
|
||||
|
|
@ -178,6 +195,7 @@ func NewExecToolWithConfig(
|
|||
restrictToWorkspace: restrict,
|
||||
allowRemote: allowRemote,
|
||||
sessionManager: getSessionManager(),
|
||||
tirithConfig: tirithCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -322,6 +340,11 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
|
|||
return ErrorResult(guardError)
|
||||
}
|
||||
|
||||
// Tirith content-level security gate (after cheap regex guards)
|
||||
if tirithError := tirithGuard(ctx, command, cwd, t.tirithConfig); tirithError != "" {
|
||||
return ErrorResult(tirithError)
|
||||
}
|
||||
|
||||
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
|
||||
// between validation and cmd.Dir assignment.
|
||||
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
|
||||
|
|
|
|||
536
pkg/tools/tirith.go
Normal file
536
pkg/tools/tirith.go
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
// Tirith pre-exec security scanning.
|
||||
//
|
||||
// Tirith (https://github.com/sheeki03/tirith) is a terminal security tool
|
||||
// that scans commands for content-level threats: homograph/punycode URLs,
|
||||
// pipe-to-interpreter patterns, base64 decode-execute chains, terminal
|
||||
// injection, suspicious packages/URLs, insecure transport, and local
|
||||
// threat-intelligence matches when configured in Tirith.
|
||||
//
|
||||
// PicoClaw invokes a locally installed Tirith binary when explicitly enabled.
|
||||
// It does not download, install, vendor, or bundle Tirith, and it disables
|
||||
// Tirith auto-update / live enrichment for this pre-exec check.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
tirithDefaultBin = "tirith"
|
||||
tirithDefaultTimeout = 5
|
||||
tirithStdoutLimitBytes = 256 * 1024
|
||||
tirithStderrLimitBytes = 8 * 1024
|
||||
tirithMaxFindings = 50
|
||||
tirithMaxSummaryRunes = 500
|
||||
)
|
||||
|
||||
var tirithScrubbedEnvVars = map[string]struct{}{
|
||||
"TIRITH": {},
|
||||
"TIRITH_POLICY_ROOT": {},
|
||||
"TIRITH_SERVER_URL": {},
|
||||
"TIRITH_API_KEY": {},
|
||||
"TIRITH_ALLOW_HTTP": {},
|
||||
"GOOGLE_SAFE_BROWSING_API_KEY": {},
|
||||
"GOOGLE_SAFE_BROWSING_KEY": {},
|
||||
"SAFE_BROWSING_API_KEY": {},
|
||||
"ABUSECH_AUTH_KEY": {},
|
||||
"ABUSE_CH_AUTH_KEY": {},
|
||||
"URLHAUS_AUTH_KEY": {},
|
||||
"THREATFOX_AUTH_KEY": {},
|
||||
}
|
||||
|
||||
const tirithLocalOnlyPolicy = `policy_server_url: null
|
||||
policy_server_api_key: null
|
||||
allow_bypass_env: false
|
||||
allow_bypass_env_noninteractive: false
|
||||
threat_intel:
|
||||
auto_update_hours: 0
|
||||
osv_enabled: false
|
||||
deps_dev_enabled: false
|
||||
google_safe_browsing_key: null
|
||||
abusech_auth_key: null
|
||||
phishing_army_enabled: false
|
||||
`
|
||||
|
||||
// TirithConfig holds Tirith security scanner settings (tools-internal).
|
||||
// Mapped from config.TirithConfig at ExecTool construction time.
|
||||
type TirithConfig struct {
|
||||
Enabled bool
|
||||
BinPath string
|
||||
Timeout int
|
||||
FailOpen bool
|
||||
}
|
||||
|
||||
var tirithWarningCache = struct {
|
||||
sync.Mutex
|
||||
seen map[string]struct{}
|
||||
}{
|
||||
seen: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
// tirithGuard scans a command with tirith for content-level threats.
|
||||
// Call AFTER guardCommand() because cheap regex guards reject known-bad commands first.
|
||||
// Returns empty string if allowed, error message if blocked.
|
||||
func tirithGuard(ctx context.Context, command, cwd string, cfg TirithConfig) string {
|
||||
if !cfg.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
binPath, cacheKey, err := resolveTirithPath(cfg.BinPath)
|
||||
if err != nil {
|
||||
return tirithScannerFailure(cacheKey, cfg, err)
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = tirithDefaultTimeout
|
||||
}
|
||||
|
||||
policyRoot, cleanupPolicy, err := createTirithLocalOnlyPolicyRoot()
|
||||
if err != nil {
|
||||
return tirithScannerFailure(cacheKey, cfg, err)
|
||||
}
|
||||
defer cleanupPolicy()
|
||||
|
||||
parentCtx := ctx
|
||||
ctx, cancel := context.WithTimeout(parentCtx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
shellFlag := "posix"
|
||||
if runtime.GOOS == "windows" {
|
||||
shellFlag = "powershell"
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, "check", "--format", "json", "--non-interactive",
|
||||
"--no-daemon", "--shell", shellFlag, "--", command)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
cmd.Env = tirithSubprocessEnv(policyRoot)
|
||||
var stdout cappedBuffer
|
||||
var stderr cappedBuffer
|
||||
stdout.limit = tirithStdoutLimitBytes
|
||||
stderr.limit = tirithStderrLimitBytes
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err = runTirithCommand(ctx, cmd)
|
||||
if err != nil {
|
||||
if parentCtx.Err() != nil {
|
||||
return "Tirith security scan canceled"
|
||||
}
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return tirithScannerFailure(cacheKey, cfg, fmt.Errorf("timed out after %ds", timeout))
|
||||
}
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return "Tirith security scan canceled"
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return tirithHandleExit(exitErr.ExitCode(), stdout.Bytes(), cfg)
|
||||
}
|
||||
|
||||
if stderr.Len() > 0 {
|
||||
err = fmt.Errorf("%w: %s", err, tirithSanitizeText(stderr.String()))
|
||||
}
|
||||
return tirithScannerFailure(cacheKey, cfg, err)
|
||||
}
|
||||
|
||||
return tirithHandleExit(0, stdout.Bytes(), cfg)
|
||||
}
|
||||
|
||||
func runTirithCommand(ctx context.Context, cmd *exec.Cmd) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prepareCommandForTermination(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
_ = terminateProcessTree(cmd)
|
||||
err := <-done
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func createTirithLocalOnlyPolicyRoot() (string, func(), error) {
|
||||
root, err := os.MkdirTemp("", "picoclaw-tirith-policy-*")
|
||||
if err != nil {
|
||||
return "", func() {}, fmt.Errorf("create local-only Tirith policy root: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.RemoveAll(root)
|
||||
}
|
||||
|
||||
policyDir := filepath.Join(root, ".tirith")
|
||||
if err := os.MkdirAll(policyDir, 0o700); err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, fmt.Errorf("create local-only Tirith policy directory: %w", err)
|
||||
}
|
||||
|
||||
policyPath := filepath.Join(policyDir, "policy.yaml")
|
||||
if err := os.WriteFile(policyPath, []byte(tirithLocalOnlyPolicy), 0o600); err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, fmt.Errorf("write local-only Tirith policy: %w", err)
|
||||
}
|
||||
|
||||
return root, cleanup, nil
|
||||
}
|
||||
|
||||
func tirithSubprocessEnv(policyRoot string) []string {
|
||||
env := make([]string, 0, len(os.Environ())+1)
|
||||
for _, entry := range os.Environ() {
|
||||
name, _, ok := strings.Cut(entry, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tirithShouldScrubEnv(name) {
|
||||
continue
|
||||
}
|
||||
env = append(env, entry)
|
||||
}
|
||||
env = append(env, "TIRITH_POLICY_ROOT="+policyRoot)
|
||||
return env
|
||||
}
|
||||
|
||||
func tirithShouldScrubEnv(name string) bool {
|
||||
_, ok := tirithScrubbedEnvVars[strings.ToUpper(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func tirithHandleExit(exitCode int, jsonOutput []byte, cfg TirithConfig) string {
|
||||
switch exitCode {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return fmt.Sprintf("Command blocked by Tirith security scan: %s", tirithSummarize(jsonOutput))
|
||||
case 2:
|
||||
log.Printf("[tirith] warning: %s", tirithSummarize(jsonOutput))
|
||||
return ""
|
||||
case 3:
|
||||
log.Printf("[tirith] warning acknowledgement required; allowing in non-interactive mode: %s", tirithSummarize(jsonOutput))
|
||||
return ""
|
||||
default:
|
||||
return tirithScannerFailure(normalizeTirithBin(cfg.BinPath), cfg, fmt.Errorf("unexpected exit code %d", exitCode))
|
||||
}
|
||||
}
|
||||
|
||||
func tirithScannerFailure(cacheKey string, cfg TirithConfig, err error) string {
|
||||
message := tirithSanitizeText(err.Error())
|
||||
if cfg.FailOpen {
|
||||
tirithLogOnce(cacheKey, "scanner-failure", "[tirith] unavailable (fail-open): %s", message)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Tirith security scan failed (fail-closed): %s", message)
|
||||
}
|
||||
|
||||
func tirithLogOnce(cacheKey, category, format string, args ...any) {
|
||||
cacheKey = normalizeTirithBin(cacheKey)
|
||||
key := cacheKey + "\x00" + category + "\x00" + fmt.Sprintf(format, args...)
|
||||
|
||||
tirithWarningCache.Lock()
|
||||
defer tirithWarningCache.Unlock()
|
||||
if _, ok := tirithWarningCache.seen[key]; ok {
|
||||
return
|
||||
}
|
||||
tirithWarningCache.seen[key] = struct{}{}
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
func tirithSummarize(jsonOutput []byte) string {
|
||||
if len(jsonOutput) == 0 {
|
||||
return "security issue detected"
|
||||
}
|
||||
var data struct {
|
||||
Summary string `json:"summary"`
|
||||
Findings []struct {
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description"`
|
||||
} `json:"findings"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonOutput, &data); err != nil {
|
||||
return "security issue detected (details unavailable)"
|
||||
}
|
||||
|
||||
if summary := tirithTruncateRunes(tirithSanitizeText(data.Summary), tirithMaxSummaryRunes); summary != "" {
|
||||
return summary
|
||||
}
|
||||
|
||||
limit := len(data.Findings)
|
||||
if limit > tirithMaxFindings {
|
||||
limit = tirithMaxFindings
|
||||
}
|
||||
parts := make([]string, 0, limit)
|
||||
for _, f := range data.Findings[:limit] {
|
||||
title := f.Title
|
||||
if title == "" {
|
||||
title = f.Message
|
||||
}
|
||||
if title == "" {
|
||||
title = f.Description
|
||||
}
|
||||
title = tirithSanitizeText(title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
severity := tirithSanitizeText(f.Severity)
|
||||
if severity == "" {
|
||||
parts = append(parts, title)
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("[%s] %s", severity, title))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "security issue detected"
|
||||
}
|
||||
if len(data.Findings) > limit {
|
||||
return tirithJoinFindingSummary(parts, len(data.Findings)-limit)
|
||||
}
|
||||
return tirithTruncateRunes(strings.Join(parts, "; "), tirithMaxSummaryRunes)
|
||||
}
|
||||
|
||||
func tirithJoinFindingSummary(parts []string, more int) string {
|
||||
summary := strings.Join(parts, "; ")
|
||||
if more <= 0 {
|
||||
return tirithTruncateRunes(summary, tirithMaxSummaryRunes)
|
||||
}
|
||||
|
||||
suffix := fmt.Sprintf("; ...and %d more", more)
|
||||
summaryRunes := []rune(summary)
|
||||
suffixRunes := []rune(suffix)
|
||||
if len(summaryRunes)+len(suffixRunes) <= tirithMaxSummaryRunes {
|
||||
return summary + suffix
|
||||
}
|
||||
prefixLimit := tirithMaxSummaryRunes - len(suffixRunes)
|
||||
if prefixLimit <= 0 {
|
||||
return tirithTruncateRunes(strings.TrimPrefix(suffix, "; "), tirithMaxSummaryRunes)
|
||||
}
|
||||
prefix := strings.TrimSpace(string(summaryRunes[:prefixLimit]))
|
||||
prefix = strings.TrimSuffix(prefix, ";")
|
||||
if prefix == "" {
|
||||
return strings.TrimPrefix(suffix, "; ")
|
||||
}
|
||||
return prefix + suffix
|
||||
}
|
||||
|
||||
// resolveTirithPath resolves only a user-installed Tirith binary. It deliberately
|
||||
// does not cache failed lookups so installing Tirith after a miss works immediately.
|
||||
func resolveTirithPath(configured string) (string, string, error) {
|
||||
normalized := normalizeTirithBin(configured)
|
||||
if isExplicitTirithPath(normalized) {
|
||||
if err := validateTirithPath(normalized); err != nil {
|
||||
return "", normalized, err
|
||||
}
|
||||
return normalized, normalized, nil
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(normalized)
|
||||
if err != nil {
|
||||
return "", normalized, err
|
||||
}
|
||||
if err := validateTirithPath(path); err != nil {
|
||||
return "", normalized, err
|
||||
}
|
||||
return path, normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTirithBin(configured string) string {
|
||||
bin := strings.TrimSpace(configured)
|
||||
if bin == "" {
|
||||
bin = tirithDefaultBin
|
||||
}
|
||||
if bin == "~" || strings.HasPrefix(bin, "~/") || strings.HasPrefix(bin, `~\`) {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
if bin == "~" {
|
||||
bin = home
|
||||
} else {
|
||||
bin = filepath.Join(home, bin[2:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if isExplicitTirithPath(bin) {
|
||||
cleaned := filepath.Clean(bin)
|
||||
if filepath.IsAbs(cleaned) {
|
||||
return cleaned
|
||||
}
|
||||
if abs, err := filepath.Abs(cleaned); err == nil {
|
||||
return abs
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func isExplicitTirithPath(path string) bool {
|
||||
return filepath.IsAbs(path) ||
|
||||
strings.Contains(path, "/") ||
|
||||
strings.Contains(path, `\`)
|
||||
}
|
||||
|
||||
func validateTirithPath(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s is a directory", path)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
|
||||
return fmt.Errorf("%s is not executable", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cappedBuffer struct {
|
||||
limit int
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Write(p []byte) (int, error) {
|
||||
written := len(p)
|
||||
if b.limit <= 0 || b.buf.Len() >= b.limit {
|
||||
return written, nil
|
||||
}
|
||||
remaining := b.limit - b.buf.Len()
|
||||
if len(p) > remaining {
|
||||
p = p[:remaining]
|
||||
}
|
||||
_, _ = b.buf.Write(p)
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Bytes() []byte {
|
||||
return b.buf.Bytes()
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) String() string {
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Len() int {
|
||||
return b.buf.Len()
|
||||
}
|
||||
|
||||
func tirithSanitizeText(input string) string {
|
||||
input = tirithStripANSI(input)
|
||||
var b strings.Builder
|
||||
b.Grow(len(input))
|
||||
for _, r := range input {
|
||||
if tirithDropRune(r) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func tirithDropRune(r rune) bool {
|
||||
if unicode.IsControl(r) {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case r >= '\u202a' && r <= '\u202e':
|
||||
return true
|
||||
case r >= '\u2066' && r <= '\u2069':
|
||||
return true
|
||||
case r >= '\u200b' && r <= '\u200f':
|
||||
return true
|
||||
case r == '\u2060' || r == '\ufeff':
|
||||
return true
|
||||
case r >= '\ufe00' && r <= '\ufe0f':
|
||||
return true
|
||||
case r >= '\U000e0100' && r <= '\U000e01ef':
|
||||
return true
|
||||
case r >= '\U000e0000' && r <= '\U000e007f':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tirithStripANSI(input string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(input); {
|
||||
r, size := utf8.DecodeRuneInString(input[i:])
|
||||
if r != '\x1b' {
|
||||
b.WriteString(input[i : i+size])
|
||||
i += size
|
||||
continue
|
||||
}
|
||||
|
||||
i += size
|
||||
if i >= len(input) {
|
||||
continue
|
||||
}
|
||||
switch input[i] {
|
||||
case '[':
|
||||
i++
|
||||
for i < len(input) {
|
||||
c := input[i]
|
||||
i++
|
||||
if c >= 0x40 && c <= 0x7e {
|
||||
break
|
||||
}
|
||||
}
|
||||
case ']':
|
||||
i++
|
||||
for i < len(input) {
|
||||
if input[i] == 0x07 {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if input[i] == '\x1b' && i+1 < len(input) && input[i+1] == '\\' {
|
||||
i += 2
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func tirithTruncateRunes(s string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= limit {
|
||||
return s
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
473
pkg/tools/tirith_test.go
Normal file
473
pkg/tools/tirith_test.go
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewExecTool_DefaultTirithDisabled(t *testing.T) {
|
||||
tool, err := NewExecTool("", false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, tool.tirithConfig.Enabled)
|
||||
require.Equal(t, "tirith", tool.tirithConfig.BinPath)
|
||||
require.Equal(t, 5, tool.tirithConfig.Timeout)
|
||||
require.True(t, tool.tirithConfig.FailOpen)
|
||||
}
|
||||
|
||||
func TestTirithGuard_DisabledDoesNotResolve(t *testing.T) {
|
||||
cfg := TirithConfig{
|
||||
Enabled: false,
|
||||
BinPath: filepath.Join(t.TempDir(), "missing-tirith"),
|
||||
Timeout: 5,
|
||||
FailOpen: false,
|
||||
}
|
||||
require.Empty(t, tirithGuard(context.Background(), "echo hello", "", cfg))
|
||||
}
|
||||
|
||||
func TestTirithGuard_MissingBinaryFailOpenAndFailClosed(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "missing-tirith")
|
||||
|
||||
failOpen := tirithGuard(context.Background(), "echo hello", "", TirithConfig{
|
||||
Enabled: true,
|
||||
BinPath: missing,
|
||||
Timeout: 5,
|
||||
FailOpen: true,
|
||||
})
|
||||
require.Empty(t, failOpen)
|
||||
|
||||
failClosed := tirithGuard(context.Background(), "echo hello", "", TirithConfig{
|
||||
Enabled: true,
|
||||
BinPath: missing,
|
||||
Timeout: 5,
|
||||
FailOpen: false,
|
||||
})
|
||||
require.Contains(t, failClosed, "fail-closed")
|
||||
}
|
||||
|
||||
func TestResolveTirithPath_NormalizationAndValidation(t *testing.T) {
|
||||
require.Equal(t, "tirith", normalizeTirithBin(""))
|
||||
require.Equal(t, "tirith", normalizeTirithBin(" "))
|
||||
|
||||
dir := t.TempDir()
|
||||
_, _, err := resolveTirithPath(dir)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "directory")
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Setenv("USERPROFILE", home)
|
||||
}
|
||||
homeBin := filepath.Join(home, "tirith")
|
||||
writeExecutableFile(t, homeBin, fakeTirithScript(`printf '{"findings":[]}'`+"\nexit 0\n"))
|
||||
resolved, cacheKey, err := resolveTirithPath("~/tirith")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, filepath.Clean(homeBin), resolved)
|
||||
require.Equal(t, filepath.Clean(homeBin), cacheKey)
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
notExecutable := filepath.Join(t.TempDir(), "tirith")
|
||||
require.NoError(t, os.WriteFile(notExecutable, []byte("#!/bin/sh\nexit 0\n"), 0o644))
|
||||
_, _, err = resolveTirithPath(notExecutable)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not executable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTirithPath_PathLookup(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript(`printf '{"findings":[]}'`+"\nexit 0\n"))
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
resolved, cacheKey, err := resolveTirithPath("tirith")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, bin, resolved)
|
||||
require.Equal(t, "tirith", cacheKey)
|
||||
}
|
||||
|
||||
func TestResolveTirithPath_ExplicitRelativePathDoesNotUsePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldWD, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldWD))
|
||||
})
|
||||
require.NoError(t, os.Chdir(dir))
|
||||
|
||||
local := filepath.Join(dir, "tirith")
|
||||
writeExecutableFile(t, local, fakeTirithScript(`printf '{"findings":[]}'`+"\nexit 0\n"))
|
||||
|
||||
pathDir := t.TempDir()
|
||||
pathBin := filepath.Join(pathDir, "tirith")
|
||||
writeExecutableFile(t, pathBin, fakeTirithScript("exit 1\n"))
|
||||
t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
resolved, cacheKey, err := resolveTirithPath("./tirith")
|
||||
require.NoError(t, err)
|
||||
expected, err := filepath.Abs("tirith")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, resolved)
|
||||
require.Equal(t, expected, cacheKey)
|
||||
}
|
||||
|
||||
func TestTirithGuard_DoesNotCacheFailedResolution(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: true}
|
||||
|
||||
require.Empty(t, tirithGuard(context.Background(), "echo first", "", cfg))
|
||||
|
||||
writeExecutableFile(t, bin, fakeTirithScript(`printf '{"findings":[{"severity":"HIGH","title":"later install"}]}'`+"\nexit 1\n"))
|
||||
got := tirithGuard(context.Background(), "echo second", "", cfg)
|
||||
require.Contains(t, got, "Command blocked by Tirith")
|
||||
require.Contains(t, got, "later install")
|
||||
}
|
||||
|
||||
func TestTirithGuard_Argv(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript(`
|
||||
printf '%s\n' "$@" > "$TIRITH_ARGS_FILE"
|
||||
printf '{"findings":[]}'
|
||||
exit 0
|
||||
`))
|
||||
t.Setenv("TIRITH_ARGS_FILE", argsFile)
|
||||
|
||||
got := tirithGuard(context.Background(), "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false})
|
||||
require.Empty(t, got)
|
||||
|
||||
argsData, err := os.ReadFile(argsFile)
|
||||
require.NoError(t, err)
|
||||
args := strings.Split(strings.TrimSpace(string(argsData)), "\n")
|
||||
require.Equal(t, []string{
|
||||
"check",
|
||||
"--format",
|
||||
"json",
|
||||
"--non-interactive",
|
||||
"--no-daemon",
|
||||
"--shell",
|
||||
"posix",
|
||||
"--",
|
||||
"echo hello",
|
||||
}, args)
|
||||
}
|
||||
|
||||
func TestTirithGuard_UsesLocalOnlyPolicyEnvironment(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
envFile := filepath.Join(t.TempDir(), "env.txt")
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript(`
|
||||
{
|
||||
printf 'policy_root=%s\n' "$TIRITH_POLICY_ROOT"
|
||||
printf 'server=%s\n' "${TIRITH_SERVER_URL-unset}"
|
||||
printf 'api=%s\n' "${TIRITH_API_KEY-unset}"
|
||||
printf 'bypass=%s\n' "${TIRITH-unset}"
|
||||
printf 'allow_http=%s\n' "${TIRITH_ALLOW_HTTP-unset}"
|
||||
printf 'gsb=%s\n' "${GOOGLE_SAFE_BROWSING_API_KEY-unset}"
|
||||
printf 'abusech=%s\n' "${ABUSECH_AUTH_KEY-unset}"
|
||||
printf 'path=%s\n' "${PATH-unset}"
|
||||
printf 'home=%s\n' "${HOME-unset}"
|
||||
printf '%s\n' '---policy---'
|
||||
cat "$TIRITH_POLICY_ROOT/.tirith/policy.yaml"
|
||||
} > "$TIRITH_ENV_FILE"
|
||||
printf '{"findings":[]}'
|
||||
exit 0
|
||||
`))
|
||||
t.Setenv("TIRITH_ENV_FILE", envFile)
|
||||
t.Setenv("TIRITH_POLICY_ROOT", "/should/not/be/used")
|
||||
t.Setenv("TIRITH_SERVER_URL", "https://policy.example.invalid")
|
||||
t.Setenv("TIRITH_API_KEY", "secret")
|
||||
t.Setenv("TIRITH", "0")
|
||||
t.Setenv("TIRITH_ALLOW_HTTP", "1")
|
||||
t.Setenv("GOOGLE_SAFE_BROWSING_API_KEY", "gsb-secret")
|
||||
t.Setenv("ABUSECH_AUTH_KEY", "abusech-secret")
|
||||
|
||||
got := tirithGuard(context.Background(), "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false})
|
||||
require.Empty(t, got)
|
||||
|
||||
data, err := os.ReadFile(envFile)
|
||||
require.NoError(t, err)
|
||||
text := string(data)
|
||||
require.Contains(t, text, "policy_root=")
|
||||
require.NotContains(t, text, "/should/not/be/used")
|
||||
require.Contains(t, text, "server=unset")
|
||||
require.Contains(t, text, "api=unset")
|
||||
require.Contains(t, text, "bypass=unset")
|
||||
require.Contains(t, text, "allow_http=unset")
|
||||
require.Contains(t, text, "gsb=unset")
|
||||
require.Contains(t, text, "abusech=unset")
|
||||
require.Contains(t, text, "path=")
|
||||
require.Contains(t, text, "home=")
|
||||
require.Contains(t, text, "allow_bypass_env: false")
|
||||
require.Contains(t, text, "allow_bypass_env_noninteractive: false")
|
||||
require.Contains(t, text, "auto_update_hours: 0")
|
||||
require.Contains(t, text, "osv_enabled: false")
|
||||
require.Contains(t, text, "deps_dev_enabled: false")
|
||||
require.Contains(t, text, "phishing_army_enabled: false")
|
||||
}
|
||||
|
||||
func TestTirithGuard_UsesCommandWorkingDirectory(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
cwd := t.TempDir()
|
||||
pwdFile := filepath.Join(t.TempDir(), "pwd.txt")
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript(`
|
||||
pwd > "$TIRITH_PWD_FILE"
|
||||
printf '{"findings":[]}'
|
||||
exit 0
|
||||
`))
|
||||
t.Setenv("TIRITH_PWD_FILE", pwdFile)
|
||||
|
||||
got := tirithGuard(context.Background(), "echo hello", cwd, TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false})
|
||||
require.Empty(t, got)
|
||||
|
||||
data, err := os.ReadFile(pwdFile)
|
||||
require.NoError(t, err)
|
||||
expectedCWD, err := filepath.EvalSymlinks(cwd)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedCWD, strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
func TestTirithGuard_ExitCodes(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
exitCode int
|
||||
failOpen bool
|
||||
wantBlock bool
|
||||
wantText string
|
||||
}{
|
||||
{name: "allow", exitCode: 0, failOpen: false},
|
||||
{name: "block", exitCode: 1, failOpen: false, wantBlock: true, wantText: "Pipe to shell"},
|
||||
{name: "warn", exitCode: 2, failOpen: false},
|
||||
{name: "warn ack defensive", exitCode: 3, failOpen: false},
|
||||
{name: "unknown fail open", exitCode: 9, failOpen: true},
|
||||
{name: "unknown fail closed", exitCode: 9, failOpen: false, wantBlock: true, wantText: "fail-closed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript(`printf '{"findings":[{"severity":"HIGH","title":"Pipe to shell"}]}'`+"\nexit "+itoa(tt.exitCode)+"\n"))
|
||||
got := tirithGuard(context.Background(), "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: tt.failOpen})
|
||||
if tt.wantBlock {
|
||||
require.NotEmpty(t, got)
|
||||
require.Contains(t, got, tt.wantText)
|
||||
} else {
|
||||
require.Empty(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTirithGuard_InvalidJSONKeepsExitVerdict(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript("printf 'not json'\nexit 1\n"))
|
||||
|
||||
got := tirithGuard(context.Background(), "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false})
|
||||
require.Contains(t, got, "Command blocked by Tirith")
|
||||
require.Contains(t, got, "details unavailable")
|
||||
}
|
||||
|
||||
func TestTirithGuard_TimeoutUsesFailOpen(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript("sleep 2\nexit 0\n"))
|
||||
|
||||
require.Empty(t, tirithGuard(context.Background(), "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 1, FailOpen: true}))
|
||||
|
||||
got := tirithGuard(context.Background(), "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 1, FailOpen: false})
|
||||
require.Contains(t, got, "fail-closed")
|
||||
require.Contains(t, got, "timed out")
|
||||
}
|
||||
|
||||
func TestTirithGuard_TimeoutKillsProcessTree(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
childPIDFile := filepath.Join(t.TempDir(), "child.pid")
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript(`
|
||||
sleep 30 &
|
||||
echo $! > "$TIRITH_CHILD_PID_FILE"
|
||||
wait
|
||||
`))
|
||||
t.Setenv("TIRITH_CHILD_PID_FILE", childPIDFile)
|
||||
|
||||
start := time.Now()
|
||||
require.Empty(t, tirithGuard(context.Background(), "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 1, FailOpen: true}))
|
||||
require.Less(t, time.Since(start), 5*time.Second)
|
||||
|
||||
data, err := os.ReadFile(childPIDFile)
|
||||
require.NoError(t, err)
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
return !tirithTestProcessExists(pid)
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestTirithGuard_ContextCanceledBlocksExecution(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript("sleep 2\nexit 0\n"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
got := tirithGuard(ctx, "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: true})
|
||||
require.Equal(t, "Tirith security scan canceled", got)
|
||||
}
|
||||
|
||||
func TestTirithGuard_ParentDeadlineBlocksExecution(t *testing.T) {
|
||||
skipWindowsProcessTest(t)
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "tirith")
|
||||
writeExecutableFile(t, bin, fakeTirithScript("sleep 2\nexit 0\n"))
|
||||
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
|
||||
got := tirithGuard(ctx, "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: true})
|
||||
require.Equal(t, "Tirith security scan canceled", got)
|
||||
}
|
||||
|
||||
func TestCappedBuffer_DiscardReturnsFullWriteLength(t *testing.T) {
|
||||
var buf cappedBuffer
|
||||
buf.limit = 5
|
||||
|
||||
n, err := buf.Write([]byte("abcdef"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 6, n)
|
||||
require.Equal(t, "abcde", buf.String())
|
||||
|
||||
n, err = buf.Write([]byte("ghij"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 4, n)
|
||||
require.Equal(t, "abcde", buf.String())
|
||||
}
|
||||
|
||||
func TestTirithSummarize_SanitizesAndCapsFindings(t *testing.T) {
|
||||
findings := make([]map[string]string, 0, tirithMaxFindings+5)
|
||||
findings = append(findings, map[string]string{
|
||||
"severity": "\x1b[31mHIGH\x1b[0m",
|
||||
"title": "bad\u202e\u200b title",
|
||||
})
|
||||
for i := 0; i < tirithMaxFindings+4; i++ {
|
||||
findings = append(findings, map[string]string{"severity": "LOW", "title": "extra"})
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{"findings": findings})
|
||||
require.NoError(t, err)
|
||||
|
||||
got := tirithSummarize(payload)
|
||||
require.Contains(t, got, "[HIGH] bad title")
|
||||
require.NotContains(t, got, "\x1b")
|
||||
require.NotContains(t, got, "[31m")
|
||||
require.NotContains(t, got, "\u202e")
|
||||
require.NotContains(t, got, "\u200b")
|
||||
require.Contains(t, got, "...and 5 more")
|
||||
require.LessOrEqual(t, len([]rune(got)), tirithMaxSummaryRunes+3)
|
||||
}
|
||||
|
||||
func TestTirithSummarize_CapsSummary(t *testing.T) {
|
||||
payload, err := json.Marshal(map[string]any{"summary": strings.Repeat("x", tirithMaxSummaryRunes+20)})
|
||||
require.NoError(t, err)
|
||||
|
||||
got := tirithSummarize(payload)
|
||||
require.Len(t, []rune(got), tirithMaxSummaryRunes+3)
|
||||
require.True(t, strings.HasSuffix(got, "..."))
|
||||
}
|
||||
|
||||
func TestTirithWarningCacheSuppressesRepeatedScannerFailureLogs(t *testing.T) {
|
||||
resetTirithWarningCacheForTest()
|
||||
|
||||
var logs bytes.Buffer
|
||||
previousOutput := log.Writer()
|
||||
previousFlags := log.Flags()
|
||||
log.SetOutput(&logs)
|
||||
log.SetFlags(0)
|
||||
defer func() {
|
||||
log.SetOutput(previousOutput)
|
||||
log.SetFlags(previousFlags)
|
||||
}()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing-tirith")
|
||||
cfg := TirithConfig{Enabled: true, BinPath: missing, Timeout: 5, FailOpen: true}
|
||||
require.Empty(t, tirithGuard(context.Background(), "echo one", "", cfg))
|
||||
require.Empty(t, tirithGuard(context.Background(), "echo two", "", cfg))
|
||||
|
||||
require.Equal(t, 1, strings.Count(logs.String(), "unavailable (fail-open)"))
|
||||
}
|
||||
|
||||
func TestTirithSummarize_InvalidAndEmptyJSON(t *testing.T) {
|
||||
require.Contains(t, tirithSummarize([]byte("not json")), "details unavailable")
|
||||
require.Equal(t, "security issue detected", tirithSummarize([]byte(`{"findings":[]}`)))
|
||||
require.Equal(t, "security issue detected", tirithSummarize(nil))
|
||||
}
|
||||
|
||||
func resetTirithWarningCacheForTest() {
|
||||
tirithWarningCache.Lock()
|
||||
defer tirithWarningCache.Unlock()
|
||||
tirithWarningCache.seen = make(map[string]struct{})
|
||||
}
|
||||
|
||||
func writeExecutableFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
|
||||
if runtime.GOOS != "windows" {
|
||||
require.NoError(t, os.Chmod(path, 0o755))
|
||||
}
|
||||
}
|
||||
|
||||
func fakeTirithScript(body string) string {
|
||||
return "#!/bin/sh\n" + body
|
||||
}
|
||||
|
||||
func skipWindowsProcessTest(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script Tirith fake is Unix-only")
|
||||
}
|
||||
}
|
||||
|
||||
func tirithTestProcessExists(pid int) bool {
|
||||
return exec.Command("kill", "-0", itoa(pid)).Run() == nil
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var digits [20]byte
|
||||
i := len(digits)
|
||||
for v > 0 {
|
||||
i--
|
||||
digits[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(digits[i:])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue