Merge ca7168aa9a into 412705783d
This commit is contained in:
commit
707b0f8102
8 changed files with 1321 additions and 7 deletions
|
|
@ -391,7 +391,13 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"enable_deny_patterns": true,
|
"enable_deny_patterns": true,
|
||||||
"custom_deny_patterns": null,
|
"custom_deny_patterns": null,
|
||||||
"custom_allow_patterns": null
|
"custom_allow_patterns": null,
|
||||||
|
"tirith": {
|
||||||
|
"enabled": false,
|
||||||
|
"bin": "tirith",
|
||||||
|
"timeout_seconds": 5,
|
||||||
|
"fail_open": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@ The exec tool is used to execute shell commands.
|
||||||
| `enabled` | bool | true | Enable the exec tool |
|
| `enabled` | bool | true | Enable the exec tool |
|
||||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||||
|
| `tirith.enabled` | bool | false | Enable optional Tirith pre-exec scanning |
|
||||||
|
|
||||||
### Disabling the Exec Tool
|
### 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
|
- **`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
|
- **`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
|
### Default Blocked Command Patterns
|
||||||
|
|
||||||
By default, PicoClaw blocks the following dangerous commands:
|
By default, PicoClaw blocks the following dangerous commands:
|
||||||
|
|
|
||||||
|
|
@ -928,13 +928,25 @@ type CronToolsConfig struct {
|
||||||
AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
|
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 {
|
type ExecConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
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"`
|
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
||||||
CustomDenyPatterns []string ` json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
AllowRemote bool `json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"`
|
||||||
CustomAllowPatterns []string ` json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||||
TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s)
|
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
||||||
|
|
||||||
|
// 0 means use default (60s)
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"`
|
||||||
|
|
||||||
|
Tirith TirithConfig `json:"tirith" envPrefix:"PICOCLAW_TOOLS_EXEC_TIRITH_"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkillsToolsConfig struct {
|
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) {
|
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
if !cfg.Tools.FilterSensitiveData {
|
if !cfg.Tools.FilterSensitiveData {
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,12 @@ func DefaultConfig() *Config {
|
||||||
EnableDenyPatterns: true,
|
EnableDenyPatterns: true,
|
||||||
AllowRemote: true,
|
AllowRemote: true,
|
||||||
TimeoutSeconds: 60,
|
TimeoutSeconds: 60,
|
||||||
|
Tirith: TirithConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Bin: "tirith",
|
||||||
|
TimeoutSeconds: 5,
|
||||||
|
FailOpen: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Skills: SkillsToolsConfig{
|
Skills: SkillsToolsConfig{
|
||||||
ToolConfig: ToolConfig{
|
ToolConfig: ToolConfig{
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ type ExecTool struct {
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
allowRemote bool
|
allowRemote bool
|
||||||
sessionManager *SessionManager
|
sessionManager *SessionManager
|
||||||
|
tirithConfig TirithConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -168,6 +169,22 @@ func NewExecToolWithConfig(
|
||||||
timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
|
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{
|
return &ExecTool{
|
||||||
workingDir: workingDir,
|
workingDir: workingDir,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
|
|
@ -178,6 +195,7 @@ func NewExecToolWithConfig(
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
allowRemote: allowRemote,
|
allowRemote: allowRemote,
|
||||||
sessionManager: getSessionManager(),
|
sessionManager: getSessionManager(),
|
||||||
|
tirithConfig: tirithCfg,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,6 +340,11 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
|
||||||
return ErrorResult(guardError)
|
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
|
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
|
||||||
// between validation and cmd.Dir assignment.
|
// between validation and cmd.Dir assignment.
|
||||||
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
|
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
|
||||||
|
|
|
||||||
543
pkg/tools/tirith.go
Normal file
543
pkg/tools/tirith.go
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
// Package tools integrates 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 {
|
||||||
|
tirithLogOncef(cacheKey, "scanner-failure", "[tirith] unavailable (fail-open): %s", message)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Tirith security scan failed (fail-closed): %s", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tirithLogOncef(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]) + "..."
|
||||||
|
}
|
||||||
521
pkg/tools/tirith_test.go
Normal file
521
pkg/tools/tirith_test.go
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false}
|
||||||
|
got := tirithGuard(context.Background(), "echo hello", cwd, cfg)
|
||||||
|
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")
|
||||||
|
script := `printf '{"findings":[{"severity":"HIGH","title":"Pipe to shell"}]}'` +
|
||||||
|
"\nexit " + itoa(tt.exitCode) + "\n"
|
||||||
|
writeExecutableFile(t, bin, fakeTirithScript(script))
|
||||||
|
|
||||||
|
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: tt.failOpen}
|
||||||
|
got := tirithGuard(context.Background(), "echo hello", "", cfg)
|
||||||
|
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"))
|
||||||
|
|
||||||
|
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false}
|
||||||
|
got := tirithGuard(context.Background(), "echo hello", "", cfg)
|
||||||
|
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"))
|
||||||
|
|
||||||
|
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 1, FailOpen: true}
|
||||||
|
require.Empty(t, tirithGuard(context.Background(), "echo hello", "", cfg))
|
||||||
|
|
||||||
|
cfg.FailOpen = false
|
||||||
|
got := tirithGuard(context.Background(), "echo hello", "", cfg)
|
||||||
|
require.Contains(t, got, "fail-closed")
|
||||||
|
require.Contains(t, got, "timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTirithCommand_CancelKillsProcessTree(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()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.Command(bin)
|
||||||
|
cmd.Env = append(os.Environ(), "TIRITH_CHILD_PID_FILE="+childPIDFile)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- runTirithCommand(ctx, cmd)
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
_, err := os.Stat(childPIDFile)
|
||||||
|
return err == nil
|
||||||
|
}, 5*time.Second, 100*time.Millisecond)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
var runErr error
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case runErr = <-errCh:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 5*time.Second, 100*time.Millisecond)
|
||||||
|
require.Error(t, runErr)
|
||||||
|
require.Less(t, time.Since(start), 7*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