Compare commits

...
Author SHA1 Message Date
Joe 4c2e8d4bda Simplify unit tests and strengthen deterministic coverage (#2433) 2026-11-06 15:32:31 -08:00
Joe 9f8f1d11be Fix DNS canary embedding and detection (#2432)
* Preserve DNS canaries in obfuscated implants

* Refactor canary regression assertions for lint

* Fix DNS canary detection and trigger persistence
2026-11-05 09:42:05 -08:00
Joe 6cf3ad78bd Revert Dependabot docs dependency updates (#2425) 2026-10-24 21:27:15 -07:00
Joe 56e140826f build(deps): combine compatible Dependabot updates (#2424) 2026-10-24 20:29:44 -07:00
Joe ad6ba0a61b Merge pull request #2423 from BishopFox/feat/protobuf-obfuscation-hardening
Harden protobuf obfuscation generation
2026-10-24 11:29:43 -07:00
moloch-- 9e1c1e7c67 Obfuscate all implant protobuf field tags 2026-10-24 09:45:23 -07:00
moloch-- a36b0b1d1d Refactor protobuf rewrites to satisfy lint complexity 2026-10-24 08:44:06 -07:00
moloch-- 4c1350d8cb Harden protobuf obfuscation generation 2026-10-24 08:39:08 -07:00
Joe 3e409bdc91 Merge pull request #2422 from BishopFox/feat/implant-size-consolidated
Reduce implant sizes across obfuscated and release builds
2026-10-23 08:30:02 -07:00
moloch-- 46eb1c22a4 feat: compact obfuscated amd64 executable alignment 2026-10-22 16:08:32 -07:00
moloch-- f857903c83 Strip non-debug plain implant executables 2026-10-22 16:04:49 -07:00
moloch-- ee53c01313 Reduce WASM extension gzip dependency in implants 2026-10-22 16:04:33 -07:00
moloch-- 14a43e6510 Reduce obfuscated implant size with shared short literal decoding 2026-10-22 13:46:04 -07:00
moloch-- 931e3f9046 Refresh bundled Garble when asset source changes 2026-10-22 13:45:46 -07:00
moloch-- a6d5e97d45 Pin Garble v1.27.3a signed release 2026-10-22 12:55:46 -07:00
moloch-- f0ef60e25f Reduce obfuscated implant size with shared long literal decoding 2026-10-22 12:50:04 -07:00
Joe 9fdc66e4a8 Merge pull request #2414 from BishopFox/feat/go-garble-1.27.3
Update embedded Go and Garble to 1.27.3
2026-10-22 10:43:57 -07:00
moloch-- ab960fd4ed Make SOCKS receive test shutdown deterministic 2026-10-22 08:42:19 -07:00
moloch-- 34fc1c5ad6 Update embedded Go and Garble to 1.27.3 2026-10-22 08:23:51 -07:00
Joe ac65aa3708 Merge pull request #2412 from BishopFox/feat/fix-initial-prompt
Fix update reminder corrupting the initial console prompt
2026-10-21 15:30:21 -07:00
426 changed files with 28081 additions and 3966 deletions

No files matched your search

+2 -2
View File
@@ -60,10 +60,10 @@ jobs:
with:
fetch-depth: 0
- name: Go 1.27.2
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
go-version: "1.27.3"
- name: Install Minisign
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps:
# Setup go environment
- name: Go 1.27.2
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
go-version: "1.27.3"
- name: Checkout repository
uses: actions/checkout@v7
+2 -2
View File
@@ -43,10 +43,10 @@ jobs:
with:
fetch-depth: 0
- name: Go 1.27.2
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
go-version: "1.27.3"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
+8 -8
View File
@@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.27.2
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
go-version: "1.27.3"
- name: OS Packages
run: |
@@ -73,10 +73,10 @@ jobs:
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.27.2
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
go-version: "1.27.3"
- name: Install make
run: choco install make -y --no-progress
@@ -117,10 +117,10 @@ jobs:
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.27.2
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
go-version: "1.27.3"
- name: Check Out Code
uses: actions/checkout@v7
@@ -155,10 +155,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.27.2
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.2"
go-version: "1.27.3"
- name: Check Out Code
uses: actions/checkout@v7
+1
View File
@@ -9,6 +9,7 @@ run:
- osusergo
- netgo
- go_sqlite
- sliver_generate
# Raw implant entry points are Go templates; lint their supporting packages.
- sliver_lint
+2 -2
View File
@@ -199,7 +199,7 @@ clean-all: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
.downloaded_assets: util/assets/constants.go util/assets/garble.go
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
@@ -324,7 +324,7 @@ clean-all: clean
clean:
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
.downloaded_assets:
.downloaded_assets: util/assets/constants.go util/assets/garble.go
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
@type NUL > .downloaded_assets
+4 -18
View File
@@ -156,13 +156,6 @@ func TestMessageBlockLabelIgnoresUnknownOperatorPlaceholders(t *testing.T) {
}
}
func TestTranscriptSpeakerStyleUsesPrimaryThemeForUserMessages(t *testing.T) {
styles := transcriptSpeakerStyle("alice", "user")
assertColorEqual(t, styles.border.GetForeground(), clienttheme.Primary())
assertColorEqual(t, styles.label.GetBackground(), clienttheme.Primary())
}
func TestThinkingLevelOptionFocusedStyleUsesDarkerPrimaryShade(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
@@ -544,19 +537,12 @@ func TestRenderHeaderUsesSingleCompactRow(t *testing.T) {
}
}
func TestTranscriptSpeakerPaletteVariesAcrossUsers(t *testing.T) {
seen := map[int]struct{}{}
for _, label := range []string{"alice", "bob", "charlie", "dana", "erin", "frank"} {
seen[transcriptSpeakerPaletteIndex(label, "user")] = struct{}{}
}
if len(seen) < 2 {
t.Fatalf("expected distinct users to map to more than one transcript color, got %d palette entries", len(seen))
}
}
func TestTranscriptSpeakerStyleUsesPrimaryBorderForUserMessages(t *testing.T) {
userStyles := transcriptSpeakerStyle("alice", "user")
userBorder := transcriptSpeakerStyle("alice", "user").border.Render("│")
assertColorEqual(t, userStyles.border.GetForeground(), clienttheme.Primary())
assertColorEqual(t, userStyles.label.GetBackground(), clienttheme.Primary())
userBorder := userStyles.border.Render("│")
wantUserBorder := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
if userBorder != wantUserBorder {
t.Fatalf("expected user message border to use theme primary color")
-1
View File
@@ -1 +0,0 @@
package armory
+9 -10
View File
@@ -3,6 +3,8 @@ package exec
import (
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
@@ -87,14 +89,11 @@ func TestShouldConvertExecuteShellcodePE(t *testing.T) {
func newExecuteShellcodeFlagsTestCommand(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "execute-shellcode"}
cmd.Flags().Uint32("shellcode-entropy", 1, "")
cmd.Flags().Bool("shellcode-compress", false, "")
cmd.Flags().Uint32("shellcode-exitopt", 1, "")
cmd.Flags().Uint32("shellcode-bypass", 3, "")
cmd.Flags().Uint32("shellcode-headers", 1, "")
cmd.Flags().Bool("shellcode-thread", false, "")
cmd.Flags().Bool("shellcode-unicode", false, "")
cmd.Flags().Uint32("shellcode-oep", 0, "")
return cmd
for _, cmd := range Commands(&console.SliverClient{}) {
if cmd.Name() == consts.ExecuteShellcodeStr {
return cmd
}
}
t.Fatalf("missing %q command", consts.ExecuteShellcodeStr)
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package generate
import (
"strconv"
"testing"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
func TestCanariesCommandRegistered(t *testing.T) {
root := &cobra.Command{Use: "sliver"}
root.AddCommand(Commands(&console.SliverClient{})...)
cmd, _, err := root.Find([]string{consts.CanariesStr})
if err != nil {
t.Fatalf("find canaries command: %v", err)
}
if cmd.Parent() != root {
t.Fatal("canaries command is not registered at the root")
}
if cmd.Run == nil {
t.Fatal("canaries command has no handler")
}
if cmd.GroupID != consts.PayloadsHelpGroup {
t.Errorf("canaries group = %q, want %q", cmd.GroupID, consts.PayloadsHelpGroup)
}
for _, want := range []struct {
name string
shorthand string
valueType string
defaultValue string
}{
{name: "burned", shorthand: "b", valueType: "bool", defaultValue: "false"},
{name: "timeout", shorthand: "t", valueType: "int64", defaultValue: strconv.Itoa(flags.DefaultTimeout)},
} {
flag := cmd.Flags().Lookup(want.name)
if flag == nil {
t.Fatalf("canaries command is missing --%s", want.name)
}
if flag.Shorthand != want.shorthand {
t.Errorf("--%s shorthand = %q, want %q", want.name, flag.Shorthand, want.shorthand)
}
if flag.Value.Type() != want.valueType {
t.Errorf("--%s type = %q, want %q", want.name, flag.Value.Type(), want.valueType)
}
if flag.DefValue != want.defaultValue {
t.Errorf("--%s default = %q, want %q", want.name, flag.DefValue, want.defaultValue)
}
}
}
+1 -1
View File
@@ -313,7 +313,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd}
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd, canariesCmd}
}
// coreImplantFlags binds all flags common to all sliver implant types.
+60 -40
View File
@@ -3,57 +3,77 @@ package console
import (
"sync"
"testing"
"testing/synctest"
"time"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestRemoveEventListenerUnblocksInFlightDispatchAndClosesReceiver(t *testing.T) {
con := &SliverClient{EventListeners: &sync.Map{}}
listenerID, events := con.CreateEventListener()
value, ok := con.EventListeners.Load(listenerID)
if !ok {
t.Fatal("created listener is not registered")
}
listener := value.(*consoleEventListener)
for index := 0; index < cap(listener.events); index++ {
listener.events <- &clientpb.Event{EventType: "buffered"}
}
dispatchDone := make(chan struct{})
go func() {
listener.send(&clientpb.Event{EventType: "in-flight"})
close(dispatchDone)
}()
con.RemoveEventListener(listenerID)
select {
case <-dispatchDone:
case <-time.After(time.Second):
t.Fatal("listener removal did not unblock in-flight dispatch")
}
if _, stillRegistered := con.EventListeners.Load(listenerID); stillRegistered {
t.Fatal("removed listener remains registered")
}
for event := range events {
_ = event
}
}
func TestConcurrentEventDispatchAndRemovalDoesNotPanic(_ *testing.T) {
for iteration := 0; iteration < 1_000; iteration++ {
synctest.Test(t, func(t *testing.T) {
con := &SliverClient{EventListeners: &sync.Map{}}
listenerID, events := con.CreateEventListener()
value, ok := con.EventListeners.Load(listenerID)
if !ok {
t.Fatal("created listener is not registered")
}
listener := value.(*consoleEventListener)
buffered := &clientpb.Event{EventType: "buffered"}
for index := 0; index < cap(listener.events); index++ {
listener.events <- buffered
}
dispatchDone := make(chan struct{})
go func() {
con.triggerEventListeners(&clientpb.Event{EventType: "race"})
event := &clientpb.Event{EventType: "in-flight"}
con.triggerEventListeners(event)
close(dispatchDone)
}()
con.RemoveEventListener(listenerID)
<-dispatchDone
for event := range events {
_ = event
// The full buffer and Wait establish that send is blocked before removal.
synctest.Wait()
select {
case <-dispatchDone:
t.Fatal("dispatch completed before listener removal")
default:
}
}
removeDone := make(chan struct{})
go func() {
con.RemoveEventListener(listenerID)
// Dispatch and repeated removal after closure must remain harmless.
con.triggerEventListeners(&clientpb.Event{EventType: "removed"})
listener.send(&clientpb.Event{EventType: "closed"})
con.RemoveEventListener(listenerID)
close(removeDone)
}()
for _, done := range []<-chan struct{}{dispatchDone, removeDone} {
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("listener removal and in-flight dispatch did not complete")
}
}
if _, stillRegistered := con.EventListeners.Load(listenerID); stillRegistered {
t.Fatal("removed listener remains registered")
}
deadline := time.After(time.Second)
for received := 0; ; received++ {
select {
case event, open := <-events:
if !open {
if received != cap(listener.events) {
t.Fatalf("received %d buffered events, want %d", received, cap(listener.events))
}
return
}
if event != buffered {
t.Fatalf("unexpected event after removal: %v", event)
}
case <-deadline:
t.Fatal("removed listener receiver did not close")
}
}
})
}
+6
View File
@@ -1598,6 +1598,8 @@ func TestSocksReceiveLoopOverloadedConnectionDoesNotBlockHealthyConnection(t *te
t.Fatal("healthy SOCKS terminal did not close after ordered data")
}
// Start local shutdown before releasing the fake receive stream.
proxy.beginStop()
stream.results <- socksReceiveTestResult{err: io.EOF}
if err := proxy.Stop(); err != nil {
t.Fatalf("stop SOCKS receive test proxy: %v", err)
@@ -1689,6 +1691,8 @@ func TestSocksReceiveLoopRejectsInvalidAcknowledgementAtExactTunnelScope(t *test
t.Fatal("invalid acknowledgement closed healthy sibling")
}
// Start local shutdown before releasing the fake receive stream.
proxy.beginStop()
stream.results <- socksReceiveTestResult{err: io.EOF}
if err := proxy.Stop(); err != nil {
t.Fatalf("stop acknowledgement test proxy: %v", err)
@@ -1754,6 +1758,8 @@ func TestSocksReceiveLoopAppliesValidAcknowledgementOutOfBand(t *testing.T) {
}
}
// Start local shutdown before releasing the fake receive stream.
proxy.beginStop()
stream.results <- socksReceiveTestResult{err: io.EOF}
if err := proxy.Stop(); err != nil {
t.Fatalf("stop valid acknowledgement test proxy: %v", err)
+95 -105
View File
@@ -16,113 +16,103 @@ func TestMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
}
}
func TestSelectMultiplayerDialStrategyAutoWithoutWGConfigUsesDirectMTLS(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyAutoLegacyWGConfigUsesDirectMTLS(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigRequiresCompleteConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
Enabled: true,
ServerPubKey: "server-pub",
ClientIP: "100.64.0.2",
func TestSelectMultiplayerDialStrategy(t *testing.T) {
for _, test := range []struct {
name string
mode MultiplayerConnectMode
config *assets.ClientConfig
want multiplayerDialStrategy
wantErr error
wantErrText string
}{
{
name: "auto without WireGuard config",
mode: MultiplayerConnectAuto,
config: &assets.ClientConfig{},
want: multiplayerDialDirect,
},
})
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
t.Fatalf("expected incomplete WG config error, got %v", err)
}
}
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigUsesWireGuard(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialWireGuard {
t.Fatalf("expected WireGuard strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyEnableWGRejectsMissingWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if !errors.Is(err, ErrMissingWireGuardConfig) {
t.Fatalf("expected missing WG config error, got %v", err)
}
}
func TestSelectMultiplayerDialStrategyEnableWGRejectsIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientIP: "100.64.0.2",
{
name: "auto with legacy WireGuard config",
mode: MultiplayerConnectAuto,
config: completeWGClientConfig(false),
want: multiplayerDialDirect,
},
})
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
t.Fatalf("expected incomplete WG config error, got %v", err)
}
}
func TestSelectMultiplayerDialStrategyEnableWGUsesCompleteLegacyConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialWireGuard {
t.Fatalf("expected WireGuard strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyDisableWGOverridesEnabledConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectDisableWG)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyRejectsInvalidMode(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectMode(255))
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
if strategy != multiplayerDialDirect {
t.Fatalf("expected invalid mode to fall back to direct strategy, got %v", strategy)
}
if err == nil || err.Error() != "invalid multiplayer connect mode 255" {
t.Fatalf("expected invalid mode error, got %v", err)
{
name: "auto with incomplete enabled config",
mode: MultiplayerConnectAuto,
config: &assets.ClientConfig{WG: &assets.ClientWGConfig{
Enabled: true, ServerPubKey: "server-pub", ClientIP: "100.64.0.2",
}},
want: multiplayerDialDirect,
wantErr: ErrIncompleteWireGuardConfig,
},
{
name: "auto with enabled WireGuard config",
mode: MultiplayerConnectAuto,
config: completeWGClientConfig(true),
want: multiplayerDialWireGuard,
},
{
name: "enable with missing WireGuard config",
mode: MultiplayerConnectEnableWG,
config: &assets.ClientConfig{},
want: multiplayerDialDirect,
wantErr: ErrMissingWireGuardConfig,
},
{
name: "enable with incomplete WireGuard config",
mode: MultiplayerConnectEnableWG,
config: &assets.ClientConfig{WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub", ClientIP: "100.64.0.2",
}},
want: multiplayerDialDirect,
wantErr: ErrIncompleteWireGuardConfig,
},
{
name: "enable with complete legacy config",
mode: MultiplayerConnectEnableWG,
config: completeWGClientConfig(false),
want: multiplayerDialWireGuard,
},
{
name: "disable overrides enabled config",
mode: MultiplayerConnectDisableWG,
config: completeWGClientConfig(true),
want: multiplayerDialDirect,
},
{
name: "invalid mode",
mode: MultiplayerConnectMode(255),
config: completeWGClientConfig(true),
want: multiplayerDialDirect,
wantErrText: "invalid multiplayer connect mode 255",
},
{
name: "nil config",
mode: MultiplayerConnectAuto,
want: multiplayerDialDirect,
wantErrText: "client config is required",
},
} {
t.Run(test.name, func(t *testing.T) {
setTestMultiplayerConnectMode(t, test.mode)
strategy, err := selectMultiplayerDialStrategy(test.config)
if strategy != test.want {
t.Errorf("dial strategy = %v, want %v", strategy, test.want)
}
switch {
case test.wantErr != nil:
if !errors.Is(err, test.wantErr) {
t.Fatalf("select dial strategy error = %v, want %v", err, test.wantErr)
}
case test.wantErrText != "":
if err == nil || err.Error() != test.wantErrText {
t.Fatalf("select dial strategy error = %v, want %q", err, test.wantErrText)
}
case err != nil:
t.Fatalf("select dial strategy: %v", err)
}
})
}
}
@@ -2,7 +2,7 @@ Sliver can be compiled on Linux, macOS, and native Windows. Windows users can al
To compile from source you'll need:
- Go v1.27.2 or later
- Go v1.27.3 or later
- `make` (on MacOS you may need to install XCode and accompanying cli tools)
```asciinema
+4 -4
View File
@@ -280,7 +280,6 @@ try {
$pkg = if ($testDir.StartsWith(".")) { $testDir } else { "./$testDir" }
switch -Wildcard ($pkg) {
"./server/c2" { continue }
"./server/generate" { continue }
"./client/*" {
$clientTestPkgs += $pkg
continue
@@ -359,6 +358,7 @@ try {
}
}
# Includes generation helpers without the sliver_generate compilation tests.
foreach ($pkg in $serverUtilTestPkgs) {
if (Test-SkipPackage -Package $pkg -Tags "server,$tags") {
continue
@@ -421,7 +421,7 @@ try {
Invoke-TestStep -Name "./server/generate" -Action {
Invoke-ExternalCommand -FilePath "go" `
-Arguments @("test", "-timeout", "6h", "-p", $generateGoP, "-parallel", $generateTestParallel, "-tags=server,$tags", "./server/generate") `
-Arguments @("test", "-timeout", "6h", "-p", $generateGoP, "-parallel", $generateTestParallel, "-tags=server,$tags,sliver_generate", "./server/generate") `
-Environment @{
GOMAXPROCS = $generateGoMaxProcs
GOPROXY = "off"
@@ -430,9 +430,9 @@ try {
} else {
Write-Host ""
if ($UnitOnly) {
Write-Host "Skipping ./server/generate tests (--unit-only)"
Write-Host "Skipping ./server/generate compilation tests (--unit-only)"
} else {
Write-Host "Skipping ./server/generate tests (--skip-generate)"
Write-Host "Skipping ./server/generate compilation tests (--skip-generate)"
}
}
} finally {
+92 -23
View File
@@ -27,16 +27,70 @@ fi
SKIP_GENERATE=0
UNIT_ONLY=0
for arg in "$@"; do
if [ "$arg" = "--skip-generate" ]; then
GENERATE_ONLY=0
GENERATE_RUN=""
usage() {
cat <<'EOF'
Usage: ./go-tests.sh [--skip-generate | --unit-only | --generate-only] [--run REGEX]
--skip-generate Skip implant compilation tests.
--unit-only Skip implant compilation and C2 end-to-end tests.
--generate-only Run only the server/generate package tests.
--run REGEX Select generation tests by Go test name regex; requires --generate-only.
--help Show this help.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--skip-generate)
SKIP_GENERATE=1
fi
if [ "$arg" = "--unit-only" ]; then
;;
--unit-only)
UNIT_ONLY=1
SKIP_GENERATE=1
fi
;;
--generate-only)
GENERATE_ONLY=1
;;
--run)
if [ "$#" -lt 2 ] || [ -z "$2" ] || [[ "$2" == --* ]]; then
echo "--run requires a non-empty regex" >&2
exit 2
fi
GENERATE_RUN="$2"
shift
;;
--run=*)
GENERATE_RUN="${1#--run=}"
if [ -z "$GENERATE_RUN" ]; then
echo "--run requires a non-empty regex" >&2
exit 2
fi
;;
--help | -h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
if [ "$GENERATE_ONLY" -eq 1 ] && [ "$SKIP_GENERATE" -eq 1 ]; then
echo "--generate-only cannot be combined with --skip-generate or --unit-only" >&2
exit 2
fi
if [ -n "$GENERATE_RUN" ] && [ "$GENERATE_ONLY" -eq 0 ]; then
echo "--run requires --generate-only" >&2
exit 2
fi
echo "----------------------------------------------------------------"
echo "WARNING: Running unit tests on slow systems can take a LONG time"
echo " Recommended to only run on 16+ CPU cores and 32Gb+ RAM"
@@ -175,8 +229,35 @@ unpack_server_assets() {
"$HOST_GO" run -tags=server,go_sqlite ./server unpack --force
}
run_generate_tests() {
# Reclaim the host build cache before generation so long CI runs do not
# exhaust the runner's disk while retaining the implant compiler cache.
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || return 1
export GOPROXY=off
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
# parallelism conservative by default to avoid OOM kills on smaller systems.
local generate_gomaxprocs="${SLIVER_GENERATE_GOMAXPROCS:-2}"
local generate_go_p="${SLIVER_GENERATE_GO_P:-1}"
local generate_test_parallel="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
local generate_test_cmd=(
"$HOST_GO" test -timeout 6h -p "$generate_go_p" -parallel "$generate_test_parallel" -tags="server,$TAGS,sliver_generate"
)
if [ -n "$GENERATE_RUN" ]; then
# Keep test selection out of GOFLAGS, which implant compiler children inherit.
generate_test_cmd+=(-run "$GENERATE_RUN" -count=1 -v)
fi
generate_test_cmd+=(./server/generate)
run_test_cmd "./server/generate" \
env GOMAXPROCS="$generate_gomaxprocs" "${generate_test_cmd[@]}"
}
run_test_cmd "unpack server assets" unpack_server_assets || exit 1
if [ "$GENERATE_ONLY" -eq 1 ]; then
run_generate_tests || exit 1
exit 0
fi
TEST_DIRS=()
while IFS= read -r test_dir; do
TEST_DIRS+=("$test_dir")
@@ -191,7 +272,7 @@ E2E_SUPPORT_TEST_PKGS=()
for test_dir in "${TEST_DIRS[@]}"; do
pkg="./$test_dir"
case "$pkg" in
./server/c2 | ./server/generate)
./server/c2)
# handled separately below
;;
./client/*)
@@ -249,7 +330,7 @@ for pkg in "${IMPLANT_TEST_PKGS[@]}"; do
run_test_cmd "$pkg" "$HOST_GO" test "$pkg" || exit 1
done
## Server + Util
## Server + Util, including generation helpers without compilation tests.
for pkg in "${SERVER_UTIL_TEST_PKGS[@]}"; do
if should_skip_package "$pkg" "server,$TAGS"; then
continue
@@ -277,26 +358,14 @@ else
run_test_cmd "./server/c2 (e2e dns)" "$HOST_GO" test -tags="server,$TAGS,sliver_e2e" ./server/c2 -run 'TestDNS_' -count=1 || exit 1
fi
## Server generate
## Implant compilation tests opt in with sliver_generate; helper tests run above.
if [ "$SKIP_GENERATE" -eq 0 ]; then
# The package tests above no longer need their Go build cache. Reclaim it
# before the cross-platform generation matrix starts so long CI runs do not
# exhaust the runner's disk while retaining the implant compiler cache.
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || exit 1
export GOPROXY=off
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
# parallelism conservative by default to avoid OOM kills on smaller systems.
GENERATE_GOMAXPROCS="${SLIVER_GENERATE_GOMAXPROCS:-2}"
GENERATE_GO_P="${SLIVER_GENERATE_GO_P:-1}"
GENERATE_TEST_PARALLEL="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
run_test_cmd "./server/generate" \
env GOMAXPROCS="$GENERATE_GOMAXPROCS" \
"$HOST_GO" test -timeout 6h -p "$GENERATE_GO_P" -parallel "$GENERATE_TEST_PARALLEL" -tags="server,$TAGS" ./server/generate || exit 1
run_generate_tests || exit 1
else
echo
if [ "$UNIT_ONLY" -eq 1 ]; then
echo "Skipping ./server/generate tests (--unit-only)"
echo "Skipping ./server/generate compilation tests (--unit-only)"
else
echo "Skipping ./server/generate tests (--skip-generate)"
echo "Skipping ./server/generate compilation tests (--skip-generate)"
fi
fi
+17 -17
View File
@@ -1,6 +1,6 @@
module github.com/bishopfox/sliver
go 1.27.2
go 1.27.3
replace github.com/rsteube/carapace v0.36.3 => github.com/reeflective/carapace v0.46.3-0.20231214181515-27e49f3c3b69
@@ -10,7 +10,7 @@ replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-202
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/bubbletea/v2 v2.1.0
charm.land/glamour/v2 v2.0.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.6
@@ -24,27 +24,29 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/carapace-sh/carapace v1.15.3
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f
github.com/cheggaaa/pb/v3 v3.1.7
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.1
github.com/ebitengine/purego v0.11.0
github.com/glebarez/sqlite v1.11.0
github.com/go-sql-driver/mysql v1.9.3
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang/protobuf v1.5.4
github.com/gorilla/mux v1.8.1
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/hashicorp/yamux v0.1.2
github.com/jackc/pgx/v5 v5.10.0
github.com/jedib0t/go-pretty/v6 v6.8.3
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
github.com/klauspost/compress v1.18.4
github.com/klauspost/compress v1.18.7
github.com/lesnuages/go-winio v0.4.19
github.com/lesnuages/snitch v0.6.0
github.com/lucasb-eyer/go-colorful v1.4.1
github.com/mark3labs/mcp-go v0.45.0
github.com/mattn/go-sqlite3 v1.14.50
github.com/mattn/go-sqlite3 v1.14.52
github.com/miekg/dns v1.1.70
github.com/moloch--/asciicast v0.1.1
github.com/moloch--/go-keystone v0.0.2
@@ -73,7 +75,7 @@ require (
golang.org/x/crypto v0.55.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
golang.org/x/sys v0.47.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
@@ -150,7 +152,7 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
@@ -187,12 +189,11 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-lark/lark v1.16.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
@@ -218,7 +219,6 @@ require (
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jezek/xgb v1.1.1 // indirect
@@ -238,7 +238,7 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
@@ -286,7 +286,7 @@ require (
github.com/utahta/go-linenotify v0.5.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.5 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
@@ -295,11 +295,11 @@ require (
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
+30 -32
View File
@@ -6,8 +6,8 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
@@ -179,10 +179,10 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
@@ -316,8 +316,8 @@ github.com/go-lark/lark v1.16.0 h1:U6BwkLM9wrZedSM7cIiMofganr8PCvJN+M75w2lf2Gg=
github.com/go-lark/lark v1.16.0/go.mod h1:6ltbSztPZRT6IaO9ZIQyVaY5pVp/KeMizDYtfZkU+vM=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
@@ -484,8 +484,8 @@ github.com/kevinburke/twilio-go v0.0.0-20260522200016-74f727d55757 h1:dQyNAHiOY8
github.com/kevinburke/twilio-go v0.0.0-20260522200016-74f727d55757/go.mod h1:Z7bqFOTtIqCCIO+uPtod7Fp3gPMR8aTCcp5Pe8D+3fE=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -528,10 +528,10 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
@@ -552,8 +552,6 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/moloch--/asciicast v0.1.1 h1:btwwniCK9br3ys33XZ71IG/M3ZujSSCcBzsH/sieiXg=
github.com/moloch--/asciicast v0.1.1/go.mod h1:OckO16UDLgxVLclrCnbocL1ix15Br/8Xv/caBoYq98o=
github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701 h1:6caCgDZpovjvv+eclRytFDf0GpVEnjDmoJRHpfkb42E=
github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701/go.mod h1:UdqyqqCSnPu3Zc8Q3kpDszrq1hcyBHsG17hlWlRJmWI=
github.com/moloch--/console v0.0.0-20261002031128-9c7d5fba75f5 h1:BwamAdS8GG5tsxKisLphOOC5RVmTcy8Ixf+oGyhh/Jc=
github.com/moloch--/console v0.0.0-20261002031128-9c7d5fba75f5/go.mod h1:UdqyqqCSnPu3Zc8Q3kpDszrq1hcyBHsG17hlWlRJmWI=
github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+NTTRL8=
@@ -763,8 +761,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yiya1989/sshkrb5 v0.0.1 h1:oAKpXLBeZExjYDV9eTL2KypmXU+5oPrJtqDuR5JbWic=
github.com/yiya1989/sshkrb5 v0.0.1/go.mod h1:r1bHcNa22x8rrg/Kj/cDF5bX6UtZrHiQgRFvj3Mnpl8=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -793,20 +791,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
@@ -928,8 +926,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/bishopfox/sliver
go 1.27.2
go 1.27.3
require (
filippo.io/age v1.1.1
+10 -3
View File
@@ -25,10 +25,17 @@ var (
SliverName = `{{.Name}}`
)
// Message - Fake message for embedding canaries
// Message carries canaries in struct tags, which remain plaintext under Garble.
type Message struct {
Command string `c2:"[[GenerateCanary]]"`
// {{if .Config.IncludeMTLS}}
MTLS string `c2:"[[GenerateCanary]]"`
// {{end}}
// {{if .Config.IncludeWG}}
WireGuard string `c2:"[[GenerateCanary]]"`
// {{end}}
}
// never obfuscate the Message type
var _ = reflect.TypeOf(Message{})
// Access a field at runtime to retain the type metadata and its canary tags.
// A discarded TypeOf call alone is optimized away, removing the canaries.
var _ = reflect.TypeOf(Message{}).Field(0)
+62 -29
View File
@@ -19,6 +19,8 @@ package extension
*/
import (
"bytes"
"io"
"io/fs"
"testing"
)
@@ -33,7 +35,11 @@ func TestBasicMemFSOpenFile(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if stat, _ := fi.Stat(); stat.Name() != "memfs/test.1" {
stat, err := fi.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/test.1" {
t.Fatalf("expected memfs/test.1, got %s", stat.Name())
}
defer fi.Close()
@@ -58,20 +64,24 @@ func TestMemFSOpenRoot(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(entries) != 3 {
for entry := range entries {
t.Logf("entry: %s", entries[entry].Name())
wantEntries := map[string]bool{"memfs/test.1": false, "memfs/test.2": false, "memfs/a": true}
if len(entries) != len(wantEntries) {
t.Fatalf("expected %d entries, got %d", len(wantEntries), len(entries))
}
for _, entry := range entries {
wantDir, ok := wantEntries[entry.Name()]
if !ok || entry.IsDir() != wantDir {
t.Fatalf("unexpected entry %q (directory=%v)", entry.Name(), entry.IsDir())
}
t.Fatalf("expected 3 entries, got %d", len(entries))
delete(wantEntries, entry.Name())
}
di, err := dirNode.Stat()
if err != nil {
t.Fatal(err)
}
t.Logf("name: %s, is dir: %v", di.Name(), di.IsDir())
for _, entry := range entries {
t.Logf("entry: %s", entry.Name())
if di.Name() != "memfs" || !di.IsDir() {
t.Fatalf("expected memfs root directory, got %q (directory=%v)", di.Name(), di.IsDir())
}
}
@@ -80,7 +90,7 @@ func TestBasicMemFSOpenDir(t *testing.T) {
"test/foo.1": {0x00},
"test/foo": {0x00},
"testing/foo": {0x00},
"a/b/c/d/e/f/test/a.txt": {0x00},
"a/b/c/d/e/f/test/a.txt": {0x31, 0x00, 0x7f},
}}
// Test Open File
@@ -91,7 +101,11 @@ func TestBasicMemFSOpenDir(t *testing.T) {
if f == nil {
t.Fatal("expected file, got nil and no error")
}
if stat, _ := f.Stat(); stat.Name() != "memfs/test" || !stat.IsDir() {
stat, err := f.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/test" || !stat.IsDir() {
t.Fatalf("expected 'memfs/test' dir, got '%s'", stat.Name())
}
defer f.Close()
@@ -100,7 +114,16 @@ func TestBasicMemFSOpenDir(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if stat, _ := dir.Stat(); stat.Name() != "memfs/a/b/c/d/e/f/test" || !stat.IsDir() {
defer func() {
if err := dir.Close(); err != nil {
t.Errorf("close directory: %v", err)
}
}()
stat, err = dir.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/a/b/c/d/e/f/test" || !stat.IsDir() {
t.Fatalf("expected 'memfs/a/b/c/d/e/f/test' dir, got '%s'", stat.Name())
}
@@ -108,19 +131,17 @@ func TestBasicMemFSOpenDir(t *testing.T) {
if err != nil {
t.Fatal(err)
}
data := make([]byte, 1)
_, err = f.Read(data)
if err != nil {
t.Fatal(err)
}
if data[0] != 0x00 {
t.Fatalf("expected 0x00, got %x", data[0])
}
defer func() {
if err := f.Close(); err != nil {
t.Errorf("close file: %v", err)
}
}()
assertMemFSFileContents(t, f, []byte{0x31, 0x00, 0x7f})
}
func TestMemFSReadFileData(t *testing.T) {
wasmFS := WasmMemoryFS{memFS: map[string][]byte{
"test/foo.1": {0x00},
"test/foo.1": {0x41, 0x00, 0xff, 0x42},
}}
// Test Open File
@@ -128,19 +149,31 @@ func TestMemFSReadFileData(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if stat, _ := fi.Stat(); stat.Name() != "memfs/test/foo.1" {
stat, err := fi.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/test/foo.1" {
t.Fatalf("expected memfs/test/foo.1, got %s", stat.Name())
}
defer fi.Close()
// Read file
data := make([]byte, 1)
_, err = fi.Read(data)
if err != nil {
t.Fatal(err)
assertMemFSFileContents(t, fi, []byte{0x41, 0x00, 0xff, 0x42})
}
func assertMemFSFileContents(t *testing.T, file fs.File, want []byte) {
t.Helper()
// Read in chunks to verify byte counts and advancement of the file offset.
for offset := 0; offset < len(want); {
end := min(offset+2, len(want))
data := make([]byte, end-offset)
n, err := file.Read(data)
if err != nil || n != len(data) || !bytes.Equal(data, want[offset:end]) {
t.Fatalf("Read at %d = (%x, %d, %v), want (%x, %d, nil)", offset, data, n, err, want[offset:end], len(data))
}
offset = end
}
if data[0] != 0x00 {
t.Fatalf("expected 0x00, got %x", data[0])
if n, err := file.Read(make([]byte, 1)); n != 0 || err != io.EOF {
t.Fatalf("Read after file contents = (%d, %v), want (0, EOF)", n, err)
}
}
@@ -2,6 +2,7 @@ package tunnel_handlers
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"sync"
@@ -10,7 +11,6 @@ import (
"log"
// {{end}}
"github.com/bishopfox/sliver/implant/sliver/encoders"
"github.com/bishopfox/sliver/implant/sliver/extension"
"github.com/bishopfox/sliver/implant/sliver/transports"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -39,7 +39,7 @@ func ExecWasmExtensionHandler(envelope *pb.Envelope, connection *transports.Conn
return
}
wasmBin, err := encoders.Gzip.Decode(extReq.WasmGz)
wasmBin, err := decodeWasmGzip(extReq.WasmGz)
if err != nil {
// {{if .Config.Debug}}
log.Printf("error decoding Wasm extension: %v", err)
@@ -76,6 +76,19 @@ func ExecWasmExtensionHandler(envelope *pb.Envelope, connection *transports.Conn
}
}
func decodeWasmGzip(data []byte) ([]byte, error) {
reader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
var buf bytes.Buffer
_, err = buf.ReadFrom(reader)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func runNonInteractive(req *pb.ExecWasmExtensionReq, wasm *extension.WasmExtension) ([]byte, error) {
// {{if .Config.Debug}}
log.Printf("Executing non-interactive wasm extension")
@@ -0,0 +1,32 @@
package tunnel_handlers
import (
"bytes"
"compress/gzip"
"testing"
)
func TestDecodeWasmGzip(t *testing.T) {
// An empty WebAssembly v1 module has the magic and version headers.
wasm := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(wasm); err != nil {
t.Fatalf("compress WASM: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("finish gzip stream: %v", err)
}
got, err := decodeWasmGzip(compressed.Bytes())
if err != nil {
t.Fatalf("decode valid WASM payload: %v", err)
}
if !bytes.Equal(got, wasm) {
t.Fatalf("decoded payload = %x, want %x", got, wasm)
}
if _, err := decodeWasmGzip([]byte("not a gzip stream")); err == nil {
t.Fatal("expected malformed gzip to fail")
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ func ReadEnvelope(r io.Reader) (*pb.Envelope, error) {
rawSigBuf := make([]byte, cryptography.RawSigSize)
dataLengthBuf := make([]byte, 4) // Size of uint32
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || r == nil {
panic("[[GenerateCanary]]")
panic("invalid envelope reader")
}
n, err := io.ReadFull(r, rawSigBuf)
@@ -182,7 +182,7 @@ func ReadEnvelope(connection net.Conn) (*pb.Envelope, error) {
rawSigBuf := make([]byte, cryptography.RawSigSize)
dataLengthBuf := make([]byte, 4) // Size of uint32
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || connection == nil {
panic("[[GenerateCanary]]")
panic("invalid envelope connection")
}
n, err := io.ReadFull(connection, rawSigBuf)
+454 -179
View File
@@ -24,7 +24,6 @@ import (
"bytes"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"go/ast"
"go/parser"
@@ -35,7 +34,6 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
@@ -229,11 +227,8 @@ func setupGo(appDir string) error {
// SetupGoPath - Extracts dependencies to goPathSrc
func SetupGoPath(goPathSrc string, includeDNS bool) error {
// GOPATH setup
if _, err := os.Stat(goPathSrc); os.IsNotExist(err) {
setupLog.Infof("Creating GOPATH directory: %s", goPathSrc)
os.MkdirAll(goPathSrc, 0700)
if err := os.MkdirAll(goPathSrc, 0700); err != nil {
return fmt.Errorf("create implant source directory %s: %w", goPathSrc, err)
}
// Sliver PB
@@ -252,17 +247,27 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
setupLog.Info("Static asset not found: capabilities.go")
return err
}
sliverpbGoSrc = xorPBRawBytes(sliverpbGoSrc)
sliverpbGoSrc = stripSliverpb(sliverpbGoSrc)
sliverpbGoSrc, err = xorPBRawBytes(sliverpbGoSrc)
if err != nil {
return fmt.Errorf("obfuscate sliverpb descriptor: %w", err)
}
sliverpbGoSrc, err = rewriteProtobufStructTags(sliverpbGoSrc)
if err != nil {
return fmt.Errorf("obfuscate sliverpb tags: %w", err)
}
sliverpbDir := filepath.Join(goPathSrc, "protobuf", "sliverpb")
setupLog.Infof("Creating sliverpb directory: %s", sliverpbDir)
os.MkdirAll(sliverpbDir, 0700)
os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600)
os.WriteFile(filepath.Join(sliverpbDir, "constants.go"), sliverpbConstSrc, 0600)
err = os.WriteFile(filepath.Join(sliverpbDir, "capabilities.go"), sliverpbCapabilitiesSrc, 0600)
if err != nil {
setupLog.Errorf("Failed to write capabilities.go: %s", err)
return err
if err := os.MkdirAll(sliverpbDir, 0700); err != nil {
return fmt.Errorf("create sliverpb directory: %w", err)
}
if err := os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600); err != nil {
return fmt.Errorf("write sliver.pb.go: %w", err)
}
if err := os.WriteFile(filepath.Join(sliverpbDir, "constants.go"), sliverpbConstSrc, 0600); err != nil {
return fmt.Errorf("write sliverpb constants.go: %w", err)
}
if err := os.WriteFile(filepath.Join(sliverpbDir, "capabilities.go"), sliverpbCapabilitiesSrc, 0600); err != nil {
return fmt.Errorf("write sliverpb capabilities.go: %w", err)
}
// Common PB
@@ -271,10 +276,21 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
setupLog.Info("Static asset not found: common.pb.go")
return err
}
commonpbSrc = xorPBRawBytes(commonpbSrc)
commonpbSrc, err = xorPBRawBytes(commonpbSrc)
if err != nil {
return fmt.Errorf("obfuscate commonpb descriptor: %w", err)
}
commonpbSrc, err = rewriteProtobufStructTags(commonpbSrc)
if err != nil {
return fmt.Errorf("obfuscate commonpb tags: %w", err)
}
commonpbDir := filepath.Join(goPathSrc, "protobuf", "commonpb")
os.MkdirAll(commonpbDir, 0700)
os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600)
if err := os.MkdirAll(commonpbDir, 0700); err != nil {
return fmt.Errorf("create commonpb directory: %w", err)
}
if err := os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600); err != nil {
return fmt.Errorf("write common.pb.go: %w", err)
}
// DNS PB
if includeDNS {
@@ -283,34 +299,242 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
setupLog.Info("Static asset not found: dns.pb.go")
return err
}
dnspbSrc = xorPBRawBytes(dnspbSrc)
dnspbSrc, err = xorPBRawBytes(dnspbSrc)
if err != nil {
return fmt.Errorf("obfuscate dnspb descriptor: %w", err)
}
dnspbSrc, err = rewriteProtobufStructTags(dnspbSrc)
if err != nil {
return fmt.Errorf("obfuscate dnspb tags: %w", err)
}
dnspbDir := filepath.Join(goPathSrc, "protobuf", "dnspb")
os.MkdirAll(dnspbDir, 0700)
os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600)
if err := os.MkdirAll(dnspbDir, 0700); err != nil {
return fmt.Errorf("create dnspb directory: %w", err)
}
if err := os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600); err != nil {
return fmt.Errorf("write dns.pb.go: %w", err)
}
}
return nil
}
func stripSliverpb(src []byte) []byte {
out := src
re := regexp.MustCompile(`protobuf:"[a-z]+,\d+,[a-z]+,name=(?P<FieldName1>[a-zA-Z0-9]+),proto3(,enum=(?P<EnumName>[a-zA-Z\.]+))?" json:"(?P<FiledName2>[a-zA-Z0-9]+),[a-z]+"`)
found := re.FindAllSubmatch(src, -1)
for _, x := range found {
line := x[0] // line that matched
typeName := x[1] // first named capturing group (FieldName1)
enumName := x[3]
if string(enumName) != "" {
newEnumName := pseudoRandStringRunes(len(enumName))
newEnumLine := bytes.ReplaceAll(line, enumName, []byte(newEnumName))
out = bytes.ReplaceAll(out, line, []byte(newEnumLine))
line = newEnumLine
type structTagPart struct {
key string
value string
}
// parseStructTagParts parses the key/value pairs in a Go struct tag without
// assuming an order or a particular set of protobuf options.
func parseStructTagParts(tag string) ([]structTagPart, error) {
var parts []structTagPart
for tag != "" {
tag = strings.TrimLeft(tag, " ")
if tag == "" {
break
}
colon := strings.IndexByte(tag, ':')
if colon <= 0 || strings.ContainsAny(tag[:colon], " \t\n\r\"`") {
return nil, fmt.Errorf("invalid struct tag key near %q", tag)
}
key := tag[:colon]
tag = tag[colon+1:]
if !strings.HasPrefix(tag, `"`) {
return nil, fmt.Errorf("missing quoted value for struct tag %q", key)
}
end := 1
for end < len(tag) {
if tag[end] == '\\' {
end += 2
continue
}
if tag[end] == '"' {
break
}
end++
}
if end >= len(tag) {
return nil, fmt.Errorf("unterminated struct tag value for %q", key)
}
value, err := strconv.Unquote(tag[:end+1])
if err != nil {
return nil, fmt.Errorf("unquote struct tag %q: %w", key, err)
}
parts = append(parts, structTagPart{key: key, value: value})
tag = tag[end+1:]
if tag != "" && tag[0] != ' ' {
return nil, fmt.Errorf("missing separator after struct tag %q", key)
}
// we don't care about FieldName2 because its value is the same as FieldName1
newItem := pseudoRandStringRunes(len(typeName))
newLine := bytes.ReplaceAll(line, typeName, []byte(newItem))
out = bytes.ReplaceAll(out, line, []byte(newLine))
}
return out
return parts, nil
}
func renderStructTagParts(parts []structTagPart) string {
var out strings.Builder
for i, part := range parts {
if i != 0 {
out.WriteByte(' ')
}
out.WriteString(part.key)
out.WriteByte(':')
out.WriteString(strconv.Quote(part.value))
}
return out.String()
}
func randomDifferentName(original string) string {
for {
candidate := pseudoRandStringRunes(len(original))
if candidate != original {
return candidate
}
}
}
func rewriteProtobufTagValue(value string) (string, string, string, error) {
options := strings.Split(value, ",")
nameIndex, enumIndex := -1, -1
fieldNumberFound := false
for i, option := range options {
if _, err := strconv.ParseUint(option, 10, 32); err == nil {
fieldNumberFound = true
}
if strings.HasPrefix(option, "name=") {
if nameIndex >= 0 {
return "", "", "", fmt.Errorf("duplicate protobuf field name")
}
nameIndex = i
}
if strings.HasPrefix(option, "enum=") {
enumIndex = i
}
}
if !fieldNumberFound || nameIndex < 0 || len(options[nameIndex]) == len("name=") {
return "", "", "", fmt.Errorf("protobuf struct tag lacks a field number or name: %q", value)
}
originalName := strings.TrimPrefix(options[nameIndex], "name=")
newName := randomDifferentName(originalName)
options[nameIndex] = "name=" + newName
if enumIndex >= 0 {
enumName := strings.TrimPrefix(options[enumIndex], "enum=")
if enumName == "" {
return "", "", "", fmt.Errorf("empty protobuf enum name")
}
options[enumIndex] = "enum=" + randomDifferentName(enumName)
}
return strings.Join(options, ","), originalName, newName, nil
}
func rewriteJSONTagValue(value, originalName, newName string) (string, error) {
options := strings.SplitN(value, ",", 2)
if options[0] == "" || options[0] == "-" {
return "", fmt.Errorf("unsupported JSON field name %q", options[0])
}
if options[0] == originalName {
options[0] = newName
} else {
options[0] = randomDifferentName(options[0])
}
return strings.Join(options, ","), nil
}
func rewritePBStructTag(raw string, cache map[string]string) (string, bool, error) {
if rewritten, ok := cache[raw]; ok {
return rewritten, true, nil
}
parts, err := parseStructTagParts(raw)
if err != nil {
return "", false, err
}
protobufIndex, jsonIndex := -1, -1
for i, part := range parts {
switch part.key {
case "protobuf":
if protobufIndex >= 0 {
return "", false, fmt.Errorf("duplicate protobuf struct tag")
}
protobufIndex = i
case "json":
if jsonIndex >= 0 {
return "", false, fmt.Errorf("duplicate JSON struct tag")
}
jsonIndex = i
}
}
if protobufIndex < 0 {
return raw, false, nil
}
if jsonIndex < 0 {
return "", false, fmt.Errorf("protobuf struct tag is missing a JSON tag")
}
protobufValue, originalName, newName, err := rewriteProtobufTagValue(parts[protobufIndex].value)
if err != nil {
return "", false, err
}
parts[protobufIndex].value = protobufValue
parts[jsonIndex].value, err = rewriteJSONTagValue(parts[jsonIndex].value, originalName, newName)
if err != nil {
return "", false, err
}
rewritten := renderStructTagParts(parts)
cache[raw] = rewritten
return rewritten, true, nil
}
// rewriteProtobufStructTags rewrites generated protobuf struct tags after parsing their
// syntax, including packed fields and future option ordering changes.
func rewriteProtobufStructTags(src []byte) ([]byte, error) {
fset := token.NewFileSet()
fileAst, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("parse protobuf source: %w", err)
}
cache := make(map[string]string)
rewritten := 0
var rewriteErr error
ast.Inspect(fileAst, func(node ast.Node) bool {
if rewriteErr != nil {
return false
}
structType, ok := node.(*ast.StructType)
if !ok || structType.Fields == nil {
return true
}
for _, field := range structType.Fields.List {
if field.Tag == nil {
continue
}
raw, err := strconv.Unquote(field.Tag.Value)
if err != nil {
rewriteErr = fmt.Errorf("parse struct tag at %s: %w", fset.Position(field.Pos()), err)
return false
}
newTag, changed, err := rewritePBStructTag(raw, cache)
if err != nil {
rewriteErr = fmt.Errorf("rewrite struct tag at %s: %w", fset.Position(field.Pos()), err)
return false
}
if changed {
rewritten++
if strings.ContainsRune(newTag, '`') {
field.Tag.Value = strconv.Quote(newTag)
} else {
field.Tag.Value = "`" + newTag + "`"
}
}
}
return true
})
if rewriteErr != nil {
return nil, rewriteErr
}
if rewritten == 0 {
return nil, fmt.Errorf("no protobuf struct tags found in source")
}
var out bytes.Buffer
if err := printer.Fprint(&out, fset, fileAst); err != nil {
return nil, fmt.Errorf("print rewritten protobuf source: %w", err)
}
return out.Bytes(), nil
}
// UntarSkipTopLevel - Untar a tar file, skipping the top level directory
@@ -390,174 +614,225 @@ func unzipSkipTopLevel(dst string, z *zip.Reader) error {
return err
}
func xorPBRawBytes(src []byte) []byte {
var (
fileAst *ast.File
err error
fset = token.NewFileSet()
parserMode = parser.ParseComments
)
fileAst, err = parser.ParseFile(fset, "", src, parserMode)
if err != nil {
// Panic because this is mandatory for the agent to work
panic(err)
func descriptorStringLiterals(expr ast.Expr) ([]*ast.BasicLit, error) {
var literals []*ast.BasicLit
var walk func(ast.Expr) error
walk = func(expr ast.Expr) error {
switch node := expr.(type) {
case *ast.BasicLit:
if node.Kind != token.STRING {
return fmt.Errorf("descriptor expression contains a non-string literal")
}
if _, err := strconv.Unquote(node.Value); err != nil {
return fmt.Errorf("decode descriptor string literal: %w", err)
}
literals = append(literals, node)
return nil
case *ast.BinaryExpr:
if node.Op != token.ADD {
return fmt.Errorf("descriptor expression uses operator %s", node.Op)
}
if err := walk(node.X); err != nil {
return err
}
return walk(node.Y)
case *ast.ParenExpr:
return walk(node.X)
default:
return fmt.Errorf("unsupported descriptor expression %T", expr)
}
}
var xorKey [8]byte
// generate random xor key
if _, err := rand.Read(xorKey[:]); err != nil {
// Panic because this is mandatory for the agent to work
panic(err)
if err := walk(expr); err != nil {
return nil, err
}
return literals, nil
}
func descriptorByteLiterals(expr *ast.CompositeLit) ([]*ast.BasicLit, []byte, error) {
arrayType, ok := expr.Type.(*ast.ArrayType)
if !ok || arrayType.Len != nil {
return nil, nil, fmt.Errorf("descriptor must be a byte slice literal")
}
elementType, ok := arrayType.Elt.(*ast.Ident)
if !ok || (elementType.Name != "byte" && elementType.Name != "uint8") {
return nil, nil, fmt.Errorf("descriptor must be a byte slice literal")
}
literals := make([]*ast.BasicLit, 0, len(expr.Elts))
values := make([]byte, 0, len(expr.Elts))
for _, element := range expr.Elts {
literal, ok := element.(*ast.BasicLit)
if !ok || literal.Kind != token.INT {
return nil, nil, fmt.Errorf("descriptor byte slice has unsupported element %T", element)
}
value, err := strconv.ParseUint(literal.Value, 0, 8)
if err != nil {
return nil, nil, fmt.Errorf("decode descriptor byte %q: %w", literal.Value, err)
}
literals = append(literals, literal)
values = append(values, byte(value))
}
return literals, values, nil
}
// xorPBRawBytes hides the one generated raw descriptor in a protobuf source.
// The complete expression is validated before changing the AST so a new protoc
// output shape cannot result in a partially encoded descriptor.
func xorPBRawBytes(src []byte) ([]byte, error) {
fset := token.NewFileSet()
fileAst, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("parse protobuf source: %w", err)
}
declaration, valueSpec, descriptorName, err := findRawDescriptor(fileAst)
if err != nil {
return nil, err
}
if (declaration.Tok != token.CONST && declaration.Tok != token.VAR) ||
len(declaration.Specs) != 1 || len(valueSpec.Names) != 1 || len(valueSpec.Values) != 1 {
return nil, fmt.Errorf("%s has unsupported declaration shape", descriptorName)
}
ast.Inspect(fileAst, func(n ast.Node) bool {
node, ok := n.(*ast.GenDecl)
functionName, stringLiterals, byteLiterals, byteValues, err := rawDescriptorLiterals(declaration, valueSpec, descriptorName)
if err != nil {
return nil, err
}
var xorKey [8]byte
if _, err := rand.Read(xorKey[:]); err != nil {
return nil, fmt.Errorf("generate protobuf descriptor key: %w", err)
}
encodeRawDescriptorLiterals(declaration, functionName, stringLiterals, byteLiterals, byteValues, xorKey[:])
valueSpec.Values[0] = &ast.CallExpr{
Fun: ast.NewIdent(functionName),
Args: []ast.Expr{
valueSpec.Values[0],
ast.NewIdent("xorKey"),
},
}
if err := appendRawDescriptorHelpers(fileAst, functionName, xorKey[:]); err != nil {
return nil, err
}
var out bytes.Buffer
if err := printer.Fprint(&out, fset, fileAst); err != nil {
return nil, fmt.Errorf("print rewritten protobuf source: %w", err)
}
return out.Bytes(), nil
}
func findRawDescriptor(fileAst *ast.File) (*ast.GenDecl, *ast.ValueSpec, string, error) {
var declaration *ast.GenDecl
var valueSpec *ast.ValueSpec
var descriptorName string
for _, decl := range fileAst.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
return true
continue
}
for _, spec := range node.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
for _, spec := range genDecl.Specs {
candidate, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for valueIndex, id := range valueSpec.Names {
if !strings.HasSuffix(id.Name, "_rawDesc") || strings.HasSuffix(id.Name, "_rawDescData") {
for _, name := range candidate.Names {
if !strings.HasSuffix(name.Name, "_rawDesc") {
continue
}
if valueIndex >= len(valueSpec.Values) {
continue
}
switch rawDesc := valueSpec.Values[valueIndex].(type) {
case *ast.CompositeLit:
for i, v := range rawDesc.Elts {
elt := v.(*ast.BasicLit)
elt.Value = xorByte(elt.Value, xorKey[i%len(xorKey)])
}
valueSpec.Values[valueIndex] = &ast.CallExpr{
Fun: ast.NewIdent("xorBytes"),
Args: []ast.Expr{
rawDesc,
ast.NewIdent("xorKey"),
},
}
default:
offset := 0
if xorStringExpr(rawDesc, xorKey, &offset) {
node.Tok = token.VAR
valueSpec.Values[valueIndex] = &ast.CallExpr{
Fun: ast.NewIdent("xorString"),
Args: []ast.Expr{
rawDesc,
ast.NewIdent("xorKey"),
},
}
}
if declaration != nil {
return nil, nil, "", fmt.Errorf("multiple protobuf raw descriptors found")
}
declaration, valueSpec, descriptorName = genDecl, candidate, name.Name
}
}
return true
})
fileAst.Decls = append(fileAst.Decls,
parseHelperDecl(`func xorBytes(input []byte, key []byte) []byte {
out := make([]byte, len(input))
for i := range input {
out[i] = input[i] ^ key[i%len(key)]
}
return out
}`),
parseHelperDecl(`func xorString(input string, key []byte) string {
if declaration == nil {
return nil, nil, "", fmt.Errorf("no protobuf raw descriptor found")
}
return declaration, valueSpec, descriptorName, nil
}
func rawDescriptorLiterals(declaration *ast.GenDecl, valueSpec *ast.ValueSpec, descriptorName string) (string, []*ast.BasicLit, []*ast.BasicLit, []byte, error) {
var stringLiterals []*ast.BasicLit
var byteLiterals []*ast.BasicLit
var byteValues []byte
var err error
functionName := "xorString"
if byteExpr, ok := valueSpec.Values[0].(*ast.CompositeLit); ok {
if declaration.Tok != token.VAR {
return "", nil, nil, nil, fmt.Errorf("%s byte descriptor is not a variable", descriptorName)
}
byteLiterals, byteValues, err = descriptorByteLiterals(byteExpr)
functionName = "xorBytes"
} else {
stringLiterals, err = descriptorStringLiterals(valueSpec.Values[0])
}
if err != nil {
return "", nil, nil, nil, fmt.Errorf("%s: %w", descriptorName, err)
}
return functionName, stringLiterals, byteLiterals, byteValues, nil
}
func encodeRawDescriptorLiterals(declaration *ast.GenDecl, functionName string, stringLiterals, byteLiterals []*ast.BasicLit, byteValues, xorKey []byte) {
if functionName == "xorString" {
offset := 0
for _, literal := range stringLiterals {
plain, _ := strconv.Unquote(literal.Value) // Validated above.
encoded := []byte(plain)
for i := range encoded {
encoded[i] ^= xorKey[(offset+i)%len(xorKey)]
}
offset += len(encoded)
literal.Value = strconv.Quote(string(encoded))
}
declaration.Tok = token.VAR
} else {
for i, literal := range byteLiterals {
literal.Value = fmt.Sprintf("0x%02x", byteValues[i]^xorKey[i%len(xorKey)])
}
}
}
func appendRawDescriptorHelpers(fileAst *ast.File, functionName string, xorKey []byte) error {
helper := `func xorString(input string, key []byte) string {
out := make([]byte, len(input))
for i := 0; i < len(input); i++ {
out[i] = input[i] ^ key[i%len(key)]
}
return string(out)
}`),
)
}`
if functionName == "xorBytes" {
helper = `func xorBytes(input []byte, key []byte) []byte {
out := make([]byte, len(input))
for i := range input {
out[i] = input[i] ^ key[i%len(key)]
}
return out
}`
}
helperDecl, err := parseHelperDecl(helper)
if err != nil {
return err
}
fileAst.Decls = append(fileAst.Decls, helperDecl)
xorTokens := make([]ast.Expr, len(xorKey))
// map xorKey to a slice of ast.BasicLit
for i, b := range xorKey {
xorTokens[i] = &ast.BasicLit{
Kind: token.INT,
Value: fmt.Sprintf("0x%x", b),
}
xorTokens[i] = &ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("0x%x", b)}
}
// add the global xorKey variable to the AST
fileAst.Decls = append(fileAst.Decls, ast.Decl(&ast.GenDecl{
fileAst.Decls = append(fileAst.Decls, &ast.GenDecl{
Tok: token.VAR,
Specs: []ast.Spec{
&ast.ValueSpec{
Names: []*ast.Ident{ast.NewIdent("xorKey")},
Values: []ast.Expr{
&ast.CompositeLit{
Type: ast.NewIdent("[]byte"),
Elts: xorTokens,
},
},
},
},
}))
outBuff := bytes.Buffer{}
// Render the AST as Go code
printer.Fprint(&outBuff, fset, fileAst)
return outBuff.Bytes()
Specs: []ast.Spec{&ast.ValueSpec{
Names: []*ast.Ident{ast.NewIdent("xorKey")},
Values: []ast.Expr{&ast.CompositeLit{Type: ast.NewIdent("[]byte"), Elts: xorTokens}},
}},
})
return nil
}
func parseHelperDecl(src string) ast.Decl {
func parseHelperDecl(src string) (ast.Decl, error) {
fileAst, err := parser.ParseFile(token.NewFileSet(), "", "package assets\n"+src, 0)
if err != nil {
panic(err)
}
return fileAst.Decls[0]
}
func xorByte(raw string, key byte) string {
// strip 0x
raw = raw[2:]
if len(raw) == 1 {
// Because we got `0x8` at some point
raw = fmt.Sprintf("0%s", raw)
}
hexByte, err := hex.DecodeString(raw)
if err != nil {
panic(err)
}
newByte := hex.EncodeToString([]byte{hexByte[0] ^ key})
return fmt.Sprintf("0x%s", newByte)
}
func xorStringExpr(expr ast.Expr, key [8]byte, offset *int) bool {
switch node := expr.(type) {
case *ast.BasicLit:
if node.Kind != token.STRING {
return false
}
raw, err := strconv.Unquote(node.Value)
if err != nil {
panic(err)
}
rawBytes := []byte(raw)
for i := range rawBytes {
rawBytes[i] ^= key[(*offset+i)%len(key)]
}
*offset += len(rawBytes)
node.Value = strconv.Quote(string(rawBytes))
return true
case *ast.BinaryExpr:
if node.Op != token.ADD {
return false
}
leftOkay := xorStringExpr(node.X, key, offset)
rightOkay := xorStringExpr(node.Y, key, offset)
return leftOkay && rightOkay
case *ast.ParenExpr:
return xorStringExpr(node.X, key, offset)
default:
return false
return nil, fmt.Errorf("parse protobuf descriptor helper: %w", err)
}
return fileAst.Decls[0], nil
}
func unzip(src string, dest string) ([]string, error) {
+407
View File
@@ -0,0 +1,407 @@
package assets
import (
"bytes"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
protobufs "github.com/bishopfox/sliver/protobuf"
)
func parseProtobufSource(t *testing.T, src []byte) *ast.File {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), "protobuf.pb.go", src, 0)
if err != nil {
t.Fatalf("parse protobuf source: %v", err)
}
return file
}
func namedValue(t *testing.T, file *ast.File, name string) (token.Token, ast.Expr) {
t.Helper()
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range gen.Specs {
value, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, ident := range value.Names {
if ident.Name == name && i < len(value.Values) {
return gen.Tok, value.Values[i]
}
}
}
}
t.Fatalf("declaration %s not found", name)
return token.ILLEGAL, nil
}
func descriptorLiteralBytes(t *testing.T, expr ast.Expr) []byte {
t.Helper()
switch value := expr.(type) {
case *ast.BasicLit:
if value.Kind != token.STRING {
t.Fatalf("descriptor literal has token %s, want STRING", value.Kind)
}
unquoted, err := strconv.Unquote(value.Value)
if err != nil {
t.Fatalf("unquote descriptor literal: %v", err)
}
return []byte(unquoted)
case *ast.BinaryExpr:
if value.Op != token.ADD {
t.Fatalf("descriptor expression uses %s, want +", value.Op)
}
return append(descriptorLiteralBytes(t, value.X), descriptorLiteralBytes(t, value.Y)...)
case *ast.ParenExpr:
return descriptorLiteralBytes(t, value.X)
case *ast.CompositeLit:
decoded := make([]byte, len(value.Elts))
for i, element := range value.Elts {
literal, ok := element.(*ast.BasicLit)
if !ok || literal.Kind != token.INT {
t.Fatalf("descriptor byte %d has type %T, want integer literal", i, element)
}
n, err := strconv.ParseUint(literal.Value, 0, 8)
if err != nil {
t.Fatalf("parse descriptor byte %d: %v", i, err)
}
decoded[i] = byte(n)
}
return decoded
default:
t.Fatalf("unsupported descriptor expression %T", expr)
return nil
}
}
func decodedTransformedDescriptor(t *testing.T, file *ast.File, name string) []byte {
t.Helper()
_, expr := namedValue(t, file, name)
call, ok := expr.(*ast.CallExpr)
if !ok || len(call.Args) != 2 {
t.Fatalf("%s initializer = %T, want decoder call with two arguments", name, expr)
}
decoder, ok := call.Fun.(*ast.Ident)
if !ok || (decoder.Name != "xorString" && decoder.Name != "xorBytes") {
t.Fatalf("%s uses decoder %T, want xorString or xorBytes", name, call.Fun)
}
keyRef, ok := call.Args[1].(*ast.Ident)
if !ok || keyRef.Name != "xorKey" {
t.Fatalf("%s decoder does not use xorKey", name)
}
_, keyExpr := namedValue(t, file, "xorKey")
key := descriptorLiteralBytes(t, keyExpr)
if len(key) == 0 {
t.Fatal("xorKey is empty")
}
encoded := descriptorLiteralBytes(t, call.Args[0])
decoded := make([]byte, len(encoded))
for i := range encoded {
decoded[i] = encoded[i] ^ key[i%len(key)]
}
return decoded
}
func TestXorPBRawBytesPreservesEmbeddedDescriptors(t *testing.T) {
for _, tc := range []struct {
path string
name string
}{
{"sliverpb/sliver.pb.go", "file_sliverpb_sliver_proto_rawDesc"},
{"commonpb/common.pb.go", "file_commonpb_common_proto_rawDesc"},
{"dnspb/dns.pb.go", "file_dnspb_dns_proto_rawDesc"},
} {
t.Run(tc.path, func(t *testing.T) {
source, err := protobufs.FS.ReadFile(tc.path)
if err != nil {
t.Fatal(err)
}
_, originalExpr := namedValue(t, parseProtobufSource(t, source), tc.name)
want := descriptorLiteralBytes(t, originalExpr)
transformed, err := xorPBRawBytes(source)
if err != nil {
t.Fatalf("transform descriptor: %v", err)
}
got := decodedTransformedDescriptor(t, parseProtobufSource(t, transformed), tc.name)
if !bytes.Equal(got, want) {
t.Fatalf("decoded descriptor differs from embedded %s (%d versus %d bytes)", tc.path, len(got), len(want))
}
})
}
}
func TestXorPBRawBytesRejectsUnsupportedDescriptorShapes(t *testing.T) {
for _, tc := range []struct {
name string
src string
}{
{"missing descriptor", "package p\nconst other = \"abc\"\n"},
{"unsupported suffix", "package p\nconst suffix = \"def\"\nconst file_x_rawDesc = \"abc\" + suffix\n"},
{"grouped declaration", "package p\nconst (\nfile_x_rawDesc = \"abc\"\nother = \"def\"\n)\n"},
{"unsupported byte element", "package p\nvar value = byte(1)\nvar file_x_rawDesc = []byte{0x00, value}\n"},
} {
t.Run(tc.name, func(t *testing.T) {
source := []byte(tc.src)
original := bytes.Clone(source)
if _, err := xorPBRawBytes(source); err == nil {
t.Fatal("expected an unsupported descriptor shape to fail")
}
if !bytes.Equal(source, original) {
t.Fatal("failed transform modified its input")
}
})
}
}
func TestXorPBRawBytesSupportsByteLiterals(t *testing.T) {
const source = "package p\nvar file_x_rawDesc = []byte{0x00, 1, 0xff}\n"
transformed, err := xorPBRawBytes([]byte(source))
if err != nil {
t.Fatalf("transform byte descriptor: %v", err)
}
got := decodedTransformedDescriptor(t, parseProtobufSource(t, transformed), "file_x_rawDesc")
if want := []byte{0, 1, 255}; !bytes.Equal(got, want) {
t.Fatalf("decoded byte descriptor = %v, want %v", got, want)
}
}
type protobufFieldTag struct {
structName string
fieldName string
protobuf string
json string
raw string
}
func protobufFieldTags(t *testing.T, src []byte) []protobufFieldTag {
t.Helper()
file := parseProtobufSource(t, src)
tags := []protobufFieldTag{}
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structure, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
for _, field := range structure.Fields.List {
if field.Tag == nil {
continue
}
raw, err := strconv.Unquote(field.Tag.Value)
if err != nil {
t.Fatalf("unquote %s struct tag: %v", typeSpec.Name.Name, err)
}
protoTag, ok := reflect.StructTag(raw).Lookup("protobuf")
if !ok {
continue
}
jsonTag := reflect.StructTag(raw).Get("json")
for _, name := range field.Names {
tags = append(tags, protobufFieldTag{typeSpec.Name.Name, name.Name, protoTag, jsonTag, raw})
}
}
}
}
return tags
}
func nameFromTag(t *testing.T, value string) string {
t.Helper()
for _, part := range strings.Split(value, ",") {
if strings.HasPrefix(part, "name=") {
return strings.TrimPrefix(part, "name=")
}
}
t.Fatalf("protobuf tag %q has no name", value)
return ""
}
func enumFromTag(value string) string {
for _, part := range strings.Split(value, ",") {
if strings.HasPrefix(part, "enum=") {
return strings.TrimPrefix(part, "enum=")
}
}
return ""
}
func tagWithoutNames(value string) string {
parts := strings.Split(value, ",")
for i, part := range parts {
if strings.HasPrefix(part, "name=") {
parts[i] = "name=*"
} else if strings.HasPrefix(part, "enum=") {
parts[i] = "enum=*"
}
}
return strings.Join(parts, ",")
}
func assertRewrittenProtobufFieldTag(t *testing.T, index int, old, newTag protobufFieldTag) {
t.Helper()
if old.structName != newTag.structName || old.fieldName != newTag.fieldName {
t.Fatalf("field %d changed from %s.%s to %s.%s", index, old.structName, old.fieldName, newTag.structName, newTag.fieldName)
}
if tagWithoutNames(old.protobuf) != tagWithoutNames(newTag.protobuf) {
t.Fatalf("wire tag changed for %s.%s: %q -> %q", old.structName, old.fieldName, old.protobuf, newTag.protobuf)
}
oldName := nameFromTag(t, old.protobuf)
newName := nameFromTag(t, newTag.protobuf)
if oldName == newName || len(oldName) != len(newName) {
t.Fatalf("field name was not rewritten with the same length for %s.%s: %q -> %q", old.structName, old.fieldName, oldName, newName)
}
oldJSON := strings.SplitN(old.json, ",", 2)
newJSON := strings.SplitN(newTag.json, ",", 2)
if len(oldJSON) != len(newJSON) || oldJSON[0] == newJSON[0] || len(oldJSON[0]) != len(newJSON[0]) || strings.Join(oldJSON[1:], ",") != strings.Join(newJSON[1:], ",") {
t.Fatalf("JSON tag options changed for %s.%s: %q -> %q", old.structName, old.fieldName, old.json, newTag.json)
}
oldEnum, newEnum := enumFromTag(old.protobuf), enumFromTag(newTag.protobuf)
if oldEnum != "" && (newEnum == oldEnum || len(newEnum) != len(oldEnum)) {
t.Fatalf("enum tag was not rewritten with the same length for %s.%s: %q -> %q", old.structName, old.fieldName, oldEnum, newEnum)
}
}
func assertUnchangedMapEntryTags(t *testing.T, before, after []protobufFieldTag) int {
t.Helper()
count := 0
for i, old := range before {
original := reflect.StructTag(old.raw)
key, hasKey := original.Lookup("protobuf_key")
value, hasValue := original.Lookup("protobuf_val")
if !hasKey && !hasValue {
continue
}
if !hasKey || !hasValue {
t.Fatalf("incomplete map entry tags for %s.%s", old.structName, old.fieldName)
}
rewritten := reflect.StructTag(after[i].raw)
if rewritten.Get("protobuf_key") != key || rewritten.Get("protobuf_val") != value {
t.Fatalf("map entry tags changed for %s.%s", old.structName, old.fieldName)
}
count++
}
return count
}
func TestRewriteProtobufStructTagsPreservesSliverpbWireNumbers(t *testing.T) {
source, err := protobufs.FS.ReadFile("sliverpb/sliver.pb.go")
if err != nil {
t.Fatal(err)
}
transformed, err := rewriteProtobufStructTags(source)
if err != nil {
t.Fatalf("rewrite protobuf tags: %v", err)
}
before := protobufFieldTags(t, source)
after := protobufFieldTags(t, transformed)
if len(before) != 656 || len(after) != len(before) {
t.Fatalf("protobuf tag coverage = %d original, %d rewritten; want 656 each", len(before), len(after))
}
rewrittenByOriginal := make(map[string]string)
packedFound := false
for i, old := range before {
newTag := after[i]
assertRewrittenProtobufFieldTag(t, i, old, newTag)
if old.structName == "PivotStartListenerReq" && old.fieldName == "Options" {
packedFound = true
if !strings.Contains(newTag.protobuf, ",packed,") {
t.Fatalf("packed Options tag lost packed option: %q", newTag.protobuf)
}
}
if earlier, exists := rewrittenByOriginal[old.raw]; exists && earlier != newTag.raw {
t.Fatalf("identical original tag %q rewrote inconsistently", old.raw)
}
rewrittenByOriginal[old.raw] = newTag.raw
}
if !packedFound {
t.Fatal("packed PivotStartListenerReq.Options tag not found")
}
if count := assertUnchangedMapEntryTags(t, before, after); count != 3 {
t.Fatalf("map entry tag coverage = %d, want 3", count)
}
}
func TestRewriteProtobufStructTagsRejectsMissingTags(t *testing.T) {
if _, err := rewriteProtobufStructTags([]byte("package p\ntype Empty struct{}\n")); err == nil {
t.Fatal("expected a source without protobuf field tags to fail")
}
}
func TestSetupGoPathRewritesAllImplantProtobufFieldTags(t *testing.T) {
destination := t.TempDir()
if err := SetupGoPath(destination, true); err != nil {
t.Fatalf("prepare implant protobuf source: %v", err)
}
for _, tc := range []struct {
path string
fieldCount int
}{
{"sliverpb/sliver.pb.go", 656},
{"commonpb/common.pb.go", 19},
{"dnspb/dns.pb.go", 6},
} {
t.Run(tc.path, func(t *testing.T) {
original, err := protobufs.FS.ReadFile(tc.path)
if err != nil {
t.Fatal(err)
}
generated, err := os.ReadFile(filepath.Join(destination, "protobuf", filepath.FromSlash(tc.path)))
if err != nil {
t.Fatal(err)
}
before := protobufFieldTags(t, original)
after := protobufFieldTags(t, generated)
if len(before) != tc.fieldCount || len(after) != len(before) {
t.Fatalf("protobuf field tags = %d original, %d generated; want %d each", len(before), len(after), tc.fieldCount)
}
for i := range before {
assertRewrittenProtobufFieldTag(t, i, before[i], after[i])
}
})
}
}
func TestSetupGoPathReportsTransformedFileWriteErrors(t *testing.T) {
for _, tc := range []struct {
name string
path string
includeDNS bool
}{
{"sliver", "protobuf/sliverpb/sliver.pb.go", false},
{"common", "protobuf/commonpb/common.pb.go", false},
{"dns", "protobuf/dnspb/dns.pb.go", true},
} {
t.Run(tc.name, func(t *testing.T) {
destination := t.TempDir()
blocker := filepath.Join(destination, filepath.FromSlash(tc.path))
if err := os.MkdirAll(blocker, 0o700); err != nil {
t.Fatal(err)
}
err := SetupGoPath(destination, tc.includeDNS)
if err == nil || !strings.Contains(err.Error(), filepath.Base(blocker)) {
t.Fatalf("SetupGoPath() error = %v, want path %s", err, blocker)
}
})
}
}
@@ -20,11 +20,10 @@ package traffic_encoders
import (
"bytes"
"crypto/rand"
_ "embed"
"encoding/hex"
"fmt"
"testing"
"time"
implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders/traffic"
serverEncoders "github.com/bishopfox/sliver/util/encoders/traffic"
@@ -34,102 +33,124 @@ import (
var hexWASM []byte
func TestTrafficEncoderCompatibility_hex(t *testing.T) {
// Hex
implantSideHex, err := implantEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
t.Log(msg)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := implantSideHex.Close(); err != nil {
t.Errorf("close implant encoder: %v", err)
}
})
serverSideHex, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
t.Log(msg)
})
if err != nil {
t.Fatal(err)
}
data := make([]byte, 1024)
_, err = rand.Read(data)
if err != nil {
t.Fatal(err)
}
encodedData, err := implantSideHex.Encode(data)
if err != nil {
t.Fatal(err)
}
decodedData, err := serverSideHex.Decode(encodedData)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, decodedData) {
t.Fatal("Decoded data does not match original")
}
data = make([]byte, 1024)
_, err = rand.Read(data)
if err != nil {
t.Fatal(err)
}
encodedData, err = serverSideHex.Encode(data)
if err != nil {
t.Fatal(err)
}
decodedData, err = implantSideHex.Decode(encodedData)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, decodedData) {
t.Fatal("Decoded data does not match original")
}
}
// Encoder specific tests
func TestHexPerformance(t *testing.T) {
sizes := []int{1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024}
// Stock encoder
for i := 0; i < len(sizes); i++ {
originalValue := make([]byte, sizes[i])
rand.Read(originalValue)
stock := time.Now()
encodedValue := hex.EncodeToString(originalValue)
decodedValue, err := hex.DecodeString(encodedValue)
if err != nil {
t.Fatal(err)
t.Cleanup(func() {
if err := serverSideHex.Close(); err != nil {
t.Errorf("close server encoder: %v", err)
}
t.Logf("Stock encoder took %v (%d bytes)", time.Since(stock), sizes[i])
if !bytes.Equal(originalValue, decodedValue) {
t.Fatalf("Expected %v but got %v", originalValue, decodedValue)
}
}
})
// Traffic encoder
for i := 0; i < len(sizes); i++ {
encoder, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
t.Log(msg)
directions := []struct {
name string
encode func([]byte) ([]byte, error)
decode func([]byte) ([]byte, error)
}{
{"implant_to_server", implantSideHex.Encode, serverSideHex.Decode},
{"server_to_implant", serverSideHex.Encode, implantSideHex.Decode},
}
for _, direction := range directions {
t.Run(direction.name, func(t *testing.T) {
// Exercise empty and binary inputs, both sides of a 64 KiB WASM
// page boundary, and the largest payload from the old timing test.
for _, size := range []int{0, 1, 256, 65535, 65536, 65537, 4 * 1024 * 1024} {
t.Run(fmt.Sprintf("bytes_%d", size), func(t *testing.T) {
payload := hexTestPayload(size)
encoded, err := direction.encode(payload)
if err != nil {
t.Fatalf("encode: %v", err)
}
if !bytes.Equal(encoded, []byte(hex.EncodeToString(payload))) {
t.Fatal("WASM output does not match standard hex encoding")
}
decoded, err := direction.decode(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(payload, decoded) {
t.Fatalf("round trip changed the %d-byte payload (decoded %d bytes)", len(payload), len(decoded))
}
})
}
})
if err != nil {
t.Fatal(err)
}
defer encoder.Close()
originalValue := make([]byte, sizes[i])
rand.Read(originalValue)
start := time.Now()
encodedValue, err := encoder.Encode(originalValue)
if err != nil {
t.Fatal(err)
}
// t.Logf("Got encoded value (%d bytes)", len(encodedValue))
decodedValue, err := encoder.Decode(encodedValue)
if err != nil {
t.Fatal(err)
}
t.Logf("WASM Hex encoder took %v (%d bytes)", time.Since(start), sizes[i])
if !bytes.Equal(originalValue, decodedValue) {
t.Fatalf("Expected %v but got %v", originalValue, decodedValue)
}
}
}
func BenchmarkHex(b *testing.B) {
for _, size := range []int{1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024} {
payload := hexTestPayload(size)
b.Run(fmt.Sprintf("bytes_%d", size), func(b *testing.B) {
b.Run("standard_round_trip", func(b *testing.B) {
b.SetBytes(int64(size))
b.ReportAllocs()
for b.Loop() {
encoded := hex.EncodeToString(payload)
decoded, err := hex.DecodeString(encoded)
if err != nil {
b.Fatal(err)
}
if !bytes.Equal(payload, decoded) {
b.Fatal("decoded data does not match original")
}
}
})
b.Run("wasm_fresh_runtime_round_trip", func(b *testing.B) {
b.SetBytes(int64(size))
b.ReportAllocs()
// Include runtime creation and close in each iteration. The WASM
// API retains output buffers until the runtime is closed, so an
// unbounded loop over one runtime would accumulate guest memory.
for b.Loop() {
benchmarkHexWASMLifecycle(b, payload)
}
})
})
}
}
func benchmarkHexWASMLifecycle(b *testing.B, payload []byte) {
b.Helper()
encoder, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(string) {})
if err != nil {
b.Fatal(err)
}
defer func() {
if err := encoder.Close(); err != nil {
b.Errorf("close encoder: %v", err)
}
}()
encoded, err := encoder.Encode(payload)
if err != nil {
b.Fatal(err)
}
decoded, err := encoder.Decode(encoded)
if err != nil {
b.Fatal(err)
}
if !bytes.Equal(payload, decoded) {
b.Fatal("decoded data does not match original")
}
}
func hexTestPayload(size int) []byte {
payload := make([]byte, size)
for i := range payload {
payload[i] = byte(i)
}
return payload
}
+40 -41
View File
@@ -46,6 +46,7 @@ import (
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/dnspb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/server/core"
@@ -576,15 +577,7 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
return
}
var resp *dns.Msg
isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name)
if isC2 {
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
resp = s.handleC2(domain, req)
} else if canaries {
dnsLog.Debugf("checking '%s' for DNS canary matches", req.Question[0].Name)
resp = s.handleCanary(req)
}
resp := s.handleDNSQuery(domains, canaries, req)
if resp != nil {
// These responses often have near-max-length QNAMEs. Enable compression to
// keep UDP responses under the minimum DNS message size limits.
@@ -595,6 +588,27 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
}
}
func (s *SliverDNSServer) handleDNSQuery(domains []string, canaries bool, req *dns.Msg) *dns.Msg {
if canaries {
canary, err := db.CanaryByDomain(req.Question[0].Name)
if err == nil && canary != nil {
return s.handleCanary(req, canary)
}
if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
dnsLog.Errorf("Failed to look up DNS canary: %s", err)
}
}
if isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name); isC2 {
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
return s.handleC2(domain, req)
}
if canaries {
return s.nameErrorResp(req)
}
return nil
}
// Returns true if the requested domain is a c2 subdomain, and the domain it matched with
func (s *SliverDNSServer) isC2SubDomain(domains []string, reqDomain string) (bool, string) {
for _, parentDomain := range domains {
@@ -1142,39 +1156,24 @@ func (s *SliverDNSServer) handleNOP(domain string, msg *dnspb.DNSMessage, checks
// Canary Record Handler
// ---------------------------
// Canary -> valid? -> trigger alert event
func (s *SliverDNSServer) handleCanary(req *dns.Msg) *dns.Msg {
// Don't block, return error as fast as possible
go func() {
defer recoverAndLogPanic(dnsLog.Errorf, "dns handleCanary")
func (s *SliverDNSServer) handleCanary(req *dns.Msg, canary *clientpb.DNSCanary) *dns.Msg {
firstTrigger, err := generate.TriggerCanary(canary)
if err != nil {
dnsLog.Errorf("Failed to record DNS canary trigger for '%s': %s", canary.Domain, err)
return s.nameErrorResp(req)
}
reqDomain := strings.ToLower(req.Question[0].Name)
if !strings.HasSuffix(reqDomain, ".") {
reqDomain += "." // Ensure we have the FQDN
}
canary, err := db.CanaryByDomain(reqDomain)
if err != nil {
dnsLog.Errorf("Failed to find canary: %s", err)
return
}
if canary != nil {
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
if !canary.Triggered {
// Defer publishing the event until we're sure the db is sync'd
defer core.EventBroker.Publish(core.Event{
Session: &core.Session{
Name: canary.ImplantName,
},
Data: []byte(canary.Domain),
EventType: consts.CanaryEvent,
})
canary.Triggered = true
canary.FirstTriggered = time.Now().Format(time.RFC1123)
}
canary.LatestTrigger = time.Now().Format(time.RFC1123)
canary.Count++
generate.UpdateCanary(canary)
}
}()
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
if firstTrigger {
// Publish only after the trigger transaction commits successfully.
core.EventBroker.Publish(core.Event{
Session: &core.Session{
Name: canary.ImplantName,
},
Data: []byte(canary.Domain),
EventType: consts.CanaryEvent,
})
}
return s.nameErrorResp(req)
}
+297
View File
@@ -0,0 +1,297 @@
package c2
import (
"encoding/binary"
"errors"
"hash/crc32"
"net"
"strings"
"sync/atomic"
"testing"
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/dnspb"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"github.com/miekg/dns"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
)
func TestDNSCanarySameParentTriggersOnce(t *testing.T) {
const parent = "canary-parent.invalid."
// Legacy records can contain mixed-case parent names, and DNS queries are
// case-insensitive independently of how the registered name was spelled.
canary := createDNSCanaryRecord(t, models.NewUUID().String()+".CaNaRy-PaReNt.InVaLiD.")
events := subscribeDNSCanaryEvents(t)
lookups := countDNSCanaryLookups(t)
server := newTestDNSServer()
response := queryDNSCanary(t, server, parent, strings.ToUpper(canary.Domain), true)
assertDNSCanaryResponse(t, response)
if got := lookups.Load(); got != 1 {
t.Fatalf("first query performed %d canary lookups, want 1", got)
}
first := assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
lookups.Store(0)
response = queryDNSCanary(t, server, parent, strings.ToLower(canary.Domain), true)
assertDNSCanaryResponse(t, response)
if got := lookups.Load(); got != 1 {
t.Fatalf("repeat query performed %d canary lookups, want 1", got)
}
second := assertDNSCanaryRecord(t, canary, 2)
if !second.FirstTrigger.Equal(first.FirstTrigger) || second.LatestTrigger.Before(first.LatestTrigger) {
t.Error("repeat query changed the first trigger or moved the latest trigger backwards")
}
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
}
func TestDNSCanaryC2Routing(t *testing.T) {
tests := []struct {
name string
registered bool
enabled bool
}{
{name: "unknown_name", enabled: true},
{name: "disabled_detection", registered: true},
{name: "registered_name_precedes_c2", registered: true, enabled: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, parent, domain, checksum := newDNSCanaryNOP(t)
var canary *models.DNSCanary
if tt.registered {
canary = createDNSCanaryRecord(t, domain)
}
events := subscribeDNSCanaryEvents(t)
lookups := countDNSCanaryLookups(t)
response := queryDNSCanary(t, server, parent, domain, tt.enabled)
wantLookups := int64(0)
if tt.enabled {
wantLookups = 1
}
if got := lookups.Load(); got != wantLookups {
t.Fatalf("performed %d canary lookups, want %d", got, wantLookups)
}
if tt.registered && tt.enabled {
assertDNSCanaryResponse(t, response)
assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
return
}
assertDNSCanaryNOP(t, response, checksum)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
if tt.registered {
assertDNSCanaryRecord(t, canary, 0)
}
})
}
}
func TestDNSCanaryFailedTriggerDoesNotPublish(t *testing.T) {
const parent = "canary-failure.invalid."
canary := createDNSCanaryRecord(t, models.NewUUID().String()+"."+parent)
events := subscribeDNSCanaryEvents(t)
reject := rejectDNSCanaryUpdates(t)
server := newTestDNSServer()
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
assertDNSCanaryRecord(t, canary, 0)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
reject.Store(false)
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
}
func createDNSCanaryRecord(t *testing.T, domain string) *models.DNSCanary {
t.Helper()
canary := &models.DNSCanary{ImplantName: "dns-canary-" + models.NewUUID().String(), Domain: domain}
if err := db.Session().Create(canary).Error; err != nil {
t.Fatalf("create canary: %v", err)
}
t.Cleanup(func() {
if err := db.Session().Where("id = ?", canary.ID).Delete(&models.DNSCanary{}).Error; err != nil {
t.Errorf("delete canary: %v", err)
}
})
return canary
}
func assertDNSCanaryRecord(t *testing.T, canary *models.DNSCanary, count uint32) models.DNSCanary {
t.Helper()
var stored models.DNSCanary
if err := db.Session().Where("id = ?", canary.ID).First(&stored).Error; err != nil {
t.Fatalf("reload canary: %v", err)
}
if stored.Count != count || stored.Triggered != (count > 0) {
t.Fatalf("stored count=%d triggered=%v, want count=%d triggered=%v", stored.Count, stored.Triggered, count, count > 0)
}
if stored.FirstTrigger.IsZero() != (count == 0) || stored.LatestTrigger.IsZero() != (count == 0) {
t.Error("trigger timestamps do not match the persisted count")
}
return stored
}
func countDNSCanaryLookups(t *testing.T) *atomic.Int64 {
t.Helper()
var count atomic.Int64
name := "test:count-dns-canary-lookups-" + models.NewUUID().String()
if err := db.Client.Callback().Query().Before("gorm:query").Register(name, func(tx *gorm.DB) {
if tx.Statement.Table == "dns_canaries" {
count.Add(1)
}
}); err != nil {
t.Fatalf("register canary query counter: %v", err)
}
t.Cleanup(func() { _ = db.Client.Callback().Query().Remove(name) })
return &count
}
func rejectDNSCanaryUpdates(t *testing.T) *atomic.Bool {
t.Helper()
var reject atomic.Bool
reject.Store(true)
name := "test:reject-dns-canary-updates-" + models.NewUUID().String()
if err := db.Client.Callback().Update().Before("gorm:update").Register(name, func(tx *gorm.DB) {
if reject.Load() && tx.Statement.Table == "dns_canaries" {
_ = tx.AddError(errors.New("injected DNS canary update failure"))
}
}); err != nil {
t.Fatalf("register canary update failure: %v", err)
}
t.Cleanup(func() { _ = db.Client.Callback().Update().Remove(name) })
return &reject
}
func subscribeDNSCanaryEvents(t *testing.T) chan core.Event {
t.Helper()
events := core.EventBroker.Subscribe()
t.Cleanup(func() { core.EventBroker.Unsubscribe(events) })
// Subscription registration is asynchronous. Complete a broker round trip
// before sending requests so the first canary event cannot be missed.
collectDNSCanaryEvents(t, events, true)
return events
}
func collectDNSCanaryEvents(t *testing.T, events <-chan core.Event, subscribing bool) []core.Event {
t.Helper()
marker := core.Event{EventType: "dns-canary-test-barrier", Data: []byte(models.NewUUID().String())}
core.EventBroker.Publish(marker)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
var canaries []core.Event
for {
select {
case event := <-events:
if event.EventType == marker.EventType && string(event.Data) == string(marker.Data) {
return canaries
}
if event.EventType == consts.CanaryEvent {
canaries = append(canaries, event)
}
case <-ticker.C:
if subscribing {
marker.Data = []byte(models.NewUUID().String())
core.EventBroker.Publish(marker)
}
case <-timer.C:
t.Fatal("timed out waiting for DNS canary event barrier")
}
}
}
func assertDNSCanaryEvents(t *testing.T, events []core.Event, canary *models.DNSCanary, count int) {
t.Helper()
if len(events) != count {
t.Fatalf("received %d canary events, want %d", len(events), count)
}
for _, event := range events {
if event.Session == nil || event.Session.Name != canary.ImplantName || string(event.Data) != canary.Domain {
t.Errorf("unexpected canary event: %+v", event)
}
}
}
func newDNSCanaryNOP(t *testing.T) (*SliverDNSServer, string, string, uint32) {
t.Helper()
server := newTestDNSServer()
const sessionID = uint32(0x123456)
server.sessions.Store(sessionID, newTestDNSSession(sessionID))
parent := models.NewUUID().String() + ".canary-c2.invalid."
msg := &dnspb.DNSMessage{Type: dnspb.DNSMessageType_NOP, ID: (1 << 24) | sessionID, Data: []byte("canary-routing")}
raw, err := proto.Marshal(msg)
if err != nil {
t.Fatalf("marshal DNS NOP: %v", err)
}
domain, err := encodeMessageDomain("."+parent, msg)
if err != nil {
t.Fatalf("encode DNS NOP: %v", err)
}
return server, parent, domain, crc32.ChecksumIEEE(raw)
}
func queryDNSCanary(t *testing.T, server *SliverDNSServer, parent, domain string, enabled bool) *dns.Msg {
t.Helper()
req := new(dns.Msg)
req.SetQuestion(domain, dns.TypeA)
writer := &canaryDNSResponseWriter{}
server.HandleDNSRequest([]string{parent}, enabled, writer, req)
if writer.response == nil {
t.Fatal("DNS handler did not write a response")
}
return writer.response
}
func assertDNSCanaryResponse(t *testing.T, response *dns.Msg) {
t.Helper()
if response.Rcode != dns.RcodeNameError || !response.Authoritative {
t.Fatalf("canary response rcode=%d authoritative=%v, want authoritative NXDOMAIN", response.Rcode, response.Authoritative)
}
}
func assertDNSCanaryNOP(t *testing.T, response *dns.Msg, checksum uint32) {
t.Helper()
if response.Rcode != dns.RcodeSuccess || len(response.Answer) != 1 {
t.Fatalf("NOP response rcode=%d answers=%d, want success with one answer", response.Rcode, len(response.Answer))
}
answer, ok := response.Answer[0].(*dns.A)
if !ok || len(answer.A) != 4 {
t.Fatalf("unexpected DNS NOP answer: %v", response.Answer[0])
}
if got := binary.LittleEndian.Uint32(answer.A); got != checksum {
t.Fatalf("NOP checksum=%d, want %d", got, checksum)
}
}
type canaryDNSResponseWriter struct {
response *dns.Msg
}
func (w *canaryDNSResponseWriter) LocalAddr() net.Addr { return &net.UDPAddr{} }
func (w *canaryDNSResponseWriter) RemoteAddr() net.Addr { return &net.UDPAddr{} }
func (w *canaryDNSResponseWriter) Close() error { return nil }
func (w *canaryDNSResponseWriter) TsigStatus() error { return nil }
func (w *canaryDNSResponseWriter) TsigTimersOnly(bool) {}
func (w *canaryDNSResponseWriter) Hijack() {}
func (w *canaryDNSResponseWriter) WriteMsg(msg *dns.Msg) error {
w.response = msg.Copy()
return nil
}
func (w *canaryDNSResponseWriter) Write(data []byte) (int, error) {
msg := new(dns.Msg)
if err := msg.Unpack(data); err != nil {
return 0, err
}
w.response = msg
return len(data), nil
}
-173
View File
@@ -1,173 +0,0 @@
package c2
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// import (
// "bytes"
// "fmt"
// insecureRand "math/rand"
// "net/http"
// "net/http/httptest"
// "net/url"
// "testing"
// implantCrypto "github.com/bishopfox/sliver/implant/sliver/cryptography"
// implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders"
// implantTransports "github.com/bishopfox/sliver/implant/sliver/transports/httpclient"
// "github.com/bishopfox/sliver/protobuf/sliverpb"
// "github.com/bishopfox/sliver/server/configs"
// "github.com/bishopfox/sliver/server/cryptography"
// "google.golang.org/protobuf/proto"
// )
// func TestStartSessionHandler(t *testing.T) {
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
// server, err := StartHTTPListener(&HTTPServerConfig{
// Addr: "127.0.0.1:8888",
// Secure: false,
// EnforceOTP: true,
// })
// if err != nil {
// t.Fatalf("Listener failed to start %s", err)
// return
// }
// client := implantTransports.SliverHTTPClient{}
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: fmt.Sprintf("/test/foo.%s", c2Config.ImplantConfig.StartSessionFileExt),
// }
// nonce, encoder := implantEncoders.RandomEncoder(0)
// testURL := client.NonceQueryArgument(baseURL, nonce)
// testURL = client.OTPQueryArgument(testURL, implantCrypto.GetOTPCode())
// // Generate key exchange request
// sKey := cryptography.RandomKey()
// httpSessionInit := &sliverpb.HTTPSessionInit{Key: sKey[:]}
// data, _ := proto.Marshal(httpSessionInit)
// encryptedSessionInit, err := implantCrypto.ECCEncryptToServer(data)
// if err != nil {
// t.Fatalf("Failed to encrypt session init %s", err)
// }
// payload, _ := encoder.Encode(encryptedSessionInit)
// body := bytes.NewReader(payload)
// validReq := httptest.NewRequest(http.MethodPost, testURL.String(), body)
// t.Logf("[http] req request uri: '%v'", validReq.RequestURI)
// rr := httptest.NewRecorder()
// server.HTTPServer.Handler.ServeHTTP(rr, validReq)
// if status := rr.Code; status != http.StatusOK {
// t.Fatalf("handler returned wrong status code: got %d want %d", status, http.StatusOK)
// }
// }
// func TestGetOTPFromURL(t *testing.T) {
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
// client := implantTransports.SliverHTTPClient{}
// for i := 0; i < 100; i++ {
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: "/test/foo.txt",
// }
// value := fmt.Sprintf("%d", insecureRand.Intn(99999999))
// testURL := client.OTPQueryArgument(baseURL, value)
// urlValue, err := getOTPFromURL(testURL)
// if err != nil {
// t.Fatal(err)
// }
// if urlValue != value {
// t.Fatalf("Mismatched OTP values %s (%s != %s)", testURL.String(), value, urlValue)
// }
// }
// }
// func TestGetNonceFromURL(t *testing.T) {
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
// client := implantTransports.SliverHTTPClient{}
// for i := 0; i < 100; i++ {
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: "/test/foo.txt",
// }
// nonce, encoder := implantEncoders.RandomEncoder(0)
// testURL := client.NonceQueryArgument(baseURL, nonce)
// t.Log(testURL.String())
// urlNonce, err := getNonceFromURL(testURL)
// if err != nil {
// t.Errorf("Nonce '%d' triggered error from %#v", nonce, encoder)
// t.Fatal(err)
// }
// if urlNonce != nonce {
// t.Fatalf("Mismatched encoder nonces %s (%d != %d)", testURL.String(), nonce, urlNonce)
// }
// }
// }
// func TestGetNonceFromURLWithCustomQueryArgs(t *testing.T) {
// for j := 0; j < 100; j++ {
// queryArgs := randomArgs(1)
// implantTransports.SetNonceQueryArgChars(queryArgs)
// t.Logf("Using query args: %s", queryArgs)
// client := implantTransports.SliverHTTPClient{}
// for i := 0; i < 10; i++ {
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: "/test/foo.txt",
// }
// nonce, encoder := implantEncoders.RandomEncoder(0)
// testURL := client.NonceQueryArgument(baseURL, nonce)
// t.Log(testURL.String())
// urlNonce, err := getNonceFromURL(testURL)
// if err != nil {
// t.Errorf("Nonce '%d' triggered error from %#v", nonce, encoder)
// t.Fatal(err)
// }
// if urlNonce != nonce {
// t.Fatalf("Mismatched encoder nonces %s (%d != %d)", testURL.String(), nonce, urlNonce)
// }
// }
// }
// }
// func randomArgs(min int) string {
// var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()")
// size := insecureRand.Intn(25) + min
// b := make(map[rune]interface{}, size)
// for len(b) <= size {
// b[letters[insecureRand.Intn(len(letters))]] = nil
// }
// var keys string
// for k := range b {
// keys += string(k)
// }
// return keys
// }
+42 -17
View File
@@ -465,16 +465,48 @@ func TestRegistryRevokeSessionIsScopedAndIdempotent(t *testing.T) {
func TestRegistryGeneratesUniqueOpaqueIDs(t *testing.T) {
t.Parallel()
registry := NewRegistry()
seen := map[AuthorizationID]struct{}{}
for index := 0; index < 256; index++ {
id, err := registry.Begin("session", "127.0.0.1:8080", 0)
must.NoError(t, err)
assert.Len(t, id, 43)
assert.False(t, strings.ContainsAny(id.String(), "+/="))
_, duplicate := seen[id]
assert.False(t, duplicate)
seen[id] = struct{}{}
firstBytes := bytes.Repeat([]byte{0xff}, authorizationIDBytes)
secondBytes := bytes.Repeat([]byte{0x42}, authorizationIDBytes)
for _, test := range []struct {
name string
random []byte
wantError bool
wantRemaining int
}{
{name: "distinct IDs", random: secondBytes},
{name: "retry collision", random: bytes.Join([][]byte{firstBytes, secondBytes}, nil)},
{
name: "exhaust collisions",
random: bytes.Join([][]byte{bytes.Repeat(firstBytes, maxAuthorizationIDAttempts), secondBytes}, nil),
wantError: true,
wantRemaining: authorizationIDBytes,
},
} {
t.Run(test.name, func(t *testing.T) {
random := bytes.NewReader(bytes.Join([][]byte{firstBytes, test.random}, nil))
registry := newRegistry(random)
firstID, err := registry.Begin("session", "127.0.0.1:8080", 0)
must.NoError(t, err)
assert.Len(t, firstID, 43)
assert.False(t, strings.ContainsAny(firstID.String(), "+/="))
secondID, err := registry.Begin("session", "127.0.0.1:8081", 0)
if test.wantError {
must.ErrorIs(t, err, ErrAuthorizationIDGeneration)
assert.Empty(t, secondID)
assert.Len(t, registry.List("session"), 1)
} else {
must.NoError(t, err)
assert.NotEqual(t, firstID, secondID)
assert.Len(t, secondID, 43)
assert.False(t, strings.ContainsAny(secondID.String(), "+/="))
assert.Len(t, registry.List("session"), 2)
}
assert.Equal(t, test.wantRemaining, random.Len())
first, ok := registry.Lookup("session", firstID)
must.True(t, ok)
assert.Equal(t, "127.0.0.1:8080", first.Address)
})
}
}
@@ -484,13 +516,6 @@ func (errorReader) Read(_ []byte) (int, error) {
return 0, io.ErrUnexpectedEOF
}
func TestAuthorizationErrorsAreComparable(t *testing.T) {
t.Parallel()
_, err := canonicalizeAddress("invalid")
assert.True(t, errors.Is(err, ErrInvalidForwardAddress))
}
func acquirePlanForTest(registry *Registry, sessionID string, id AuthorizationID) (dialPlan, error) {
plan, _, reservation, err := registry.reserve(sessionID, id, "")
if err == nil {
+20 -19
View File
@@ -134,26 +134,27 @@ func TestAgeKeyExTamper(t *testing.T) {
t.Fatal(err)
}
// Tamper with the ciphertext
ciphertext := allCiphertext[32:]
ciphertext[insecureRand.Intn(len(ciphertext))] ^= 0xFF
_, err = AgeKeyExFromImplant(
serverAgeKeyPair.Private,
implantPeerAgeKeyPair.Private,
ciphertext,
)
if err == nil {
t.Fatal(err)
}
tamperedCiphertext := bytes.Clone(allCiphertext[32:])
tamperedCiphertext[len(tamperedCiphertext)-1] ^= 0xFF
// Leave an invalid header with valid ciphertext
_, err = AgeKeyExFromImplant(
serverAgeKeyPair.Private,
implantPeerAgeKeyPair.Private,
allCiphertext,
)
if err == nil {
t.Fatal(err)
for _, test := range []struct {
name string
ciphertext []byte
}{
{name: "tampered ciphertext", ciphertext: tamperedCiphertext},
// Retaining the public key hash adds an invalid header to otherwise valid ciphertext.
{name: "invalid header", ciphertext: bytes.Clone(allCiphertext)},
} {
t.Run(test.name, func(t *testing.T) {
_, err := AgeKeyExFromImplant(
serverAgeKeyPair.Private,
implantPeerAgeKeyPair.Private,
test.ciphertext,
)
if err == nil {
t.Fatal("expected key exchange decryption to reject invalid ciphertext")
}
})
}
}
+52
View File
@@ -0,0 +1,52 @@
package db
import (
"errors"
"strings"
"testing"
"github.com/bishopfox/sliver/server/db/models"
)
func TestCanaryByDomain(t *testing.T) {
canary := &models.DNSCanary{
ImplantName: "canary_lookup_" + models.NewUUID().String(),
Domain: "lookup-" + models.NewUUID().String() + ".Canary.Example.",
}
if err := Session().Create(canary).Error; err != nil {
t.Fatalf("create canary: %v", err)
}
t.Cleanup(func() {
if err := Session().Delete(canary).Error; err != nil {
t.Errorf("delete canary: %v", err)
}
})
for _, domain := range []string{
canary.Domain,
strings.ToLower(canary.Domain),
strings.ToUpper(canary.Domain),
" " + strings.TrimSuffix(canary.Domain, ".") + " ",
} {
t.Run(domain, func(t *testing.T) {
found, err := CanaryByDomain(domain)
if err != nil {
t.Fatalf("lookup canary: %v", err)
}
if found.ID != canary.ID.String() || found.Domain != canary.Domain {
t.Errorf("lookup returned %v, want ID %s and domain %s", found, canary.ID, canary.Domain)
}
})
}
}
func TestCanaryByDomainNotFound(t *testing.T) {
for _, domain := range []string{"", " \t", "missing-" + models.NewUUID().String() + ".invalid."} {
t.Run(domain, func(t *testing.T) {
found, err := CanaryByDomain(domain)
if !errors.Is(err, ErrRecordNotFound) || found != nil {
t.Fatalf("lookup returned (%v, %v), want (nil, record not found)", found, err)
}
})
}
}
+16 -3
View File
@@ -1,3 +1,4 @@
// Package db provides persistence and queries for Sliver server state.
package db
/*
@@ -742,13 +743,25 @@ func ListCanaries() ([]*clientpb.DNSCanary, error) {
// CanaryByDomain - Check if a canary exists
func CanaryByDomain(domain string) (*clientpb.DNSCanary, error) {
if len(domain) < 1 {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, ErrRecordNotFound
}
if !strings.HasSuffix(domain, ".") {
domain += "."
}
dbSession := Session()
canary := models.DNSCanary{}
err := dbSession.Where(&models.DNSCanary{Domain: domain}).First(&canary).Error
return canary.ToProtobuf(), err
// Older builds may have stored mixed-case parent domains. DNS names are
// case-insensitive, so normalize both sides without rewriting those records.
result := dbSession.Where("LOWER(domain) = ?", domain).Limit(1).Find(&canary)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, ErrRecordNotFound
}
return canary.ToProtobuf(), nil
}
// WebsiteByName - Get website by name
+1
View File
@@ -48,6 +48,7 @@ func (c *DNSCanary) BeforeCreate(tx *gorm.DB) (err error) {
// ToProtobuf - Converts to protobuf object
func (c *DNSCanary) ToProtobuf() *clientpb.DNSCanary {
return &clientpb.DNSCanary{
ID: c.ID.String(),
ImplantName: c.ImplantName,
Domain: c.Domain,
Triggered: c.Triggered,
-14
View File
@@ -19,13 +19,10 @@ package models
*/
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"path/filepath"
"strings"
"testing"
uuid "uuid"
gosqlite "github.com/glebarez/sqlite"
"gorm.io/gorm"
@@ -89,9 +86,6 @@ func TestUUIDSQLAndJSONEncoding(t *testing.T) {
want := NewUUID()
standard := want.Standard()
var _ sql.Scanner = (*UUID)(nil)
var _ driver.Valuer = UUID{}
value, err := want.Value()
if err != nil {
t.Fatalf("Value: %v", err)
@@ -188,11 +182,3 @@ func TestUUIDGORMRoundTrip(t *testing.T) {
t.Fatalf("stored UUID = %q, want canonical %q", storedID, want.ID.String())
}
}
func TestUUIDMatchesStandardLibrary(t *testing.T) {
standard := uuid.NewV4()
model := UUIDFrom(standard)
if model.Standard() != standard {
t.Fatalf("Standard = %q, want %q", model.Standard(), standard)
}
}
@@ -1,23 +0,0 @@
package wasmsqlite
import (
"database/sql"
"log"
"testing"
)
func TestSQLiteVersion(t *testing.T) {
var version string
db, err := sql.Open(DriverName, ":memory:")
if err != nil {
log.Fatal(err)
}
row := db.QueryRow("select sqlite_version()")
if row.Scan(&version) != nil {
log.Fatal(err)
}
t.Log(version)
}
+50 -191
View File
@@ -20,206 +20,65 @@ package encoders
import (
"bytes"
"crypto/rand"
insecureRand "math/rand"
"fmt"
"testing"
implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders"
util "github.com/bishopfox/sliver/util/encoders"
)
const (
sampleSizeMax = 8192
)
func randomDataRandomSize(maxSize int) []byte {
buf := make([]byte, insecureRand.Intn(maxSize))
rand.Read(buf)
return buf
}
func TestCompatibilityBase64(t *testing.T) {
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(sampleSizeMax)
output, _ := Base64.Encode(sample)
data, err := implantEncoders.Base64.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into base64")
return
}
if !bytes.Equal(sample, data) {
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
}
sample2 := randomDataRandomSize(sampleSizeMax)
output, _ = implantEncoders.Base64.Encode(sample2)
data, err = Base64.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into base64")
return
}
if !bytes.Equal(sample2, data) {
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
}
}
}
func TestCompatibilityBase58(t *testing.T) {
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(sampleSizeMax)
output, _ := Base58.Encode(sample)
data, err := implantEncoders.Base58.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into base58")
return
}
if !bytes.Equal(sample, data) {
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
}
sample2 := randomDataRandomSize(sampleSizeMax)
output, _ = Base58.Encode(sample2)
data, err = implantEncoders.Base58.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into base58")
return
}
if !bytes.Equal(sample2, data) {
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
}
}
}
func TestCompatibilityBase32(t *testing.T) {
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(sampleSizeMax)
output, _ := Base32.Encode(sample)
data, err := implantEncoders.Base32.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into base32")
return
}
if !bytes.Equal(sample, data) {
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
}
sample2 := randomDataRandomSize(sampleSizeMax)
output, _ = Base32.Encode(sample2)
data, err = implantEncoders.Base32.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into base32")
return
}
if !bytes.Equal(sample2, data) {
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
}
}
}
func TestCompatibilityEnglish(t *testing.T) {
func TestEncoderCompatibility(t *testing.T) {
util.SetEnglishDictionary(getTestEnglishDictionary())
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(sampleSizeMax)
output, _ := English.Encode(sample)
data, err := implantEncoders.English.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into english")
return
}
if !bytes.Equal(sample, data) {
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
}
sample2 := randomDataRandomSize(sampleSizeMax)
output, _ = implantEncoders.English.Encode(sample2)
data, err = English.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into english")
return
}
if !bytes.Equal(sample2, data) {
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
}
encoders := []struct {
name string
server util.Encoder
implant util.Encoder
}{
{"base64", Base64, implantEncoders.Base64},
{"base58", Base58, implantEncoders.Base58},
{"base32", Base32, implantEncoders.Base32},
{"english", English, implantEncoders.English},
{"hex", Hex, implantEncoders.Hex},
{"gzip", Gzip, implantEncoders.Gzip},
{"png", PNG, implantEncoders.PNG},
}
}
func TestCompatibilityHex(t *testing.T) {
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(sampleSizeMax)
output, _ := Hex.Encode(sample)
data, err := implantEncoders.Hex.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into hex")
return
}
if !bytes.Equal(sample, data) {
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
}
sample2 := randomDataRandomSize(sampleSizeMax)
output, _ = Hex.Encode(sample2)
data, err = implantEncoders.Hex.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into hex")
return
}
if !bytes.Equal(sample2, data) {
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
}
}
}
func TestCompatibilityGzip(t *testing.T) {
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(sampleSizeMax)
output, _ := Gzip.Encode(sample)
data, err := implantEncoders.Gzip.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into gzip")
return
}
if !bytes.Equal(sample, data) {
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
}
sample2 := randomDataRandomSize(sampleSizeMax)
output, _ = Gzip.Encode(sample2)
data, err = implantEncoders.Gzip.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into gzip")
return
}
if !bytes.Equal(sample2, data) {
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
}
}
}
func TestCompatibilityPNG(t *testing.T) {
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(sampleSizeMax)
output, _ := PNG.Encode(sample)
data, err := implantEncoders.PNG.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into png")
return
}
if !bytes.Equal(sample, data) {
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
}
sample2 := randomDataRandomSize(sampleSizeMax)
output, _ = PNG.Encode(sample2)
data, err = implantEncoders.PNG.Decode(output)
if err != nil {
t.Error("Failed to encode/decode sample data into png")
return
}
if !bytes.Equal(sample2, data) {
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
}
for _, encoder := range encoders {
t.Run(encoder.name, func(t *testing.T) {
directions := []struct {
name string
encoder util.Encoder
decoder util.Encoder
}{
{"server_to_implant", encoder.server, encoder.implant},
{"implant_to_server", encoder.implant, encoder.server},
}
for _, direction := range directions {
t.Run(direction.name, func(t *testing.T) {
// Cover encoding block boundaries, every byte value, leading and
// trailing zero bytes, and the old random corpus's upper bound.
for _, size := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 256, 257, 8192} {
t.Run(fmt.Sprintf("bytes_%d", size), func(t *testing.T) {
payload := make([]byte, size)
for i := range payload {
payload[i] = byte(i)
}
encoded, err := direction.encoder.Encode(payload)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := direction.decoder.Decode(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(payload, decoded) {
t.Fatalf("round trip changed the %d-byte payload (decoded %d bytes)", len(payload), len(decoded))
}
})
}
})
}
})
}
}
+24 -2
View File
@@ -198,6 +198,21 @@ func appendToLDFlags(ldflags []string, extra string) []string {
return []string{joined}
}
// applyCompactExecutableFunctionAlignment removes unnecessary padding between
// functions in non-debug obfuscated executables. Go's linker keeps explicit
// per-symbol alignments, so functions that require them remain aligned.
func applyCompactExecutableFunctionAlignment(config *clientpb.ImplantConfig, ldflags []string) []string {
if config.Debug || !config.ObfuscateSymbols {
return ldflags
}
switch config.GOARCH {
case "amd64":
return appendToLDFlags(ldflags, "-funcalign=1")
default:
return ldflags
}
}
const linuxShellcodeBuildIDLDFlag = "-extldflags=-Wl,--build-id"
func applyLinuxShellcodeBuildID(ldflags []string) []string {
@@ -802,6 +817,9 @@ func SliverExecutable(name string, build *clientpb.ImplantBuild, config *clientp
tags = append(tags, "netgo")
}
ldflags := []string{""} // Garble will automatically add "-s -w -buildid="
if !config.Debug && !config.ObfuscateSymbols {
ldflags[0] = "-s -w"
}
if !config.Debug && goConfig.GOOS == WINDOWS {
ldflags[0] += " -H=windowsgui"
}
@@ -811,6 +829,7 @@ func SliverExecutable(name string, build *clientpb.ImplantBuild, config *clientp
gcFlags = "all=-N -l"
ldflags = []string{}
}
ldflags = applyCompactExecutableFunctionAlignment(config, ldflags)
_, err = gogo.GoBuild(*goConfig, pkgPath, dest, "", tags, ldflags, gcFlags, asmFlags)
if err != nil {
return "", err
@@ -845,8 +864,11 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
// srcDir - ~/.sliver/slivers/<os>/<arch>/<name>/src
srcDir := filepath.Join(projectGoPathDir, "src")
assets.SetupGoPath(srcDir, config.IncludeDNS) // Extract GOPATH dependency files
err := util.ChmodR(srcDir, 0600, 0700) // Ensures src code files are writable
// Extract GOPATH dependency files before rendering the implant.
if err := assets.SetupGoPath(srcDir, config.IncludeDNS); err != nil {
return "", fmt.Errorf("setup implant source in %s: %w", srcDir, err)
}
err := util.ChmodR(srcDir, 0600, 0700) // Ensures src code files are writable
if err != nil {
buildLog.Errorf("fs perms: %v", err)
return "", err
+2
View File
@@ -1,3 +1,5 @@
//go:build sliver_generate
package generate
/*
+39 -6
View File
@@ -21,11 +21,13 @@ package generate
import (
"fmt"
"strings"
"time"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"github.com/bishopfox/sliver/util"
"gorm.io/gorm"
)
const (
@@ -50,11 +52,42 @@ func canarySubDomain() string {
return string(subdomain)
}
// UpdateCanary - Update an existing canary
func UpdateCanary(canary *clientpb.DNSCanary) error {
dbSession := db.Session()
result := dbSession.Save(&canary)
return result.Error
// TriggerCanary records a DNS query and reports whether it first triggered the
// existing canary. The caller's status may be stale; only its ID is used.
func TriggerCanary(canary *clientpb.DNSCanary) (bool, error) {
if canary == nil || canary.ID == "" {
return false, fmt.Errorf("missing canary ID")
}
id, err := models.ParseUUID(canary.ID)
if err != nil || id == models.NilUUID() {
return false, fmt.Errorf("invalid canary ID %q", canary.ID)
}
firstTrigger := false
err = db.Session().Transaction(func(tx *gorm.DB) error {
count := tx.Model(&models.DNSCanary{}).Where("id = ?", id).
UpdateColumn("count", gorm.Expr("count + ?", 1))
if count.Error != nil {
return count.Error
}
if count.RowsAffected != 1 {
return db.ErrRecordNotFound
}
// The increment locks the existing row before timestamps are sampled,
// so waiting queries cannot move LatestTrigger backwards.
first := tx.Model(&models.DNSCanary{}).
Where("id = ? AND triggered = ?", id, false).
Updates(models.DNSCanary{Triggered: true, FirstTrigger: time.Now()})
if first.Error != nil {
return first.Error
}
firstTrigger = first.RowsAffected == 1
return tx.Model(&models.DNSCanary{}).Where("id = ?", id).
Updates(models.DNSCanary{LatestTrigger: time.Now()}).Error
})
return firstTrigger && err == nil, err
}
// CanaryGenerator - Holds data related to canary generation
@@ -75,7 +108,7 @@ func (g *CanaryGenerator) GenerateCanary() string {
// Don't need secure random here
index := util.Intn(len(g.ParentDomains))
parentDomain := g.ParentDomains[index]
parentDomain := strings.ToLower(strings.TrimSpace(g.ParentDomains[index]))
parentDomain = strings.TrimPrefix(parentDomain, ".")
if !strings.HasSuffix(parentDomain, ".") {
parentDomain += "." // Ensure we have the FQDN
+126
View File
@@ -0,0 +1,126 @@
//go:build sliver_generate
package generate
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/certs"
"github.com/bishopfox/sliver/server/configs"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
)
// Canary domains must remain visible to a binary scanner after the production
// compiler and obfuscator run. Checking rendered source alone misses both
// literal obfuscation and linker removal of unused type metadata.
func TestCanariesSurviveObfuscation(t *testing.T) {
const parentDomain = "canary-regression.invalid."
certs.SetupCAs()
tests := []struct {
name string
goos string
protocol string
beacon bool
wantCanaries int
}{
{name: "windows_http_session", goos: "windows", protocol: "http", wantCanaries: 1},
{name: "linux_http_beacon", goos: "linux", protocol: "http", beacon: true, wantCanaries: 1},
{name: "windows_mtls_session", goos: "windows", protocol: "mtls", wantCanaries: 2},
{name: "linux_wireguard_session", goos: "linux", protocol: "wg", wantCanaries: 2},
{name: "linux_http_without_canaries", goos: "linux", protocol: "http"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name := "canary_" + strings.ReplaceAll(models.NewUUID().String(), "-", "")
config := &clientpb.ImplantConfig{
GOOS: tt.goos,
GOARCH: "amd64",
Debug: false,
ObfuscateSymbols: true,
NetGoEnabled: true,
IsBeacon: tt.beacon,
C2: []*clientpb.ImplantC2{
{URL: tt.protocol + "://c2.invalid"},
},
}
if tt.wantCanaries > 0 {
config.CanaryDomains = []string{parentDomain}
}
switch tt.protocol {
case "http":
config.IncludeHTTP = true
case "mtls":
config.IncludeMTLS = true
case "wg":
config.IncludeWG = true
config.WGKeyExchangePort = 1234
config.WGTcpCommsPort = 5678
certs.SetupWGKeys()
}
// Register cleanup before generation so failed builds do not leave
// source trees or canary records behind.
projectDir := filepath.Join(GetSliversDir(), config.GOOS, config.GOARCH, name)
t.Cleanup(func() {
if err := os.RemoveAll(projectDir); err != nil {
t.Errorf("remove generated project: %v", err)
}
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
t.Errorf("remove generated canaries: %v", err)
}
})
build, err := GenerateConfig(name, config)
if err != nil {
t.Fatalf("generate implant config: %v", err)
}
httpConfig := configs.GenerateDefaultHTTPC2Config()
binPath, err := SliverExecutable(name, build, config, httpConfig.ImplantConfig)
if err != nil {
t.Fatalf("build obfuscated implant: %v", err)
}
binary, err := os.ReadFile(binPath)
if err != nil {
t.Fatalf("read obfuscated implant: %v", err)
}
var canaries []models.DNSCanary
if err := db.Session().Where("implant_name = ?", name).Find(&canaries).Error; err != nil {
t.Fatalf("read generated canaries: %v", err)
}
assertCanaryBinary(t, binary, canaries, parentDomain, tt.wantCanaries)
t.Logf("checked %d registered canaries in %s/%s obfuscated binary (%d bytes)", len(canaries), config.GOOS, config.GOARCH, len(binary))
})
}
}
func assertCanaryBinary(t *testing.T, binary []byte, canaries []models.DNSCanary, parentDomain string, wantCanaries int) {
t.Helper()
if bytes.Contains(binary, []byte("[[GenerateCanary]]")) {
t.Error("unexpanded canary placeholder remains in binary")
}
if len(canaries) != wantCanaries {
t.Fatalf("registered %d canaries, want %d", len(canaries), wantCanaries)
}
if wantCanaries == 0 {
if bytes.Contains(binary, []byte(parentDomain)) {
t.Error("build without canaries contains the canary parent domain")
}
return
}
for _, canary := range canaries {
if canary.Domain == "" || !strings.HasSuffix(canary.Domain, "."+parentDomain) {
t.Errorf("unexpected generated canary domain %q", canary.Domain)
continue
}
if !bytes.Contains(binary, []byte(canary.Domain)) {
t.Errorf("registered canary %q is absent from the obfuscated binary", canary.Domain)
}
}
}
+199
View File
@@ -0,0 +1,199 @@
package generate
import (
"errors"
"strings"
"sync/atomic"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"gorm.io/gorm"
)
func createCanaryStateFixture(t *testing.T) *models.DNSCanary {
t.Helper()
canary := &models.DNSCanary{
ImplantName: "canary_state_" + models.NewUUID().String(),
Domain: models.NewUUID().String() + ".canary.invalid.",
}
if err := db.Session().Create(canary).Error; err != nil {
t.Fatalf("create canary: %v", err)
}
t.Cleanup(func() {
if err := db.Session().Delete(canary).Error; err != nil {
t.Errorf("delete canary: %v", err)
}
})
return canary
}
func readCanaryState(t *testing.T, id models.UUID) models.DNSCanary {
t.Helper()
var canary models.DNSCanary
if err := db.Session().First(&canary, "id = ?", id).Error; err != nil {
t.Fatalf("read canary: %v", err)
}
return canary
}
func assertCanaryTriggerCount(t *testing.T, canary models.DNSCanary, count uint32) {
t.Helper()
if !canary.Triggered || canary.Count != count {
t.Errorf("triggered/count = %v/%d, want true/%d", canary.Triggered, canary.Count, count)
}
if canary.FirstTrigger.IsZero() || canary.LatestTrigger.Before(canary.FirstTrigger) {
t.Errorf("invalid trigger times: first=%v latest=%v", canary.FirstTrigger, canary.LatestTrigger)
}
}
func TestTriggerCanaryPersistsRepeatedQueries(t *testing.T) {
canary := createCanaryStateFixture(t)
// Reuse a stale caller snapshot; persistence must use the database state.
snapshot := &clientpb.DNSCanary{ID: canary.ID.String(), Count: 99}
first, err := TriggerCanary(snapshot)
if err != nil || !first {
t.Fatalf("first trigger = (%v, %v), want (true, nil)", first, err)
}
initial := readCanaryState(t, canary.ID)
assertCanaryTriggerCount(t, initial, 1)
first, err = TriggerCanary(snapshot)
if err != nil || first {
t.Fatalf("repeat trigger = (%v, %v), want (false, nil)", first, err)
}
latest := readCanaryState(t, canary.ID)
assertCanaryTriggerCount(t, latest, 2)
if !latest.FirstTrigger.Equal(initial.FirstTrigger) || latest.LatestTrigger.Before(initial.LatestTrigger) {
t.Error("repeat query changed first trigger time or moved latest trigger backwards")
}
if latest.Domain != canary.Domain || latest.ImplantName != canary.ImplantName {
t.Error("trigger overwrote canary identity from the caller snapshot")
}
}
func TestTriggerCanaryConcurrentQueries(t *testing.T) {
const queries = 12
canary := createCanaryStateFixture(t)
snapshot := &clientpb.DNSCanary{ID: canary.ID.String()}
type result struct {
first bool
err error
}
results := make(chan result, queries)
start := make(chan struct{})
for range queries {
go func() {
<-start
first, err := TriggerCanary(snapshot)
results <- result{first: first, err: err}
}()
}
close(start)
firstTriggers := 0
for range queries {
got := <-results
if got.err != nil {
t.Errorf("concurrent trigger: %v", got.err)
}
if got.first {
firstTriggers++
}
}
if firstTriggers != 1 {
t.Errorf("first triggers = %d, want 1", firstTriggers)
}
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), queries)
}
func TestTriggerCanaryRejectsMissingIdentity(t *testing.T) {
canary := createCanaryStateFixture(t)
var before int64
if err := db.Session().Model(&models.DNSCanary{}).Count(&before).Error; err != nil {
t.Fatalf("count canaries: %v", err)
}
for _, snapshot := range []*clientpb.DNSCanary{
nil,
{},
{ID: "not-a-uuid"},
{ID: models.NilUUID().String()},
{ID: models.NewUUID().String()},
} {
first, err := TriggerCanary(snapshot)
if err == nil || first {
t.Errorf("trigger for %v = (%v, %v), want (false, error)", snapshot, first, err)
}
}
var after int64
if err := db.Session().Model(&models.DNSCanary{}).Count(&after).Error; err != nil {
t.Fatalf("count canaries: %v", err)
}
if after != before {
t.Errorf("invalid identity inserted canaries: count changed from %d to %d", before, after)
}
unchanged := readCanaryState(t, canary.ID)
if unchanged.Triggered || unchanged.Count != 0 {
t.Error("invalid identity changed an existing canary")
}
}
func TestTriggerCanaryRollsBackWriteFailure(t *testing.T) {
canary := createCanaryStateFixture(t)
injectedErr := errors.New("injected canary timestamp write failure")
callbackName := "test:canary-trigger-write-" + canary.ID.String()
var rejectWrite atomic.Bool
rejectWrite.Store(true)
if err := db.Client.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
update, ok := tx.Statement.Dest.(models.DNSCanary)
if rejectWrite.Load() && ok && !update.LatestTrigger.IsZero() {
_ = tx.AddError(injectedErr)
}
}); err != nil {
t.Fatalf("register write failure: %v", err)
}
t.Cleanup(func() {
rejectWrite.Store(false)
if err := db.Client.Callback().Update().Remove(callbackName); err != nil {
t.Errorf("remove write failure callback: %v", err)
}
})
first, err := TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
if first || !errors.Is(err, injectedErr) {
t.Fatalf("failed trigger = (%v, %v), want (false, injected error)", first, err)
}
unchanged := readCanaryState(t, canary.ID)
if unchanged.Triggered || unchanged.Count != 0 || !unchanged.FirstTrigger.IsZero() || !unchanged.LatestTrigger.IsZero() {
t.Error("failed trigger did not roll back count and trigger timestamps")
}
rejectWrite.Store(false)
first, err = TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
if err != nil || !first {
t.Fatalf("trigger after rollback = (%v, %v), want (true, nil)", first, err)
}
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), 1)
}
func TestGenerateCanaryNormalizesParent(t *testing.T) {
for _, parent := range []string{"MiXeD.Example", "MIXED.EXAMPLE.", " \t.MiXeD.Example \t"} {
t.Run(parent, func(t *testing.T) {
name := "canary_normalization_" + models.NewUUID().String()
t.Cleanup(func() {
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
t.Errorf("delete generated canary: %v", err)
}
})
generator := CanaryGenerator{ImplantName: name, ParentDomains: []string{parent}}
domain := generator.GenerateCanary()
if !strings.HasSuffix(domain, ".mixed.example.") || domain != strings.ToLower(domain) {
t.Fatalf("generated domain %q was not normalized", domain)
}
canary, err := db.CanaryByDomain(domain)
if err != nil {
t.Fatalf("look up normalized canary: %v", err)
}
if canary.Domain != domain || canary.ImplantName != name {
t.Errorf("stored canary = %v, want domain %s and implant %s", canary, domain, name)
}
})
}
}
+28
View File
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/gogo"
)
@@ -135,3 +136,30 @@ func TestSanitizeZigForBuild_Windows(t *testing.T) {
t.Fatalf("expected -fdebug-prefix-map to be set for zig builds, got CXX=%q", cfg.CXX)
}
}
func TestApplyCompactExecutableFunctionAlignment(t *testing.T) {
tests := []struct {
name string
arch string
debug bool
obfuscate bool
input string
want string
}{
{name: "amd64", arch: "amd64", obfuscate: true, want: "-funcalign=1"},
{name: "arm64 unchanged", arch: "arm64", obfuscate: true, want: ""},
{name: "windows gui", arch: "amd64", obfuscate: true, input: "-H=windowsgui", want: "-H=windowsgui -funcalign=1"},
{name: "debug", arch: "amd64", debug: true, obfuscate: true, want: ""},
{name: "plain", arch: "amd64", want: ""},
{name: "unsupported arch", arch: "386", obfuscate: true, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &clientpb.ImplantConfig{GOARCH: tt.arch, Debug: tt.debug, ObfuscateSymbols: tt.obfuscate}
got := applyCompactExecutableFunctionAlignment(config, []string{tt.input})
if len(got) != 1 || got[0] != tt.want {
t.Fatalf("applyCompactExecutableFunctionAlignment() = %q, want %q", got, tt.want)
}
})
}
}
-19
View File
@@ -1,19 +0,0 @@
package generate
/*
Sliver Implant Framework
Copyright (C) 2019 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+2
View File
@@ -1,3 +1,5 @@
//go:build sliver_generate
package generate
/*
+1 -1
View File
@@ -149,7 +149,7 @@ func GarbleCmd(config GoConfig, cwd string, command []string) ([]byte, error) {
return nil, fmt.Errorf("%s", fmt.Sprintf("Invalid compiler target: %s", target))
}
garbleBinPath := goToolPath(config.GOROOT, "garble")
garbleFlags := []string{"-seed=random", "-literals", "-tiny"}
garbleFlags := []string{"-seed=random", "-literals", "-literals-shared", "-literals-shared-short", "-tiny"}
command = append(garbleFlags, command...)
cmd := exec.Command(garbleBinPath, command...)
cmd.Dir = cwd
-34
View File
@@ -123,40 +123,6 @@ func TestAllLoot(t *testing.T) {
}
}
func TestAllLootOf(t *testing.T) {
lootStore := GetLootStore()
_, err := lootStore.Add(&clientpb.Loot{
Name: name1,
FileType: clientpb.FileType_BINARY,
File: &commonpb.File{
Name: name1,
Data: data1,
},
})
if err != nil {
t.Fatal(err)
}
_, err = lootStore.Add(&clientpb.Loot{
Name: name2,
FileType: clientpb.FileType_TEXT,
File: &commonpb.File{
Name: name1,
Data: []byte("hello world"),
},
})
if err != nil {
t.Fatal(err)
}
// Cleanup
for _, loot := range lootStore.All().Loot {
err = lootStore.Rm(loot.ID)
if err != nil {
t.Fatal(err)
}
}
}
func TestLootErrors(t *testing.T) {
lootStore := GetLootStore()
loot, err := lootStore.Add(&clientpb.Loot{
-23
View File
@@ -48,29 +48,6 @@ func randomData() []byte {
return buf
}
func TestAddContent(t *testing.T) {
webContent := clientpb.WebContent{
Path: "/data1",
ContentType: contentType1,
Size: uint64(len(data1)),
Content: data1,
}
err := AddContent(website1, &webContent)
if err != nil {
t.Fatal(err)
}
webContent2 := clientpb.WebContent{
Path: "/data2",
ContentType: contentType2,
Size: uint64(len(data2)),
Content: data1,
}
err = AddContent(website2, &webContent2)
if err != nil {
t.Fatal(err)
}
}
func TestGetContent(t *testing.T) {
webContent := clientpb.WebContent{
+16
View File
@@ -165,6 +165,22 @@ CGO_ENABLED=0 go test -c -mod=vendor -trimpath \
-implant-modes session,beacon
```
To verify the WASM extension path on a generated, obfuscated implant, use the same server and driver build commands above, then run the focused lifecycle scope. The driver starts a loopback listener and generates fresh session and beacon implants. Both modes register a tiny deterministic gzip-compressed WASI module, confirm inventory, and complete a subsequent `Ping`; the session also executes the module and asserts its distinctive exit code. Beacon execution uses a tunnel handler that the beacon task dispatcher does not route, so the focused run logs that cell as unsupported. For example, on a native Darwin/arm64 host:
```sh
./sliver-comprehensive-e2e \
-test.v -test.run=^TestComprehensiveE2E$ -test.timeout=0 \
-repo . -server ./sliver-server-e2e \
-target-os darwin -target-arch arm64 \
-results ./e2e-results -suite-scope wasm \
-transports mtls -implant-modes session,beacon \
-implant-obfuscate
```
Run the same command natively on `linux/amd64` or `windows/amd64`, changing the target flags and executable suffix on Windows. WASM extensions are unsupported on Linux/arm64 and Windows/arm64. The `wasm` scope writes PASS/FAIL details to the driver log; it does not claim the full comprehensive command matrix passed.
Pass `-implant-obfuscate` to generate implants with Garble symbol and literal obfuscation during a local run. The driver logs each generated implant size and SHA256, and verifies that amd64 builds outside debug mode used `-funcalign=1` before launching them.
For a local feature-only run, add either `-suite-scope rportfwd` or `-suite-scope portfwd-socks5`. Both scopes automatically select sessions even if the default `session,beacon` mode list is retained. The latter is intentionally local/Proxmox-only for now: the existing GitHub Actions dispatch input has not been changed to accept it. Its default `base` profile is diagnostic only; add `-tunnel-acceptance-profile proxmox` together with all required range target flags for an acceptance run. In GitHub Actions, `rportfwd` remains the only focused choice on the manually dispatched **Comprehensive e2e Tests** workflow.
Run the shellcode group with the same server and test binary by changing the selector to `-test.run=^TestShellcodeE2E$` and writing results to a separate directory such as `./shellcode-results`. The default four SGN samples match the workflow; `-shellcode-sgn-samples` can request a higher stress depth. Shellcode aggregation expects the complete four-target workflow matrix; a single local target is useful as a runtime smoke test but intentionally reports the other required targets as `NOT RUN`.
+2 -1
View File
@@ -35,7 +35,7 @@ func init() {
flag.StringVar(&testOptions.resultsDir, "results", "", "directory for JSON and Markdown coverage reports")
flag.StringVar(&testOptions.transportCSV, "transports", strings.Join(transportOrder, ","), "comma-separated transports to run (mtls,wg,http)")
flag.StringVar(&testOptions.modeCSV, "implant-modes", "session,beacon", "comma-separated implant modes to run (session,beacon)")
flag.StringVar(&testOptions.suiteScope, "suite-scope", defaultSuiteScope(), "E2E suite scope (comprehensive, rportfwd, or portfwd-socks5)")
flag.StringVar(&testOptions.suiteScope, "suite-scope", defaultSuiteScope(), "E2E suite scope (comprehensive, wasm, rportfwd, or portfwd-socks5)")
flag.DurationVar(&testOptions.timeout, "e2e-timeout", 4*time.Hour, "overall comprehensive E2E timeout")
flag.DurationVar(&testOptions.startupTimeout, "startup-timeout", 10*time.Minute, "Sliver daemon startup timeout")
flag.DurationVar(&testOptions.connectTimeout, "connect-timeout", 5*time.Minute, "implant connection timeout")
@@ -51,6 +51,7 @@ func init() {
flag.IntVar(&testOptions.rdpCredentialsFD, "tunnel-rdp-credentials-fd", -1, "optional descriptor containing one runtime-only RDP credential and certificate-pin JSON object")
flag.StringVar(&testOptions.tunnelAcceptanceProfile, "tunnel-acceptance-profile", tunnelAcceptanceProfileBase, "focused tunnel evidence profile (base or proxmox)")
flag.BoolVar(&testOptions.implantDebug, "implant-debug", false, "generate debug implants and capture their transport logs")
flag.BoolVar(&testOptions.implantObfuscate, "implant-obfuscate", false, "generate implants with Garble symbol and literal obfuscation")
flag.BoolVar(&goroutineLeakProfile, "goroutine-leak-profile", false, "write the goroutine leak profile at comprehensive E2E shutdown")
}
+40 -3
View File
@@ -13,6 +13,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"sync"
@@ -35,6 +36,7 @@ import (
const (
operatorName = "e2e-operator"
suiteScopeComprehensive = "comprehensive"
suiteScopeWasm = "wasm"
suiteScopeRportFwd = "rportfwd"
suiteScopePortfwdSocks5 = "portfwd-socks5"
tunnelAcceptanceProfileBase = "base"
@@ -76,6 +78,7 @@ type options struct {
rdpCredentialsFD int
tunnelAcceptanceProfile string
implantDebug bool
implantObfuscate bool
}
type suite struct {
@@ -402,6 +405,9 @@ func validateOptions(opts *options) error {
if !supported[target] {
return fmt.Errorf("target %s is not in the comprehensive E2E matrix", target)
}
if opts.suiteScope == suiteScopeWasm && (target == "linux/arm64" || target == "windows/arm64") {
return fmt.Errorf("WASM extensions are not supported on %s", target)
}
if runtime.GOOS != opts.targetOS || runtime.GOARCH != opts.targetArch {
return fmt.Errorf("E2E driver must run as the implant target: driver is %s/%s, target is %s", runtime.GOOS, runtime.GOARCH, target)
}
@@ -528,8 +534,8 @@ func validateSocksFuzzOptions(caseCount int, replayCase int) error {
func normalizeSuiteScope(value string) (string, error) {
scope := strings.TrimSpace(value)
if scope != suiteScopeComprehensive && scope != suiteScopeRportFwd && scope != suiteScopePortfwdSocks5 {
return "", fmt.Errorf("unknown E2E suite scope %q (want %s, %s, or %s)", scope, suiteScopeComprehensive, suiteScopeRportFwd, suiteScopePortfwdSocks5)
if scope != suiteScopeComprehensive && scope != suiteScopeWasm && scope != suiteScopeRportFwd && scope != suiteScopePortfwdSocks5 {
return "", fmt.Errorf("unknown E2E suite scope %q (want %s, %s, %s, or %s)", scope, suiteScopeComprehensive, suiteScopeWasm, suiteScopeRportFwd, suiteScopePortfwdSocks5)
}
return scope, nil
}
@@ -861,7 +867,7 @@ func (s *suite) runImplant(listener *listener, beacon bool) error {
GOARCH: s.opts.targetArch,
TemplateName: "sliver",
Debug: s.opts.implantDebug,
ObfuscateSymbols: false,
ObfuscateSymbols: s.opts.implantObfuscate,
IsBeacon: beacon,
BeaconInterval: int64(s.opts.beaconInterval),
BeaconJitter: 0,
@@ -905,6 +911,15 @@ func (s *suite) runImplant(listener *listener, beacon bool) error {
if generated.File == nil || generated.File.Name == "" || len(generated.File.Data) == 0 {
return fmt.Errorf("generate %s returned an empty executable", implantName)
}
if s.opts.implantObfuscate {
hash := sha256.Sum256(generated.File.Data)
s.t.Logf("Generated obfuscated implant %s: %d bytes, SHA256 %x", implantName, len(generated.File.Data), hash)
if s.opts.targetArch == "amd64" && !s.opts.implantDebug {
if err := verifyCompactExecutableBuild(filepath.Join(s.serverRoot, "logs", "sliver.json"), implantName); err != nil {
return fmt.Errorf("verify compact obfuscated build for %s: %w", implantName, err)
}
}
}
remoteRoot := filepath.Join(s.workDir, "targets", implantName)
if err := createKnownTree(remoteRoot); err != nil {
@@ -960,6 +975,9 @@ func (s *suite) runImplant(listener *listener, beacon bool) error {
if s.opts.suiteScope == suiteScopeComprehensive {
exerciseErrors = appendIfError(exerciseErrors, s.exerciseCommands(target, remoteRoot, listener.transport))
}
if s.opts.suiteScope == suiteScopeWasm {
exerciseErrors = appendIfError(exerciseErrors, s.exerciseWasmExtensionLifecycle(target, listener.transport))
}
if target.session != nil && s.opts.suiteScope == suiteScopePortfwdSocks5 {
exerciseErrors = appendIfError(exerciseErrors, s.exercisePortForward(target, listener.transport))
exerciseErrors = appendIfError(exerciseErrors, s.exerciseSocks5(target, listener.transport))
@@ -1303,6 +1321,25 @@ func readLogTailBytes(path string, limit int64) string {
return strings.TrimSpace(string(data))
}
// verifyCompactExecutableBuild checks the server's recorded compiler command
// for the exact generated implant before the E2E driver launches it.
func verifyCompactExecutableBuild(logPath, implantName string) error {
data, err := os.ReadFile(logPath)
if err != nil {
return fmt.Errorf("read compiler log: %w", err)
}
for _, line := range strings.Split(string(data), "\n") {
if !strings.Contains(line, "garble cmd:") || !strings.Contains(line, implantName) {
continue
}
if !slices.Contains(strings.Fields(line), "-funcalign=1") {
return fmt.Errorf("compiler command for %s omitted -funcalign=1", implantName)
}
return nil
}
return fmt.Errorf("compiler command for %s not found", implantName)
}
func readCompilerStderr(path string) string {
type compilerLogEntry struct {
Message string `json:"msg"`
+24
View File
@@ -359,3 +359,27 @@ func TestReadCompilerStderrReturnsLatestCompilerMessageOnly(t *testing.T) {
t.Fatalf("compiler stderr included unrelated log content: %q", diagnostics)
}
}
func TestVerifyCompactExecutableBuildRequiresExactAlignmentFlag(t *testing.T) {
for _, test := range []struct {
name string
ldflags string
wantErr bool
}{
{name: "exact flag", ldflags: "-funcalign=1"},
{name: "larger alignment", ldflags: "-funcalign=16", wantErr: true},
{name: "missing flag", ldflags: "-s -w", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "sliver.json")
line := `{"msg":"garble cmd: '/tmp/garble -literals build -ldflags ` + test.ldflags + ` -o /tmp/e2e-test/bin/e2e-test .'"}`
if err := os.WriteFile(path, []byte(line), 0o600); err != nil {
t.Fatal(err)
}
err := verifyCompactExecutableBuild(path, "e2e-test")
if (err != nil) != test.wantErr {
t.Fatalf("verifyCompactExecutableBuild() error = %v, want error %t", err, test.wantErr)
}
})
}
}
+125
View File
@@ -0,0 +1,125 @@
package e2e
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"time"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
)
// wasmExitSeven is a minimal WASI module. Its _start calls
// wasi_snapshot_preview1.proc_exit(7). A distinctive exit code proves that
// the implant decoded, compiled, and ran the registered module.
var wasmExitSeven = [...]byte{
0, 97, 115, 109, 1, 0, 0, 0,
1, 8, 2, 96, 1, 127, 0, 96, 0, 0,
2, 36, 1, 22, 119, 97, 115, 105, 95, 115, 110, 97, 112, 115,
104, 111, 116, 95, 112, 114, 101, 118, 105, 101, 119, 49,
9, 112, 114, 111, 99, 95, 101, 120, 105, 116, 0, 0,
3, 2, 1, 1,
7, 10, 1, 6, 95, 115, 116, 97, 114, 116, 0, 1,
10, 8, 1, 6, 0, 65, 7, 16, 0, 11,
}
func gzipWasmExitSeven() ([]byte, error) {
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(wasmExitSeven[:]); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
return compressed.Bytes(), nil
}
func (s *suite) liveWasmStep(target implantTarget, transport, method string, fn func() error) error {
started := time.Now()
if err := fn(); err != nil {
return fmt.Errorf("%s (%s/%s %s/%s, %s): %w", method,
s.opts.targetOS, s.opts.targetArch, transport, target.mode(),
time.Since(started).Round(time.Millisecond), err)
}
s.t.Logf("PASS live %s/%s %s %s %s (%s)", s.opts.targetOS,
s.opts.targetArch, transport, target.mode(), method,
time.Since(started).Round(time.Millisecond))
return nil
}
func (s *suite) exerciseWasmExtensionLifecycle(target implantTarget, transport string) error {
const name = "sliver-e2e-exit-seven.wasm"
compressed, err := gzipWasmExitSeven()
if err != nil {
return fmt.Errorf("compress deterministic WASM fixture: %w", err)
}
if err := s.liveWasmStep(target, transport, "RegisterWasmExtension", func() error {
_, err := invokeRPC(s, target, "RegisterWasmExtension", func(ctx context.Context, request *commonpb.Request) (*sliverpb.RegisterWasmExtension, error) {
return s.rpc.RegisterWasmExtension(ctx, &sliverpb.RegisterWasmExtensionReq{
Name: name, WasmGz: compressed, Request: request,
})
}, func(response *sliverpb.RegisterWasmExtension) *commonpb.Response {
return response.GetResponse()
})
return err
}); err != nil {
return err
}
if err := s.liveWasmStep(target, transport, "ListWasmExtensions", func() error {
response, err := invokeRPC(s, target, "ListWasmExtensions", func(ctx context.Context, request *commonpb.Request) (*sliverpb.ListWasmExtensions, error) {
return s.rpc.ListWasmExtensions(ctx, &sliverpb.ListWasmExtensionsReq{Request: request})
}, func(response *sliverpb.ListWasmExtensions) *commonpb.Response {
return response.GetResponse()
})
if err != nil {
return err
}
if len(response.Names) != 1 || response.Names[0] != name {
return fmt.Errorf("registered WASM inventory = %v, want [%s]", response.Names, name)
}
return nil
}); err != nil {
return err
}
// Beacon tasks use the standard handler table. WASM execution is a tunnel
// handler, so only a session can exercise this execution path today.
if target.beacon != nil {
s.t.Logf("SKIP live %s/%s %s beacon ExecWasmExtension: beacon tasks do not dispatch tunnel handlers",
s.opts.targetOS, s.opts.targetArch, transport)
} else if err := s.liveWasmStep(target, transport, "ExecWasmExtension", func() error {
response, err := invokeRPC(s, target, "ExecWasmExtension", func(ctx context.Context, request *commonpb.Request) (*sliverpb.ExecWasmExtension, error) {
return s.rpc.ExecWasmExtension(ctx, &sliverpb.ExecWasmExtensionReq{
Name: name, Request: request,
})
}, func(response *sliverpb.ExecWasmExtension) *commonpb.Response {
return response.GetResponse()
})
if err != nil {
return err
}
if response.ExitCode != 7 {
return fmt.Errorf("WASM exit code = %d, want 7", response.ExitCode)
}
return nil
}); err != nil {
return err
}
return s.liveWasmStep(target, transport, "Ping after WASM lifecycle", func() error {
const nonce = int32(0x5a17c0de)
response, err := invokeRPC(s, target, "Ping", func(ctx context.Context, request *commonpb.Request) (*sliverpb.Ping, error) {
return s.rpc.Ping(ctx, &sliverpb.Ping{Nonce: nonce, Request: request})
}, func(response *sliverpb.Ping) *commonpb.Response {
return response.GetResponse()
})
if err != nil {
return err
}
if response.Nonce != nonce {
return fmt.Errorf("Ping nonce = %d, want %d", response.Nonce, nonce)
}
return nil
})
}
+2 -2
View File
@@ -1,8 +1,8 @@
package assets
const (
goVersion = "1.27.2"
garbleVersion = "1.27.2"
goVersion = "1.27.3"
garbleVersion = "1.27.3b"
zigVersion = "0.15.2"
zigSourceParam = "source=sliver"
+36 -34
View File
@@ -26,43 +26,45 @@ import (
"testing"
)
var (
imageTests = []struct {
Input []byte
}{
{[]byte("abc")}, // byte count on image pixel alignment
{[]byte("abcde")}, // byte count offset of image pixel alignment
{[]byte{0x0, 0x01, 0x02, 0x03, 0x04}},
{[]byte{0x01, 0x02, 0x03, 0x04, 0x0}},
}
)
func TestPNG(t *testing.T) {
pngEncoder := new(PNGEncoder)
for _, test := range imageTests {
buf, _ := pngEncoder.Encode(test.Input)
decodeOutput, err := pngEncoder.Decode(buf)
if err != nil {
t.Errorf("png decode returned error: %q", err)
}
if !bytes.Equal(test.Input, decodeOutput) {
t.Errorf("png Decode(img) => %q, expected %q", decodeOutput, test.Input)
}
allBytes := make([]byte, 256)
for i := range allBytes {
allBytes[i] = byte(i)
}
tests := []struct {
name string
payload []byte
}{
{"empty", nil},
{"one_zero", []byte{0}},
{"one_escape", []byte{1}},
{"partial_pixel", []byte{2, 3}},
{"pixel_aligned", []byte("abc")},
{"pixel_overflow", []byte("abcde")},
{"before_square_boundary", bytes.Repeat([]byte{2}, 11)},
{"at_square_boundary", bytes.Repeat([]byte{2}, 12)},
{"after_square_boundary", bytes.Repeat([]byte{2}, 13)},
{"leading_zero", []byte{0, 1, 2, 3, 4}},
{"trailing_zero", []byte{1, 2, 3, 4, 0}},
{"adjacent_escapes", []byte{0, 1, 0, 1, 1, 0}},
{"all_bytes", allBytes},
{"one_megabyte", bytes.Repeat(allBytes, 4096)},
}
}
func TestPNGRandomDataRandomSize(t *testing.T) {
pngEncoder := new(PNGEncoder)
for i := 0; i < 100; i++ {
sample := randomDataRandomSize(1024 * 1024)
buf, _ := pngEncoder.Encode(sample)
decodeOutput, err := pngEncoder.Decode(buf)
if err != nil {
t.Errorf("png decode returned error: %q", err)
}
if !bytes.Equal(sample, decodeOutput) {
t.Errorf("png Decode(img) => %q, expected %q", decodeOutput, sample)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
encoded, err := pngEncoder.Encode(test.payload)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := pngEncoder.Decode(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(test.payload, decoded) {
t.Fatalf("round trip changed the %d-byte payload (decoded %d bytes)", len(test.payload), len(decoded))
}
})
}
}
+3
View File
@@ -33,6 +33,9 @@ linters:
generated: lax
presets:
- common-false-positives
settings:
exhaustive:
default-signifies-exhaustive: true
issues:
max-issues-per-linter: 0
max-same-issues: 0
+2 -2
View File
@@ -64,7 +64,7 @@ import will be the Bubble Tea library, which we'll call `tea` for short.
```go
package main
// These imports will be used later on the tutorial. If you save the file
// These imports will be used later in the tutorial. If you save the file
// now, Go might complain they are unused, but that's fine.
// You may also need to run `go mod tidy` to download bubbletea and its
// dependencies.
@@ -397,6 +397,6 @@ of days past.
Part of [Charm](https://charm.sh).
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-softy.jpg" width="400"></a>
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
+26 -2
View File
@@ -1,6 +1,6 @@
# https://taskfile.dev
version: '3'
version: "3"
tasks:
lint:
@@ -11,4 +11,28 @@ tasks:
test:
desc: Run tests
cmds:
- go test ./... {{.CLI_ARGS}}
- go test -race -count 4 -cpu 1,4 ./... {{.CLI_ARGS}}
release:
desc: Create and push a new tag following semver
vars:
NEXT:
sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always
prompt: "This will release {{.NEXT}}. Continue?"
preconditions:
- sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
msg: Not on main branch
- sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
msg: "Git is dirty"
- sh: 'gh run list --workflow build.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success'
msg: "Test build for this commit failed or not present"
cmds:
- task: fetch-tags
- git commit --allow-empty -m "{{.NEXT}}"
- git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
- echo "Pushing {{.NEXT}}..."
- git push origin main --follow-tags
fetch-tags:
cmds:
- git fetch --tags
+1
View File
@@ -98,6 +98,7 @@ The `tea.View` struct has fields for everything that used to be controlled by op
| `ForegroundColor` | Set the terminal foreground color |
| `BackgroundColor` | Set the terminal background color |
| `ProgressBar` | Show a native terminal progress bar |
| `ProgramStatus` | Report whether the program is working, waiting on the user, done, or failed (OSC 7501) |
| `KeyboardEnhancements` | Request keyboard enhancement features |
| `OnMouse` | Intercept mouse messages based on view content |
+4 -2
View File
@@ -38,7 +38,8 @@ func SetClipboard(s string) Cmd {
type readClipboardMsg struct{}
// ReadClipboard produces a command that reads the system clipboard using OSC52.
// Note that OSC52 is not supported in all terminals.
// Note that OSC52 is not supported in all terminals. Use [WithInput] to enable
// input, since the reply cannot be read otherwise.
func ReadClipboard() Msg {
return readClipboardMsg{}
}
@@ -64,7 +65,8 @@ type readPrimaryClipboardMsg struct{}
// ReadPrimaryClipboard produces a command that reads the primary clipboard
// using OSC52. Primary clipboard selection is a feature present in X11 and
// Wayland only.
// Note that OSC52 is not supported in all terminals.
// Note that OSC52 is not supported in all terminals. Use [WithInput] to enable
// input, since the reply cannot be read otherwise.
func ReadPrimaryClipboard() Msg {
return readPrimaryClipboardMsg{}
}
+8 -4
View File
@@ -10,6 +10,7 @@ import (
type backgroundColorMsg struct{}
// RequestBackgroundColor is a command that requests the terminal background color.
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
func RequestBackgroundColor() Msg {
return backgroundColorMsg{}
}
@@ -18,6 +19,7 @@ func RequestBackgroundColor() Msg {
type foregroundColorMsg struct{}
// RequestForegroundColor is a command that requests the terminal foreground color.
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
func RequestForegroundColor() Msg {
return foregroundColorMsg{}
}
@@ -26,6 +28,7 @@ func RequestForegroundColor() Msg {
type cursorColorMsg struct{}
// RequestCursorColor is a command that requests the terminal cursor color.
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
func RequestCursorColor() Msg {
return cursorColorMsg{}
}
@@ -49,20 +52,21 @@ func (e ForegroundColorMsg) IsDark() bool {
// emitted when the program requests the terminal background color with the
// [RequestBackgroundColor] Cmd.
//
// This is commonly used in [Update.Init] to get the terminal background color
// This is commonly used in [Model.Init] to get the terminal background color
// for style definitions. For that you'll want to call
// [BackgroundColorMsg.IsDark] to determine if the color is dark or light. For
// example:
//
// func (m Model) Init() (Model, Cmd) {
// return m, RequestBackgroundColor()
// func (m model) Init() Cmd {
// return RequestBackgroundColor
// }
//
// func (m Model) Update(msg Msg) (Model, Cmd) {
// func (m model) Update(msg Msg) (Model, Cmd) {
// switch msg := msg.(type) {
// case BackgroundColorMsg:
// m.styles = newStyles(msg.IsDark())
// }
// return m, nil
// }
type BackgroundColorMsg struct{ color.Color }
+3 -3
View File
@@ -9,9 +9,9 @@ import (
//
// Example:
//
// func (m model) Init() (Model, Cmd) {
// return m, tea.Batch(someCommand, someOtherCommand)
// }
// func (m model) Init() tea.Cmd {
// return tea.Batch(someCommand, someOtherCommand)
// }
func Batch(cmds ...Cmd) Cmd {
return compactCmds[BatchMsg](cmds)
}
+143 -26
View File
@@ -33,6 +33,8 @@ type cursedRenderer struct {
mapnl bool
syncdUpdates bool // whether to use synchronized output mode for updates
starting bool // indicates whether the renderer is starting after being stopped
pendingErase bool // an scr.Erase() is pending and hasn't been drained by flush yet
noInput bool // whether input is disabled, in which case keyboard enhancement queries are pointless
}
var _ renderer = &cursedRenderer{}
@@ -55,13 +57,40 @@ func (s *cursedRenderer) setLogger(logger uv.Logger) {
s.mu.Unlock()
}
// setNoInput disables keyboard enhancement requests. When the program runs
// without input, the terminal's response to a keyboard enhancement query
// would arrive after the program has exited and leak into the shell.
func (s *cursedRenderer) setNoInput(noInput bool) {
s.noInput = noInput
}
// resetKeyboardEnhancements writes the sequences that reset keyboard
// enhancement protocols when switching between the main and alt screens.
// modifyOtherKeys has no stack, so it is reset in place; the Kitty keyboard
// stack is popped, but only if we previously pushed an entry (i.e. this is
// not the first render). With input disabled the keyboard protocol is never
// touched.
func (s *cursedRenderer) resetKeyboardEnhancements(buf *bytes.Buffer) {
if s.noInput {
return
}
_, _ = buf.WriteString(ansi.ResetModifyOtherKeys)
if s.lastView != nil {
_, _ = buf.WriteString(ansi.PopKittyKeyboard(1))
}
}
// setOptimizations sets the cursor movement optimizations.
func (s *cursedRenderer) setOptimizations(hardTabs, backspace, mapnl bool) {
s.mu.Lock()
s.hardTabs = hardTabs
s.backspace = backspace
s.mapnl = mapnl
s.scr.SetTabStops(s.width)
if s.hardTabs {
s.scr.SetTabStops(s.width)
} else {
s.scr.SetTabStops(-1)
}
s.scr.SetBackspace(s.backspace)
s.scr.SetMapNewline(s.mapnl)
s.mu.Unlock()
@@ -127,15 +156,19 @@ func (s *cursedRenderer) start() {
if s.lastView.ProgressBar != nil {
setProgressBar(s, s.lastView.ProgressBar)
}
// Enable modifyOtherKeys and Kitty keyboard protocol.
// Both can coexist; terminals ignore what they don't support.
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
if s.lastView.ProgramStatus != nil {
setProgramStatus(s, nil, s.lastView.ProgramStatus)
}
if !s.noInput {
// Enable modifyOtherKeys and Kitty keyboard protocol.
// Both can coexist; terminals ignore what they don't support.
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
kittyFlags := ansi.KittyDisambiguateEscapeCodes
if s.lastView.KeyboardEnhancements.ReportEventTypes {
kittyFlags |= ansi.KittyReportEventTypes
kittyFlags := keyboardEnhancementsFlags(s.lastView.KeyboardEnhancements)
// The entry was popped when the renderer was stopped, so push a fresh
// one for the screen we're about to restore.
_, _ = s.scr.WriteString(ansi.PushKittyKeyboard(kittyFlags))
}
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
}
// close implements renderer.
@@ -152,10 +185,14 @@ func (s *cursedRenderer) close() (err error) {
// two registries for the main and alt screens. We disable keyboard
// enhancements whenever we enter/exit alt screen mode in
// [cursedRenderer.flush].
// Here, we reset the keyboard protocol of the last screen used
// assuming the other screen is already reset when we switched screens.
_, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys)
_, _ = s.buf.WriteString(ansi.KittyKeyboard(0, 1))
// Here, we pop the keyboard protocol of the last screen used
// assuming the other screen is already popped when we switched
// screens. With input disabled we never pushed an entry, so there is
// nothing to pop.
if !s.noInput {
_, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys)
_, _ = s.buf.WriteString(ansi.PopKittyKeyboard(1))
}
// Go to the bottom of the screen.
// We need to go to the bottom of the screen regardless of whether
@@ -211,6 +248,9 @@ func (s *cursedRenderer) close() (err error) {
if lv.ProgressBar != nil && lv.ProgressBar.State != ProgressBarNone {
_, _ = s.scr.WriteString(ansi.ResetProgressBar)
}
// Unlike the progress bar, the program status is left in place: done
// and error reports should outlive the program, and terminals drop
// working and blocked records when the process exits.
}
if s.cellbuf.Method == ansi.GraphemeWidth {
@@ -278,13 +318,19 @@ func (s *cursedRenderer) flush(closing bool) error {
}
}
if !s.starting && !closing && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() {
// Restore tab stops if we have tab optimizations enabled.
if s.starting && s.hardTabs {
_, _ = s.scr.WriteString(ansi.SetTabEvery8Columns)
}
if !s.starting && !closing && !s.pendingErase && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() {
// No changes, nothing to do.
return nil
}
// We're no longer starting.
s.starting = false
s.pendingErase = false
if frameArea != s.cellbuf.Bounds() {
s.scr.Erase() // Force a full redraw to avoid artifacts.
@@ -369,9 +415,12 @@ func (s *cursedRenderer) flush(closing bool) error {
}
}
// kitty keyboard protocol
if s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements ||
view.AltScreen != s.lastView.AltScreen {
// kitty keyboard protocol. Skipped entirely when input is disabled: the
// enhancements only affect keyboard input, and querying the terminal
// would leave its response unconsumed, leaking into the shell after
// the program exits.
if !s.noInput && (s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements ||
view.AltScreen != s.lastView.AltScreen) {
// NOTE: We need to reset the keyboard protocol when switching
// between main and alt screen. This is because the specs specify
// two different states for the main and alt screen.
@@ -379,11 +428,20 @@ func (s *cursedRenderer) flush(closing bool) error {
// Enable modifyOtherKeys and Kitty keyboard protocol.
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
kittyFlags := ansi.KittyDisambiguateEscapeCodes // always enable basic key disambiguation
if view.KeyboardEnhancements.ReportEventTypes {
kittyFlags |= ansi.KittyReportEventTypes
kittyFlags := keyboardEnhancementsFlags(view.KeyboardEnhancements)
if s.lastView == nil || view.AltScreen != s.lastView.AltScreen {
// First render or screen switch: the previous screen's entry
// (if any) is popped below, so push a fresh one for this
// screen.
_, _ = s.scr.WriteString(ansi.PushKittyKeyboard(kittyFlags))
} else {
// Only the flags changed while the same screen stays active.
// Update the topmost stack entry in place instead of popping
// and re-pushing, so a keyboard change doesn't churn the
// stack. Note that this overwrites whatever entry is currently
// on top, which is normally ours.
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
}
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
if !closing {
// Request keyboard enhancements when they change
_, _ = s.scr.WriteString(ansi.RequestKittyKeyboard)
@@ -454,6 +512,15 @@ func (s *cursedRenderer) flush(closing bool) error {
setProgressBar(s, view.ProgressBar)
}
// Report the program status if it's changed.
var lps *ProgramStatus
if s.lastView != nil {
lps = s.lastView.ProgramStatus
}
if !programStatusEquals(lps, view.ProgramStatus) {
setProgramStatus(s, lps, view.ProgramStatus)
}
// Render and queue changes to the screen buffer.
s.scr.Render(s.cellbuf.RenderBuffer)
@@ -508,11 +575,10 @@ func (s *cursedRenderer) flush(closing bool) error {
var buf bytes.Buffer
if shouldUpdateAltScreen {
// We always disable keyboard enhancements when switching screens
// We always reset keyboard enhancements when switching screens
// because the terminal is expected to have two different keyboard
// registries for main and alt screens.
_, _ = buf.WriteString(ansi.ResetModifyOtherKeys)
_, _ = buf.WriteString(ansi.KittyKeyboard(0, 1))
s.resetKeyboardEnhancements(&buf)
if view.AltScreen {
// Entering alt screen mode.
buf.WriteString(ansi.SetModeAltScreenSaveCursor)
@@ -593,7 +659,11 @@ func reset(s *cursedRenderer) {
scr.SetColorProfile(s.profile)
scr.SetRelativeCursor(true) // Always start in inline mode
scr.SetFullscreen(false) // Always start in inline mode
scr.SetTabStops(s.width)
if s.hardTabs {
scr.SetTabStops(s.width)
} else {
scr.SetTabStops(-1)
}
scr.SetBackspace(s.backspace)
scr.SetMapNewline(s.mapnl)
scr.SetScrollOptim(runtime.GOOS != "windows") // disable scroll optimization on Windows due to bugs in some terminals
@@ -620,6 +690,7 @@ func (s *cursedRenderer) resize(w, h int) {
s.scr.Erase()
s.width, s.height = w, h
s.scr.Resize(s.width, s.height)
s.pendingErase = true
s.mu.Unlock()
}
@@ -630,6 +701,7 @@ func (s *cursedRenderer) clearScreen() {
// screen redraw.
s.scr.MoveTo(0, 0)
s.scr.Erase()
s.pendingErase = true
s.mu.Unlock()
}
@@ -757,8 +829,14 @@ func (s *cursedRenderer) insertAbove(str string) error {
// onMouse implements renderer.
func (s *cursedRenderer) onMouse(m MouseMsg) Cmd {
if s.lastView != nil && s.lastView.OnMouse != nil {
return s.lastView.OnMouse(m)
var onMouse func(MouseMsg) Cmd
s.mu.Lock()
if s.lastView != nil {
onMouse = s.lastView.OnMouse
}
s.mu.Unlock()
if onMouse != nil {
return onMouse(m)
}
return nil
}
@@ -787,11 +865,33 @@ func setProgressBar(s *cursedRenderer, pb *ProgressBar) {
}
}
func programStatusEquals(a, b *ProgramStatus) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// setProgramStatus reports cur to the terminal. When cur is nil, or addresses
// a different record than prev, the record of prev is cleared first.
func setProgramStatus(s *cursedRenderer, prev, cur *ProgramStatus) {
if prev != nil && (cur == nil || cur.ID != prev.ID) {
_, _ = s.scr.WriteString(ansi.ClearProgramStatusID(prev.ID))
}
if cur != nil {
_, _ = s.scr.WriteString(ansi.SetProgramStatus(*cur))
}
}
func viewEquals(a, b *View) bool {
if a == nil || b == nil {
return false
}
if !programStatusEquals(a.ProgramStatus, b.ProgramStatus) {
return false
}
if a.Content != b.Content ||
a.AltScreen != b.AltScreen ||
a.DisableBracketedPasteMode != b.DisableBracketedPasteMode ||
@@ -828,3 +928,20 @@ func viewEquals(a, b *View) bool {
return true
}
func keyboardEnhancementsFlags(ke KeyboardEnhancements) int {
flags := 1 // always enable basic key disambiguation
if ke.ReportEventTypes {
flags |= ansi.KittyReportEventTypes
}
if ke.ReportAlternateKeys {
flags |= ansi.KittyReportAlternateKeys
}
if ke.ReportAllKeysAsEscapeCodes {
flags |= ansi.KittyReportAllKeysAsEscapeCodes
}
if ke.ReportAssociatedText {
flags |= ansi.KittyReportAssociatedKeys
}
return flags
}
+1
View File
@@ -23,6 +23,7 @@ type requestCursorPosMsg struct{}
// RequestCursorPosition is a command that requests the cursor position.
// The cursor position will be sent as a [CursorPositionMsg] message.
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
func RequestCursorPosition() Msg {
return requestCursorPosMsg{}
}
+2
View File
@@ -49,6 +49,8 @@ func (p *Program) translateInputEvent(e uv.Event) Msg {
return KeyboardEnhancementsMsg(e)
case uv.ModeReportEvent:
return ModeReportMsg(e)
case uv.ProgramStatusSupportEvent:
return ProgramStatusSupportMsg{}
}
return e
}
+1 -1
View File
@@ -152,7 +152,7 @@ const (
KeyMediaRewind = uv.KeyMediaRewind
KeyMediaNext = uv.KeyMediaNext
KeyMediaPrev = uv.KeyMediaPrev
KeyMediaRecord
KeyMediaRecord = uv.KeyMediaRecord
KeyLowerVol = uv.KeyLowerVol
KeyRaiseVol = uv.KeyRaiseVol
+18
View File
@@ -39,3 +39,21 @@ func (k KeyboardEnhancementsMsg) SupportsKeyDisambiguation() bool {
func (k KeyboardEnhancementsMsg) SupportsEventTypes() bool {
return k.Flags&ansi.KittyReportEventTypes != 0
}
// SupportsAlternateKeys returns whether the terminal supports reporting
// alternate key codes.
func (k KeyboardEnhancementsMsg) SupportsAlternateKeys() bool {
return k.Flags&ansi.KittyReportAlternateKeys != 0
}
// SupportsAllKeysAsEscapeCodes returns whether the terminal supports reporting
// all keys as escape codes.
func (k KeyboardEnhancementsMsg) SupportsAllKeysAsEscapeCodes() bool {
return k.Flags&ansi.KittyReportAllKeysAsEscapeCodes != 0
}
// SupportsAssociatedText returns whether the terminal supports reporting
// associated text with key events.
func (k KeyboardEnhancementsMsg) SupportsAssociatedText() bool {
return k.Flags&ansi.KittyReportAssociatedKeys != 0
}
+1 -1
View File
@@ -38,7 +38,7 @@ const (
MouseBackward = uv.MouseBackward
MouseForward = uv.MouseForward
MouseButton10 = uv.MouseButton10
MouseButton11
MouseButton11 = uv.MouseButton11
)
// MouseMsg represents a mouse message. This is a generic mouse message that
+8 -1
View File
@@ -37,6 +37,13 @@ func WithOutput(output io.Writer) ProgramOption {
// won't need to use this. To disable input entirely pass nil.
//
// p := NewProgram(model, WithInput(nil))
//
// Note that with input disabled, no terminal query is ever sent, since the
// reply could not be read: commands that expect a response, such as
// [RequestBackgroundColor], [RequestCursorPosition], [ReadClipboard], or
// [RequestTerminalVersion], are skipped, as is the startup capability probe.
// A model waiting on the reply message of any of those commands will never
// receive it.
func WithInput(input io.Reader) ProgramOption {
return func(p *Program) {
p.input = input
@@ -54,7 +61,7 @@ func WithInput(input io.Reader) ProgramOption {
// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package
// pty, _, _ := sess.Pty()
// environ := append(sess.Environ(), "TERM="+pty.Term)
// p := tea.NewProgram(model, tea.WithEnvironment(environ)
// p := tea.NewProgram(model, tea.WithEnvironment(environ))
func WithEnvironment(env []string) ProgramOption {
return func(p *Program) {
p.environ = env
+55
View File
@@ -0,0 +1,55 @@
package tea
import uv "github.com/charmbracelet/ultraviolet"
// ProgramStatus is a Program Status Protocol (OSC 7501) report. It tells the
// terminal whether the program is idle, working, waiting on the user, done,
// or failed, and why. Set it on [View.ProgramStatus].
//
// Support depends on the terminal. Terminals that don't support it ignore it.
//
// See: https://www.superlogical.com/rex/docs/build/program-status
type ProgramStatus = uv.ProgramStatus
// ProgramState is the state of a [ProgramStatus].
type ProgramState = uv.ProgramState
// ProgramStatusKind says what a blocked program waits for.
type ProgramStatusKind = uv.ProgramStatusKind
// Program states.
const (
ProgramStateIdle = uv.ProgramStateIdle
ProgramStateWorking = uv.ProgramStateWorking
ProgramStateDone = uv.ProgramStateDone
ProgramStateBlocked = uv.ProgramStateBlocked
ProgramStateError = uv.ProgramStateError
ProgramStateClear = uv.ProgramStateClear
)
// Program status kinds.
const (
ProgramStatusKindPermission = uv.ProgramStatusKindPermission
ProgramStatusKindQuestion = uv.ProgramStatusKindQuestion
ProgramStatusKindAuth = uv.ProgramStatusKindAuth
)
// ProgramStatusSupportMsg is sent when the terminal replies to
// [RequestProgramStatusSupport], meaning it supports the Program Status
// Protocol.
type ProgramStatusSupportMsg struct{}
// programStatusSupportMsg is an internal message that queries the terminal
// for Program Status Protocol support.
type programStatusSupportMsg struct{}
// RequestProgramStatusSupport is a command that asks the terminal whether it
// supports the Program Status Protocol (OSC 7501). A supporting terminal
// replies with a [ProgramStatusSupportMsg]. Terminals that don't support it
// never reply, so pick your own timeout. Use [WithInput] to enable input,
// since the reply cannot be read otherwise.
//
// Detection is optional: reports can be sent without it.
func RequestProgramStatusSupport() Msg {
return programStatusSupportMsg{}
}
+1 -3
View File
@@ -3,9 +3,7 @@ package tea
import "github.com/charmbracelet/x/ansi"
// WindowSizeMsg is used to report the terminal size. It's sent to Update once
// initially and then on every terminal resize. Note that Windows does not
// have support for reporting when resizes occur as it does not support the
// SIGWINCH signal.
// initially and then on every terminal resize.
type WindowSizeMsg struct {
Width int
Height int
+93 -28
View File
@@ -4,9 +4,9 @@
// both. It's been battle-tested in several large projects and is
// production-ready.
//
// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials
// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/main/tutorials
//
// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples
// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/main/examples
package tea
import (
@@ -146,6 +146,20 @@ type View struct {
// progress bar section. Support depends on the terminal.
ProgressBar *ProgressBar
// ProgramStatus when not nil, reports the program status to the terminal
// using the Program Status Protocol (OSC 7501): whether the program is
// idle, working, waiting on the user, done, or failed. Support depends on
// the terminal.
//
// The status usually addresses the root record (empty ID). Changing it
// sends a new report, and setting it back to nil clears the record.
//
// The status is not cleared when the program exits, so done and error
// reports stay visible to the user. Report [ProgramStateDone],
// [ProgramStateError], or [ProgramStateIdle] before quitting rather than
// leaving a working or blocked status behind.
ProgramStatus *ProgramStatus
// AltScreen puts the program in the alternate screen buffer
// (i.e. the program goes into full window mode). Note that the altscreen will
// be automatically exited when the program quits.
@@ -245,6 +259,25 @@ type KeyboardEnhancements struct {
// [KeyPressMsg] with the [Key.IsRepeat] field set indicating that this is
// a it's part of a key repeat sequence.
ReportEventTypes bool
// ReportAlternateKeys requests the terminal to report alternate key values
// in addition to the main ones.
// Note that only key events represented as escape codes will affected by
// this enhancement.
ReportAlternateKeys bool
// ReportAllKeysAsEscapeCodes requests the terminal to report all key
// events, including plain text keys, as escape codes.
// When this is enabled, text won't be sent as plain text but instead as
// escape codes that encode the key value and modifiers.
ReportAllKeysAsEscapeCodes bool
// ReportAssociatedText requests the terminal to report the text associated
// with key events.
// Note that this is an enhancement to
// [KeyboardEnhancements.ReportAllKeysAsEscapeCodes] and only has an effect
// if that is enabled.
ReportAssociatedText bool
}
// SetContent is a helper method to set the content of a [View] with a styled
@@ -298,15 +331,23 @@ const (
ProgressBarWarning
)
// String return a human-readable value for the given [ProgressBarState].
// String returns a human-readable name for the given [ProgressBarState].
// Values outside the known range return "Unknown".
func (s ProgressBarState) String() string {
return [...]string{
"None",
"Default",
"Error",
"Indeterminate",
"Warning",
}[s]
switch s {
case ProgressBarNone:
return "None"
case ProgressBarDefault:
return "Default"
case ProgressBarError:
return "Error"
case ProgressBarIndeterminate:
return "Indeterminate"
case ProgressBarWarning:
return "Warning"
default:
return "Unknown"
}
}
// ProgressBar represents the terminal progress bar.
@@ -317,7 +358,7 @@ func (s ProgressBarState) String() string {
type ProgressBar struct {
// State is the current state of the progress bar. It can be one of
// [ProgressBarNone], [ProgressBarDefault], [ProgressBarError],
// [ProgressBarIndeterminate], and [ProgressBarWarn].
// [ProgressBarIndeterminate], and [ProgressBarWarning].
State ProgressBarState
// Value is the current value of the progress bar. It should be between
// 0 and 100.
@@ -647,11 +688,13 @@ func (p *Program) handleSignals() chan struct{} {
case s := <-sig:
if atomic.LoadUint32(&p.ignoreSignals) == 0 {
switch s {
case syscall.SIGINT:
p.msgs <- InterruptMsg{}
default:
p.msgs <- QuitMsg{}
var msg Msg = QuitMsg{}
if s == syscall.SIGINT {
msg = InterruptMsg{}
}
select {
case p.msgs <- msg:
case <-p.ctx.Done():
}
return
}
@@ -789,35 +832,38 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
}
case readClipboardMsg:
p.execute(ansi.RequestSystemClipboard)
p.executeQuery(ansi.RequestSystemClipboard)
case setClipboardMsg:
p.execute(ansi.SetSystemClipboard(string(msg)))
case readPrimaryClipboardMsg:
p.execute(ansi.RequestPrimaryClipboard)
p.executeQuery(ansi.RequestPrimaryClipboard)
case setPrimaryClipboardMsg:
p.execute(ansi.SetPrimaryClipboard(string(msg)))
case backgroundColorMsg:
p.execute(ansi.RequestBackgroundColor)
p.executeQuery(ansi.RequestBackgroundColor)
case foregroundColorMsg:
p.execute(ansi.RequestForegroundColor)
p.executeQuery(ansi.RequestForegroundColor)
case cursorColorMsg:
p.execute(ansi.RequestCursorColor)
p.executeQuery(ansi.RequestCursorColor)
case programStatusSupportMsg:
p.executeQuery(ansi.RequestProgramStatusSupport)
case execMsg:
// NB: this blocks.
p.exec(msg.cmd, msg.fn)
case terminalVersion:
p.execute(ansi.RequestNameVersion)
p.executeQuery(ansi.RequestNameVersion)
case requestCapabilityMsg:
p.execute(ansi.RequestTermcap(string(msg)))
p.executeQuery(ansi.RequestTermcap(string(msg)))
case BatchMsg:
go p.execBatchMsg(msg)
@@ -834,7 +880,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
go p.checkResize()
case requestCursorPosMsg:
p.execute(ansi.RequestCursorPositionReport)
p.executeQuery(ansi.RequestCursorPositionReport)
case RawMsg:
p.execute(fmt.Sprint(msg.Msg))
@@ -1028,7 +1074,7 @@ func (p *Program) Run() (returnModel Model, returnErr error) {
return p.initialModel, fmt.Errorf("bubbletea: error getting terminal size: %w", err)
}
width, height = w, h
width, height = p.fallbackDimensions(w, h)
}
p.width, p.height = width, height
@@ -1046,6 +1092,7 @@ func (p *Program) Run() (returnModel Model, returnErr error) {
p.height,
)
r.setLogger(p.logger)
r.setNoInput(p.disableInput)
// XXX: This breaks many things especially when we want the output
// to be compatible with terminals that are not necessary a TTY.
// This was originally done to work around a Wish emulated-pty
@@ -1091,7 +1138,7 @@ func (p *Program) Run() (returnModel Model, returnErr error) {
// Query for synchronized updates support (mode 2026) and unicode core
// (mode 2027). If the terminal supports it, the renderer will enable
// it once we get the response.
p.execute(ansi.RequestModeSynchronizedOutput +
p.executeQuery(ansi.RequestModeSynchronizedOutput +
ansi.RequestModeUnicodeCore)
}
@@ -1198,6 +1245,22 @@ func (p *Program) execute(seq string) {
p.mu.Unlock()
}
// executeQuery writes a terminal query to the program output. Queries expect a
// reply on p.input, so when input is disabled the query is skipped: the
// program cannot read the response, and the terminal's reply would leak into
// the shell once the program exits (#1590).
//
// This covers every query that expects a reply, not just the startup
// capability probe: background, foreground, and cursor color requests, cursor
// position reports, clipboard reads, and terminal version and termcap
// queries.
func (p *Program) executeQuery(seq string) {
if p.disableInput {
return
}
p.execute(seq)
}
// flush flushes the output buffer to the program output.
func (p *Program) flush() error {
p.mu.Lock()
@@ -1328,8 +1391,10 @@ func (p *Program) RestoreTerminal() error {
if err := p.initTerminal(); err != nil {
return err
}
if err := p.initInputReader(false); err != nil {
return err
if p.input != nil {
if err := p.initInputReader(false); err != nil {
return err
}
}
p.startRenderer()
+2 -1
View File
@@ -5,7 +5,8 @@ package tea
type requestCapabilityMsg string
// RequestCapability is a command that requests the terminal to send its
// Termcap/Terminfo response for the given capability.
// Termcap/Terminfo response for the given capability. Use [WithInput] to
// enable input, since the reply cannot be read otherwise.
//
// Bubble Tea recognizes the following capabilities and will use them to
// upgrade the program's color profile:
+40
View File
@@ -3,6 +3,7 @@ package tea
import (
"fmt"
"os"
"strconv"
"time"
uv "github.com/charmbracelet/ultraviolet"
@@ -104,6 +105,43 @@ func (p *Program) waitForReadLoop() {
}
}
const (
defaultWidth = 80
defaultHeight = 24
)
// fallbackDimensions returns width and height, falling back to COLUMNS/LINES
// environment variables or 80x24 defaults when dimensions are non-positive.
func (p *Program) fallbackDimensions(width, height int) (int, int) {
if width <= 0 {
var cols string
if p != nil && p.environ != nil {
cols = p.environ.Getenv("COLUMNS")
} else {
cols = os.Getenv("COLUMNS")
}
if c, err := strconv.Atoi(cols); err == nil && c > 0 {
width = c
} else {
width = defaultWidth
}
}
if height <= 0 {
var lines string
if p != nil && p.environ != nil {
lines = p.environ.Getenv("LINES")
} else {
lines = os.Getenv("LINES")
}
if l, err := strconv.Atoi(lines); err == nil && l > 0 {
height = l
} else {
height = defaultHeight
}
}
return width, height
}
// checkResize detects the current size of the output and informs the program
// via a WindowSizeMsg.
func (p *Program) checkResize() {
@@ -122,6 +160,8 @@ func (p *Program) checkResize() {
return
}
w, h = p.fallbackDimensions(w, h)
p.width, p.height = w, h
p.Send(WindowSizeMsg{Width: w, Height: h})
}
+1
View File
@@ -40,6 +40,7 @@ const suspendSupported = true
func suspendProcess() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGCONT)
defer signal.Stop(c)
_ = syscall.Kill(0, syscall.SIGTSTP)
// blocks until a CONT happens...
<-c
+2 -1
View File
@@ -16,7 +16,8 @@ type terminalVersion struct{}
// RequestTerminalVersion is a command that queries the terminal for its
// version using XTVERSION. Note that some terminals may not support this
// command.
// command. Use [WithInput] to enable input, since the reply cannot be read
// otherwise.
func RequestTerminalVersion() Msg {
return terminalVersion{}
}
+39 -11
View File
@@ -648,6 +648,11 @@ func TrimSpace(s string) string {
// parts of the screen that have changed.
type RenderBuffer struct {
*Buffer
// Touched records which lines changed since the last render. It holds at
// least one entry per screen row, and a nil entry means that row is
// unchanged. Shortening it is not meaningful, since the rows still exist, so
// the renderer restores the length rather than checking it at every read.
Touched []*LineData
}
@@ -665,14 +670,7 @@ func (b *RenderBuffer) TouchLine(x, y, n int) {
return
}
if y >= len(b.Touched) {
b.Touched = append(b.Touched, make([]*LineData, y-len(b.Touched)+1)...)
}
// Re-check bounds: a concurrent resize may have cleared Touched
if y >= len(b.Touched) {
return
}
b.growTouched()
ch := b.Touched[y]
if ch == nil {
@@ -691,9 +689,6 @@ func (b *RenderBuffer) Touch(x, y int) {
// TouchedLines returns the number of touched lines in the buffer.
func (b *RenderBuffer) TouchedLines() int {
if b.Touched == nil {
return 0
}
count := 0
for _, t := range b.Touched {
if t != nil {
@@ -799,6 +794,22 @@ func (b *RenderBuffer) DeleteCellArea(x, y, n int, c *Cell, area Rectangle) {
b.TouchLine(x, y, n)
}
// Resize resizes the buffer and touches every cell it creates, since the
// screen there still shows whatever the last render left.
func (b *RenderBuffer) Resize(width, height int) {
curWidth, curHeight := b.Width(), b.Height()
b.Buffer.Resize(width, height)
if width > curWidth {
for y := range min(curHeight, height) {
b.TouchLine(curWidth, y, width-curWidth)
}
}
for y := curHeight; y < height; y++ {
b.TouchLine(0, y, width)
}
}
// Clear clears the buffer with space cells and marks all lines as touched.
func (b *RenderBuffer) Clear() {
b.Buffer.Clear()
@@ -832,3 +843,20 @@ func (b *RenderBuffer) FillArea(c *Cell, area Rectangle) {
b.TouchLine(area.Min.X, y, w)
}
}
// growTouched restores the one-entry-per-row invariant, which an application is
// free to break by shortening or dropping the list.
//
// Only ever grows. Truncating to the screen would be the tidier rule, but
// [RenderBuffer.TouchedLines] counts non-nil records rather than touched ones,
// so discarding the entries of a frame that collapsed makes it look untouched
// and skips the erase that clears the rows it gave up. Growing is enough: every
// reader indexes by screen row, and a longer list has an entry for each.
func (b *RenderBuffer) growTouched() {
if len(b.Touched) >= len(b.Lines) {
return
}
touched := make([]*LineData, len(b.Lines))
copy(touched, b.Touched)
b.Touched = touched
}
+5 -1
View File
@@ -854,6 +854,10 @@ func (p *EventDecoder) parseOsc(b []byte) (int, Event) {
sel := ClipboardSelection(parts[0][0]) //nolint:unconvert
return i, ClipboardEvent{Selection: sel, Content: string(bts)}
case 7501:
if data[0] == '?' {
return i, ProgramStatusSupportEvent{}
}
}
return i, UnknownOscEvent(b[:i])
@@ -1761,7 +1765,7 @@ func rgbToHSL(r, g, b uint8) (h, s, l float64) {
} else {
switch Cmax {
case Rnot:
h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
h = 60 * math.Mod((Gnot-Bnot)/Δ, 6)
case Gnot:
h = 60 * (((Bnot - Rnot) / Δ) + 2)
case Bnot:
+7
View File
@@ -562,6 +562,13 @@ func (e ClipboardEvent) Clipboard() ClipboardSelection {
return e.Selection
}
// ProgramStatusSupportEvent is emitted when the terminal replies to a Program
// Status Protocol (OSC 7501) feature detection query, meaning the terminal
// supports the protocol.
//
// See: https://www.superlogical.com/rex/docs/build/program-status
type ProgramStatusSupportEvent struct{}
// ignoredEvent represents a sequence event that is ignored by the terminal
// reader. This is used to ignore certain sequences that can be canceled.
type ignoredEvent string
+81 -4
View File
@@ -98,6 +98,53 @@ func (s *StyledString) Bounds() Rectangle {
// printString draws a string starting at the given position. If s is nil, it
// will build and return a slice of [Line]s instead (unwrapped, ignoring bounds).
// passThrough reports whether a zero-width sequence should be carried in a
// cell's content and replayed to the terminal, rather than dropped.
//
// The string-type sequences carry private data rather than driving the cursor:
// APC for image protocols, DCS for device control, SOS and PM for whatever an
// application agrees with its terminal. One means it to reach the terminal, and
// a cell carrying one still advances by its own width, so the renderer's column
// model still holds.
//
// Everything else is dropped, as it was before:
//
// - A CSI can move the cursor or erase part of the screen, and an ESC Fs
// sequence can reset the terminal outright. Replaying one from inside a
// cell would move the real cursor somewhere the model cannot see, which is
// the drift a cell buffer exists to keep out.
// - An OSC is dropped despite carrying data, because a cell is painted again
// every time it changes and on every full repaint. A window title survives
// that, but a clipboard write (OSC 52) or a notification (OSC 9) is not
// something to fire again on each resize. Hyperlinks, the one OSC a cell
// has a place for, are read into [Link] above.
func passThrough[T []byte | string](seq T) bool {
if !ansi.HasApcPrefix(seq) && !ansi.HasDcsPrefix(seq) &&
!ansi.HasSosPrefix(seq) && !ansi.HasPmPrefix(seq) {
return false
}
return terminated(seq)
}
// terminated reports whether a string-type sequence ended with ST.
//
// The parser hands back whatever it has when the input runs out, so a string
// that stops mid-sequence still arrives here. Carrying an unterminated
// introducer into a cell would be worse than dropping it: the terminal would
// swallow everything painted after that cell, looking for an end that never
// comes.
//
// Only the two-byte ST is accepted. The one-byte C1 form, 0x9c, is also a
// UTF-8 continuation byte, so a sequence that ran out partway through a
// character such as U+071C ("\u071c", encoded dc 9c) ends in a byte
// indistinguishable from a terminator. The parser and the terminal need not
// resolve that ambiguity the same way, and guessing wrong reintroduces exactly
// the swallowing this guards against.
func terminated[T []byte | string](seq T) bool {
n := len(seq)
return n >= 2 && seq[n-1] == '\\' && seq[n-2] == ansi.ESC
}
func printString[T []byte | string](
s Screen,
m WidthMethod,
@@ -129,6 +176,7 @@ func printString[T []byte | string](
var link Link
var state byte
lastX, lastY := -1, -1 // last cell written, for folding in combining marks
var pending []byte // pass-through sequences awaiting a cell to ride on
for len(str) > 0 {
seq, width, n, newState := decoder(str, state, p)
// The decoder's ASCII fast path doesn't check for trailing combining
@@ -148,6 +196,15 @@ func printString[T []byte | string](
case width > 0:
cell.Width = width
cell.Content = string(seq)
// Any pass-through sequences seen since the last cell belong in
// front of this glyph, the order they arrived in. Checked rather
// than joined unconditionally, because there are none at all on the
// overwhelmingly common path and the join is not free.
if len(pending) > 0 {
pending = append(pending, cell.Content...)
cell.Content = string(pending)
pending = pending[:0]
}
cell.Style = style
cell.Link = link
@@ -232,8 +289,8 @@ func printString[T []byte | string](
s.SetCell(lastX, lastY, &folded)
}
}
default:
cell.Content += string(seq)
case passThrough(seq):
pending = append(pending, string(seq)...)
}
}
@@ -241,13 +298,31 @@ func printString[T []byte | string](
state = newState
str = str[n:]
if y >= bounds.Max.Y {
if s != nil && y >= bounds.Max.Y {
// We've reached the bottom of the bounds, stop processing further
// lines.
break
}
}
// Pass-through sequences left at the end of the string have no glyph to
// ride in front of, so fold them into the last cell written instead. The
// alternative is a width-0 cell one past the content, which lands outside
// the bounds whenever the string filled them.
if len(pending) > 0 {
if s == nil {
if lastY >= 0 && lastY < len(lines) && lastX >= 0 && lastX < len(lines[lastY]) {
lines[lastY][lastX].Content += string(pending)
}
} else if lastX >= 0 {
if prev := s.CellAt(lastX, lastY); prev != nil {
folded := *prev
folded.Content += string(pending)
s.SetCell(lastX, lastY, &folded)
}
}
}
// Make sure to set the last cell if it's not empty.
if !cell.IsZero() && s != nil {
s.SetCell(x, y, &cell)
@@ -365,7 +440,9 @@ func ReadStyle(params ansi.Params, pen *Style) {
// ReadLink reads a hyperlink escape sequence from a data buffer into link.
func ReadLink(p []byte, link *Link) {
params := bytes.Split(p, []byte{';'})
// OSC 8 sequences have this structure `OSC 8 ; params ; URI ST`.
// Only the first two semicolons are delimiters, semicolons that follow after are part of the URI.
params := bytes.SplitN(p, []byte{';'}, 3)
if len(params) != 3 {
return
}
+16 -2
View File
@@ -191,14 +191,28 @@ func (t *Terminal) Start() error {
}
// input loop
//
// The loop holds its own reference to the reader for its lifetime. Stop
// sets t.pr to nil, and a loop that reads the field on every iteration
// can evaluate it after that has happened and dereference nil. Cancel
// and Close on the reader are what stop this loop; nilling the field is
// only bookkeeping for a later Start.
pr := t.pr
t.errg.Go(func() error {
for {
n, err := t.pr.Read(t.buf)
n, err := pr.Read(t.buf)
if err != nil {
return fmt.Errorf("reading terminal input: %w", err)
}
// Hand the event loop its own copy. t.buf is reused by the
// next Read, and the receiver only copies out of the slice
// after the send has completed, so sending t.buf[:n] lets
// one read's bytes be overwritten by the next before they
// have been consumed.
data := make([]byte, n)
copy(data, t.buf[:n])
select {
case bufc <- t.buf[:n]:
case bufc <- data:
case <-t.donec:
return nil
}
+1 -1
View File
@@ -134,7 +134,7 @@ func (d *TerminalReader) serializeWin32InputRecords(records []xwindows.InputReco
var button MouseButton
alt := mevent.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0
ctrl := mevent.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0
shift := mevent.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0
shift := mevent.ControlKeyState&xwindows.SHIFT_PRESSED != 0
wheelDirection := int16(highWord(mevent.ButtonState)) //nolint:gosec
switch mevent.EventFlags {
case 0, xwindows.DOUBLE_CLICK:
+301 -111
View File
@@ -6,6 +6,7 @@ import (
"hash/maphash"
"io"
"strings"
"unicode/utf8"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
@@ -144,7 +145,11 @@ type TerminalRenderer struct {
clear bool // whether to force clear the screen
caps capabilities // terminal control sequence capabilities
atPhantom bool // whether the cursor is out of bounds and at a phantom cell
lineHadWide bool // whether the line currently being transformed contained a wide cell
noWrapLine bool // whether autowrap is off for the line currently being repainted
driftRows []bool // rows holding a cell the terminal may measure differently
termW, termH int // the size [TerminalRenderer.Resize] was last told
termResized bool // whether the terminal changed size since the last render
damaged []bool // rows this render disturbed itself, see [TerminalRenderer.damage]
logger Logger // The logger used for debugging.
// profile is the color profile to use when downsampling colors. This is
@@ -455,7 +460,13 @@ func (s *TerminalRenderer) move(newbuf *RenderBuffer, x, y int) {
// }
if height > 0 {
if s.cur.Y > height-1 {
// Only clamp the remembered cursor row in fullscreen mode, where the
// buffer covers the whole screen. In relative mode the terminal
// screen extends beyond the frame buffer: after a shrink the
// real cursor can legitimately sit below the new frame's last row,
// and clamping the model here would skip the cursor-up move and leave
// the old frame's top lines on screen.
if !s.flags.Contains(tRelativeCursor) && s.cur.Y > height-1 {
s.cur.Y = height - 1
}
if y > height-1 {
@@ -492,9 +503,33 @@ func cellEqual(a, b *Cell) bool {
}
// putCell draws a cell at the current cursor position.
//
// The lower right corner is written with autowrap off, since a pending wrap
// there scrolls the screen as soon as anything else prints. A multi-codepoint
// cluster is the exception: with autowrap off the terminal never advances past
// the margin and reads the combining codepoints as belonging to the cell to the
// left, so the cluster keeps autowrap on and the wrap is cancelled after.
func (s *TerminalRenderer) putCell(newbuf *RenderBuffer, cell *Cell) {
width, height := newbuf.Width(), newbuf.Height()
if s.flags.Contains(tFullscreen) && s.cur.X == width-1 && s.cur.Y == height-1 {
atMargin := s.cur.X == width-1 && !s.noWrapLine
lowerRight := atMargin && s.flags.Contains(tFullscreen) && s.cur.Y == height-1
// Only the corner cell can care, so the cluster is measured only there.
if lowerRight && cell != nil && utf8.RuneCountInString(cell.Content) > 1 {
s.putAttrCell(newbuf, cell)
// The cluster kept autowrap on so it landed whole, which leaves the
// cursor pending wrap. On the last row there is no next row to wrap
// onto, so the next print scrolls the screen instead. Cancel it here
// rather than leave the frame ending in a state the model denies.
if s.atPhantom {
_ = s.buf.WriteByte('\r')
s.cur.X = 0
s.atPhantom = false
}
return
}
if lowerRight {
s.putCellLR(newbuf, cell)
} else {
s.putAttrCell(newbuf, cell)
@@ -542,10 +577,6 @@ func (s *TerminalRenderer) putAttrCell(newbuf *RenderBuffer, cell *Cell) {
if s.cur.X >= newbuf.Width() {
s.atPhantom = true
}
if cellWidth > 1 {
s.lineHadWide = true
}
}
// putCellLR draws a cell at the lower right corner of the screen.
@@ -565,14 +596,7 @@ func (s *TerminalRenderer) putCellLR(newbuf *RenderBuffer, cell *Cell) {
// updatePen updates the cursor pen styles.
func (s *TerminalRenderer) updatePen(cell *Cell) {
if cell == nil {
if !s.cur.Style.IsZero() {
_, _ = s.buf.WriteString(ansi.ResetStyle)
s.cur.Style = Style{} // Reset style
}
if !s.cur.Link.IsZero() {
_, _ = s.buf.WriteString(ansi.ResetHyperlink())
}
return
cell = &EmptyCell
}
// Downsample pen when we don't have a [colorprofile.TrueColor],
@@ -820,22 +844,47 @@ func lineHasDrift(m ansi.Method, line Line) bool {
if c == nil || c.Width == 0 || len(c.Content) == 0 {
continue
}
if c.Width > 1 || m.StringWidth(c.Content) != ansi.StringWidth(c.Content) {
if c.Width > 1 {
return true
}
// One printable ASCII byte is one column under every width model, so
// it can never be a cell the models disagree about. Worth saying out
// loud because working the width out means segmenting the content,
// and this runs for every cell of every line the renderer diffs.
if len(c.Content) == 1 && c.Content[0] >= 0x20 && c.Content[0] < 0x7f {
continue
}
if m.StringWidth(c.Content) != ansi.StringWidth(c.Content) {
return true
}
}
return false
}
// repaintLine repaints a line from scratch. Unlike
// [TerminalRenderer.transformLine] it does not diff against the previous
// frame: it erases the whole line from column 0 with [ansi.EraseLineRight]
// and then writes the new frame's cells. Erasing from column 0 removes the
// entire line including any wide cells the terminal painted at a different
// width than the model measured, whose real extent the model cannot know.
// The repaint then starts from a known-empty line and an absolute cursor
// position, so the result does not depend on where the previous frame left
// the cursor.
// paintLine brings row y of the screen in line with newbuf. A drift-prone row,
// or any row when force is set, is repainted; every other row is diffed.
func (s *TerminalRenderer) paintLine(newbuf *RenderBuffer, y int, force bool) {
drift := lineHasDrift(s.method, s.curbuf.Line(y)) || lineHasDrift(s.method, newbuf.Line(y))
s.markDrift(y, newbuf.Height(), drift)
switch {
case drift && !s.flags.Contains(tGraphemeWidth):
// Clip a cluster the terminal measures wider than the model instead of
// letting it spill onto the next row, then put the cursor back.
s.noWrapLine = true
_, _ = s.buf.WriteString(ansi.ResetModeAutoWrap)
s.repaintLine(newbuf, y)
s.noWrapLine = false
_, _ = s.buf.WriteString(ansi.SetModeAutoWrap)
s.reanchorWideLine(newbuf)
case drift || force:
s.repaintLine(newbuf, y)
default:
s.transformLine(newbuf, y)
}
}
// repaintLine erases row y from column 0 and writes newbuf's cells, so the
// result does not depend on what the model believes is on screen.
func (s *TerminalRenderer) repaintLine(newbuf *RenderBuffer, y int) {
oldLine := s.curbuf.Line(y)
newLine := newbuf.Line(y)
@@ -846,12 +895,24 @@ func (s *TerminalRenderer) repaintLine(newbuf *RenderBuffer, y int) {
_, _ = s.buf.WriteString(ansi.EraseLineRight)
s.cur.X = 0
// A cell whose width runs past the right margin cannot be shown, so stop
// there. The buffer can hold one after a resize narrowed the line around
// it, and painting it would put a clipped half-glyph on the screen.
width := newbuf.Width()
clipped := width
for x := 0; x < width; x++ {
if c := newLine.At(x); c != nil && !c.isWidePlaceholder() && x+c.Width > width {
clipped = x
break
}
}
// Write the content cells. The line was just erased, so trailing blanks
// need no write, and writing them would risk wrapping past the right
// margin if the terminal painted a wide cell wider than the model
// measured.
last := -1
for x := 0; x < newbuf.Width(); x++ {
for x := 0; x < clipped; x++ {
if c := newLine.At(x); c != nil && !c.isWidePlaceholder() && !cellEqual(c, blank) {
last = x
}
@@ -860,39 +921,40 @@ func (s *TerminalRenderer) repaintLine(newbuf *RenderBuffer, y int) {
s.putCell(newbuf, newLine.At(x))
}
// Adopt the new line as the model of what is on screen.
// Adopt the new line as the model of what is on screen. The clipped tail
// was never painted, so the model records it as blank: claiming otherwise
// would let a later frame skip the cell, and a resize that widens the line
// would leave the half-glyph on screen forever.
if len(oldLine) == len(newLine) {
copy(oldLine, newLine)
for x := clipped; x < width && x < len(oldLine); x++ {
oldLine[x] = Cell{}
}
}
}
// transformLine transforms the given line in the current window to the
// corresponding line in the new window. It uses [ansi.ICH] and [ansi.DCH] to
// insert or delete characters.
// markDrift records whether a row holds a cell the terminal may measure
// differently than the model, so a later width change knows which rows to
// repaint. Recorded here rather than rescanned later, because the diff already
// had to work it out.
func (s *TerminalRenderer) markDrift(y, height int, drift bool) {
if y < 0 {
return
}
if n := max(height, y+1); len(s.driftRows) < n {
s.driftRows = append(s.driftRows, make([]bool, n-len(s.driftRows))...)
}
s.driftRows[y] = drift
}
// transformLine diffs row y against the model and writes only what changed,
// using [ansi.ICH] and [ansi.DCH] to shift cells. It must not see a drift-prone
// row; [TerminalRenderer.paintLine] routes those to a repaint.
func (s *TerminalRenderer) transformLine(newbuf *RenderBuffer, y int) {
var firstCell, oLastCell, nLastCell int // first, old last, new last index
oldLine := s.curbuf.Line(y)
newLine := newbuf.Line(y)
s.lineHadWide = false
defer s.reanchorWideLine(newbuf)
// If either frame's line holds a cell that a cell-level diff cannot
// safely reposition across, repaint the whole line instead. A wide cell
// (width > 1) occupies several columns, so a diff that lands on a
// continuation column splits the character, and per DEC semantics (also
// implemented by ghostty and x/vt) an erase that splits a multi-cell
// character erases the whole character, including a head cell that
// belongs to the new frame. A cell whose width the terminal measures
// differently than the model (an emoji cluster, a keycap) leaves the
// real cursor at a column the model cannot predict, so a later erase in
// the same transform fires at the wrong column. Repainting depends only
// on an absolute cursor position and a known-empty line.
if lineHasDrift(s.method, oldLine) || lineHasDrift(s.method, newLine) {
s.repaintLine(newbuf, y)
return
}
// Find the first changed cell in the line
blank := newLine.At(0)
@@ -1083,20 +1145,39 @@ func (s *TerminalRenderer) transformLine(newbuf *RenderBuffer, y int) {
}
}
// reanchorWideLine re-anchors the cursor with a single absolute horizontal
// move after a line that contained a wide cell. This is a best-effort fallback
// that bounds cursor desync to one line on terminals whose width model
// disagrees with ours. When the terminal negotiated Unicode grapheme width
// (mode 2027) the models agree, so no re-anchor is needed.
// reanchorWideLine re-anchors the cursor after a line that may have left it
// adrift. This is a best-effort fallback that bounds cursor desync to one line
// on terminals whose width model disagrees with ours. When the terminal
// negotiated Unicode grapheme width (mode 2027) the models agree, so no
// re-anchor is needed.
//
// The row is re-anchored along with the column. A terminal that measures a
// cluster wider than the model does runs past the right margin and wraps, so a
// line the model believes fits can leave the real cursor a row further down.
// Recovering the column alone would then leave every following line painted one
// row off, which is the drift this re-anchor exists to contain.
func (s *TerminalRenderer) reanchorWideLine(newbuf *RenderBuffer) {
if !s.lineHadWide || s.flags.Contains(tGraphemeWidth) {
// In relative cursor mode there is no absolute row to move to, so the
// column is all that can be recovered.
if s.flags.Contains(tRelativeCursor) {
if s.atPhantom || s.cur.X < 0 || s.cur.X >= newbuf.Width() {
return
}
_, _ = s.buf.WriteString(ansi.CursorHorizontalAbsolute(s.cur.X + 1))
return
}
s.lineHadWide = false
if s.atPhantom || s.cur.X < 0 || s.cur.X >= newbuf.Width() {
if s.cur.X < 0 || s.cur.Y < 0 {
return
}
_, _ = s.buf.WriteString(ansi.CursorHorizontalAbsolute(s.cur.X + 1))
// A pending wrap is as untrustworthy as the column: the terminal may have
// already taken it. An absolute move cancels it and lands on a column the
// model can name, so clamp into the line and record that.
x := min(s.cur.X, newbuf.Width()-1)
_, _ = s.buf.WriteString(ansi.CursorPosition(x+1, s.cur.Y+1))
s.cur.X = x
s.atPhantom = false
}
// deleteCells deletes the count cells at the current cursor position and moves
@@ -1161,7 +1242,11 @@ func (s *TerminalRenderer) clearBottom(newbuf *RenderBuffer, total int) (top int
}
if top < total {
s.move(newbuf, 0, max(0, top-1)) // top is 1-based
// top is the first row of the trailing blank region, so the
// erase starts there. Starting a row earlier would wipe a row
// this frame did not touch, and the loop that repaints only
// touched rows would never put it back.
s.move(newbuf, 0, top)
s.clearToBottom(blank)
if s.oldhash != nil && s.newhash != nil &&
row < len(s.oldhash) && row < len(s.newhash) {
@@ -1209,7 +1294,7 @@ func (s *TerminalRenderer) clearUpdate(newbuf *RenderBuffer) {
}
nonEmpty = s.clearBottom(newbuf, nonEmpty)
for i := 0; i < nonEmpty && i < newbuf.Height(); i++ {
s.transformLine(newbuf, i)
s.paintLine(newbuf, i, false)
}
}
@@ -1239,6 +1324,97 @@ func (s *TerminalRenderer) Flush() (err error) {
return
}
// sizeChange is what a render owes to the screen or the frame having changed
// shape since the last one.
type sizeChange struct {
// frameResized reports that the frame is a different shape than the model,
// so the model has to be resized before anything is diffed against it.
frameResized bool
// repaintAll requires every row to be painted whatever the model says.
repaintAll bool
}
// reconcileSize decides what a change of size costs, and is the only place that
// decides it.
//
// A terminal that changed size rewrapped what was on it, and a frame that
// changed shape leaves no row holding its old meaning. Either way the model
// cannot be trusted. What differs is how much of the screen is the renderer's to
// put right: fullscreen owns every cell and clears it, while an inline frame
// shares the screen with whatever came before and so repaints its own rows and
// leaves the rest alone.
func (s *TerminalRenderer) reconcileSize(newbuf *RenderBuffer) sizeChange {
newWidth, newHeight := newbuf.Width(), newbuf.Height()
curWidth, curHeight := s.curbuf.Width(), s.curbuf.Height()
fullscreen := s.flags.Contains(tFullscreen)
change := sizeChange{
frameResized: curWidth != newWidth || curHeight != newHeight,
}
if s.termResized {
if fullscreen {
s.clear = true
} else {
change.repaintAll = true
}
s.termResized = false
}
if change.frameResized {
// No row keeps its meaning, so the hashes that describe them are worth
// nothing. Inline uses the narrower partial clear in Render instead.
s.oldhash, s.newhash = nil, nil
if fullscreen {
s.clear = true
}
// Nor does the application's touch list, which never saw rows that
// vanished and came back between two frames.
s.damage(0, newHeight)
}
// The terminal clips a row it measures wider than the model does, and the
// model believes it painted the whole row. Changing the width changes what
// fits, so a row holding a cell the two might measure differently has to be
// painted again. Rows of plain cells are measured the same by everyone, so an
// ASCII screen pays nothing.
if !fullscreen && curWidth != newWidth {
for y, drifts := range s.driftRows {
if drifts {
s.damage(y, 1)
}
}
}
return change
}
// damage marks n rows from y as rows this render disturbed on its own: rows it
// scrolled, rows it erased, rows the terminal is about to measure differently
// than the model does. The diff loop paints a damaged row whether or not the
// application drew into it, because the application has no reason to redraw a
// row it did not change and no way to know the renderer moved it.
func (s *TerminalRenderer) damage(y, n int) {
for i := max(y, 0); i < min(y+n, len(s.damaged)); i++ {
s.damaged[i] = true
}
}
// beginDamage starts a fresh account, sized to the screen this render is about
// to paint. A row outside it cannot be painted, so it cannot be damaged, and
// clamping here is what lets every reader index the record without checking.
// Reset on the way in rather than the way out, so no future exit path has to
// remember to do it. The slice is kept when the size holds, so a steady stream
// of frames allocates nothing.
func (s *TerminalRenderer) beginDamage(height int) {
if len(s.damaged) != height {
s.damaged = make([]bool, height)
return
}
clear(s.damaged)
}
// Redraw forces a full redraw of the screen. It's equivalent to calling
// [TerminalRenderer.Erase] and [TerminalRenderer.Render].
func (s *TerminalRenderer) Redraw(newbuf *RenderBuffer) {
@@ -1249,31 +1425,30 @@ func (s *TerminalRenderer) Redraw(newbuf *RenderBuffer) {
// Render renders changes of the screen to the internal buffer. Call
// [terminalWriter.Flush] to flush pending changes to the screen.
func (s *TerminalRenderer) Render(newbuf *RenderBuffer) {
// Do we need to render anything?
// Do we need to render anything? A latched resize counts: the terminal moved
// content the application cannot know about, so a frame it considers
// unchanged still has to be put back on screen.
touchedLines := newbuf.TouchedLines()
if !s.clear && touchedLines == 0 {
if !s.clear && !s.termResized && touchedLines == 0 {
return
}
// Every reader below indexes the touch list by screen row, so the list has to
// cover the screen before any of them run. Growing here is what lets them do
// it without a length check each.
newbuf.growTouched()
if s.curbuf == nil || s.curbuf.Bounds().Empty() {
// Initialize the current buffer
s.curbuf = NewRenderBuffer(newbuf.Width(), newbuf.Height())
}
newWidth, newHeight := newbuf.Width(), newbuf.Height()
curWidth, curHeight := s.curbuf.Width(), s.curbuf.Height()
curHeight := s.curbuf.Height()
if curWidth != newWidth || curHeight != newHeight {
s.oldhash, s.newhash = nil, nil
// A shrink makes the terminal reflow in emulator-defined ways the
// incremental model cannot predict; force a full repaint. Only in
// fullscreen, where the renderer owns every cell it is about to
// clear. Inline mode shares the screen with whatever came before,
// so it uses the narrower partial clear below instead.
if s.flags.Contains(tFullscreen) && (newWidth < curWidth || newHeight < curHeight) {
s.clear = true
}
}
s.beginDamage(newHeight)
size := s.reconcileSize(newbuf)
// TODO: Investigate whether this is necessary. Theoretically, terminals
// can add/remove tab stops and we should be able to handle that. We could
@@ -1288,29 +1463,37 @@ func (s *TerminalRenderer) Render(newbuf *RenderBuffer) {
var nonEmpty int
// XXX: In inline mode, after a screen resize, we need to clear the extra
// lines at the bottom of the screen. This is because in inline mode, we
// don't use the full screen height and the current buffer size might be
// larger than the new buffer size.
// An inline frame that gives up rows has to erase what it no longer covers,
// or the tail of the taller frame stays on screen below the shorter one.
// Fullscreen has no such rows: it repaints the whole screen instead.
//
// The erase runs from the new last row to the bottom of the screen, so it
// reaches that residue wherever the terminal moved it. A width change at
// the same time is therefore a reason to erase rather than a reason to skip
// it, since a terminal that rewrapped those rows has spread them further
// than the model can account for.
partialClear := !s.flags.Contains(tFullscreen) && s.cur.X != -1 && s.cur.Y != -1 &&
curWidth == newWidth &&
curHeight > 0 &&
curHeight > newHeight
if !s.clear && partialClear {
s.clearBelow(newbuf, nil, newHeight-1)
// From the first row the frame gave up, so the frame's own rows are left
// alone: nothing it still owns needs putting back, and a frame that
// collapsed to nothing starts at its own first row rather than the one
// above, which belongs to whatever shared the screen first.
s.clearBelow(newbuf, nil, newHeight)
}
// Resize the model before diffing so the loop below walks every row
// of the new screen, including rows added by a grow.
if curWidth != newWidth || curHeight != newHeight {
if size.frameResized {
s.curbuf.Resize(newWidth, newHeight)
}
if s.clear { //nolint:nestif
s.clearUpdate(newbuf)
s.clear = false
} else if touchedLines > 0 {
} else if touchedLines > 0 || size.repaintAll {
// On Windows, there's a bug with Windows Terminal where [ansi.DECSTBM]
// misbehaves and moves the cursor outside of the scrolling region. For
// now, we disable the optimizations completely on Windows.
@@ -1329,44 +1512,24 @@ func (s *TerminalRenderer) Render(newbuf *RenderBuffer) {
nonEmpty = s.clearBottom(newbuf, nonEmpty)
for i = 0; i < nonEmpty; i++ {
if newbuf.Touched == nil || i >= len(newbuf.Touched) || (newbuf.Touched[i] != nil &&
// A repaintAll row has to be put back whatever the model says.
if size.repaintAll || s.damaged[i] || (newbuf.Touched[i] != nil &&
(newbuf.Touched[i].FirstCell != -1 || newbuf.Touched[i].LastCell != -1)) {
s.transformLine(newbuf, i)
s.paintLine(newbuf, i, size.repaintAll)
changedLines++
}
// Mark line changed successfully.
if i < len(newbuf.Touched) && i <= newbuf.Height()-1 {
newbuf.Touched[i] = &LineData{
FirstCell: -1, LastCell: -1,
}
}
if i < len(s.curbuf.Touched) && i < s.curbuf.Height()-1 {
s.curbuf.Touched[i] = &LineData{
FirstCell: -1, LastCell: -1,
}
}
}
}
if !s.flags.Contains(tFullscreen) && (curWidth != newWidth || curHeight != newHeight) {
s.move(newbuf, 0, newHeight-1)
if !s.flags.Contains(tFullscreen) && size.frameResized {
s.move(newbuf, 0, max(newHeight-1, 0))
}
// Sync windows and screen
newbuf.Touched = make([]*LineData, newHeight)
for i := range newbuf.Touched {
newbuf.Touched[i] = &LineData{
FirstCell: -1, LastCell: -1,
}
}
for i := range s.curbuf.Touched {
s.curbuf.Touched[i] = &LineData{
FirstCell: -1, LastCell: -1,
}
}
newbuf.growTouched()
resetTouched(newbuf.Touched)
s.updatePen(nil) // nil indicates a blank cell with no styles
s.updatePen(nil)
}
// Erase marks the screen to be fully erased on the next render.
@@ -1385,10 +1548,25 @@ func (s *TerminalRenderer) Erase() {
// -1 there means "first move, assume the origin" rather than "unknown", so
// invalidating would assert a position instead of forgetting one. Keep the
// old model in that mode and let the next render diff against it.
func (s *TerminalRenderer) Resize(width, _ int) {
//
// A resize also forces the next render to repaint, since the screen moves
// content around to fit and the model cannot see where it went. Whether that
// means the whole screen or only the frame's own rows is decided in
// [TerminalRenderer.Render].
func (s *TerminalRenderer) Resize(width, height int) {
if s.tabs != nil {
s.tabs.Resize(width)
}
// Latched against the size last reported, not against the model, because
// Render sees only the size the application draws at: a screen that shrinks
// and grows back between two frames looks unchanged by the time it gets
// there. What the resize costs is decided in Render, which knows the mode.
if width > 0 && height > 0 {
s.termResized = s.termResized || (s.termW > 0 && (width != s.termW || height != s.termH))
s.termW, s.termH = width, height
}
if !s.flags.Contains(tRelativeCursor) {
s.cur.X, s.cur.Y = -1, -1
}
@@ -1724,3 +1902,15 @@ func xtermCaps(termtype string) (v capabilities) {
return v
}
// resetTouched marks every line as untouched, reusing the records already there
// rather than allocating a new one per line on every render.
func resetTouched(touched []*LineData) {
for i, ld := range touched {
if ld == nil {
touched[i] = &LineData{FirstCell: -1, LastCell: -1}
continue
}
ld.FirstCell, ld.LastCell = -1, -1
}
}
@@ -110,6 +110,11 @@ func (s *TerminalRenderer) scrolln(newbuf *RenderBuffer, n, top, bot, maxY int)
s.scrollBuffer(s.curbuf, n, top, bot, blank)
// Every row in the range moved, whether or not the application drew into
// it. A row the scroll already put in the right place costs a comparison
// and no output, which is the whole point of scrolling in hardware.
s.damage(top, bot-top+1)
// shift hash values too, they can be reused
s.scrollOldhash(n, top, bot)
@@ -144,25 +149,6 @@ func (s *TerminalRenderer) scrollBuffer(b *RenderBuffer, n, top, bot int, blank
b.FillArea(blank, Rect(0, line, b.Width(), 1))
}
}
s.touchLine(b, top, bot-top+1, true)
}
// touchLine marks the line as touched.
func (s *TerminalRenderer) touchLine(newbuf *RenderBuffer, y, n int, changed bool) {
height := newbuf.Height()
if n < 0 || y < 0 || y >= height || newbuf.Touched == nil || len(newbuf.Touched) < height {
return // Nothing to touch
}
width := newbuf.Width()
for i := y; i < y+n && i < height && i < len(newbuf.Touched); i++ {
if changed {
newbuf.TouchLine(0, i, width)
} else {
newbuf.Touched[i] = nil
}
}
}
// scrollUp scrolls the screen up by n lines.
+1 -1
View File
@@ -30,7 +30,7 @@ func (s *TerminalRenderer) updateHashmap(newbuf *RenderBuffer) {
if len(s.oldhash) >= height && len(s.newhash) >= height {
// rehash changed lines
for i := range height {
if newbuf.Touched == nil || newbuf.Touched[i] != nil {
if newbuf.Touched[i] != nil {
// TODO: Investigate why this is needed. If we remove this
// line, scroll optimization does not work correctly. This
// should happen else where.
+26 -1
View File
@@ -35,6 +35,7 @@ type TerminalScreen struct {
backgroundColor color.Color
foregroundColor color.Color
progressBar *ProgressBar
programStatus *ProgramStatus
windowTitle string
syncUpdates bool // mode 2026
resetTabs bool // DECST8C - reset terminal tabs on start
@@ -205,7 +206,6 @@ func (s *TerminalScreen) Resize(width, height int) {
s.rbuf.Resize(width, height)
s.rend.Resize(width, height)
s.rend.Erase()
s.rbuf.Touched = nil
}
// Display clears the screen and draws the given [Drawable] onto the terminal
@@ -621,6 +621,24 @@ func (s *TerminalScreen) ProgressBar() *ProgressBar {
return s.progressBar
}
// SetProgramStatus reports the program status to the terminal using the
// Program Status Protocol (OSC 7501). A nil status removes every program
// status record on the terminal.
//
// The changes can be committed to the underlying writer by calling the
// [TerminalScreen.Flush] method.
func (s *TerminalScreen) SetProgramStatus(ps *ProgramStatus) {
EncodeProgramStatus(s.buf, ps) //nolint:errcheck
s.programStatus = ps
}
// ProgramStatus returns the last program status reported to the terminal.
//
// A nil value indicates that no program status is currently set.
func (s *TerminalScreen) ProgramStatus() *ProgramStatus {
return s.programStatus
}
// Reset resets the terminal screen to its default state, clearing the screen,
// switching back to the main screen buffer if necessary, and resetting all
// terminal settings to their defaults.
@@ -674,6 +692,10 @@ func (s *TerminalScreen) Reset() {
if s.progressBar != nil && s.progressBar.State != ProgressBarNone {
sb.WriteString(ansi.ResetProgressBar)
}
// The program status is deliberately left in place. The Program Status
// Protocol expects done and error records to outlive the program, and
// terminals drop working and blocked records on their own when the
// process exits or the next shell prompt starts.
s.buf.WriteString(sb.String())
@@ -742,6 +764,9 @@ func (s *TerminalScreen) Restore() {
if s.progressBar != nil && s.progressBar.State != ProgressBarNone {
EncodeProgressBar(&sb, s.progressBar)
}
if s.programStatus != nil {
EncodeProgramStatus(&sb, s.programStatus) //nolint:errcheck
}
s.buf.WriteString(sb.String())
+47 -1
View File
@@ -25,7 +25,7 @@ type Drawable interface {
// DrawableFunc is a function that implements the [Drawable] interface.
type DrawableFunc func(scr Screen, rect Rectangle)
var _ Drawable = (DrawableFunc)(nil)
var _ Drawable = DrawableFunc(nil)
// Draw implements the [Drawable] interface.
func (f DrawableFunc) Draw(scr Screen, rect Rectangle) {
@@ -382,6 +382,52 @@ func EncodeProgressBar(w io.Writer, pb *ProgressBar) error {
return nil
}
// ProgramStatus is a Program Status Protocol (OSC 7501) report.
//
// See: https://www.superlogical.com/rex/docs/build/program-status
type ProgramStatus = ansi.ProgramStatus
// ProgramState is the state of a [ProgramStatus].
type ProgramState = ansi.ProgramState
// ProgramStatusKind says what a blocked program waits for.
type ProgramStatusKind = ansi.ProgramStatusKind
// Program states.
const (
ProgramStateIdle = ansi.ProgramStateIdle
ProgramStateWorking = ansi.ProgramStateWorking
ProgramStateDone = ansi.ProgramStateDone
ProgramStateBlocked = ansi.ProgramStateBlocked
ProgramStateError = ansi.ProgramStateError
ProgramStateClear = ansi.ProgramStateClear
)
// Program status kinds.
const (
ProgramStatusKindPermission = ansi.ProgramStatusKindPermission
ProgramStatusKindQuestion = ansi.ProgramStatusKindQuestion
ProgramStatusKindAuth = ansi.ProgramStatusKindAuth
)
// EncodeProgramStatus encodes the program status to the given writer. A nil
// status removes every program status record on the terminal.
func EncodeProgramStatus(w io.Writer, ps *ProgramStatus) error {
seq := ansi.ClearProgramStatus
if ps != nil {
seq = ansi.SetProgramStatus(*ps)
if seq == "" {
return fmt.Errorf("invalid program status: state=%q id=%q", ps.State, ps.ID)
}
}
if _, err := io.WriteString(w, seq); err != nil {
return fmt.Errorf("failed to set program status: %w", err)
}
return nil
}
// EncodeKeyboardEnhancements encodes the keyboard enhancements to the given
// writer.
func EncodeKeyboardEnhancements(w io.Writer, ke *KeyboardEnhancements) error {
+234
View File
@@ -0,0 +1,234 @@
package ansi
import (
"encoding/base64"
"strconv"
"strings"
"unicode/utf8"
)
// ProgramState is the state of a program reported with the Program Status
// Protocol (OSC 7501).
//
// See: https://www.superlogical.com/rex/docs/build/program-status
type ProgramState string
// Program states.
const (
// ProgramStateIdle means the program is at rest, waiting for the user's
// next instruction.
ProgramStateIdle ProgramState = "idle"
// ProgramStateWorking means the program is running.
ProgramStateWorking ProgramState = "working"
// ProgramStateDone means the program finished a piece of work and the
// result is ready to look at.
ProgramStateDone ProgramState = "done"
// ProgramStateBlocked means the program cannot continue until the user
// does something.
ProgramStateBlocked ProgramState = "blocked"
// ProgramStateError means the program failed and stopped.
ProgramStateError ProgramState = "error"
// ProgramStateClear removes the addressed record and every record beneath
// it. With no id, it removes every record on the terminal.
ProgramStateClear ProgramState = "clear"
)
// ProgramStatusKind says what a blocked program is waiting for.
type ProgramStatusKind string
// Program status kinds.
const (
// ProgramStatusKindPermission means the program waits for approval to do
// something.
ProgramStatusKindPermission ProgramStatusKind = "permission"
// ProgramStatusKindQuestion means the user must type an answer.
ProgramStatusKindQuestion ProgramStatusKind = "question"
// ProgramStatusKindAuth means the program waits for a login, token, or
// credential.
ProgramStatusKindAuth ProgramStatusKind = "auth"
)
// ProgramStatus is a report of the Program Status Protocol (OSC 7501).
//
// Each report replaces its record completely, so fields such as App and Title
// should be included in every report.
//
// See: https://www.superlogical.com/rex/docs/build/program-status
type ProgramStatus struct {
// State is the program state. Required.
State ProgramState
// ID addresses a record. Empty means the root record. It is a "/"
// separated path of segments matching [A-Za-z0-9_.+-]{1,32}.
ID string
// App is a stable machine-readable program name matching
// [A-Za-z0-9_.+-]{1,32}. Invalid values are omitted.
App string
// Kind says what a blocked program waits for. Only used with
// [ProgramStateBlocked].
Kind ProgramStatusKind
// Progress is a percentage between 0 and 100. Only used with
// [ProgramStateWorking] and [ProgramStateBlocked] when HasProgress is
// true. Otherwise progress is indeterminate.
Progress int
// HasProgress reports whether Progress is set.
HasProgress bool
// Title is a short human-readable label for the record.
Title string
// Message is one human-readable line describing the record.
Message string
}
// Program Status Protocol limits on decoded text.
const (
programStatusMaxTitle = 192
programStatusMaxMessage = 2048
)
// ClearProgramStatus is a sequence that removes every program status record
// on the terminal.
//
// OSC 7501 ; state=clear BEL
//
// See: https://www.superlogical.com/rex/docs/build/program-status
const ClearProgramStatus = "\x1b]7501;state=clear\x07"
// RequestProgramStatusSupport is a sequence that asks the terminal whether it
// supports the Program Status Protocol. A supporting terminal replies with
// OSC 7501 ; ? ST.
//
// OSC 7501 ; ? BEL
//
// See: https://www.superlogical.com/rex/docs/build/program-status
const RequestProgramStatusSupport = "\x1b]7501;?\x07"
// ClearProgramStatusID returns a sequence that removes the record with the
// given id and every record beneath it. An empty id removes every record. It
// returns an empty string if the id is invalid.
//
// OSC 7501 ; state=clear:id=Id BEL
//
// See: https://www.superlogical.com/rex/docs/build/program-status
func ClearProgramStatusID(id string) string {
return SetProgramStatus(ProgramStatus{State: ProgramStateClear, ID: id})
}
// SetProgramStatus returns a sequence that reports a program status using the
// Program Status Protocol (OSC 7501).
//
// OSC 7501 ; key=value:key=value BEL
//
// Terminals discard a whole report when any part of it is invalid, so the
// report is sanitized: control characters in Title and Message are replaced
// with spaces and the text is truncated to the protocol limits, and an invalid
// App, or a Kind or Progress used with the wrong state, is omitted. It returns
// an empty string if the state is unknown or the id is invalid.
//
// See: https://www.superlogical.com/rex/docs/build/program-status
func SetProgramStatus(s ProgramStatus) string {
switch s.State {
case ProgramStateIdle, ProgramStateWorking, ProgramStateDone,
ProgramStateBlocked, ProgramStateError, ProgramStateClear:
default:
return ""
}
if s.ID != "" && !validProgramStatusID(s.ID) {
return ""
}
pairs := []string{"state=" + string(s.State)}
if s.ID != "" {
pairs = append(pairs, "id="+s.ID)
}
if s.State == ProgramStateClear {
return programStatusSequence(pairs)
}
if s.State == ProgramStateBlocked && validProgramStatusKind(s.Kind) {
pairs = append(pairs, "kind="+string(s.Kind))
}
if s.HasProgress && (s.State == ProgramStateWorking || s.State == ProgramStateBlocked) {
pairs = append(pairs, "progress="+strconv.Itoa(min(max(s.Progress, 0), 100)))
}
if validProgramStatusSegment(s.App) {
pairs = append(pairs, "app="+s.App)
}
if t := programStatusText(s.Title, programStatusMaxTitle); t != "" {
pairs = append(pairs, "title="+t)
}
if m := programStatusText(s.Message, programStatusMaxMessage); m != "" {
pairs = append(pairs, "msg="+m)
}
return programStatusSequence(pairs)
}
func programStatusSequence(pairs []string) string {
return "\x1b]7501;" + strings.Join(pairs, ":") + "\x07"
}
func validProgramStatusKind(k ProgramStatusKind) bool {
switch k {
case ProgramStatusKindPermission, ProgramStatusKindQuestion, ProgramStatusKindAuth:
return true
}
return false
}
// programStatusText sanitizes, truncates, and base64 encodes free text.
func programStatusText(s string, limit int) string {
s = strings.ToValidUTF8(s, "\uFFFD")
var sb strings.Builder
space := false
for _, r := range s {
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
if !space {
sb.WriteByte(' ')
space = true
}
continue
}
space = false
sb.WriteRune(r)
}
s = strings.TrimSpace(sb.String())
if len(s) > limit {
s = s[:limit]
for len(s) > 0 && !utf8.ValidString(s) {
s = s[:len(s)-1]
}
s = strings.TrimSpace(s)
}
if s == "" {
return ""
}
return base64.StdEncoding.EncodeToString([]byte(s))
}
func validProgramStatusID(id string) bool {
if len(id) > 128 {
return false
}
segs := strings.Split(id, "/")
if len(segs) > 8 {
return false
}
for _, seg := range segs {
if !validProgramStatusSegment(seg) {
return false
}
}
return true
}
func validProgramStatusSegment(s string) bool {
if len(s) < 1 || len(s) > 32 {
return false
}
for i := range len(s) {
c := s[i]
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') &&
c != '_' && c != '.' && c != '+' && c != '-' {
return false
}
}
return true
}
-1
View File
@@ -1,5 +1,4 @@
//go:build !go1.21
// +build !go1.21
/*
Copyright 2019 The logr Authors.
-1
View File
@@ -1,5 +1,4 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2019 The logr Authors.
+36 -11
View File
@@ -426,7 +426,7 @@ func (f Formatter) colon() byte {
}
func (f Formatter) pretty(value any) string {
return f.prettyWithFlags(value, 0, 0)
return f.prettyWithFlags(value, 0, 0, 0, nil)
}
const (
@@ -434,7 +434,13 @@ const (
)
// TODO: This is not fast. Most of the overhead goes here.
func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
// value: The value to render
// flags: Bitmask of flags (see above)
// depth: The current depth of nested structs, slices, arrays, and maps
// ptrDepth: The current depth of including pointer dereferences
// ptrMap: A map of pointers already seen, to avoid infinite recursion (usually
// nil unless ptrDepth is large)
func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth int, ptrMap map[uintptr]bool) string {
if depth > f.opts.MaxLogDepth {
return `"<max-log-depth-exceeded>"`
}
@@ -504,7 +510,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
// arbitrary keys might need escaping
buf.WriteString(prettyString(k))
buf.WriteByte(f.colon())
buf.WriteString(f.prettyWithFlags(v[i+1], 0, depth+1))
buf.WriteString(f.prettyWithFlags(v[i+1], 0, depth+1, ptrDepth+1, ptrMap))
}
if flags&flagRawStruct == 0 {
buf.WriteByte('}')
@@ -576,7 +582,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
}
printComma = true // if we got here, we are rendering a field
if fld.Anonymous && fld.Type.Kind() == reflect.Struct && name == "" {
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), flags|flagRawStruct, depth+1))
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), flags|flagRawStruct, depth+1, ptrDepth+1, ptrMap))
continue
}
if name == "" {
@@ -585,7 +591,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
// field names can't contain characters which need escaping
buf.WriteString(f.quoted(name, false))
buf.WriteByte(f.colon())
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), 0, depth+1))
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), 0, depth+1, ptrDepth+1, ptrMap))
}
if flags&flagRawStruct == 0 {
buf.WriteByte('}')
@@ -612,7 +618,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
buf.WriteByte(f.comma())
}
e := v.Index(i)
buf.WriteString(f.prettyWithFlags(e.Interface(), 0, depth+1))
buf.WriteString(f.prettyWithFlags(e.Interface(), 0, depth+1, ptrDepth+1, ptrMap))
}
buf.WriteByte(']')
return buf.String()
@@ -637,7 +643,8 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
keystr = prettyString(keystr)
} else {
// prettyWithFlags will produce already-escaped values
keystr = f.prettyWithFlags(it.Key().Interface(), 0, depth+1)
// key depth is unrelated to overall depth
keystr = f.prettyWithFlags(it.Key().Interface(), 0, 0, ptrDepth, ptrMap)
if t.Key().Kind() != reflect.String {
// JSON only does string keys. Unlike Go's standard JSON, we'll
// convert just about anything to a string.
@@ -646,16 +653,34 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
}
buf.WriteString(keystr)
buf.WriteByte(f.colon())
buf.WriteString(f.prettyWithFlags(it.Value().Interface(), 0, depth+1))
buf.WriteString(f.prettyWithFlags(it.Value().Interface(), 0, depth+1, ptrDepth+1, ptrMap))
i++
}
buf.WriteByte('}')
return buf.String()
case reflect.Ptr, reflect.Interface:
case reflect.Pointer, reflect.Interface:
if v.IsNil() {
return "null"
}
return f.prettyWithFlags(v.Elem().Interface(), 0, depth)
// Special case: recursive pointers. For normal use we do not want to
// count pointer dereferences as depth, but if we see the same pointer
// again we have a recursion and need to stop. After a large number of
// pointer dereferences we will start tracking pointers to avoid the
// perf hit of doing it in the normal path.
//
// This should not happen accidentally (e.g. json decoding should never
// do this) but we can handle it gracefully.
if ptrMap != nil && ptrMap[uintptr(v.Pointer())] {
depth = f.opts.MaxLogDepth + 1 // force a depth error
}
const maxDepthFactor = 4 // arbitrary, but we want it large enough to not false-alert
if ptrDepth > f.opts.MaxLogDepth*maxDepthFactor && ptrMap == nil {
ptrMap = map[uintptr]bool{}
}
if ptrMap != nil {
ptrMap[(uintptr)(v.Pointer())] = true
}
return f.prettyWithFlags(v.Elem().Interface(), 0, depth, ptrDepth+1, ptrMap)
}
return fmt.Sprintf(`"<unhandled-%s>"`, t.Kind().String())
}
@@ -697,7 +722,7 @@ func isEmpty(v reflect.Value) bool {
return v.Float() == 0
case reflect.Complex64, reflect.Complex128:
return v.Complex() == 0
case reflect.Interface, reflect.Ptr:
case reflect.Interface, reflect.Pointer:
return v.IsNil()
}
return false
+16 -6
View File
@@ -1,5 +1,4 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2023 The logr Authors.
@@ -33,7 +32,7 @@ const extraSlogSinkDepth = 3 // 2 for slog, 1 for SlogSink
func (l fnlogger) Handle(_ context.Context, record slog.Record) error {
kvList := make([]any, 0, 2*record.NumAttrs())
record.Attrs(func(attr slog.Attr) bool {
kvList = attrToKVs(attr, kvList)
kvList = attrToKVs(attr, kvList, l.opts.MaxLogDepth)
return true
})
@@ -49,7 +48,7 @@ func (l fnlogger) Handle(_ context.Context, record slog.Record) error {
func (l fnlogger) WithAttrs(attrs []slog.Attr) logr.SlogSink {
kvList := make([]any, 0, 2*len(attrs))
for _, attr := range attrs {
kvList = attrToKVs(attr, kvList)
kvList = attrToKVs(attr, kvList, l.opts.MaxLogDepth)
}
l.AddValues(kvList)
return &l
@@ -61,14 +60,25 @@ func (l fnlogger) WithGroup(name string) logr.SlogSink {
}
// attrToKVs appends a slog.Attr to a logr-style kvList. It handle slog Groups
// and other details of slog.
func attrToKVs(attr slog.Attr, kvList []any) []any {
// and other details of slog. maxDepth bounds recursion into nested groups so a
// deeply-nested slog.Group cannot exhaust the stack; it is decremented per group
// level and starts at the Formatter's MaxLogDepth (past which the formatter would
// truncate the rendering anyway).
func attrToKVs(attr slog.Attr, kvList []any, maxDepth int) []any {
attrVal := attr.Value.Resolve()
if attrVal.Kind() == slog.KindGroup {
if maxDepth <= 0 {
// Nesting is too deep to build without risking a stack overflow.
// Stop here; the formatter truncates below MaxLogDepth regardless.
if attr.Key != "" {
kvList = append(kvList, attr.Key, "<max-log-depth-exceeded>")
}
return kvList
}
groupVal := attrVal.Group()
grpKVs := make([]any, 0, 2*len(groupVal))
for _, attr := range groupVal {
grpKVs = attrToKVs(attr, grpKVs)
grpKVs = attrToKVs(attr, grpKVs, maxDepth-1)
}
if attr.Key == "" {
// slog says we have to inline these
-1
View File
@@ -1,5 +1,4 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2023 The logr Authors.
-1
View File
@@ -1,5 +1,4 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2023 The logr Authors.
-1
View File
@@ -1,5 +1,4 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2023 The logr Authors.
+1
View File
@@ -1,2 +1,3 @@
* -text
*.bin -text -diff
*.md text eol=lf
+10 -1
View File
@@ -31,6 +31,9 @@ builds:
- mips64le
goarm:
- 7
ignore:
- goos: windows
goarch: arm
-
id: "s2d"
binary: s2d
@@ -57,6 +60,9 @@ builds:
- mips64le
goarm:
- 7
ignore:
- goos: windows
goarch: arm
-
id: "s2sx"
binary: s2sx
@@ -84,6 +90,9 @@ builds:
- mips64le
goarm:
- 7
ignore:
- goos: windows
goarch: arm
archives:
-
@@ -91,7 +100,7 @@ archives:
name_template: "s2-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format_overrides:
- goos: windows
format: zip
formats: ['zip']
files:
- unpack/*
- s2/LICENSE
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff. Show more