Compare commits

..
Author SHA1 Message Date
moloch-- e4c6221c3c Expand test harness 2026-01-29 12:44:28 -08:00
moloch-- c3108c4e70 Add e2e implant test harness 2026-01-29 10:48:55 -08:00
5659 changed files with 309653 additions and 929141 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @BishopFox/bf-red-team
* @BishopFox/red-team
+12 -31
View File
@@ -2,8 +2,8 @@ name: Release
on:
push:
tags:
- "v*.*.*"
branches:
- master
permissions:
contents: write
@@ -17,53 +17,34 @@ jobs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Check Out Code
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate Release Tag
- name: Find Semver Tag
id: tag
run: |
git fetch --prune --tags -f origin
tag="${GITHUB_REF_NAME}"
if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag $tag is not a release semver tag."
exit 0
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/master"; then
echo "Tag $tag does not point to a commit on origin/master."
exit 0
fi
git fetch --prune --tags -f
tag=$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
echo "tag=$tag" >> "$GITHUB_OUTPUT"
e2e:
name: End-to-end test suites
needs: tag-check
if: needs.tag-check.outputs.tag != ''
uses: ./.github/workflows/comprehensive-e2e.yml
release:
name: Release
needs:
- tag-check
- e2e
if: needs.tag-check.outputs.tag != '' && needs.e2e.result == 'success'
needs: tag-check
if: needs.tag-check.outputs.tag != ''
runs-on: ubuntu-latest
environment: release
timeout-minutes: 45
steps:
- name: Check Out Code
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Go 1.27.3
uses: actions/setup-go@v7
- name: Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.27.3"
go-version: "^1.25"
- name: Install Minisign
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
+5 -5
View File
@@ -16,13 +16,13 @@ jobs:
steps:
# Setup go environment
- name: Go 1.27.3
uses: actions/setup-go@v7
- name: Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.27.3"
go-version: "^1.25"
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
# Must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head of the pull request.
@@ -36,7 +36,7 @@ jobs:
git build-essential zlib1g zlib1g-dev wget zip unzip
- name: Check Out Code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
-551
View File
@@ -1,551 +0,0 @@
name: Comprehensive e2e Tests
on:
workflow_dispatch:
inputs:
test_scope:
description: Test scope to run across the complete platform matrix
required: true
default: comprehensive
type: choice
options:
- comprehensive
- rportfwd
workflow_call:
inputs:
test_scope:
description: Test scope to run across the complete platform matrix
required: false
default: comprehensive
type: string
permissions:
contents: read
env:
SLIVER_E2E_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
concurrency:
group: comprehensive-e2e-${{ github.ref }}
cancel-in-progress: false
jobs:
authorize:
name: Authorize invocation
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check actor repository permission
uses: actions/github-script@v9
env:
REQUESTING_ACTOR: ${{ github.triggering_actor }}
REQUESTED_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
with:
script: |
const testScope = process.env.REQUESTED_SCOPE || "comprehensive";
if (!new Set(["comprehensive", "rportfwd"]).has(testScope)) {
core.setFailed(`Unsupported E2E test scope: ${JSON.stringify(testScope)}`);
return;
}
core.info(`Validated E2E test scope ${testScope}`);
const isReleaseTag = context.eventName === "push" && context.ref.startsWith("refs/tags/v");
if (isReleaseTag) {
core.info(`Authorized release test invocation for ${context.ref}`);
return;
}
const actor = process.env.REQUESTING_ACTOR || context.actor;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: actor,
});
core.info(`${actor} has repository permission ${data.permission} (${data.role_name})`);
if (data.permission !== "admin") {
core.setFailed("Comprehensive e2e Tests may only be run by repository administrators");
}
rportfwd-security-regression:
name: Reverse-port-forward malicious protocol regression
needs: authorize
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
env:
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Test malicious destination poisoning and cross-session isolation
env:
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
run: >-
go test -buildvcs=false -mod=vendor
-tags=server,osusergo,netgo,go_sqlite,sliver_e2e
./server/c2
-run '^TestMTLSYamux_ReversePortForwardAuthorizationCannotBePoisonedOrReplayed$'
-count=1 -v
darwin:
name: comprehensive-e2e-darwin-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
goarch: amd64
server_arch: amd64
- runner: macos-15
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'darwin/%s\n' '${{ matrix.goarch }}' > e2e-results/target-darwin-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: darwin
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: darwin
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e ./test/e2e
- name: Run selected E2E scope
run: |
set -o pipefail
./sliver-comprehensive-e2e \
-test.v \
-test.run=^TestComprehensiveE2E$ \
-test.timeout=0 \
-repo . \
-server ./sliver-server \
-server-arch ${{ matrix.server_arch }} \
-target-os darwin \
-target-arch ${{ matrix.goarch }} \
-results ./e2e-results \
-transports mtls,wg,http \
-implant-modes session,beacon \
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os darwin
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-darwin-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
linux-native:
name: comprehensive-e2e-linux-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
goarch: amd64
server_arch: amd64
- runner: ubuntu-24.04-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'linux/%s\n' '${{ matrix.goarch }}' > e2e-results/target-linux-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: linux
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: linux
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e ./test/e2e
- name: Run selected E2E scope
run: |
set -o pipefail
./sliver-comprehensive-e2e \
-test.v \
-test.run=^TestComprehensiveE2E$ \
-test.timeout=0 \
-repo . \
-server ./sliver-server \
-server-arch ${{ matrix.server_arch }} \
-target-os linux \
-target-arch ${{ matrix.goarch }} \
-results ./e2e-results \
-transports mtls,wg,http \
-implant-modes session,beacon \
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os linux
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-linux-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
windows:
name: comprehensive-e2e-windows-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: windows-2022
goarch: amd64
server_arch: amd64
- runner: windows-11-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'windows/%s\n' '${{ matrix.goarch }}' > e2e-results/target-windows-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: windows
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server.exe ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: windows
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e.exe ./test/e2e
- name: Run selected E2E scope
run: |
set -o pipefail
./sliver-comprehensive-e2e.exe \
-test.v \
-test.run=^TestComprehensiveE2E$ \
-test.timeout=0 \
-repo . \
-server ./sliver-server.exe \
-server-arch ${{ matrix.server_arch }} \
-target-os windows \
-target-arch ${{ matrix.goarch }} \
-results ./e2e-results \
-transports mtls,wg,http \
-implant-modes session,beacon \
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os windows
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-windows-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
aggregate-rportfwd:
name: Aggregate reverse-port-forward E2E coverage
needs:
- authorize
- rportfwd-security-regression
- darwin
- linux-native
- windows
if: ${{ always() && needs.authorize.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download target coverage
id: download
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: comprehensive-e2e-target-*
path: rportfwd-coverage-input
- name: Prepare aggregate artifact
if: ${{ always() }}
run: |
mkdir -p rportfwd-coverage-input rportfwd-coverage-summary
printf 'Reverse-port-forward E2E coverage aggregate\n' > rportfwd-coverage-summary/README.txt
- name: Aggregate coverage and enforce all 162 required cells
id: aggregate
if: ${{ always() }}
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input rportfwd-coverage-input
-output rportfwd-coverage-summary
- name: Publish reverse-port-forward coverage table
if: ${{ always() }}
run: |
report="rportfwd-coverage-summary/rportfwd-coverage.md"
if [[ -s "${report}" ]]; then
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
else
printf 'Reverse-port-forward coverage report is missing or empty: %s\n' "${report}" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload aggregate reverse-port-forward coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-rportfwd-coverage-summary
path: rportfwd-coverage-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed prerequisites, missing, malformed, or unavailable coverage
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.download.outcome != 'success' || steps.aggregate.outcome != 'success' }}
run: exit 1
aggregate-coverage:
name: Aggregate comprehensive E2E coverage
needs:
- authorize
- rportfwd-security-regression
- darwin
- linux-native
- windows
if: ${{ always() && needs.authorize.result == 'success' && inputs.test_scope != 'rportfwd' }}
outputs:
command_coverage_markdown: ${{ steps.publish_command_coverage.outputs.command_coverage_markdown }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download target coverage
uses: actions/download-artifact@v8
with:
pattern: comprehensive-e2e-target-*
path: coverage-input
merge-multiple: true
- name: Prepare aggregate artifact
run: |
mkdir -p coverage-input coverage-summary
printf 'Comprehensive E2E coverage aggregate\n' > coverage-summary/README.txt
- name: Aggregate coverage and enforce required cells
id: aggregate
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/report
-input coverage-input
-output coverage-summary
- name: Publish command coverage table
id: publish_command_coverage
if: ${{ always() }}
run: |
detailed_report="coverage-summary/coverage-summary.md"
report="coverage-summary/command-coverage.md"
if [[ ! -s "${report}" ]]; then
printf 'Command coverage report is missing or empty: %s\n' "${report}" >&2
exit 1
fi
delimiter="COMPREHENSIVE_E2E_COMMAND_COVERAGE_EOF"
if grep -Fxq "${delimiter}" "${report}"; then
printf 'Command coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
exit 1
fi
{
printf 'command_coverage_markdown<<%s\n' "${delimiter}"
cat "${report}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
if [[ -f "${detailed_report}" ]]; then
cat "${detailed_report}" >> "${GITHUB_STEP_SUMMARY}"
fi
printf '\n## Command coverage by OS/architecture/transport\n\n' >> "${GITHUB_STEP_SUMMARY}"
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload aggregate coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-coverage-summary
path: coverage-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed prerequisites or failed or not-run coverage
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.aggregate.outcome != 'success' }}
run: exit 1
reflektor:
name: Existing Reflektor test matrix
needs: authorize
if: ${{ inputs.test_scope != 'rportfwd' }}
uses: ./.github/workflows/reflektor-tests.yml
shellcode:
name: Shellcode E2E test matrix
needs: authorize
if: ${{ inputs.test_scope != 'rportfwd' }}
uses: ./.github/workflows/shellcode-tests.yml
+6 -6
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Detect package manager
id: detect-package-manager
@@ -56,17 +56,17 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
node-version: "lts/*"
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@v5
- name: Restore cache
uses: actions/cache@v6
uses: actions/cache@v5
with:
path: |
.next/cache
@@ -85,7 +85,7 @@ jobs:
run: ${{ steps.detect-package-manager.outputs.runner }} next build
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@v4
with:
path: ${{ github.workspace }}/docs/sliver-docs/out
@@ -99,4 +99,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@v4
-56
View File
@@ -1,56 +0,0 @@
name: golangci-lint
on:
push:
branches:
- master
pull_request:
branches:
- master
paths:
- ".golangci.yml"
- ".github/workflows/golangci-lint.yml"
- "go.mod"
- "go.sum"
- "go.work"
- "go.work.sum"
- "Makefile"
- "vendor/**"
- "**/*.go"
permissions:
contents: read
jobs:
lint:
name: golangci-lint (${{ matrix.goos }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
goos:
- linux
- darwin
- windows
env:
GOOS: ${{ matrix.goos }}
GOARCH: amd64
CGO_ENABLED: "0"
steps:
- name: Check Out Code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.3"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.14.0
# Gate only findings introduced since the event base revision.
args: --new-from-rev=${{ github.event.pull_request.base.sha || github.event.before }} ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
-164
View File
@@ -1,164 +0,0 @@
name: Reflektor Integration Test Matrix
on:
workflow_call:
permissions:
contents: read
jobs:
darwin:
name: reflektor-darwin-${{ matrix.goarch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
goarch: amd64
server_arch: amd64
- runner: macos-15
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: darwin
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: darwin
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e
-repo .
-server ./sliver-server
-target-os darwin
-target-arch ${{ matrix.goarch }}
linux-native:
name: reflektor-linux-${{ matrix.goarch }}-native
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
goarch: amd64
server_arch: amd64
- runner: ubuntu-24.04-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: linux
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: linux
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e
-repo .
-server ./sliver-server
-target-os linux
-target-arch ${{ matrix.goarch }}
windows:
name: reflektor-windows-${{ matrix.goarch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: windows-2022
goarch: amd64
server_arch: amd64
- runner: windows-11-arm
goarch: arm64
server_arch: amd64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: windows
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server.exe ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: windows
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e.exe ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e.exe
-repo .
-server ./sliver-server.exe
-target-os windows
-target-arch ${{ matrix.goarch }}
-201
View File
@@ -1,201 +0,0 @@
name: Shellcode E2E Test Matrix
on:
workflow_call:
outputs:
shellcode_coverage_markdown:
description: Shellcode E2E coverage table in Markdown
value: ${{ jobs.aggregate.outputs.shellcode_coverage_markdown }}
permissions:
contents: read
jobs:
target:
name: shellcode-e2e-${{ matrix.target_os }}-${{ matrix.target_arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: macos-26
target_os: darwin
target_arch: arm64
server_os: darwin
server_arch: arm64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: ubuntu-24.04
target_os: linux
target_arch: amd64
server_os: linux
server_arch: amd64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: ubuntu-24.04-arm
target_os: linux
target_arch: arm64
server_os: linux
server_arch: arm64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: windows-2022
target_os: windows
target_arch: amd64
server_os: windows
server_arch: amd64
server_executable: ./sliver-server.exe
test_executable: ./sliver-shellcode-e2e.exe
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare target artifact
run: |
mkdir -p shellcode-results
printf '%s/%s\n' \
'${{ matrix.target_os }}' \
'${{ matrix.target_arch }}' \
> 'shellcode-results/target-${{ matrix.target_os }}-${{ matrix.target_arch }}.txt'
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor -trimpath ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: ${{ matrix.server_os }}
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o ${{ matrix.server_executable }} ./server
- name: Compile shellcode E2E client
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.target_arch }}
GOOS: ${{ matrix.target_os }}
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o ${{ matrix.test_executable }} ./test/e2e
- name: Run shellcode generation and execution tests
run: >-
${{ matrix.test_executable }}
-test.v
-test.run=^TestShellcodeE2E$
-test.timeout=0
-repo .
-server ${{ matrix.server_executable }}
-server-arch ${{ matrix.server_arch }}
-target-os ${{ matrix.target_os }}
-target-arch ${{ matrix.target_arch }}
-results ./shellcode-results
-transports mtls,wg,http
-implant-modes session,beacon
-beacon-interval 10s
-shellcode-sgn-samples 4
- name: Upload target shellcode results
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: shellcode-e2e-target-${{ matrix.target_os }}-${{ matrix.target_arch }}
path: shellcode-results
if-no-files-found: error
overwrite: true
retention-days: 14
aggregate:
name: Aggregate shellcode E2E coverage
needs: target
if: ${{ always() }}
outputs:
shellcode_coverage_markdown: ${{ steps.publish_shellcode_coverage.outputs.shellcode_coverage_markdown }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Prepare aggregate artifact
run: |
mkdir -p shellcode-input shellcode-summary
printf 'Shellcode E2E coverage aggregate\n' > shellcode-summary/README.txt
- name: Download target shellcode results
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: shellcode-e2e-target-*
path: shellcode-input
merge-multiple: true
- name: Aggregate shellcode coverage
id: aggregate
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor -trimpath ./test/e2e/shellcode-report
-input ./shellcode-input
-output ./shellcode-summary
- name: Publish shellcode coverage table
id: publish_shellcode_coverage
if: ${{ always() }}
run: |
report="shellcode-summary/shellcode-coverage.md"
if [[ ! -s "${report}" ]]; then
printf 'Shellcode coverage report is missing or empty: %s\n' "${report}" >&2
exit 1
fi
delimiter="SHELLCODE_E2E_COVERAGE_EOF"
if grep -Fxq "${delimiter}" "${report}"; then
printf 'Shellcode coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
exit 1
fi
{
printf 'shellcode_coverage_markdown<<%s\n' "${delimiter}"
cat "${report}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload aggregate shellcode coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: shellcode-e2e-coverage-summary
path: shellcode-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed or not-run shellcode coverage
if: ${{ always() && steps.aggregate.outcome != 'success' }}
run: exit 1
+16 -89
View File
@@ -6,15 +6,11 @@ on:
paths:
- "go.mod"
- "go.sum"
- ".gitattributes"
- "client/command/opfor/testdata/corpus/**"
- ".github/workflows/unit-tests.yml"
- "Makefile"
- "**/*.go"
- "**/*.c"
- "**/*.h"
- "**/*.sh"
- "**/*.ps1"
types:
- opened
- reopened
@@ -22,15 +18,15 @@ on:
- ready_for_review
jobs:
linux-build-test:
name: Linux Build & Test
linux-windows-build-test:
name: Linux & Windows Test
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.27.3
uses: actions/setup-go@v7
- name: Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.27.3"
go-version: "^1.25"
- name: OS Packages
run: |
@@ -38,7 +34,7 @@ jobs:
git build-essential zlib1g zlib1g-dev wget zip unzip
- name: Check Out Code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -52,64 +48,8 @@ jobs:
- name: Make Linux (amd64)
run: make linux-amd64
- name: Determine go-tests flags
id: go-tests-flags
shell: bash
run: |
set -euo pipefail
base_sha="${{ github.event.pull_request.base.sha }}"
head_sha="${{ github.sha }}"
if git diff --name-only "$base_sha" "$head_sha" | grep -qE '^(implant/|server/gogo/|server/generate/)'; then
echo "args=" >> "$GITHUB_OUTPUT"
else
echo "args=--skip-generate" >> "$GITHUB_OUTPUT"
fi
- name: Test Linux
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh ${{ steps.go-tests-flags.outputs.args }}
windows-build-test:
name: Windows Build & Test
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.3"
- name: Install make
run: choco install make -y --no-progress
- name: Check Out Code
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
- name: Make Windows (amd64)
run: make windows-amd64
- name: Determine go-tests flags
id: go-tests-flags
shell: pwsh
run: |
$baseSha = "${{ github.event.pull_request.base.sha }}"
$headSha = "${{ github.sha }}"
$changedFiles = git diff --name-only $baseSha $headSha
if ($changedFiles | Select-String -Pattern '^(implant/|server/gogo/|server/generate/)') {
"args=" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
} else {
"args=--skip-generate" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
}
- name: Test Windows
shell: pwsh
run: |
make clean
make
./go-tests.ps1 ${{ steps.go-tests-flags.outputs.args }}
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh
macos-build-test:
@@ -117,13 +57,13 @@ jobs:
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.27.3
uses: actions/setup-go@v7
- name: Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.27.3"
go-version: "^1.25"
- name: Check Out Code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -134,34 +74,21 @@ jobs:
- name: Make MacOS (arm64)
run: make macos-arm64
- name: Determine go-tests flags
id: go-tests-flags
shell: bash
run: |
set -euo pipefail
base_sha="${{ github.event.pull_request.base.sha }}"
head_sha="${{ github.sha }}"
if git diff --name-only "$base_sha" "$head_sha" | grep -qE '^(implant/|server/gogo/|server/generate/)'; then
echo "args=" >> "$GITHUB_OUTPUT"
else
echo "args=--skip-generate" >> "$GITHUB_OUTPUT"
fi
- name: Test MacOS
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh ${{ steps.go-tests-flags.outputs.args }}
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh
clients-build:
name: Clients Build
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.27.3
uses: actions/setup-go@v7
- name: Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.27.3"
go-version: "^1.25"
- name: Check Out Code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
-5
View File
@@ -1,8 +1,3 @@
/crush
/examples
/.vscode
/.vscode/*
/.devcontainer
/.idea
-28
View File
@@ -1,28 +0,0 @@
version: "2"
run:
timeout: 10m
modules-download-mode: vendor
build-tags:
- client
- server
- osusergo
- netgo
- go_sqlite
- sliver_generate
# Raw implant entry points are Go templates; lint their supporting packages.
- sliver_lint
linters:
default: standard
enable:
- gocyclo
- misspell
- revive
settings:
gocyclo:
min-complexity: 15
formatters:
enable:
- gofmt
+24 -1
View File
@@ -1,18 +1,41 @@
# Repository Guidelines
## Project Structure & Module Organization
- `client/` holds the CLI client and interactive commands.
- `server/` contains the server runtime, C2 logic, configs, and web assets.
- `implant/` is the implant runtime and transport implementations.
- `protobuf/` stores protobuf definitions and generated Go types.
- `util/` is shared helpers (crypto, encoders, minisign, etc.).
- `docs/sliver-docs/` is the Next.js documentation site and prebuild scripts.
- `vendor/` is the vendored Go dependency tree used by builds.
- Build outputs land in the repo root as `sliver-client*` and `sliver-server*`.
## Build, Test, and Development Commands
- `make` builds server + client using vendored modules and runs `go-assets.sh`.
- `make client` builds a CGO-free client; `make debug` keeps symbols.
- `make pb` regenerates protobuf stubs from `protobuf/`.
- `./go-tests.sh` runs the Go test suite, this is the only way you should run unit tests.
- `./go-tests.sh` runs the curated Go test suite with build tags.
- Docs site: `cd docs/sliver-docs && npm install && npm run dev` for local dev; `npm run build` for a static build.
- Go toolchain: the Makefile enforces Go >= 1.24.
## Coding Style & Naming Conventions
- Run `gofmt` on all Go code; keep package names lowercase and idiomatic.
- Avoid `CGO` and empty interfaces; prefer explicit types and interfaces.
- Do not import `server` packages from `client`.
- If `math/rand` is required, import it as `insecureRand` and avoid security use.
## Testing Guidelines
- Go tests follow standard `_test.go` naming within each package.
- Prefer `docker build --target test --build-arg GO_TESTS_FLAGS=--skip-generate .`
- Add or update tests when behavior changes; some server packages run longer tests.
## Commit & Pull Request Guidelines
- Commits must be signed for PRs to `master`; work in a feature branch.
- Commit subjects are short and imperative (e.g., "Fix ...", "Add ...", "Update ...", "Bump ...").
- Changes to `vendor/` should be in a distinct commit.
- PRs should link the tracking issue, note tests run, and confirm `gofmt` plus unit tests as applicable.
## Security Notes
- Default to secure configuration; fail closed rather than open.
- Avoid insecure algorithms (MD5/SHA1/AES-ECB); prefer AES-GCM and modern curves.
- Treat user input as untrusted: canonicalize paths and use restrictive file permissions.
+144 -291
View File
@@ -2,31 +2,31 @@
# Makefile for Sliver
#
.DEFAULT_GOAL := default
GO ?= go
ARTIFACT_SUFFIX ?=
ARTIFACT_SUFFIX ?=
ENV =
TAGS ?= -tags go_sqlite
CGO_ENABLED = 0
GOLANGCI_LINT ?= golangci-lint
# The tree has legacy lint findings, so gate issues introduced by this change.
GOLANGCI_LINT_BASE ?= master
GOLANGCI_LINT_ARGS ?= --new-from-merge-base=$(GOLANGCI_LINT_BASE)
GOLANGCI_LINT_PACKAGES = \
./client/... \
./server/... \
./util/... \
./protobuf/... \
./docs \
./implant/...
ifneq (,$(findstring cgo_sqlite,$(TAGS)))
CGO_ENABLED = 1
endif
#
# Prerequisites
#
# https://stackoverflow.com/questions/5618615/check-if-a-program-exists-from-a-makefile
EXECUTABLES = uname sed git date cut $(GO)
K := $(foreach exec,$(EXECUTABLES),\
$(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH")))
#
# Build Information
#
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
MIN_SUPPORTED_GO_MINOR_VERSION = 27
MIN_SUPPORTED_GO_MINOR_VERSION = 24
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MIN_SUPPORTED_GO_MAJOR_VERSION).$(MIN_SUPPORTED_GO_MINOR_VERSION)
SLIVER_PUBLIC_KEY ?= RWTZPg959v3b7tLG7VzKHRB1/QT+d3c71Uzetfa44qAoX5rH7mGoQTTR
@@ -34,298 +34,151 @@ ARMORY_PUBLIC_KEY ?= RWSBpxpRWDrD7Fe+VvRE3c2VEDC2NK80rlNCj+BX0gz44Xw07r6KQD9L
ARMORY_REPO_URL ?= https://api.github.com/repos/sliverarmory/armory/releases
CLIENT_ASSETS_PKG = github.com/bishopfox/sliver/client/assets
SLIVER_UPDATE_PKG = github.com/bishopfox/sliver/client/command/update
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
.PHONY: lint
lint:
$(GOLANGCI_LINT) run $(GOLANGCI_LINT_ARGS) $(GOLANGCI_LINT_PACKAGES)
ifneq ($(OS),Windows_NT)
#
# Prerequisites
#
# https://stackoverflow.com/questions/5618615/check-if-a-program-exists-from-a-makefile
EXECUTABLES = uname sed git date cut $(GO)
K := $(foreach exec,$(EXECUTABLES),\
$(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH")))
#
# Build Information
#
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
LDFLAGS = -ldflags "-s -w \
-X $(SLIVER_UPDATE_PKG).SliverPublicKey=$(SLIVER_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
# Debug builds shouldn't be stripped (-s -w flags)
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
SED_INPLACE := sed -i
STATIC_TARGET := linux
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
# Debug builds shouldn't be stripped (-s -w flags)
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
SED_INPLACE := sed -i
STATIC_TARGET := linux
UNAME_S := $(shell uname -s)
UNAME_P := $(shell uname -p)
# Programs required for generating protobuf/grpc files
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
ifeq ($(MAKECMDGOALS), pb)
K := $(foreach exec,$(PB_COMPILERS),\
$(if $(shell which $(exec)),some string,$(error "Missing protobuf util $(exec) in PATH")))
endif
# *** Darwin ***
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
STATIC_TARGET := macos
endif
# If no target is specified, determine GOARCH
ifeq ($(UNAME_P),arm)
ifeq ($(MAKECMDGOALS), )
ifeq ($(origin GOARCH), undefined)
ENV += GOARCH=arm64
endif
endif
endif
ifeq ($(MAKECMDGOALS), linux)
# Redefine LDFLAGS to add the static part
LDFLAGS = -ldflags "-s -w \
-extldflags '-static' \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
#
# Targets
#
.PHONY: default
default: clean validate-go-version
env -u GOOS -u GOARCH $(MAKE) GOOS= GOARCH= .downloaded_assets
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
# Allows you to build a CGO-free client for any target e.g. `GOOS=windows GOARCH=arm64 make client`
# NOTE: WireGuard is not supported on all platforms, but most 64-bit GOOS/GOARCH combinations should work.
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client ./client
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX).exe ./client
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-386$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=windows GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-386$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe ./client
GOOS=freebsd GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=freebsd GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX) ./client
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-amd64$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-amd64$(ARTIFACT_SUFFIX) ./server
.PHONY: pb
pb:
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor $(TAGS),server $(LDFLAGS_DEBUG) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor $(TAGS),client $(LDFLAGS_DEBUG) -o sliver-client$(ARTIFACT_SUFFIX) ./client
validate-go-version:
@if [ $(GO_MAJOR_VERSION) -gt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
exit 0 ;\
elif [ $(GO_MAJOR_VERSION) -lt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
elif [ $(GO_MINOR_VERSION) -lt $(MIN_SUPPORTED_GO_MINOR_VERSION) ] ; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
fi
.PHONY: clean-all
clean-all: clean
rm -rf ./server/assets/fs/darwin/amd64
rm -rf ./server/assets/fs/darwin/arm64
rm -rf ./server/assets/fs/windows/amd64
rm -rf ./server/assets/fs/linux/amd64
rm -f ./server/assets/fs/*.zip
rm -f ./.downloaded_assets
.PHONY: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets: util/assets/constants.go util/assets/garble.go
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
# *** Darwin ***
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
STATIC_TARGET := macos
endif
# If no target is specified, determine GOARCH
ifeq ($(UNAME_P),arm)
ifeq ($(MAKECMDGOALS), )
ifeq ($(origin GOARCH), undefined)
ENV += GOARCH=arm64
endif
endif
endif
ifeq ($(MAKECMDGOALS), linux)
# Redefine LDFLAGS to add the static part
LDFLAGS = -ldflags "-s -w \
-extldflags '-static' \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
#
# >>> WINDOWS <<<
# Targets
#
else
.PHONY: default
default: clean validate-go-version
env -u GOOS -u GOARCH $(MAKE) GOOS= GOARCH= .downloaded_assets
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
SHELL := cmd.exe
.SHELLFLAGS := /C
# Allows you to build a CGO-free client for any target e.g. `GOOS=windows GOARCH=arm64 make client`
# NOTE: WireGuard is not supported on all platforms, but most 64-bit GOOS/GOARCH combinations should work.
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client ./client
GO_VERSION := $(patsubst go%,%,$(strip $(shell $(GO) env GOVERSION)))
GO_MAJOR_VERSION := $(word 1,$(subst ., ,$(GO_VERSION)))
GO_MINOR_VERSION := $(word 2,$(subst ., ,$(GO_VERSION)))
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
LDFLAGS = -ldflags "-s -w -X $(SLIVER_UPDATE_PKG).SliverPublicKey=$(SLIVER_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
COMMA := ,
ifeq ($(MAKECMDGOALS), linux)
LDFLAGS = -ldflags "-s -w -extldflags '-static' -X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
define windows_exec
$(strip $(foreach envvar,$(1),set "$(envvar)" && ))$(2)
endef
define windows_go_build
$(call windows_exec,$(ENV) GOOS=$(1) GOARCH=$(2) CGO_ENABLED=$(3),"$(GO)" build $(4) $(TAGS)$(COMMA)$(5) $(6) -o $(7) ./$(5))
endef
.PHONY: default
default: clean validate-go-version
$(call windows_exec,$(ENV) GOOS= GOARCH=,"$(MAKE)" GOOS= GOARCH= .downloaded_assets)
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client)
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,arm64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,linux,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
$(call windows_go_build,linux,arm64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,windows,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX).exe)
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_macos-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_macos-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,386,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-386$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,windows,386,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-386$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,freebsd,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,freebsd,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX))
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_linux-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_linux-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_darwin-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_darwin-amd64$(ARTIFACT_SUFFIX))
.PHONY: pb
pb: validate-pb-compilers
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor,server,$(LDFLAGS_DEBUG),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor,client,$(LDFLAGS_DEBUG),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: validate-pb-compilers
validate-pb-compilers:
@for %%P in ($(PB_COMPILERS)) do @where.exe %%P >NUL || (echo Missing protobuf util %%P in PATH & exit /b 1)
validate-go-version:
@if $(GO_MAJOR_VERSION) GTR $(MIN_SUPPORTED_GO_MAJOR_VERSION) (exit /b 0) else if $(GO_MAJOR_VERSION) LSS $(MIN_SUPPORTED_GO_MAJOR_VERSION) (echo $(GO_VERSION_VALIDATION_ERR_MSG) & exit /b 1) else if $(GO_MINOR_VERSION) LSS $(MIN_SUPPORTED_GO_MINOR_VERSION) (echo $(GO_VERSION_VALIDATION_ERR_MSG) & exit /b 1)
.PHONY: clean-all
clean-all: clean
-rmdir /S /Q server\assets\fs\darwin\amd64
-rmdir /S /Q server\assets\fs\darwin\arm64
-rmdir /S /Q server\assets\fs\windows\amd64
-rmdir /S /Q server\assets\fs\linux\amd64
-del /Q /F server\assets\fs\*.zip 2>NUL
-del /Q /F .downloaded_assets 2>NUL
.PHONY: clean
clean:
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
.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
endif
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX).exe ./client
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-386$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=windows GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-386$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe ./client
GOOS=freebsd GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=freebsd GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX) ./client
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-amd64$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-amd64$(ARTIFACT_SUFFIX) ./server
.PHONY: pb
pb:
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor $(TAGS),server $(LDFLAGS_DEBUG) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor $(TAGS),client $(LDFLAGS_DEBUG) -o sliver-client$(ARTIFACT_SUFFIX) ./client
validate-go-version:
@if [ $(GO_MAJOR_VERSION) -gt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
exit 0 ;\
elif [ $(GO_MAJOR_VERSION) -lt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
elif [ $(GO_MINOR_VERSION) -lt $(MIN_SUPPORTED_GO_MINOR_VERSION) ] ; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
fi
.PHONY: clean-all
clean-all: clean
rm -rf ./server/assets/fs/darwin/amd64
rm -rf ./server/assets/fs/darwin/arm64
rm -rf ./server/assets/fs/windows/amd64
rm -rf ./server/assets/fs/linux/amd64
rm -f ./server/assets/fs/*.zip
rm -f ./.downloaded_assets
.PHONY: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
+18 -7
View File
@@ -1,16 +1,27 @@
# Sliver
Sliver is an open-source, cross-platform adversary emulation and red team framework designed for organizations of all sizes to perform authorized security testing.
Sliver is an open source cross-platform adversary emulation/red team framework, it can be used by organizations of all sizes to perform security testing. Sliver's implants support C2 over Mutual TLS (mTLS), WireGuard, HTTP(S), and DNS and are dynamically compiled with per-binary asymmetric encryption keys.
Sliver implants support command-and-control (C2) over Mutual TLS (mTLS), WireGuard, HTTP(S), and DNS. Implants are dynamically compiled with unique per-binary asymmetric encryption keys and support a wide range of post-exploitation capabilities across macOS, Windows, and Linux.
The server and client support MacOS, Windows, and Linux. Implants are supported on MacOS, Windows, and Linux (and possibly every Golang compiler target but we've not tested them all).
Sliver includes support for native and shellcode payloads, userspace reflective loading, built-in shellcode encoding, and BOF/COFF execution on both amd64 and arm64 architectures for macOS, Windows, and Linux.
[![Release](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/BishopFox/sliver)](https://goreportcard.com/report/github.com/BishopFox/sliver) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
The Sliver server and client run on macOS, Windows, and Linux. Implants officially support those same platforms and may also run on additional targets supported by the Go compiler, although those configurations are not regularly tested.
### Features
[![Release](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [![golangci-lint](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
Visit [https://sliver.sh/](https://sliver.sh/) for tutorials and documentation.
- Dynamic code generation
- Compile-time obfuscation
- Multiplayer-mode
- Staged and Stageless payloads
- [Procedurally generated C2](https://sliver.sh/docs?name=HTTPS+C2) over HTTP(S)
- [DNS canary](https://sliver.sh/docs?name=DNS+C2) blue team detection
- [Secure C2](https://sliver.sh/docs?name=Transport+Encryption) over mTLS, WireGuard, HTTP(S), and DNS
- Fully scriptable using [Python](https://github.com/moloch--/sliver-py)
- Windows process migration, process injection, user token manipulation, etc.
- Let's Encrypt integration
- In-memory .NET assembly execution
- COFF/BOF in-memory loader
- TCP and named pipe pivots
- Much more!
### Getting Started
+1 -2
View File
@@ -115,7 +115,7 @@ func Setup(force bool, echo bool) {
if force || localVer == "" || localVer != ver.GitCommit {
if echo {
fmt.Printf(`
Sliver Copyright (C) 2026 Bishop Fox
Sliver Copyright (C) 2025 Bishop Fox
This program comes with ABSOLUTELY NO WARRANTY; for details type 'licenses'.
This is free software, and you are welcome to redistribute it
under certain conditions; type 'licenses' for details.`)
@@ -124,5 +124,4 @@ under certain conditions; type 'licenses' for details.`)
saveAssetVersion(appDir)
}
_, _ = LoadSettings()
_, _ = LoadSpoofMetadataConfig()
}
+7 -18
View File
@@ -34,26 +34,15 @@ const (
ConfigDirName = "configs"
)
// ClientWGConfig - Optional WireGuard wrapper configuration for multiplayer.
type ClientWGConfig struct {
Enabled bool `json:"enabled,omitempty"`
ServerPubKey string `json:"server_pub_key"`
ClientPrivateKey string `json:"client_private_key"`
ClientPubKey string `json:"client_pub_key"`
ClientIP string `json:"client_ip"`
ServerIP string `json:"server_ip"`
}
// ClientConfig - Client JSON config
type ClientConfig struct {
Operator string `json:"operator"` // This value is actually ignored for the most part (cert CN is used instead)
LHost string `json:"lhost"`
LPort int `json:"lport"`
Token string `json:"token"`
CACertificate string `json:"ca_certificate"`
PrivateKey string `json:"private_key"`
Certificate string `json:"certificate"`
WG *ClientWGConfig `json:"wg,omitempty"`
Operator string `json:"operator"` // This value is actually ignored for the most part (cert CN is used instead)
LHost string `json:"lhost"`
LPort int `json:"lport"`
Token string `json:"token"`
CACertificate string `json:"ca_certificate"`
PrivateKey string `json:"private_key"`
Certificate string `json:"certificate"`
}
// GetConfigDir - Returns the path to the config dir
-29
View File
@@ -1,29 +0,0 @@
package assets
import (
"encoding/json"
"testing"
)
func TestClientWGConfigEnabledIsPositiveOptIn(t *testing.T) {
legacyJSON := []byte(`{"wg":{"server_pub_key":"server-pub","client_private_key":"client-priv","client_ip":"100.64.0.2"}}`)
legacy := &ClientConfig{}
if err := json.Unmarshal(legacyJSON, legacy); err != nil {
t.Fatalf("unmarshal legacy config: %v", err)
}
if legacy.WG == nil {
t.Fatal("expected legacy WireGuard config")
}
if legacy.WG.Enabled {
t.Fatal("expected an absent enabled marker to remain disabled")
}
enabledJSON := []byte(`{"wg":{"enabled":true,"server_pub_key":"server-pub","client_private_key":"client-priv","client_ip":"100.64.0.2"}}`)
enabled := &ClientConfig{}
if err := json.Unmarshal(enabledJSON, enabled); err != nil {
t.Fatalf("unmarshal enabled config: %v", err)
}
if enabled.WG == nil || !enabled.WG.Enabled {
t.Fatal("expected enabled marker to opt in to WireGuard")
}
}
-53
View File
@@ -22,7 +22,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
@@ -32,18 +31,6 @@ const (
settingsLegacyFileName = "tui-settings.json"
)
const (
// Accepted values for tui-settings.yaml "prompt:":
// - host: show "[host]" prefix on sliver-client, "[server]" on server console
// - operator-host: show "[operator@host]" prefix on sliver-client, "[server]" on server console
// - basic: show "sliver >" (no prefix)
// - custom: render full prompt from prompt_template
PromptStyleHost = "host"
PromptStyleOperatorHost = "operator-host"
PromptStyleBasic = "basic"
PromptStyleCustom = "custom"
)
// ClientSettings - Client JSON config
type ClientSettings struct {
TableStyle string `json:"tables" yaml:"tables"`
@@ -54,8 +41,6 @@ type ClientSettings struct {
VimMode bool `json:"vim_mode" yaml:"vim_mode"`
UserConnect bool `json:"user_connect" yaml:"user_connect"`
ConsoleLogs bool `json:"console_logs" yaml:"console_logs"`
PromptStyle string `json:"prompt" yaml:"prompt"`
PromptTemplate string `json:"prompt_template" yaml:"prompt_template"`
}
// LoadSettings - Load the client settings from disk
@@ -82,8 +67,6 @@ func LoadSettings() (*ClientSettings, error) {
return defaultSettings(), err
}
// Ensure any missing/unknown values are coerced to a supported prompt style.
settings.PromptStyle = NormalizePromptStyle(settings.PromptStyle)
if err := SaveSettings(settings); err != nil {
return settings, err
}
@@ -95,38 +78,6 @@ func LoadSettings() (*ClientSettings, error) {
return settings, nil
}
// NormalizePromptStyle canonicalizes prompt style strings and returns a safe
// default if the value is empty/unknown.
func NormalizePromptStyle(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case PromptStyleOperatorHost:
return PromptStyleOperatorHost
case PromptStyleHost:
return PromptStyleHost
case PromptStyleBasic:
return PromptStyleBasic
case PromptStyleCustom:
return PromptStyleCustom
// Backward compatible aliases.
case "show host":
return PromptStyleHost
case "show user and host":
return PromptStyleOperatorHost
case "show operator and host":
return PromptStyleOperatorHost
case "operator@host":
return PromptStyleOperatorHost
case "user@host":
return PromptStyleOperatorHost
case "":
return PromptStyleHost
default:
return PromptStyleHost
}
}
func defaultSettings() *ClientSettings {
return &ClientSettings{
TableStyle: "SliverDefault",
@@ -136,13 +87,9 @@ func defaultSettings() *ClientSettings {
AlwaysOverflow: false,
VimMode: false,
ConsoleLogs: true,
PromptStyle: PromptStyleHost,
PromptTemplate: DefaultPromptTemplate,
}
}
const DefaultPromptTemplate = `{{- if .IsServer -}}{{ .Styles.Bold.Render "[server]" }} {{ .Styles.Underline.Render "sliver" }}{{ .Target.Suffix }} > {{- else -}}{{- if .Host -}}{{ .Styles.BoldPrimary.Render (printf "[%s]" .Host) }} {{- end -}}{{ .Styles.Underline.Render "sliver" }}{{ .Target.Suffix }} > {{- end -}}`
// SaveSettings - Save the current settings to disk
func SaveSettings(settings *ClientSettings) error {
rootDir, _ := filepath.Abs(GetRootAppDir())
-22
View File
@@ -21,8 +21,6 @@ always_overflow: true
vim_mode: true
user_connect: true
console_logs: false
prompt: "basic"
prompt_template: "{{.Host}}"
`)
if err := os.WriteFile(settingsPath, data, 0o600); err != nil {
t.Fatalf("failed to write yaml settings: %v", err)
@@ -56,12 +54,6 @@ prompt_template: "{{.Host}}"
if settings.ConsoleLogs {
t.Fatalf("expected ConsoleLogs false")
}
if settings.PromptStyle != PromptStyleBasic {
t.Fatalf("expected PromptStyle %q, got %q", PromptStyleBasic, settings.PromptStyle)
}
if settings.PromptTemplate != "{{.Host}}" {
t.Fatalf("expected PromptTemplate %q, got %q", "{{.Host}}", settings.PromptTemplate)
}
}
func TestLoadSettingsMigratesLegacyJSON(t *testing.T) {
@@ -79,8 +71,6 @@ func TestLoadSettingsMigratesLegacyJSON(t *testing.T) {
VimMode: true,
UserConnect: true,
ConsoleLogs: false,
PromptStyle: PromptStyleOperatorHost,
PromptTemplate: "{{.Operator}}@{{.Host}} sliver > ",
}
data, err := json.MarshalIndent(legacy, "", " ")
if err != nil {
@@ -97,12 +87,6 @@ func TestLoadSettingsMigratesLegacyJSON(t *testing.T) {
if settings.TableStyle != "Legacy" {
t.Fatalf("expected TableStyle %q, got %q", "Legacy", settings.TableStyle)
}
if settings.PromptStyle != PromptStyleOperatorHost {
t.Fatalf("expected PromptStyle %q, got %q", PromptStyleOperatorHost, settings.PromptStyle)
}
if settings.PromptTemplate != "{{.Operator}}@{{.Host}} sliver > " {
t.Fatalf("expected PromptTemplate %q, got %q", "{{.Operator}}@{{.Host}} sliver > ", settings.PromptTemplate)
}
if _, err := os.Stat(filepath.Join(rootDir, settingsFileName)); err != nil {
t.Fatalf("expected yaml settings to exist: %v", err)
}
@@ -124,12 +108,6 @@ func TestLoadSettingsWritesDefault(t *testing.T) {
if settings.TableStyle != "SliverDefault" {
t.Fatalf("expected default TableStyle %q, got %q", "SliverDefault", settings.TableStyle)
}
if settings.PromptStyle != PromptStyleHost {
t.Fatalf("expected default PromptStyle %q, got %q", PromptStyleHost, settings.PromptStyle)
}
if settings.PromptTemplate != DefaultPromptTemplate {
t.Fatalf("expected default PromptTemplate %q, got %q", DefaultPromptTemplate, settings.PromptTemplate)
}
rootDir, _ := filepath.Abs(GetRootAppDir())
if _, err := os.Stat(filepath.Join(rootDir, settingsFileName)); err != nil {
t.Fatalf("expected default settings to be written: %v", err)
-216
View File
@@ -1,216 +0,0 @@
package assets
/*
Sliver Implant Framework
Copyright (C) 2026 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 (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
const spoofMetadataFileName = "spoof-metadata.yaml"
const defaultSpoofMetadataConfigTemplate = `# Sliver executable metadata spoofing configuration.
# Used by generate commands when --spoof-metadata is set.
#
# Fields that reference file content support either:
# - path: read bytes from a local file path
# - base64: inline base64 file content
# Do not set both path and base64 for the same object.
#
# Example 1: Minimal path-based metadata cloning (most common)
pe:
source:
name: metadata-source.exe
path: ""
# Optional icon donor. Reserved for future standalone icon mutation support.
icon:
name: icon.ico
path: ""
# Example 2: Inline/base64 source (uncomment and remove "path" above)
# source:
# name: metadata-source.exe
# base64: "TVqQAAMAAAAEAAAA..."
# Example 3: Optional PE structure overrides (advanced)
# Applied after metadata/resources are cloned from source.
# Omitted fields keep cloned values.
# resource_directory:
# characteristics: 0
# time_date_stamp: 0
# major_version: 1
# minor_version: 0
# number_of_named_entries: 0
# number_of_id_entries: 0
#
# resource_directory_entries:
# - name: 1
# offset_to_data: 0
#
# resource_data_entries:
# - offset_to_data: 0
# size: 0
# code_page: 0
# reserved: 0
#
# export_directory:
# characteristics: 0
# time_date_stamp: 0
# major_version: 1
# minor_version: 0
# name: 0
# base: 0
# number_of_functions: 0
# number_of_names: 0
# address_of_functions: 0
# address_of_names: 0
# address_of_name_ordinals: 0
`
// SpoofMetadataConfig stores default metadata spoofing inputs for generate commands.
type SpoofMetadataConfig struct {
PE *PESpoofMetadataConfig `json:"pe,omitempty" yaml:"pe,omitempty"`
}
// PESpoofMetadataConfig stores PE-specific spoofing inputs.
type PESpoofMetadataConfig struct {
Source *SpoofMetadataDataSource `json:"source,omitempty" yaml:"source,omitempty"`
Icon *SpoofMetadataDataSource `json:"icon,omitempty" yaml:"icon,omitempty"`
ResourceDirectory *ImageResourceDirectory `json:"resource_directory,omitempty" yaml:"resource_directory,omitempty"`
ResourceDirectoryEntries []*ImageResourceDirectoryEntry `json:"resource_directory_entries,omitempty" yaml:"resource_directory_entries,omitempty"`
ResourceDataEntries []*ImageResourceDataEntry `json:"resource_data_entries,omitempty" yaml:"resource_data_entries,omitempty"`
ExportDirectory *ImageExportDirectory `json:"export_directory,omitempty" yaml:"export_directory,omitempty"`
}
// SpoofMetadataDataSource points to bytes that should be sent to the server.
// Either Path or Base64 may be set.
type SpoofMetadataDataSource struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Base64 string `json:"base64,omitempty" yaml:"base64,omitempty"`
}
type ImageResourceDirectory struct {
Characteristics uint32 `json:"characteristics,omitempty" yaml:"characteristics,omitempty"`
TimeDateStamp uint32 `json:"time_date_stamp,omitempty" yaml:"time_date_stamp,omitempty"`
MajorVersion uint32 `json:"major_version,omitempty" yaml:"major_version,omitempty"`
MinorVersion uint32 `json:"minor_version,omitempty" yaml:"minor_version,omitempty"`
NumberOfNamedEntries uint32 `json:"number_of_named_entries,omitempty" yaml:"number_of_named_entries,omitempty"`
NumberOfIDEntries uint32 `json:"number_of_id_entries,omitempty" yaml:"number_of_id_entries,omitempty"`
}
type ImageResourceDirectoryEntry struct {
Name uint32 `json:"name,omitempty" yaml:"name,omitempty"`
OffsetToData uint32 `json:"offset_to_data,omitempty" yaml:"offset_to_data,omitempty"`
}
type ImageResourceDataEntry struct {
OffsetToData uint32 `json:"offset_to_data,omitempty" yaml:"offset_to_data,omitempty"`
Size uint32 `json:"size,omitempty" yaml:"size,omitempty"`
CodePage uint32 `json:"code_page,omitempty" yaml:"code_page,omitempty"`
Reserved uint32 `json:"reserved,omitempty" yaml:"reserved,omitempty"`
}
type ImageExportDirectory struct {
Characteristics uint32 `json:"characteristics,omitempty" yaml:"characteristics,omitempty"`
TimeDateStamp uint32 `json:"time_date_stamp,omitempty" yaml:"time_date_stamp,omitempty"`
MajorVersion uint32 `json:"major_version,omitempty" yaml:"major_version,omitempty"`
MinorVersion uint32 `json:"minor_version,omitempty" yaml:"minor_version,omitempty"`
Name uint32 `json:"name,omitempty" yaml:"name,omitempty"`
Base uint32 `json:"base,omitempty" yaml:"base,omitempty"`
NumberOfFunctions uint32 `json:"number_of_functions,omitempty" yaml:"number_of_functions,omitempty"`
NumberOfNames uint32 `json:"number_of_names,omitempty" yaml:"number_of_names,omitempty"`
AddressOfFunctions uint32 `json:"address_of_functions,omitempty" yaml:"address_of_functions,omitempty"`
AddressOfNames uint32 `json:"address_of_names,omitempty" yaml:"address_of_names,omitempty"`
AddressOfNameOrdinals uint32 `json:"address_of_name_ordinals,omitempty" yaml:"address_of_name_ordinals,omitempty"`
}
// SpoofMetadataConfigPath returns the spoof metadata config file path.
func SpoofMetadataConfigPath() string {
rootDir, _ := filepath.Abs(GetRootAppDir())
return filepath.Join(rootDir, spoofMetadataFileName)
}
// LoadSpoofMetadataConfig loads spoof metadata config from disk, writing defaults
// if the file is missing.
func LoadSpoofMetadataConfig() (*SpoofMetadataConfig, error) {
config := defaultSpoofMetadataConfig()
configPath := SpoofMetadataConfigPath()
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
return defaultSpoofMetadataConfig(), err
}
if err := os.WriteFile(configPath, []byte(defaultSpoofMetadataConfigTemplate), 0o600); err != nil {
return defaultSpoofMetadataConfig(), err
}
data = []byte(defaultSpoofMetadataConfigTemplate)
}
if err := yaml.Unmarshal(data, config); err != nil {
return defaultSpoofMetadataConfig(), err
}
normalizeSpoofMetadataConfig(config)
return config, nil
}
// SaveSpoofMetadataConfig writes spoof metadata config to disk.
func SaveSpoofMetadataConfig(config *SpoofMetadataConfig) error {
if config == nil {
config = defaultSpoofMetadataConfig()
}
normalizeSpoofMetadataConfig(config)
data, err := yaml.Marshal(config)
if err != nil {
return err
}
return os.WriteFile(SpoofMetadataConfigPath(), data, 0o600)
}
func normalizeSpoofMetadataConfig(config *SpoofMetadataConfig) {
if config.PE == nil {
config.PE = &PESpoofMetadataConfig{}
}
if config.PE.Source == nil {
config.PE.Source = &SpoofMetadataDataSource{Name: "metadata-source.exe"}
}
if config.PE.Icon == nil {
config.PE.Icon = &SpoofMetadataDataSource{Name: "icon.ico"}
}
}
func defaultSpoofMetadataConfig() *SpoofMetadataConfig {
return &SpoofMetadataConfig{
PE: &PESpoofMetadataConfig{
Source: &SpoofMetadataDataSource{
Name: "metadata-source.exe",
Path: "",
},
Icon: &SpoofMetadataDataSource{
Name: "icon.ico",
Path: "",
},
},
}
}
+3 -33
View File
@@ -19,7 +19,6 @@ package cli
*/
import (
"errors"
"fmt"
"log"
"os"
@@ -35,8 +34,6 @@ const (
logFileName = "sliver-client.log"
)
var clientLogFile *os.File
// Initialize logging.
func initLogging(appDir string) *os.File {
log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -50,14 +47,11 @@ func initLogging(appDir string) *os.File {
func init() {
appDir := assets.GetRootAppDir()
clientLogFile = initLogging(appDir)
logFile := initLogging(appDir)
defer logFile.Close()
rootCmd.TraverseChildren = true
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
rootCmd.PersistentFlags().String(ConsoleConfigFlagName, "", "path to client config file")
rootCmd.PersistentFlags().Bool(enableWGFlag, false, "force multiplayer connections through the operator config's WireGuard wrapper")
rootCmd.PersistentFlags().Bool(disableWGFlag, false, "force multiplayer connections to use direct mTLS")
rootCmd.MarkFlagsMutuallyExclusive(enableWGFlag, disableWGFlag)
// Create the console client, without any RPC or commands bound to it yet.
// This created before anything so that multiple commands can make use of
@@ -99,34 +93,10 @@ var rootCmd = &cobra.Command{
Long: ``,
}
type commandExitError struct {
code int
err error
}
func (e *commandExitError) Error() string {
return e.err.Error()
}
func (e *commandExitError) Unwrap() error {
return e.err
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
if exitErr, ok := errors.AsType[*commandExitError](err); ok {
return exitErr.code
}
return 1
}
// Execute - Execute root command.
func Execute() {
defer func() { _ = clientLogFile.Close() }()
if err := rootCmd.Execute(); err != nil {
fmt.Printf("root command: %s\n", err)
os.Exit(commandExitCode(err))
os.Exit(1)
}
}
-23
View File
@@ -1,23 +0,0 @@
package cli
import (
"log"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/client/assets"
)
func TestPackageLoggerRemainsOpenAfterInit(t *testing.T) {
logFile, ok := log.Writer().(*os.File)
if !ok {
t.Fatalf("standard logger writer = %T, want *os.File", log.Writer())
}
if want := filepath.Join(assets.GetRootAppDir(), logFileName); filepath.Clean(logFile.Name()) != want {
t.Fatalf("standard logger file = %q, want %q", logFile.Name(), want)
}
if _, err := logFile.Stat(); err != nil {
t.Fatalf("standard logger file is closed after package initialization: %v", err)
}
}
+8 -20
View File
@@ -20,37 +20,24 @@ package cli
import (
"fmt"
"path/filepath"
"sort"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/forms"
)
func selectConfig(configPath string, stdinTTY bool) (string, *assets.ClientConfig, error) {
if configPath != "" {
config, err := loadMCPClientConfig(configPath)
if err != nil {
return "", nil, fmt.Errorf("load client config: %w", err)
}
return filepath.Base(configPath), config, nil
}
return selectConfigFrom(assets.GetConfigs(), stdinTTY)
}
func selectConfig() *assets.ClientConfig {
configs := assets.GetConfigs()
func selectConfigFrom(configs map[string]*assets.ClientConfig, stdinTTY bool) (string, *assets.ClientConfig, error) {
if len(configs) == 0 {
return "", nil, fmt.Errorf("no config files found at %s", assets.GetConfigDir())
return nil
}
if len(configs) == 1 {
for key, config := range configs {
return key, config, nil
for _, config := range configs {
return config
}
}
if !stdinTTY {
return "", nil, fmt.Errorf("multiple configs found; use --config to select one")
}
keys := make([]string, 0, len(configs))
for key := range configs {
@@ -61,8 +48,9 @@ func selectConfigFrom(configs map[string]*assets.ClientConfig, stdinTTY bool) (s
selection := keys[0]
err := forms.Select("Select a server:", keys, &selection)
if err != nil {
return "", nil, err
fmt.Println(err.Error())
return nil
}
return selection, configs[selection], nil
return configs[selection]
}
-46
View File
@@ -1,46 +0,0 @@
package cli
import (
"testing"
)
func TestConfigFlagRoutesBeforeAndAfterConsoleAndMCP(t *testing.T) {
flag := rootCmd.PersistentFlags().Lookup(ConsoleConfigFlagName)
if flag == nil {
t.Fatal("root --config flag is missing")
}
oldValue, oldChanged := flag.Value.String(), flag.Changed
t.Cleanup(func() {
_ = flag.Value.Set(oldValue)
flag.Changed = oldChanged
})
for _, command := range []string{"console", "mcp"} {
for _, before := range []bool{true, false} {
name := command + " after --config"
args := []string{"--config", "selected.cfg", command}
if !before {
name = command + " before --config"
args = []string{command, "--config", "selected.cfg"}
}
t.Run(name, func(t *testing.T) {
_ = flag.Value.Set("")
flag.Changed = false
cmd, remaining, err := rootCmd.Traverse(args)
if err != nil {
t.Fatalf("traverse %q: %v", args, err)
}
if cmd.Name() != command {
t.Fatalf("command = %q, want %q", cmd.Name(), command)
}
if err := cmd.ParseFlags(remaining); err != nil {
t.Fatalf("parse %q: %v", remaining, err)
}
got, err := cmd.Flags().GetString(ConsoleConfigFlagName)
if err != nil || got != "selected.cfg" {
t.Fatalf("--config = %q, error = %v", got, err)
}
})
}
}
}
-53
View File
@@ -1,53 +0,0 @@
package cli
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/client/assets"
)
func TestSelectConfigFromNonTTYRequiresExplicitConfigWhenMultiple(t *testing.T) {
configs := map[string]*assets.ClientConfig{
"first": {Operator: "first"},
"second": {Operator: "second"},
}
key, config, err := selectConfigFrom(configs, false)
if err == nil || !strings.Contains(err.Error(), "--config") {
t.Fatalf("error = %v, want --config guidance", err)
}
if key != "" || config != nil {
t.Fatalf("selected %q (%v) without an explicit config", key, config)
}
}
func TestSelectConfigFromNonTTYUsesSoleConfig(t *testing.T) {
want := &assets.ClientConfig{Operator: "only"}
key, config, err := selectConfigFrom(map[string]*assets.ClientConfig{"only": want}, false)
if err != nil || key != "only" || config != want {
t.Fatalf("selection = %q (%v), error = %v", key, config, err)
}
}
func TestSelectConfigWithExplicitPathOnNonTTY(t *testing.T) {
want := &assets.ClientConfig{Operator: "selected", LHost: "127.0.0.1"}
data, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "selected.cfg")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
key, config, err := selectConfig(path, false)
if err != nil {
t.Fatalf("select explicit config: %v", err)
}
if key != "selected.cfg" || config.Operator != want.Operator || config.LHost != want.LHost {
t.Fatalf("selection = %q (%v)", key, config)
}
}
-177
View File
@@ -1,177 +0,0 @@
package cli
import (
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
bspinner "charm.land/bubbles/v2/spinner"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
const (
minConnectSpinnerDuration = 900 * time.Millisecond
minConnectStatusDuration = 350 * time.Millisecond
)
type connectResult struct {
rpc rpcpb.SliverRPCClient
conn *grpc.ClientConn
err error
}
func connectWithSpinner(out io.Writer, target string, connect func(transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error)) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusCh := make(chan string, 8)
resultCh := make(chan connectResult, 1)
go func() {
rpc, conn, err := connect(func(status string) {
sendStatus(statusCh, status)
})
resultCh <- connectResult{rpc: rpc, conn: conn, err: err}
}()
currentStatus := ""
currentStatusSince := time.Time{}
queuedStatus := ""
lastWidth := 0
spinner := bspinner.New(bspinner.WithSpinner(bspinner.Line))
ticker := time.NewTicker(spinner.Spinner.FPS)
defer ticker.Stop()
startedAt := time.Now()
var pendingResult *connectResult
setStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" || status == currentStatus {
return
}
currentStatus = status
currentStatusSince = time.Now()
}
stageStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
if currentStatus == "" || time.Since(currentStatusSince) >= minConnectStatusDuration {
queuedStatus = ""
setStatus(status)
return
}
queuedStatus = status
}
flushQueuedStatus := func() {
if queuedStatus == "" || time.Since(currentStatusSince) < minConnectStatusDuration {
return
}
nextStatus := queuedStatus
queuedStatus = ""
setStatus(nextStatus)
}
canReturnPendingResult := func() bool {
if pendingResult == nil {
return false
}
if pendingResult.err != nil {
return true
}
if time.Since(startedAt) < minConnectSpinnerDuration {
return false
}
if queuedStatus != "" {
return false
}
if !currentStatusSince.IsZero() && time.Since(currentStatusSince) < minConnectStatusDuration {
return false
}
return true
}
render := func() {
line := fmt.Sprintf("%s %s", spinner.View(), formatConnectSpinnerMessage(target, currentStatus))
lastWidth = writeSpinnerLine(out, line, lastWidth)
}
render()
for {
select {
case status := <-statusCh:
stageStatus(status)
spinner, _ = spinner.Update(spinner.Tick())
render()
case result := <-resultCh:
pendingResult = &result
case <-ticker.C:
spinner, _ = spinner.Update(spinner.Tick())
flushQueuedStatus()
render()
if canReturnPendingResult() {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
if pendingResult != nil && pendingResult.err != nil {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
}
func sendStatus(statusCh chan string, status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
statusCh <- status
}
func formatConnectSpinnerMessage(target string, status string) string {
target = strings.TrimSpace(target)
status = strings.TrimSpace(status)
if target == "" {
if status == "" {
return "Connecting ..."
}
return fmt.Sprintf("Connecting (%s) ...", status)
}
if status == "" {
return fmt.Sprintf("Connecting to %s ...", target)
}
return fmt.Sprintf("Connecting to %s (%s) ...", target, status)
}
func writeSpinnerLine(out io.Writer, line string, lastWidth int) int {
width := utf8.RuneCountInString(line)
padding := ""
if lastWidth > width {
padding = strings.Repeat(" ", lastWidth-width)
}
fmt.Fprintf(out, "\r%s%s", line, padding)
if width > lastWidth {
return width
}
return lastWidth
}
func clearSpinnerLine(out io.Writer, lastWidth int) {
if lastWidth <= 0 {
return
}
fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", lastWidth))
}
-145
View File
@@ -1,145 +0,0 @@
package cli
import (
"bytes"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
func TestFormatConnectSpinnerMessage(t *testing.T) {
tests := []struct {
name string
target string
status string
want string
}{
{
name: "target only",
target: "127.0.0.1:31337",
want: "Connecting to 127.0.0.1:31337 ...",
},
{
name: "target with status",
target: "127.0.0.1:31337",
status: "wireguard",
want: "Connecting to 127.0.0.1:31337 (wireguard) ...",
},
{
name: "status without target",
status: "grpc/mtls",
want: "Connecting (grpc/mtls) ...",
},
{
name: "trim whitespace",
target: " 127.0.0.1:31337 ",
status: " grpc/mtls over wireguard ",
want: "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatConnectSpinnerMessage(test.target, test.status); got != test.want {
t.Fatalf("expected %q, got %q", test.want, got)
}
})
}
}
func TestConnectWithSpinnerOutput(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerOutput)
}
func testConnectWithSpinnerOutput(t *testing.T) {
var out bytes.Buffer
spinnerDelay := 2 * (100 * time.Millisecond)
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
time.Sleep(spinnerDelay)
statusFn("grpc/mtls over wireguard")
time.Sleep(spinnerDelay)
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected wireguard status in output, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected grpc over wireguard status in output, got %q", got)
}
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames in output, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerFastSuccessStillShowsMultipleFrames)
}
func testConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("grpc/mtls")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337 (grpc/mtls) ...") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames for fast success, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerFastSuccessShowsEachStatus)
}
func testConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
statusFn("grpc/mtls over wireguard")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected fast success output to include wireguard status, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected fast success output to include grpc over wireguard status, got %q", got)
}
if strings.Contains(got, "Connected to 127.0.0.1:31337") {
t.Fatalf("did not expect past-tense success output, got %q", got)
}
}
-26
View File
@@ -1,26 +0,0 @@
package cli
import (
"context"
"fmt"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
)
func handleConnectionLost(ln *grpc.ClientConn) {
if ln == nil {
return
}
currentState := ln.GetState()
// currentState should be "Ready" when the connection is established.
if ln.WaitForStateChange(context.Background(), currentState) {
newState := ln.GetState()
// newState will be "Idle" if the connection is lost.
if newState == connectivity.Idle {
fmt.Println("\nLost connection to server. Exiting now.")
os.Exit(1)
}
}
}
+47 -37
View File
@@ -19,21 +19,20 @@ package cli
*/
import (
"context"
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
)
// ConsoleConfigFlagName is the flag used to select a client config file.
const ConsoleConfigFlagName = "config"
// consoleCmd generates the console with required pre/post runners.
func consoleCmd(con *console.SliverClient) *cobra.Command {
consoleCmd := &cobra.Command{
@@ -47,55 +46,66 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
}
func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *cobra.Command, args []string) error) {
var ln *grpc.ClientConn
pre = func(cmd *cobra.Command, _ []string) error {
if err := applyMultiplayerConnectMode(cmd); err != nil {
return err
configs := assets.GetConfigs()
if len(configs) == 0 {
fmt.Printf("No config files found at %s (see --help)\n", assets.GetConfigDir())
return nil
}
config := selectConfig()
if config == nil {
return nil
}
rcScript, err := ReadRCScript(cmd)
if err != nil {
return err
fmt.Printf("Failed to read rc script: %s\n", err)
return nil
}
configPath := ""
if cmd.Flags().Lookup(ConsoleConfigFlagName) != nil {
configPath, err = cmd.Flags().GetString(ConsoleConfigFlagName)
if err != nil {
return err
}
}
stdinTTY := term.IsTerminal(int(os.Stdin.Fd()))
configKey, config, err := selectConfig(configPath, stdinTTY)
if err != nil {
return err
}
target := fmt.Sprintf("%s:%d", config.LHost, config.LPort)
var rpc rpcpb.SliverRPCClient
var ln *grpc.ClientConn
// Don't clobber output when simply running an implant command from system shell.
if run && stdinTTY {
rpc, ln, err = connectWithSpinner(os.Stdout, target, func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
return transport.MTLSConnectWithStatus(config, statusFn)
})
} else {
rpc, ln, err = transport.MTLSConnect(config)
if run {
fmt.Printf("Connecting to %s:%d ...\n", config.LHost, config.LPort)
}
var rpc rpcpb.SliverRPCClient
rpc, ln, err = transport.MTLSConnect(config)
if err != nil {
return fmt.Errorf("connection to server failed: %w", err)
fmt.Printf("Connection to server failed %s", err)
return nil
}
if err := console.StartClient(con, rpc, ln, &console.ConnectionDetails{ConfigKey: configKey, Config: config}, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript); err != nil {
_ = con.CloseConnection()
return err
}
return nil
// Wait for any connection state changes and exit if the connection is lost.
go handleConnectionLost(ln)
return console.StartClient(con, rpc, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
}
// Close the RPC connection once exiting
post = func(_ *cobra.Command, _ []string) error {
return con.CloseConnection()
if ln != nil {
return ln.Close()
}
return nil
}
return pre, post
}
func handleConnectionLost(ln *grpc.ClientConn) {
currentState := ln.GetState()
// currentState should be "Ready" when the connection is established.
if ln.WaitForStateChange(context.Background(), currentState) {
newState := ln.GetState()
// newState will be "Idle" if the connection is lost.
if newState == connectivity.Idle {
fmt.Println("\nLost connection to server. Exiting now.")
os.Exit(1)
}
}
}
+1 -3
View File
@@ -57,9 +57,7 @@ func makeRunners(implantCmd *cobra.Command, con *console.SliverClient) (pre, pos
// The pre-run function connects to the server and sets up a "fake" console,
// so we can have access to active sessions/beacons, and other stuff needed.
pre = func(_ *cobra.Command, args []string) error {
if err := startConsole(implantCmd, args); err != nil {
return err
}
startConsole(implantCmd, args)
// Set the active target.
target, _ := implantCmd.Flags().GetString("use")
+13 -21
View File
@@ -20,6 +20,7 @@ package cli
import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/rsteube/carapace"
@@ -31,12 +32,19 @@ func importCmd() *cobra.Command {
Use: "import",
Short: "Import a client configuration file",
Long: `import [config files]`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Missing config file path, see --help")
return nil
Run: func(cmd *cobra.Command, args []string) {
if 0 < len(args) {
for _, arg := range args {
conf, err := assets.ReadConfig(arg)
if err != nil {
fmt.Printf("[!] %s\n", err)
os.Exit(3)
}
assets.SaveConfig(conf)
}
} else {
fmt.Printf("Missing config file path, see --help")
}
return importConfigs(args)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{}, cobra.ShellCompDirectiveDefault
@@ -47,19 +55,3 @@ func importCmd() *cobra.Command {
return cmdImport
}
func importConfigs(paths []string) error {
for _, path := range paths {
conf, err := assets.ReadConfig(path)
if err != nil {
return &commandExitError{
code: 3,
err: fmt.Errorf("read import %q: %w", path, err),
}
}
if err := assets.SaveConfig(conf); err != nil {
return fmt.Errorf("save import %q: %w", path, err)
}
}
return nil
}
-125
View File
@@ -1,125 +0,0 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func writeImportConfig(t *testing.T, name, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestImportCommandReportsInvalidConfig(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := writeImportConfig(t, "missing-operator.cfg", `{"lhost":"server"}`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want error identifying %q", err, path)
}
if got := commandExitCode(err); got != 1 {
t.Fatalf("invalid config exit code = %d, want 1", got)
}
}
func TestImportCommandReportsConfigWriteFailure(t *testing.T) {
clientRoot := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
path := writeImportConfig(t, "alice.cfg", `{"operator":"alice","lhost":"server"}`)
configDir := filepath.Join(clientRoot, "configs")
if err := os.MkdirAll(filepath.Join(configDir, "alice_server.cfg"), 0o700); err != nil {
t.Fatal(err)
}
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want write error identifying %q", err, path)
}
if got := commandExitCode(err); got != 1 {
t.Fatalf("config write failure exit code = %d, want 1", got)
}
if _, ok := errors.AsType[*os.PathError](err); !ok {
t.Fatalf("config write failure does not wrap the filesystem error: %v", err)
}
}
func TestImportCommandReadFailureExitCode(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := filepath.Join(t.TempDir(), "missing.cfg")
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want read error identifying %q", err, path)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("import error does not wrap the missing file error: %v", err)
}
if got := commandExitCode(fmt.Errorf("command failed: %w", err)); got != 3 {
t.Fatalf("wrapped read failure exit code = %d, want 3", got)
}
}
func TestImportCommandParseFailureExitCode(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := writeImportConfig(t, "malformed.cfg", `{"operator":`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want parse error identifying %q", err, path)
}
if _, ok := errors.AsType[*json.SyntaxError](err); !ok {
t.Fatalf("import error does not wrap the JSON syntax error: %v", err)
}
if got := commandExitCode(err); got != 3 {
t.Fatalf("parse failure exit code = %d, want 3", got)
}
}
func TestImportCommandStopsAfterFirstFailure(t *testing.T) {
clientRoot := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
invalidPath := writeImportConfig(t, "invalid.cfg", `{"lhost":"server"}`)
validPath := writeImportConfig(t, "valid.cfg", `{"operator":"alice","lhost":"server"}`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{invalidPath, validPath})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(invalidPath)) {
t.Fatalf("import error = %v, want first file %q", err, invalidPath)
}
if _, err := os.Stat(filepath.Join(clientRoot, "configs", "alice_server.cfg")); !os.IsNotExist(err) {
t.Fatalf("later config was imported after first failure: stat error = %v", err)
}
}
func TestImportCommandMissingPathPreservesSuccess(t *testing.T) {
cmd := importCmd()
var output bytes.Buffer
cmd.SetOut(&output)
err := cmd.RunE(cmd, nil)
if err != nil {
t.Fatalf("import without a path failed: %v", err)
}
if got := commandExitCode(err); got != 0 {
t.Fatalf("missing path exit code = %d, want 0", got)
}
if got, want := output.String(), "Missing config file path, see --help"; got != want {
t.Fatalf("missing path output = %q, want %q", got, want)
}
}
+3 -19
View File
@@ -22,14 +22,12 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/console"
clientmcp "github.com/bishopfox/sliver/client/mcp"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
"google.golang.org/grpc/keepalive"
)
func mcpCmd(con *console.SliverClient) *cobra.Command {
@@ -40,14 +38,11 @@ func mcpCmd(con *console.SliverClient) *cobra.Command {
return runMCP(cmd, con)
},
}
mcpCmd.Flags().String("config", "", "path to client config file")
return mcpCmd
}
func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
if err := applyMultiplayerConnectMode(cmd); err != nil {
return err
}
configPath, _ := cmd.Flags().GetString("config")
config, err := loadMCPClientConfig(configPath)
if err != nil {
@@ -55,26 +50,15 @@ func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
return nil
}
// Keep the gRPC link alive while the MCP bridge sits idle between tool calls.
// The daemon permits pings >=30s without streams (server.yaml: grpc.keepalive).
transport.SetKeepaliveParams(keepalive.ClientParameters{
Time: 60 * time.Second,
Timeout: 20 * time.Second,
PermitWithoutStream: true,
})
rpc, ln, err := transport.MTLSConnect(config)
if err != nil {
fmt.Printf("Connection to server failed %s\n", err)
return nil
}
defer transport.CloseGRPCConnection(ln)
defer ln.Close()
con.Rpc = rpc
// Do not install the connection-lost watchdog here: gRPC channels idle
// out after 30 minutes without RPCs (grpc-go default) and transparently
// reconnect on the next call. Exiting on the Idle state would kill an
// otherwise healthy MCP bridge between tool calls.
go handleConnectionLost(ln)
cfg := clientmcp.DefaultConfig()
if err := clientmcp.ServeStdio(cfg, con.Rpc); err != nil {
-42
View File
@@ -1,42 +0,0 @@
package cli
import (
"fmt"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
const (
enableWGFlag = "enable-wg"
disableWGFlag = "disable-wg"
)
func applyMultiplayerConnectMode(cmd *cobra.Command) error {
if cmd == nil {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
return nil
}
enableWG, err := cmd.Flags().GetBool(enableWGFlag)
if err != nil {
return err
}
disableWG, err := cmd.Flags().GetBool(disableWGFlag)
if err != nil {
return err
}
if cmd.Flags().Changed(enableWGFlag) && cmd.Flags().Changed(disableWGFlag) {
return fmt.Errorf("--%s and --%s cannot be used together", enableWGFlag, disableWGFlag)
}
mode := transport.MultiplayerConnectAuto
switch {
case enableWG:
mode = transport.MultiplayerConnectEnableWG
case disableWG:
mode = transport.MultiplayerConnectDisableWG
}
transport.SetMultiplayerConnectMode(mode)
return nil
}
-136
View File
@@ -1,136 +0,0 @@
package cli
import (
"strings"
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
func TestWireGuardOverrideFlagsDefaultToFalse(t *testing.T) {
for _, name := range []string{enableWGFlag, disableWGFlag} {
flag := rootCmd.PersistentFlags().Lookup(name)
if flag == nil {
t.Fatalf("expected --%s persistent flag", name)
}
if flag.DefValue != "false" {
t.Fatalf("expected --%s to default to false, got %q", name, flag.DefValue)
}
}
}
func TestApplyMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if !transport.MultiplayerConnectUsesWireGuard(enabledWGClientConfig()) {
t.Fatal("expected automatic mode to honor an explicitly enabled WireGuard config")
}
if transport.MultiplayerConnectUsesWireGuard(legacyWGClientConfig()) {
t.Fatal("expected automatic mode to ignore a legacy WireGuard config without the opt-in marker")
}
}
func TestApplyMultiplayerConnectModeEnableWGForcesLegacyConfig(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if !transport.MultiplayerConnectUsesWireGuard(legacyWGClientConfig()) {
t.Fatal("expected --enable-wg to force a complete legacy config through WireGuard")
}
}
func TestApplyMultiplayerConnectModeDisableWGOverridesEnabledConfig(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(disableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if transport.MultiplayerConnectUsesWireGuard(enabledWGClientConfig()) {
t.Fatal("expected --disable-wg to force direct mTLS")
}
}
func TestApplyMultiplayerConnectModeRejectsConflictingOverrides(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := cmd.Flags().Set(disableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
err := applyMultiplayerConnectMode(cmd)
if err == nil {
t.Fatal("expected conflicting WireGuard overrides to fail")
}
if !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("unexpected conflict error: %v", err)
}
}
func TestApplyMultiplayerConnectModeRejectsBothOverridesWhenExplicitlyFalse(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "false"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := cmd.Flags().Set(disableWGFlag, "false"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
err := applyMultiplayerConnectMode(cmd)
if err == nil {
t.Fatal("expected explicitly combining both WireGuard override flags to fail")
}
if !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("unexpected conflict error: %v", err)
}
}
func newTransportModeTestCommand() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().Bool(enableWGFlag, false, "")
cmd.Flags().Bool(disableWGFlag, false, "")
return cmd
}
func resetMultiplayerConnectModeForTest(t *testing.T) {
t.Helper()
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
t.Cleanup(func() {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
})
}
func enabledWGClientConfig() *assets.ClientConfig {
config := legacyWGClientConfig()
config.WG.Enabled = true
return config
}
func legacyWGClientConfig() *assets.ClientConfig {
return &assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
}
}
-2
View File
@@ -12,7 +12,6 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
## Sub-packages
- `ai/` – Implements the 'ai' Bubble Tea layout preview command for the Sliver client console.
- `alias/` – Implements the 'alias' command group for the Sliver client console.
- `armory/` – Implements the 'armory' command group for the Sliver client console.
- `backdoor/` – Implements the 'backdoor' command group for the Sliver client console.
@@ -26,7 +25,6 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
- `creds/` – Implements the 'creds' command group for the Sliver client console.
- `cursed/` – Implements the 'cursed' command group for the Sliver client console.
- `dllhijack/` – Implements the 'dllhijack' command group for the Sliver client console.
- `edit/` – Implements the 'edit' command for the Sliver client console.
- `environment/` – Implements the 'environment' command group for the Sliver client console.
- `exec/` – Implements the 'exec' command group for the Sliver client console.
- `exit/` – Implements the 'exit' command group for the Sliver client console.
-5
View File
@@ -1,5 +0,0 @@
# client/command/ai
Implements the `ai` command for the Sliver client console.
The TUI loads server-backed AI conversation threads over gRPC, submits prompts to the server, and refreshes when `AIConversationEvent` updates arrive with assistant replies or failure messages.
-242
View File
@@ -1,242 +0,0 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 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 (
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/termio"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var openAIProgramTTY = tea.OpenTTY
// AICmd launches the AI conversation TUI.
func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
if con == nil || con.Rpc == nil {
if con != nil {
con.PrintErrorf("Connect to a server before using `ai`.\n")
}
return
}
listenerID, listener := con.CreateEventListener()
defer con.RemoveEventListener(listenerID)
restoreEventNotifications := con.SuppressEventNotifications()
defer restoreEventNotifications()
model := newAIModel(con, buildAIContext(con), listener)
model.showExperimentalWarningModal()
width, height := 100, 30
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
width, height = w, h
}
opts := []tea.ProgramOption{
tea.WithWindowSize(width, height),
tea.WithColorProfile(colorprofile.TrueColor),
}
ttyOpts, cleanup := configureAIProgramTTY()
if cleanup != nil {
defer cleanup()
}
opts = append(opts, ttyOpts...)
program := tea.NewProgram(model, opts...)
if _, err := program.Run(); err != nil {
con.PrintErrorf("AI TUI error: %s\n", err)
}
}
// Console logging can swap stdout/stderr to pipes so session output can be tee'd
// to disk. Bubble Tea needs a real TTY output to enable terminal features like
// mouse reporting, so bind the AI TUI back to the interactive terminal when
// stdout is no longer the controlling TTY.
func configureAIProgramTTY() ([]tea.ProgramOption, func()) {
stdinTTY := isAITTY(os.Stdin)
stdoutTTY := isAITTY(os.Stdout)
switch {
case !stdinTTY || stdoutTTY:
return nil, nil
case isAITTY(termio.InteractiveInput()) && isAITTY(termio.InteractiveOutput()):
return []tea.ProgramOption{
tea.WithInput(termio.InteractiveInput()),
tea.WithOutput(termio.InteractiveOutput()),
}, nil
default:
inTTY, outTTY, err := openAIProgramTTY()
if err != nil {
return nil, nil
}
return []tea.ProgramOption{
tea.WithInput(inTTY),
tea.WithOutput(outTTY),
}, func() {
_ = inTTY.Close()
if outTTY != inTTY {
_ = outTTY.Close()
}
}
}
}
func isAITTY(file *os.File) bool {
return file != nil && term.IsTerminal(int(file.Fd()))
}
type aiContext struct {
target aiTargetSummary
connection aiConnectionSummary
status string
}
type aiTargetSummary struct {
SessionID string
BeaconID string
Label string
Host string
OS string
Arch string
C2 string
Mode string
Details []string
}
type aiConnectionSummary struct {
Profile string
Server string
Operator string
State string
}
func defaultAITargetSummary() aiTargetSummary {
return aiTargetSummary{
Label: "No active target",
Host: "Select a session or beacon with `use` or ctrl+s",
Mode: "offline preview",
C2: "n/a",
OS: "unknown",
Arch: "unknown",
}
}
func aiSessionTargetSummary(session *clientpb.Session) aiTargetSummary {
if session == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
SessionID: session.ID,
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
Host: fallback(session.Hostname, "<unknown host>"),
OS: fallback(session.OS, "unknown"),
Arch: fallback(session.Arch, "unknown"),
C2: fallback(session.ActiveC2, "unknown"),
Mode: "interactive session",
Details: []string{
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
fmt.Sprintf("PID: %d", session.PID),
fmt.Sprintf("Remote: %s", fallback(session.RemoteAddress, "<unknown>")),
},
}
}
func aiBeaconTargetSummary(beacon *clientpb.Beacon) aiTargetSummary {
if beacon == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
BeaconID: beacon.ID,
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
Host: fallback(beacon.Hostname, "<unknown host>"),
OS: fallback(beacon.OS, "unknown"),
Arch: fallback(beacon.Arch, "unknown"),
C2: fallback(beacon.ActiveC2, "unknown"),
Mode: "asynchronous beacon",
Details: []string{
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
fmt.Sprintf("PID: %d", beacon.PID),
fmt.Sprintf("Remote: %s", fallback(beacon.RemoteAddress, "<unknown>")),
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
fmt.Sprintf("Next checkin: %s", formatUnix(beacon.NextCheckin)),
},
}
}
func buildAIContext(con *console.SliverClient) aiContext {
ctx := aiContext{
target: defaultAITargetSummary(),
connection: aiConnectionSummary{
Profile: "<disconnected>",
Server: "<unknown>",
Operator: "<unknown>",
State: "idle",
},
status: "Loading AI conversations from the server...",
}
if con != nil {
if details, state, ok := con.CurrentConnection(); ok {
ctx.connection.State = strings.ToLower(state.String())
if details != nil && details.Config != nil {
ctx.connection.Profile = fallback(details.ConfigKey, "<profile unavailable>")
ctx.connection.Server = fmt.Sprintf("%s:%d", details.Config.LHost, details.Config.LPort)
ctx.connection.Operator = fallback(details.Config.Operator, "<unknown>")
}
}
session, beacon := con.ActiveTarget.Get()
switch {
case session != nil:
ctx.target = aiSessionTargetSummary(session)
case beacon != nil:
ctx.target = aiBeaconTargetSummary(beacon)
}
}
return ctx
}
func fallback(value, def string) string {
value = strings.TrimSpace(value)
if value == "" {
return def
}
return value
}
func formatUnix(ts int64) string {
if ts <= 0 {
return "<unknown>"
}
return time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05")
}
-49
View File
@@ -1,49 +0,0 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 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 (
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
// Commands returns the ai command.
func Commands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newAICommand(consts.SliverCoreHelpGroup, con)}
}
// ServerCommands returns the ai command for the top-level client REPL.
func ServerCommands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newAICommand(consts.GenericHelpGroup, con)}
}
func newAICommand(groupID string, con *console.SliverClient) *cobra.Command {
return &cobra.Command{
Use: consts.AIStr,
Short: "Open the Sliver AI conversation TUI",
Long: help.GetHelpFor([]string{consts.AIStr}),
Args: cobra.NoArgs,
GroupID: groupID,
Run: func(cmd *cobra.Command, args []string) {
AICmd(cmd, con, args)
},
}
}
-591
View File
@@ -1,591 +0,0 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 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 (
"context"
"net"
"sync"
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/proto"
)
func TestLoadAIStateCmdLoadsConversationOverRPC(t *testing.T) {
server := &aiRPCServer{
providersResp: &clientpb.AIProviderConfigs{
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
Config: &clientpb.AIConfigSummary{
Provider: "openai",
Model: "gpt-test",
Valid: true,
},
},
conversationsResp: &clientpb.AIConversations{
Conversations: []*clientpb.AIConversation{
{ID: "conv-1", Provider: "openai", Model: "gpt-test", Title: "First"},
{ID: "conv-2", Provider: "openai", Model: "gpt-test", Title: "Second"},
},
},
conversationByID: map[string]*clientpb.AIConversation{
"conv-1": {
ID: "conv-1",
Provider: "openai",
Model: "gpt-test",
Title: "First",
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Hello"},
},
},
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")
msg := cmd()
loaded, ok := msg.(aiLoadedMsg)
if !ok {
t.Fatalf("expected aiLoadedMsg, got %T", msg)
}
if loaded.selectedID != "conv-1" {
t.Fatalf("unexpected selected conversation id: got=%q want=%q", loaded.selectedID, "conv-1")
}
if loaded.conversation == nil || loaded.conversation.GetID() != "conv-1" {
t.Fatalf("unexpected loaded conversation: %+v", loaded.conversation)
}
if len(loaded.conversations) != 2 {
t.Fatalf("unexpected conversation count: got=%d want=%d", len(loaded.conversations), 2)
}
server.mu.Lock()
defer server.mu.Unlock()
if server.getProvidersCalls != 1 {
t.Fatalf("expected GetAIProviders to be called once, got %d", server.getProvidersCalls)
}
if server.getConversationsCalls != 1 {
t.Fatalf("expected GetAIConversations to be called once, got %d", server.getConversationsCalls)
}
if len(server.getConversationReqs) != 1 {
t.Fatalf("expected GetAIConversation to be called once, got %d", len(server.getConversationReqs))
}
if server.getConversationReqs[0].GetID() != "conv-1" || !server.getConversationReqs[0].GetIncludeMessages() {
t.Fatalf("unexpected GetAIConversation request: %+v", server.getConversationReqs[0])
}
}
func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
server := &aiRPCServer{
providersResp: &clientpb.AIProviderConfigs{
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
Config: &clientpb.AIConfigSummary{
Provider: "openai",
Model: "gpt-test",
SystemPrompt: "Stay concise.",
Valid: true,
},
},
conversationsResp: &clientpb.AIConversations{},
saveConversationResp: &clientpb.AIConversation{
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
conversationByID: map[string]*clientpb.AIConversation{
"conv-created": {
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")()
loaded, ok := msg.(aiLoadedMsg)
if !ok {
t.Fatalf("expected aiLoadedMsg, got %T", msg)
}
if loaded.status != "Created a new AI conversation." {
t.Fatalf("unexpected status: %q", loaded.status)
}
if loaded.selectedID != "conv-created" {
t.Fatalf("unexpected selected conversation id: %q", loaded.selectedID)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 1 {
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
}
request := server.saveConversationReqs[0]
if request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetTitle() != "New conversation" {
t.Fatalf("unexpected SaveAIConversation request: %+v", request)
}
if request.GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected SaveAIConversation system prompt: %q", request.GetSystemPrompt())
}
}
func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
server := &aiRPCServer{
saveConversationResp: &clientpb.AIConversation{
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "Explain the workflow.",
},
saveMessageResp: &clientpb.AIConversationMessage{
ID: "msg-1",
ConversationID: "conv-created",
Role: "user",
Content: "Explain the workflow.\n\nWith details.",
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{SessionID: "session-1"},
nil,
"openai",
"gpt-test",
"Stay concise.",
"Explain the workflow.\n\nWith details.",
)()
submitted, ok := msg.(aiPromptSubmittedMsg)
if !ok {
t.Fatalf("expected aiPromptSubmittedMsg, got %T", msg)
}
if submitted.conversationID != "conv-created" {
t.Fatalf("unexpected conversation id: %q", submitted.conversationID)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 1 {
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
}
if server.saveConversationReqs[0].GetTitle() != "Explain the workflow." {
t.Fatalf("unexpected conversation title: %q", server.saveConversationReqs[0].GetTitle())
}
if server.saveConversationReqs[0].GetTargetSessionID() != "session-1" {
t.Fatalf("expected target session to be forwarded, got %+v", server.saveConversationReqs[0])
}
if server.saveConversationReqs[0].GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected conversation system prompt: %q", server.saveConversationReqs[0].GetSystemPrompt())
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
request := server.saveMessageReqs[0]
if request.GetConversationID() != "conv-created" || request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetRole() != "user" {
t.Fatalf("unexpected SaveAIConversationMessage request: %+v", request)
}
if request.GetContent() != "Explain the workflow.\n\nWith details." {
t.Fatalf("unexpected prompt content: %q", request.GetContent())
}
}
func TestSubmitPromptCmdUsesExistingConversationSettings(t *testing.T) {
server := &aiRPCServer{
saveMessageResp: &clientpb.AIConversationMessage{
ID: "msg-1",
ConversationID: "conv-1",
Role: "user",
Content: "hello",
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{},
&clientpb.AIConversation{ID: "conv-1", Provider: "anthropic", Model: "claude-test", Title: "Thread"},
"openai",
"gpt-test",
"Stay concise.",
"hello",
)()
if _, ok := msg.(aiPromptSubmittedMsg); !ok {
t.Fatalf("expected aiPromptSubmittedMsg, got %T", msg)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 0 {
t.Fatalf("did not expect SaveAIConversation to be called, got %d calls", len(server.saveConversationReqs))
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
request := server.saveMessageReqs[0]
if request.GetProvider() != "anthropic" || request.GetModel() != "claude-test" {
t.Fatalf("unexpected conversation settings in message request: %+v", request)
}
}
func TestDeleteConversationCmdSendsDeleteRequest(t *testing.T) {
server := &aiRPCServer{}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := deleteConversationCmd(
&console.SliverClient{Rpc: rpcClient},
"conv-1",
"conv-2",
`Deleted "Thread".`,
)()
deleted, ok := msg.(aiConversationDeletedMsg)
if !ok {
t.Fatalf("expected aiConversationDeletedMsg, got %T", msg)
}
if deleted.conversationID != "conv-1" || deleted.selectedID != "conv-2" {
t.Fatalf("unexpected delete message: %+v", deleted)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.deleteConversationReqs) != 1 {
t.Fatalf("expected DeleteAIConversation to be called once, got %d", len(server.deleteConversationReqs))
}
if server.deleteConversationReqs[0].GetID() != "conv-1" {
t.Fatalf("unexpected DeleteAIConversation request: %+v", server.deleteConversationReqs[0])
}
}
func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
eventPayload := &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-1",
OperatorName: "alice",
},
}
data, err := proto.Marshal(eventPayload)
if err != nil {
t.Fatalf("marshal conversation event: %v", err)
}
listener := make(chan *clientpb.Event, 2)
listener <- &clientpb.Event{EventType: consts.WatchtowerEvent}
listener <- &clientpb.Event{EventType: consts.AIConversationEvent, Data: data}
msg := waitForAIConversationEventCmd(listener)()
aiEvent, ok := msg.(aiConversationEventMsg)
if !ok {
t.Fatalf("expected aiConversationEventMsg, got %T", msg)
}
if aiEvent.event == nil || aiEvent.event.GetConversation() == nil || aiEvent.event.GetConversation().GetID() != "conv-1" {
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.event)
}
}
func TestWaitForAIConversationEventCmdReturnsToastMessages(t *testing.T) {
listener := make(chan *clientpb.Event, 1)
listener <- &clientpb.Event{
EventType: consts.ClientToastEvent,
Data: []byte("Session alpha connected"),
Err: "info",
}
msg := waitForAIConversationEventCmd(listener)()
toast, ok := msg.(aiToastMsg)
if !ok {
t.Fatalf("expected aiToastMsg, got %T", msg)
}
if toast.level != "info" || toast.message != "Session alpha connected" {
t.Fatalf("unexpected toast payload: %+v", toast)
}
}
func TestWaitForAIConversationEventCmdReturnsClosedMessage(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
msg := waitForAIConversationEventCmd(listener)()
if _, ok := msg.(aiListenerClosedMsg); !ok {
t.Fatalf("expected aiListenerClosedMsg, got %T", msg)
}
}
func TestAIModelAppliesSharedConversationEventsWithoutReload(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
model := newAIModel(nil, aiContext{
connection: aiConnectionSummary{Operator: "alice"},
}, listener)
model.loading = false
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "First"}}
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "bob",
Title: "Shared thread",
},
}})
nextModel := updated.(*aiModel)
if nextModel.loading {
t.Fatal("did not expect shared conversation event to trigger a reload")
}
if conversationIndexByID(nextModel.conversations, "conv-2") < 0 {
t.Fatalf("expected shared conversation to be merged locally, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected shared conversation event to continue listening for events")
}
}
func TestAIModelAppliesDeleteEventsWhileAwaitingResponse(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
model := newAIModel(nil, aiContext{
connection: aiConnectionSummary{Operator: "alice"},
}, listener)
model.loading = false
model.awaitingResponse = true
model.currentConversation = &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "alice",
UpdatedAt: 100,
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "still waiting"},
},
}
model.conversations = []*clientpb.AIConversation{
{ID: "conv-2", Title: "Current"},
{ID: "conv-1", Title: "Fallback"},
}
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
ConversationID: "conv-2",
Conversation: &clientpb.AIConversation{
ID: "conv-2",
},
}})
nextModel := updated.(*aiModel)
if nextModel.loading {
t.Fatal("did not expect delete tombstone to trigger a reload")
}
if nextModel.currentConversation != nil {
t.Fatalf("expected deleted conversation to be cleared, got %+v", nextModel.currentConversation)
}
if conversationIndexByID(nextModel.conversations, "conv-2") >= 0 {
t.Fatalf("expected deleted conversation to be removed, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected delete tombstone to continue listening for events")
}
}
type aiRPCServer struct {
rpcpb.UnimplementedSliverRPCServer
mu sync.Mutex
providersResp *clientpb.AIProviderConfigs
conversationsResp *clientpb.AIConversations
conversationByID map[string]*clientpb.AIConversation
saveConversationResp *clientpb.AIConversation
saveMessageResp *clientpb.AIConversationMessage
sessionsResp *clientpb.Sessions
beaconsResp *clientpb.Beacons
getProvidersCalls int
getConversationsCalls int
getSessionsCalls int
getBeaconsCalls int
getConversationReqs []*clientpb.AIConversationReq
deleteConversationReqs []*clientpb.AIConversationReq
saveConversationReqs []*clientpb.AIConversation
saveMessageReqs []*clientpb.AIConversationMessage
getConversationStart chan struct{}
getConversationWait <-chan struct{}
saveMessageStarted chan struct{}
saveMessageRelease <-chan struct{}
}
func (s *aiRPCServer) GetAIProviders(context.Context, *commonpb.Empty) (*clientpb.AIProviderConfigs, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getProvidersCalls++
if s.providersResp == nil {
return &clientpb.AIProviderConfigs{}, nil
}
return proto.Clone(s.providersResp).(*clientpb.AIProviderConfigs), nil
}
func (s *aiRPCServer) GetAIConversations(context.Context, *commonpb.Empty) (*clientpb.AIConversations, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getConversationsCalls++
if s.conversationsResp == nil {
return &clientpb.AIConversations{}, nil
}
return proto.Clone(s.conversationsResp).(*clientpb.AIConversations), nil
}
func (s *aiRPCServer) GetSessions(context.Context, *commonpb.Empty) (*clientpb.Sessions, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getSessionsCalls++
if s.sessionsResp == nil {
return &clientpb.Sessions{}, nil
}
return proto.Clone(s.sessionsResp).(*clientpb.Sessions), nil
}
func (s *aiRPCServer) GetBeacons(context.Context, *commonpb.Empty) (*clientpb.Beacons, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getBeaconsCalls++
if s.beaconsResp == nil {
return &clientpb.Beacons{}, nil
}
return proto.Clone(s.beaconsResp).(*clientpb.Beacons), nil
}
func (s *aiRPCServer) GetAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*clientpb.AIConversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getConversationReqs = append(s.getConversationReqs, proto.Clone(req).(*clientpb.AIConversationReq))
if s.getConversationStart != nil {
select {
case s.getConversationStart <- struct{}{}:
default:
}
}
if s.getConversationWait != nil {
s.mu.Unlock()
<-s.getConversationWait
s.mu.Lock()
}
if s.conversationByID == nil {
return &clientpb.AIConversation{}, nil
}
conversation := s.conversationByID[req.GetID()]
if conversation == nil {
return &clientpb.AIConversation{}, nil
}
return proto.Clone(conversation).(*clientpb.AIConversation), nil
}
func (s *aiRPCServer) SaveAIConversation(_ context.Context, req *clientpb.AIConversation) (*clientpb.AIConversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saveConversationReqs = append(s.saveConversationReqs, proto.Clone(req).(*clientpb.AIConversation))
if s.saveConversationResp == nil {
return proto.Clone(req).(*clientpb.AIConversation), nil
}
return proto.Clone(s.saveConversationResp).(*clientpb.AIConversation), nil
}
func (s *aiRPCServer) DeleteAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*commonpb.Empty, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.deleteConversationReqs = append(s.deleteConversationReqs, proto.Clone(req).(*clientpb.AIConversationReq))
return &commonpb.Empty{}, nil
}
func (s *aiRPCServer) SaveAIConversationMessage(_ context.Context, req *clientpb.AIConversationMessage) (*clientpb.AIConversationMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saveMessageReqs = append(s.saveMessageReqs, proto.Clone(req).(*clientpb.AIConversationMessage))
if s.saveMessageStarted != nil {
select {
case s.saveMessageStarted <- struct{}{}:
default:
}
}
if s.saveMessageRelease != nil {
s.mu.Unlock()
<-s.saveMessageRelease
s.mu.Lock()
}
if s.saveMessageResp == nil {
return proto.Clone(req).(*clientpb.AIConversationMessage), nil
}
return proto.Clone(s.saveMessageResp).(*clientpb.AIConversationMessage), nil
}
func newAITestRPCClient(t *testing.T, srv rpcpb.SliverRPCServer) (rpcpb.SliverRPCClient, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
grpcServer := grpc.NewServer()
rpcpb.RegisterSliverRPCServer(grpcServer, srv)
go func() {
_ = grpcServer.Serve(listener)
}()
dialer := func(context.Context, string) (net.Conn, error) { return listener.Dial() }
conn, err := grpc.DialContext(context.Background(), "bufnet",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
grpcServer.Stop()
_ = listener.Close()
t.Fatalf("dial bufconn: %v", err)
}
cleanup := func() {
_ = conn.Close()
grpcServer.Stop()
_ = listener.Close()
}
return rpcpb.NewSliverRPCClient(conn), cleanup
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-251
View File
@@ -1,251 +0,0 @@
package alias
/*
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 (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
)
// Alias commands run with cobra's DisableFlagParsing enabled (see LoadAlias),
// the same treatment extension commands received for their arguments. Before
// that, an unknown flag such as the payload's own `-pid` was rejected outright
// by pflag ("unknown flag"), and operators had to inject cobra's "--"
// separator (documented on the wiki) to pass arguments through at all (#2264).
//
// With flag parsing disabled cobra hands Run the raw, unstripped slice, and we
// extract the Sliver-owned flags ourselves here. Everything we do not own is
// passed to the payload untouched, so all of these now work:
//
// amsi-bypass -pid 6969
// fakealias --process notepad.exe arg1 arg2
// fakealias -- --anything still-here (existing workaround, unchanged)
type ownedFlagKind int
const (
ownedBool ownedFlagKind = iota
ownedString
ownedUint32
ownedInt64
)
// ownedFlagSpec describes one Sliver-owned flag that splitAliasArgs extracts
// from the raw argument slice before the rest is handed to the alias payload.
type ownedFlagSpec struct {
long string // canonical pflag name, e.g. "process-arguments"
short string // shorthand letter, e.g. "A" ("" if the flag has none)
kind ownedFlagKind // how the value is consumed and validated
}
// baseAliasOwnedFlags are registered for every alias command.
var baseAliasOwnedFlags = []ownedFlagSpec{
{long: "process", short: "p", kind: ownedString},
{long: "process-arguments", short: "A", kind: ownedString},
{long: "ppid", short: "P", kind: ownedUint32},
{long: "save", short: "s", kind: ownedBool},
{long: "timeout", short: "t", kind: ownedInt64},
}
// assemblyAliasOwnedFlags are only owned when the alias wraps a .NET assembly;
// for every other alias they are unknown tokens and pass through to the payload.
var assemblyAliasOwnedFlags = []ownedFlagSpec{
{long: "method", short: "m", kind: ownedString},
{long: "class", short: "c", kind: ownedString},
{long: "app-domain", short: "d", kind: ownedString},
{long: "arch", short: "a", kind: ownedString},
{long: "in-process", short: "i", kind: ownedBool},
{long: "runtime", short: "r", kind: ownedString},
{long: "amsi-bypass", short: "M", kind: ownedBool},
{long: "etw-bypass", short: "E", kind: ownedBool},
}
// aliasOwnedFlagSpecs returns the flag set owned by a particular alias.
func aliasOwnedFlagSpecs(isAssembly bool) []ownedFlagSpec {
if !isAssembly {
return baseAliasOwnedFlags
}
specs := make([]ownedFlagSpec, 0, len(baseAliasOwnedFlags)+len(assemblyAliasOwnedFlags))
specs = append(specs, baseAliasOwnedFlags...)
return append(specs, assemblyAliasOwnedFlags...)
}
// ownedFlagMatch is a raw token that spelled one of the Sliver-owned flags.
type ownedFlagMatch struct {
spec ownedFlagSpec
value string // value from a "--flag=value" / "-f=value" spelling
inline bool // whether the token carried its value inline
}
// splitAliasArgs separates the Sliver-owned flags from the alias payload's own
// arguments in a raw (DisableFlagParsing) argument slice. It accepts the long
// forms --flag, --flag=value, --flag value and the shorthand forms -f, -f=value,
// -f value, mirroring pflag. Two deliberate differences from pflag:
//
// - a glued shorthand value (-pnotepad) does NOT match --process: tokens
// like the payload's own "-pid" must survive intact (#2264);
// - after a literal "--" every token is copied through verbatim, preserving
// the long-standing separator workaround.
//
// The returned map holds the raw string value of each flag that was provided;
// callers re-publish the values with applyOwnedAliasFlags.
func splitAliasArgs(raw []string, specs []ownedFlagSpec) (map[string]string, bool, []string, error) {
values := make(map[string]string)
extArgs := make([]string, 0, len(raw))
byLong, byShort := ownedFlagTables(specs)
passthrough := false
for i := 0; i < len(raw); i++ {
tok := raw[i]
// Once we have seen "--", copy everything through untouched.
if passthrough {
extArgs = append(extArgs, tok)
continue
}
if tok == "--" {
passthrough = true
continue
}
if tok == "--help" || tok == "-h" {
return values, true, extArgs, nil
}
match, ok := matchOwnedFlag(tok, byLong, byShort)
if !ok {
// Not one of ours (including glued shorthands and the payload's
// own flags): the alias owns the token, pass it through untouched.
extArgs = append(extArgs, tok)
continue
}
i = takeOwnedValue(match, raw, i, values)
}
if err := validateOwnedValues(specs, values); err != nil {
return nil, false, nil, err
}
return values, false, extArgs, nil
}
// ownedFlagTables indexes the specs by long name and shorthand.
func ownedFlagTables(specs []ownedFlagSpec) (byLong, byShort map[string]ownedFlagSpec) {
byLong = make(map[string]ownedFlagSpec, len(specs))
byShort = make(map[string]ownedFlagSpec, len(specs))
for _, spec := range specs {
byLong[spec.long] = spec
if spec.short != "" {
byShort[spec.short] = spec
}
}
return byLong, byShort
}
// matchOwnedFlag checks whether tok spells one of the Sliver-owned flags, in
// the long form (--name, --name=value) or shorthand form (-f, -f=value). A
// glued shorthand value (-pnotepad) deliberately does not match: pflag would
// read "-pid" as -p with value "id", which is exactly the mangling that broke
// #2264, so such tokens must fall through to the payload.
func matchOwnedFlag(tok string, byLong, byShort map[string]ownedFlagSpec) (ownedFlagMatch, bool) {
var match ownedFlagMatch
var name, value string
var hasValue, isShort bool
switch {
case strings.HasPrefix(tok, "--"):
name, value, hasValue = strings.Cut(tok[2:], "=")
case strings.HasPrefix(tok, "-") && len(tok) > 1:
name, value, hasValue = strings.Cut(tok[1:], "=")
isShort = len(name) == 1
default:
return match, false
}
spec, ok := byLong[name]
if isShort {
spec, ok = byShort[name]
}
if !ok {
return match, false
}
match.spec, match.value, match.inline = spec, value, hasValue
return match, true
}
// takeOwnedValue records the flag's value in values and returns the index of
// the last raw token it consumed. The "--flag value" / "-f value" spellings
// consume the following token; a bool without an inline value defaults to
// true; a value flag as the very last token is dropped and keeps its default,
// mirroring the extension behaviour.
func takeOwnedValue(match ownedFlagMatch, raw []string, i int, values map[string]string) int {
switch {
case match.spec.kind == ownedBool:
if !match.inline {
match.value = "true"
}
values[match.spec.long] = match.value
case match.inline:
values[match.spec.long] = match.value
case i+1 < len(raw):
values[match.spec.long] = raw[i+1]
return i + 1
}
return i
}
// validateOwnedValues rejects non-numeric values for the numeric flag kinds,
// the way pflag's typed values would have.
func validateOwnedValues(specs []ownedFlagSpec, values map[string]string) error {
for _, spec := range specs {
value, provided := values[spec.long]
if !provided {
continue
}
var err error
switch spec.kind {
case ownedUint32:
_, err = strconv.ParseUint(value, 10, 32)
case ownedInt64:
_, err = strconv.ParseInt(value, 10, 64)
}
if err != nil {
return fmt.Errorf("invalid value %q for --%s", value, spec.long)
}
}
return nil
}
// applyOwnedAliasFlags republishes the manually parsed Sliver-owned flag values
// onto the command's flag set. DisableFlagParsing prevents pflag from
// populating them, so readers such as runAliasCommand's Get* calls and
// ActiveTarget.Request (which derives the gRPC timeout from --timeout) would
// otherwise observe only the defaults.
func applyOwnedAliasFlags(cmd *cobra.Command, values map[string]string) error {
for name, value := range values {
flag := cmd.Flags().Lookup(name)
if flag == nil {
continue // e.g. an assembly flag on a non-assembly alias
}
if err := flag.Value.Set(value); err != nil {
return fmt.Errorf("invalid value %q for --%s: %w", value, name, err)
}
}
return nil
}
-259
View File
@@ -1,259 +0,0 @@
package alias
/*
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 (
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestSplitAliasArgs covers the argument passthrough regression from #2264:
// the alias payload's own flags must reach the payload intact, while the
// Sliver-owned flags are extracted and validated.
func TestSplitAliasArgs(t *testing.T) {
base := aliasOwnedFlagSpecs(false)
assembly := aliasOwnedFlagSpecs(true)
cases := []struct {
name string
specs []ownedFlagSpec
in []string
values map[string]string
help bool
extArgs []string
wantErr bool
}{
{
name: "payload flag without -- separator (#2264 regression)",
specs: base,
in: []string{"-pid", "6969"},
values: map[string]string{},
extArgs: []string{"-pid", "6969"},
},
{
name: "glued shorthand is not ours and passes through",
specs: base,
in: []string{"-pid"},
values: map[string]string{},
extArgs: []string{"-pid"},
},
{
name: "payload flag with -- separator (existing workaround)",
specs: base,
in: []string{"--", "--pid", "6969"},
values: map[string]string{},
extArgs: []string{"--pid", "6969"},
},
{
name: "owned flag after -- belongs to the payload",
specs: base,
in: []string{"--", "--save"},
values: map[string]string{},
extArgs: []string{"--save"},
},
{
name: "long flag with separate value, then payload args",
specs: base,
in: []string{"--process", "notepad.exe", "arg1", "arg2"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1", "arg2"},
},
{
name: "long flag with equals value",
specs: base,
in: []string{"--process=notepad.exe", "arg1"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1"},
},
{
name: "shorthand with separate value",
specs: base,
in: []string{"-p", "notepad.exe", "arg1"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1"},
},
{
name: "shorthand with equals value",
specs: base,
in: []string{"-p=notepad.exe"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{},
},
{
name: "bool shorthand and long false form",
specs: base,
in: []string{"-s", "--save=false", "arg1"},
values: map[string]string{"save": "false"},
extArgs: []string{"arg1"},
},
{
name: "quoted value containing spaces stays a single argument",
specs: base,
in: []string{"-A", "-kalc -kdisable", "arg1"},
values: map[string]string{"process-arguments": "-kalc -kdisable"},
extArgs: []string{"arg1"},
},
{
name: "valid uint32 and int64 values",
specs: base,
in: []string{"-P", "1234", "--timeout=90"},
values: map[string]string{"ppid": "1234", "timeout": "90"},
extArgs: []string{},
},
{
name: "invalid uint32 value errors",
specs: base,
in: []string{"--ppid", "notanumber"},
wantErr: true,
},
{
name: "invalid int64 value errors",
specs: base,
in: []string{"--timeout=x"},
wantErr: true,
},
{
name: "assembly flags are owned for assembly aliases",
specs: assembly,
in: []string{"--method", "Run", "--class", "Prog", "-i", "-M", "--arch", "x64", "payloadArg"},
values: map[string]string{
"method": "Run", "class": "Prog", "in-process": "true",
"amsi-bypass": "true", "arch": "x64",
},
extArgs: []string{"payloadArg"},
},
{
name: "assembly flags pass through for non-assembly aliases",
specs: base,
in: []string{"--method", "Run"},
values: map[string]string{},
extArgs: []string{"--method", "Run"},
},
{
name: "help flags are detected",
specs: base,
in: []string{"--help"},
help: true,
values: map[string]string{},
extArgs: []string{},
},
{
name: "short help is detected",
specs: base,
in: []string{"-h"},
help: true,
values: map[string]string{},
extArgs: []string{},
},
{
name: "value flag as last token keeps its default",
specs: base,
in: []string{"arg1", "--process"},
values: map[string]string{},
extArgs: []string{"arg1"},
},
{
name: "empty input",
specs: base,
in: nil,
values: map[string]string{},
extArgs: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values, help, extArgs, err := splitAliasArgs(tc.in, tc.specs)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got values=%#v help=%v extArgs=%#v", values, help, extArgs)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if help != tc.help {
t.Errorf("help: got %v, want %v", help, tc.help)
}
if !reflect.DeepEqual(values, tc.values) {
t.Errorf("values: got %#v, want %#v", values, tc.values)
}
if !reflect.DeepEqual(extArgs, tc.extArgs) {
t.Errorf("extArgs: got %#v, want %#v", extArgs, tc.extArgs)
}
})
}
}
// TestApplyOwnedAliasFlags checks that manually parsed values are re-published
// on the command's flag set for every value type an alias can own.
func TestApplyOwnedAliasFlags(t *testing.T) {
cmd := &cobra.Command{}
f := pflag.NewFlagSet("alias-test", pflag.ContinueOnError)
f.StringP("process", "p", "", "host process")
f.StringP("process-arguments", "A", "", "host process arguments")
f.Uint32P("ppid", "P", 0, "parent pid")
f.BoolP("save", "s", false, "save output")
f.Int64P("timeout", "t", 60, "timeout")
f.BoolP("amsi-bypass", "M", false, "amsi bypass")
cmd.Flags().AddFlagSet(f)
err := applyOwnedAliasFlags(cmd, map[string]string{
"process": "notepad.exe",
"process-arguments": "-k foo",
"ppid": "1234",
"save": "true",
"timeout": "90",
"amsi-bypass": "true",
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
for name, want := range map[string]interface{}{
"process": "notepad.exe", "process-arguments": "-k foo",
} {
got, _ := cmd.Flags().GetString(name)
if got != want.(string) {
t.Errorf("%s: got %q, want %q", name, got, want)
}
}
if ppid, _ := cmd.Flags().GetUint32("ppid"); ppid != 1234 {
t.Errorf("ppid: got %d, want 1234", ppid)
}
if save, _ := cmd.Flags().GetBool("save"); !save {
t.Error("save: got false, want true")
}
if timeout, _ := cmd.Flags().GetInt64("timeout"); timeout != 90 {
t.Errorf("timeout: got %d, want 90", timeout)
}
if amsi, _ := cmd.Flags().GetBool("amsi-bypass"); !amsi {
t.Error("amsi-bypass: got false, want true")
}
// Unset flags keep their defaults.
if inProc := cmd.Flags().Lookup("in-process"); inProc != nil {
t.Error("in-process should not be registered on this command")
}
}
-3
View File
@@ -1,7 +1,6 @@
package alias
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -33,7 +32,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
aliasCmd.AddCommand(aliasLoadCmd)
flags.NewCompletions(aliasLoadCmd).PositionalCompletion(carapace.ActionDirectories().Tag("alias directory").Usage("path to the alias directory"))
completers.RegisterLocalFilePathPositionalCompletion(aliasLoadCmd, 0)
aliasInstallCmd := &cobra.Command{
Use: consts.InstallStr + " [ALIAS]",
@@ -46,7 +44,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
aliasCmd.AddCommand(aliasInstallCmd)
flags.NewCompletions(aliasInstallCmd).PositionalCompletion(carapace.ActionFiles().Tag("alias file"))
completers.RegisterLocalFilePathPositionalCompletion(aliasInstallCmd, 0)
aliasRemove := &cobra.Command{
Use: consts.RmStr + " [ALIAS]",
+11 -41
View File
@@ -215,6 +215,8 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
longHelp.WriteString(fmt.Sprintf("%s (%s):\t%s%s", strings.ToUpper(arg.Name), aType, optStr, arg.Desc))
}
}
longHelp.WriteString("\n\n⚠️ If you're having issues passing arguments to the alias please read:\n")
longHelp.WriteString("https://github.com/BishopFox/sliver/wiki/Aliases-&-Extensions#aliases-command-parsing")
// for each alias command, add a new app command
helpMsg := fmt.Sprintf("[%s] %s", aliasManifest.Name, aliasManifest.Help)
@@ -225,14 +227,9 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
Run: func(cmd *cobra.Command, args []string) {
runAliasCommand(cmd, con, args)
},
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
// DisableFlagParsing keeps the alias payload's own flags (which are
// unknown to pflag) from being rejected or swallowed; we split the
// Sliver-owned flags out of the raw slice ourselves in
// runAliasCommand (#2264, same treatment as extensions).
DisableFlagParsing: true,
GroupID: consts.AliasHelpGroup,
Annotations: makeAliasPlatformFilters(aliasManifest),
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
GroupID: consts.AliasHelpGroup,
Annotations: makeAliasPlatformFilters(aliasManifest),
}
if aliasManifest.IsAssembly {
@@ -242,9 +239,9 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
f.StringP("app-domain", "d", "", "AppDomain name to create for .NET assembly. Generated randomly if not set.")
f.StringP("arch", "a", "x84", "Assembly target architecture: x86, x64, x84 (x86+x64)")
f.BoolP("in-process", "i", false, "Run in the current sliver process")
f.StringP("runtime", "r", "v4.0.30319", "Runtime to use for running the assembly")
f.BoolP("amsi-bypass", "M", false, "Bypass AMSI on Windows")
f.BoolP("etw-bypass", "E", false, "Bypass ETW on Windows")
f.StringP("runtime", "r", "", "Runtime to use for running the assembly (only supported when used with --in-process)")
f.BoolP("amsi-bypass", "M", false, "Bypass AMSI on Windows (only supported when used with --in-process)")
f.BoolP("etw-bypass", "E", false, "Bypass ETW on Windows (only supported when used with --in-process)")
addAliasCmd.Flags().AddFlagSet(f)
}
@@ -253,7 +250,7 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
f.StringP("process-arguments", "A", "", "arguments to pass to the hosting process")
f.Uint32P("ppid", "P", 0, "parent process ID to use when creating the hosting process (Windows only)")
f.BoolP("save", "s", false, "Save output to disk")
f.Int64P("timeout", "t", defaultTimeout, "command timeout in seconds")
f.IntP("timeout", "t", defaultTimeout, "command timeout in seconds")
addAliasCmd.Flags().AddFlagSet(f)
// Setup completions for alias arguments
@@ -336,27 +333,6 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
return
}
aliasManifest := loadedAlias.Manifest
// The command runs with DisableFlagParsing, so args is the raw, unstripped
// slice. Separate the Sliver-owned flags from the payload's own arguments
// and re-publish them on the flag set (so the Get* readers below and
// Request()'s --timeout still work), then hand only the payload arguments
// to the execution paths (#2264, same treatment as extensions in #2323).
ownedValues, helpRequested, payloadArgs, err := splitAliasArgs(args, aliasOwnedFlagSpecs(aliasManifest.IsAssembly))
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if helpRequested {
_ = cmd.Help()
return
}
if err := applyOwnedAliasFlags(cmd, ownedValues); err != nil {
con.PrintErrorf("%s\n", err)
return
}
args = payloadArgs
binPath, err := aliasManifest.getFileForTarget(cmd.Name(), goos, goarch)
if err != nil {
con.PrintErrorf("Fail to find alias file: %s\n", err)
@@ -398,7 +374,7 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
}
con.PrintWarnf("%s", msgStr)
confirm := false
forms.Confirm("Do you want to continue?", &confirm)
_ = forms.Confirm("Do you want to continue?", &confirm)
if !confirm {
return
}
@@ -424,13 +400,7 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
var outFilePath *os.File
if save, _ := cmd.Flags().GetBool("save"); save {
hostname := ""
if session != nil {
hostname = session.GetHostname()
} else if beacon != nil {
hostname = beacon.GetHostname()
}
outFile := filepath.Base(fmt.Sprintf("%s_%s*.log", filepath.Base(cmd.Name()), filepath.Base(hostname)))
outFile := filepath.Base(fmt.Sprintf("%s_%s*.log", filepath.Base(cmd.Name()), filepath.Base(session.GetHostname())))
outFilePath, err = os.CreateTemp("", outFile)
if err != nil {
con.PrintErrorf("%s\n", err)
+16 -16
View File
@@ -164,7 +164,7 @@ func ArmoryCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
cacheEntry, ok := value.(pkgCacheEntry)
if !ok {
// Something is wrong with this entry
pkgCache.Delete(key)
pkgCache.Delete(value)
return true
}
if cacheEntry.ArmoryConfig.PublicKey != index.ArmoryConfig.PublicKey {
@@ -428,7 +428,7 @@ func PrintArmoryPackages(aliases []*alias.AliasManifest, exts []*extensions.Exte
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.StyleBold.Render("Packages"))
tw.SetTitle(console.Bold + "Packages" + console.Normal)
urlMargin := 150 // Extra margin needed to show URL column
@@ -493,26 +493,26 @@ func PrintArmoryPackages(aliases []*alias.AliasManifest, exts []*extensions.Exte
rows := []table.Row{}
for _, pkg := range entries {
style := console.StyleNormal
color := console.Normal
if extensions.CmdExists(pkg.CommandName, sliverMenu.Command) {
style = console.StyleGreen
color = console.Green
}
if con.Settings.SmallTermWidth+urlMargin < width {
rows = append(rows, table.Row{
style.Render(pkg.ArmoryName),
style.Render(pkg.CommandName),
style.Render(pkg.Version),
style.Render(pkg.Type),
style.Render(pkg.Help),
style.Render(pkg.URL),
fmt.Sprintf(color+"%s"+console.Normal, pkg.ArmoryName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.CommandName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Version),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Type),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Help),
fmt.Sprintf(color+"%s"+console.Normal, pkg.URL),
})
} else {
rows = append(rows, table.Row{
style.Render(pkg.ArmoryName),
style.Render(pkg.CommandName),
style.Render(pkg.Version),
style.Render(pkg.Type),
style.Render(pkg.Help),
fmt.Sprintf(color+"%s"+console.Normal, pkg.ArmoryName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.CommandName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Version),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Type),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Help),
})
}
}
@@ -524,7 +524,7 @@ func PrintArmoryPackages(aliases []*alias.AliasManifest, exts []*extensions.Exte
func PrintArmoryBundles(bundles []*ArmoryBundle, con *console.SliverClient) {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.StyleBold.Render("Bundles"))
tw.SetTitle(console.Bold + "Bundles" + console.Normal)
tw.AppendHeader(table.Row{
"Armory Name",
"Name",
+1
View File
@@ -0,0 +1 @@
package armory
-18
View File
@@ -1,23 +1,5 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 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 (
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
+43 -70
View File
@@ -22,7 +22,6 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
@@ -38,6 +37,7 @@ import (
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/util"
"github.com/bishopfox/sliver/util/minisign"
)
@@ -301,44 +301,32 @@ func getCommandsInCache(armoryPK string) []string {
return commandNames
}
func packageMatchesInstallName(cacheEntry pkgCacheEntry, name string) bool {
if cacheEntry.Pkg.IsAlias {
return cacheEntry.Alias != nil && cacheEntry.Alias.CommandName == name
}
if cacheEntry.Extension == nil {
return false
}
if cacheEntry.Pkg.CommandName == name || cacheEntry.Extension.PackageName == name {
return true
}
for _, command := range cacheEntry.Extension.ExtCommand {
if command.CommandName == name {
return true
}
}
return false
}
func getPackagesWithCommandName(name, armoryPK, minimumVersion string) []*pkgCacheEntry {
packages := []*pkgCacheEntry{}
pkgCache.Range(func(key, value interface{}) bool {
cacheEntry := value.(pkgCacheEntry)
if cacheEntry.LastErr != nil || !packageMatchesInstallName(cacheEntry, name) {
return true
}
if armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey != armoryPK {
return true
}
packageVersion := ""
if cacheEntry.Pkg.IsAlias {
packageVersion = cacheEntry.Alias.Version
} else {
packageVersion = cacheEntry.Extension.Version
}
if minimumVersion == "" || packageVersion >= minimumVersion {
packages = append(packages, &cacheEntry)
if cacheEntry.LastErr == nil {
if cacheEntry.Pkg.IsAlias {
if cacheEntry.Alias.CommandName == name {
if minimumVersion == "" || (minimumVersion != "" && cacheEntry.Alias.Version >= minimumVersion) {
if armoryPK == "" || (armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey == armoryPK) {
packages = append(packages, &cacheEntry)
}
}
}
} else {
for _, command := range cacheEntry.Extension.ExtCommand {
if command.CommandName == name {
if minimumVersion == "" || (minimumVersion != "" && cacheEntry.Extension.Version >= minimumVersion) {
if armoryPK == "" || (armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey == armoryPK) {
packages = append(packages, &cacheEntry)
}
break
}
}
}
}
}
return true
})
@@ -348,7 +336,7 @@ func getPackagesWithCommandName(name, armoryPK, minimumVersion string) []*pkgCac
func getPackageIDFromUser(name string, options map[string]string) string {
selectedPackageKey := ""
optionKeys := slices.Collect(maps.Keys(options))
optionKeys := util.Keys(options)
slices.Sort(optionKeys)
// Add a cancel option
optionKeys = append(optionKeys, doNotInstallOption)
@@ -647,15 +635,20 @@ func installAliasPackage(entry *pkgCacheEntry, promptToOverwrite bool, clientCon
return errors.New("signature verification failed")
}
tmpFileName, err := writeArmoryTempFile(tarGz)
tmpFile, err := os.CreateTemp("", "sliver-armory-")
if err != nil {
return err
}
defer os.Remove(tmpFileName)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write(tarGz)
if err != nil {
return err
}
tmpFile.Close()
con.Printf(console.Clearln + "\r") // Clear the line
installPath := alias.InstallFromFile(tmpFileName, entry.Alias.CommandName, promptToOverwrite, con)
installPath := alias.InstallFromFile(tmpFile.Name(), entry.Alias.CommandName, promptToOverwrite, con)
if installPath == nil {
return errors.New("failed to install alias")
}
@@ -736,43 +729,23 @@ func installExtensionPackage(entry *pkgCacheEntry, promptToOverwrite bool, clien
return errors.New("signature verification failed")
}
tmpFileName, err := writeArmoryTempFile(tarGz)
tmpFile, err := os.CreateTemp("", "sliver-armory-")
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write(tarGz)
if err != nil {
return err
}
err = tmpFile.Sync()
if err != nil {
return err
}
defer os.Remove(tmpFileName)
con.Printf(console.Clearln + "\r") // Clear download message
return extensions.InstallFromDir(tmpFileName, promptToOverwrite, con, true)
}
extensions.InstallFromDir(tmpFile.Name(), promptToOverwrite, con, true)
func writeArmoryTempFile(data []byte) (string, error) {
if len(data) == 0 {
return "", errors.New("downloaded archive is empty")
}
tmpFile, err := os.CreateTemp("", "sliver-armory-")
if err != nil {
return "", err
}
tmpFileName := tmpFile.Name()
for len(data) > 0 {
n, err := tmpFile.Write(data)
if err != nil {
tmpFile.Close()
os.Remove(tmpFileName)
return "", err
}
data = data[n:]
}
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
os.Remove(tmpFileName)
return "", err
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpFileName)
return "", err
}
return tmpFileName, nil
return nil
}
-218
View File
@@ -1,218 +0,0 @@
package armory
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/alias"
"github.com/bishopfox/sliver/client/command/extensions"
)
func resetPackageCache(t *testing.T) {
t.Helper()
pkgCache.Range(func(key, _ any) bool {
pkgCache.Delete(key)
return true
})
t.Cleanup(func() {
pkgCache.Range(func(key, _ any) bool {
pkgCache.Delete(key)
return true
})
})
}
func testExtensionCacheEntry() pkgCacheEntry {
manifest := &extensions.ExtensionManifest{
Name: "ADCS Request (Remote)",
PackageName: "remote-adcs-package",
Version: "v0.1.5",
ArmoryName: "Default Armory",
ArmoryPK: "armory-public-key",
ExtCommand: []*extensions.ExtCommand{
{CommandName: "remote-adcs-request"},
{CommandName: "remote-adcs-request-on-behalf"},
},
}
return pkgCacheEntry{
ArmoryConfig: &assets.ArmoryConfig{PublicKey: "armory-public-key"},
Pkg: ArmoryPackage{
Name: "Remote ADCS package",
CommandName: "remote-adcs-index",
},
Extension: manifest,
ID: "remote-adcs-request-id",
}
}
func TestGetPackageForCommandAcceptsStableExtensionIdentities(t *testing.T) {
resetPackageCache(t)
entry := testExtensionCacheEntry()
pkgCache.Store(entry.ID, entry)
for _, name := range []string{
"remote-adcs-index",
"remote-adcs-request",
"remote-adcs-request-on-behalf",
"remote-adcs-package",
} {
t.Run(name, func(t *testing.T) {
found, err := getPackageForCommand(name, "armory-public-key", "v0.1.5")
if err != nil {
t.Fatalf("lookup %q failed: %v", name, err)
}
if found.ID != entry.ID {
t.Fatalf("lookup %q returned %q, want %q", name, found.ID, entry.ID)
}
})
}
if _, err := getPackageForCommand("remote-adcs-package", "other-armory", "v0.1.5"); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("wrong-armory lookup error = %v, want %v", err, ErrPackageNotFound)
}
if _, err := getPackageForCommand("remote-adcs-package", "armory-public-key", "v0.1.6"); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("newer-version lookup error = %v, want %v", err, ErrPackageNotFound)
}
for _, name := range []string{"ADCS Request (Remote)", "Remote ADCS package", "unknown-package"} {
if _, err := getPackageForCommand(name, "", ""); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("unstable identity %q lookup error = %v, want %v", name, err, ErrPackageNotFound)
}
}
}
func TestPackageMatchesInstallNameLeavesAliasesCommandOnly(t *testing.T) {
entry := pkgCacheEntry{
Pkg: ArmoryPackage{
Name: "Human Alias Name",
CommandName: "alias-command",
IsAlias: true,
},
Alias: &alias.AliasManifest{CommandName: "alias-command"},
}
if packageMatchesInstallName(entry, "Human Alias Name") {
t.Fatal("alias unexpectedly matched its display name")
}
if !packageMatchesInstallName(entry, "alias-command") {
t.Fatal("alias did not match its command name")
}
}
func TestExtensionManifestsMatch(t *testing.T) {
manifest := func(name, packageName string, commands ...string) *extensions.ExtensionManifest {
extCommands := make([]*extensions.ExtCommand, 0, len(commands))
for _, command := range commands {
extCommands = append(extCommands, &extensions.ExtCommand{CommandName: command})
}
return &extensions.ExtensionManifest{Name: name, PackageName: packageName, ExtCommand: extCommands}
}
tests := []struct {
name string
local *extensions.ExtensionManifest
latest *extensions.ExtensionManifest
want bool
}{
{
name: "v2 package identity",
local: manifest("Old Display Name", "stable-package", "old-command"),
latest: manifest("New Display Name", "stable-package", "new-command"),
want: true,
},
{
name: "different v2 packages",
local: manifest("Shared Display Name", "package-one", "shared-command"),
latest: manifest("Shared Display Name", "package-two", "shared-command"),
want: false,
},
{
name: "v2 does not downgrade to legacy identity",
local: manifest("Human Display Name", "stable-package", "shared-command"),
latest: manifest("legacy-command", "", "shared-command"),
want: false,
},
{
name: "legacy command identity",
local: manifest("legacy-command", "", "legacy-command"),
latest: manifest("Human Display Name", "stable-package", "other-command", "legacy-command"),
want: true,
},
{
name: "display names are not identity",
local: manifest("Shared Name", "", "old-command"),
latest: manifest("Shared Name", "stable-package", "new-command"),
want: false,
},
{
name: "unrelated legacy package",
local: manifest("Old Name", "", "old-command"),
latest: manifest("New Name", "stable-package", "new-command"),
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := extensionManifestsMatch(test.local, test.latest); got != test.want {
t.Fatalf("extensionManifestsMatch() = %t, want %t", got, test.want)
}
})
}
}
func TestExtensionUpdateDisplayNameResolvesPackage(t *testing.T) {
resetPackageCache(t)
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
installedManifest := []byte(`{
"name":"ADCS Request (Remote)",
"package_name":"remote-adcs-package",
"version":"v0.1.3",
"commands":[{
"command_name":"remote-adcs-request",
"help":"Request a certificate",
"files":[{"os":"windows","arch":"amd64","path":"adcs_request.x64.o"}]
}]
}`)
installDir := filepath.Join(assets.GetExtensionsDir(), "remote-adcs-request")
if err := os.MkdirAll(installDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, extensions.ManifestFileName), installedManifest, 0o600); err != nil {
t.Fatal(err)
}
entry := testExtensionCacheEntry()
pkgCache.Store(entry.ID, entry)
otherEntry := testExtensionCacheEntry()
otherEntry.ID = "other-armory-package-id"
otherEntry.ArmoryConfig = &assets.ArmoryConfig{PublicKey: "other-armory"}
otherManifest := *otherEntry.Extension
otherManifest.Version = "v9.0.0"
otherManifest.ArmoryName = "Other Armory"
otherManifest.ArmoryPK = "other-armory"
otherEntry.Extension = &otherManifest
pkgCache.Store(otherEntry.ID, otherEntry)
updates := checkForExtensionUpdates("armory-public-key")
versionInfo, ok := updates["ADCS Request (Remote)"]
if !ok {
t.Fatal("display-name update was not detected")
}
if versionInfo.OldVersion != "v0.1.3" || versionInfo.NewVersion != "v0.1.5" {
t.Fatalf("unexpected versions: %#v", versionInfo)
}
if versionInfo.PackageID != entry.ID {
t.Fatalf("update package ID = %q, want %q", versionInfo.PackageID, entry.ID)
}
found := packageCacheLookupByID(versionInfo.PackageID)
if found == nil {
t.Fatal("detected update package is missing from the cache")
}
if found.ID != entry.ID {
t.Fatalf("resolved package %q, want %q", found.ID, entry.ID)
}
}
-18
View File
@@ -1,23 +1,5 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 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 (
"fmt"
"os"
+15 -41
View File
@@ -21,9 +21,7 @@ package armory
import (
"errors"
"fmt"
"maps"
"os"
"slices"
"sort"
"github.com/jedib0t/go-pretty/v6/table"
@@ -35,13 +33,13 @@ import (
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/util"
)
type VersionInformation struct {
OldVersion string
NewVersion string
ArmoryName string
PackageID string
}
type PackageType uint
@@ -126,9 +124,9 @@ func ArmoryUpdateCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
if !ok {
continue
}
updatedPackage := packageCacheLookupByID(extVersionInfo.PackageID)
if updatedPackage == nil {
con.PrintErrorf("Could not get update package for extension %s: %s\n", update.Name, ErrPackageNotFound)
updatedPackage, err := getPackageForCommand(update.Name, armoryPK, extVersionInfo.NewVersion)
if err != nil {
con.PrintErrorf("Could not get update package for extension %s: %s\n", update.Name, err)
continue
}
err = installExtensionPackage(updatedPackage, false, clientConfig, con)
@@ -175,21 +173,8 @@ func checkForAliasUpdates(armoryPK string) map[string]VersionInformation {
return results
}
func extensionManifestsMatch(localManifest, latestManifest *extensions.ExtensionManifest) bool {
if localManifest.PackageName != "" {
return latestManifest.PackageName != "" && localManifest.PackageName == latestManifest.PackageName
}
for _, localCommand := range localManifest.ExtCommand {
for _, latestCommand := range latestManifest.ExtCommand {
if localCommand.CommandName == latestCommand.CommandName {
return true
}
}
}
return false
}
func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
_, cachedExtensions := packageManifestsInCache()
// Return a map of extension name to minimum version to install
results := make(map[string]VersionInformation)
for _, extManifestPath := range assets.GetInstalledExtensionManifests() {
@@ -201,12 +186,7 @@ func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
if err != nil {
continue
}
pkgCache.Range(func(_, value interface{}) bool {
cacheEntry, ok := value.(pkgCacheEntry)
if !ok || cacheEntry.LastErr != nil || cacheEntry.Pkg.IsAlias || cacheEntry.Extension == nil {
return true
}
latestExt := cacheEntry.Extension
for _, latestExt := range cachedExtensions {
/*
We used to check if the version identifiers were different between the installed version and the
version in the armory. This worked when we had one armory, but with multiple potential armories,
@@ -214,22 +194,16 @@ func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
so we will rely on the author of the package incrementing the version identifier somehow to determine
if a package is newer.
*/
if extensionManifestsMatch(localManifest, latestExt) && latestExt.Version > localManifest.Version {
if latestExt.Name == localManifest.Name && latestExt.Version > localManifest.Version {
if latestExt.ArmoryPK == armoryPK || armoryPK == "" {
current, found := results[localManifest.Name]
if !found || latestExt.Version > current.NewVersion ||
(latestExt.Version == current.NewVersion && cacheEntry.ID < current.PackageID) {
results[localManifest.Name] = VersionInformation{
OldVersion: localManifest.Version,
NewVersion: latestExt.Version,
ArmoryName: latestExt.ArmoryName,
PackageID: cacheEntry.ID,
}
results[localManifest.Name] = VersionInformation{
OldVersion: localManifest.Version,
NewVersion: latestExt.Version,
ArmoryName: latestExt.ArmoryName,
}
}
}
return true
})
}
}
return results
@@ -245,8 +219,8 @@ func sortUpdateIdentifiers(aliasUpdates, extensionUpdates map[string]VersionInfo
result := []UpdateIdentifier{}
aliasNames := slices.Collect(maps.Keys(aliasUpdates))
extensionNames := slices.Collect(maps.Keys(extensionUpdates))
aliasNames := util.Keys(aliasUpdates)
extensionNames := util.Keys(extensionUpdates)
for _, name := range aliasNames {
result = append(result, UpdateIdentifier{
Type: AliasPackage,
@@ -283,7 +257,7 @@ func displayAvailableUpdates(con *console.SliverClient, updateKeys []UpdateIdent
if len(extensionUpdates) != 1 {
extensionSuffix = "s"
}
tw.SetTitle(console.StyleBold.Render(fmt.Sprintf(title, len(aliasUpdates), aliasSuffix, len(extensionUpdates), extensionSuffix)))
tw.SetTitle(console.Bold + fmt.Sprintf(title, len(aliasUpdates), aliasSuffix, len(extensionUpdates), extensionSuffix) + console.Normal)
tw.AppendHeader(table.Row{
"Package Name",
"Package Type",
+98 -154
View File
@@ -30,7 +30,6 @@ import (
"github.com/bishopfox/sliver/client/command/kill"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -122,30 +121,50 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
if err != nil {
width = 999
}
return renderBeaconsAtWidth(beacons, filter, filterRegex, con, width, time.Now())
}
// renderBeaconsAtWidth uses one reference time for all candidate layouts,
// preventing a second boundary from changing the table width while it is fitted.
func renderBeaconsAtWidth(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, width int, now time.Time) table.Writer {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
wideTermWidth := con.Settings.SmallTermWidth < width
windowsBeaconInList := false
for _, beacon := range beacons {
if beacon.OS == "windows" {
windowsBeaconInList = true
break
}
}
return targettable.Fit(width, windowsBeaconInList, func(layout targettable.Layout) table.Writer {
return buildBeaconTable(beacons, filter, filterRegex, con, layout, windowsBeaconInList, now)
})
}
func buildBeaconTable(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, layout targettable.Layout, windowsBeaconInList bool, now time.Time) table.Writer {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
includeIntegrity := windowsBeaconInList && layout.IncludeIntegrity
if layout.Compact {
if wideTermWidth {
if windowsBeaconInList {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Integrity",
"Operating System",
"Locale",
"Last Check-in",
"Next Check-in",
})
} else {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Operating System",
"Locale",
"Last Check-in",
"Next Check-in",
})
}
} else {
tw.AppendHeader(table.Row{
"ID",
"Name",
@@ -156,154 +175,79 @@ func buildBeaconTable(beacons []*clientpb.Beacon, filter string, filterRegex *re
"Last Check-in",
"Next Check-in",
})
} else {
header := table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
}
if includeIntegrity {
header = append(header, "Integrity")
}
header = append(header, "Operating System")
if layout.IncludeLocale {
header = append(header, "Locale")
}
header = append(header, "Last Check-in", "Next Check-in")
tw.AppendHeader(header)
}
activeBeaconID := ""
if con.ActiveTarget != nil {
if activeBeacon := con.ActiveTarget.GetBeacon(); activeBeacon != nil {
activeBeaconID = activeBeacon.ID
}
}
for _, beacon := range beacons {
integrity := beacon.Integrity
if integrity == "" {
integrity = "-"
color := console.Normal
activeBeacon := con.ActiveTarget.GetBeacon()
if activeBeacon != nil && activeBeacon.ID == beacon.ID {
color = console.Green
}
username := strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")
lastCheckin := time.Unix(beacon.LastCheckin, 0)
nextCheckin := time.Unix(beacon.NextCheckin, 0)
canonicalEntries := []string{
strings.Split(beacon.ID, "-")[0],
beacon.Name,
fmt.Sprintf("%d/%d", beacon.TasksCountCompleted, beacon.TasksCount),
beacon.Transport,
beacon.RemoteAddress,
beacon.Hostname,
username,
fmt.Sprintf("%s (%d)", beacon.Filename, beacon.PID),
integrity,
fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch),
beacon.Locale,
formatBeaconDateDelta(lastCheckin, now, true, false),
formatBeaconDateDelta(nextCheckin, now, true, false),
}
if !beaconMatchesFilter(canonicalEntries, filter, filterRegex) {
continue
}
style := console.StyleNormal
if activeBeaconID == beacon.ID {
style = console.StyleGreen
if beacon.Integrity == "" {
beacon.Integrity = "-"
}
// We need a slice of strings so we can apply filters
var rowEntries []string
if layout.Compact {
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
style.Render(beacon.Transport),
style.Render(beacon.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
formatBeaconDateDelta(lastCheckin, now, false, false),
formatBeaconDateDelta(nextCheckin, now, false, true),
}
} else {
processName := beacon.Filename
if layout.ProcessBasename {
processName = targettable.ProcessName(processName)
}
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
style.Render(fmt.Sprintf("%d/%d", beacon.TasksCountCompleted, beacon.TasksCount)),
style.Render(beacon.Transport),
style.Render(beacon.RemoteAddress),
style.Render(beacon.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s (%d)", processName, beacon.PID)),
}
if includeIntegrity {
rowEntries = append(rowEntries, style.Render(integrity))
}
rowEntries = append(rowEntries, style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)))
if layout.IncludeLocale {
rowEntries = append(rowEntries, style.Render(beacon.Locale))
}
rowEntries = append(rowEntries,
formatBeaconDateDelta(lastCheckin, now, true, false),
formatBeaconDateDelta(nextCheckin, now, true, true),
)
}
if wideTermWidth {
rowEntries = []string{
fmt.Sprintf(color+"%s"+console.Normal, strings.Split(beacon.ID, "-")[0]),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Name),
fmt.Sprintf(color+"%d/%d"+console.Normal, beacon.TasksCountCompleted, beacon.TasksCount),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Transport),
fmt.Sprintf(color+"%s"+console.Normal, beacon.RemoteAddress),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Hostname),
fmt.Sprintf(color+"%s"+console.Normal, strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
fmt.Sprintf(color+"%s (%d)"+console.Normal, beacon.Filename, beacon.PID),
}
if windowsBeaconInList {
rowEntries = append(rowEntries, fmt.Sprintf(color+"%s"+console.Normal, beacon.Integrity))
}
rowEntries = append(rowEntries, []string{
fmt.Sprintf(color+"%s/%s"+console.Normal, beacon.OS, beacon.Arch),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Locale),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
}...)
} else {
rowEntries = []string{
fmt.Sprintf(color+"%s"+console.Normal, strings.Split(beacon.ID, "-")[0]),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Name),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Transport),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Hostname),
fmt.Sprintf(color+"%s"+console.Normal, strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
fmt.Sprintf(color+"%s/%s"+console.Normal, beacon.OS, beacon.Arch),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
}
}
// Build the row struct
row := table.Row{}
for _, entry := range rowEntries {
row = append(row, entry)
}
tw.AppendRow(row)
// Apply filters if any
if filter == "" && filterRegex == nil {
tw.AppendRow(row)
} else {
for _, rowEntry := range rowEntries {
if filter != "" {
if strings.Contains(rowEntry, filter) {
tw.AppendRow(row)
break
}
}
if filterRegex != nil {
if filterRegex.MatchString(rowEntry) {
tw.AppendRow(row)
break
}
}
}
}
}
return tw
}
func beaconMatchesFilter(entries []string, filter string, filterRegex *regexp.Regexp) bool {
if filter == "" && filterRegex == nil {
return true
}
for _, entry := range entries {
if filter != "" && strings.Contains(entry, filter) {
return true
}
if filterRegex != nil && filterRegex.MatchString(entry) {
return true
}
}
return false
}
func formatBeaconDateDelta(timestamp time.Time, now time.Time, includeDate bool, color bool) string {
date := timestamp.Format(time.UnixDate)
interval := ""
if timestamp.Before(now) {
delta := now.Sub(timestamp).Round(time.Second)
if includeDate {
interval = fmt.Sprintf("%s (%s ago)", date, delta)
} else {
interval = delta.String()
}
if color {
interval = console.StyleBoldRed.Render(interval)
}
} else {
delta := timestamp.Sub(now).Round(time.Second)
if includeDate {
interval = fmt.Sprintf("%s (in %s)", date, delta)
} else {
interval = delta.String()
}
if color {
interval = console.StyleBoldGreen.Render(interval)
}
}
return interval
}
-257
View File
@@ -1,257 +0,0 @@
package beacons
import (
"regexp"
"strings"
"testing"
"time"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
var beaconTableTestNow = time.Date(2026, time.September, 3, 21, 25, 41, 0, time.UTC)
func TestRenderBeaconsUsesFullProcessPathWhenItFits(t *testing.T) {
beacon := testWindowsBeacon()
layout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := beaconCandidateWidth(t, []*clientpb.Beacon{beacon}, layout, true)
rendered := renderBeaconTableAtWidth(t, []*clientpb.Beacon{beacon}, "", nil, width)
if !strings.Contains(rendered, beacon.Filename+" (2020)") {
t.Fatalf("full process path missing from table:\n%s", rendered)
}
if !strings.Contains(rendered, "(36s ago)") || !strings.Contains(rendered, "(in 24s)") {
t.Fatalf("wide check-in timestamps changed unexpectedly:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, width)
}
func TestRenderBeaconsUsesPortableProcessBasenameWhenRequired(t *testing.T) {
tests := []struct {
name string
beacon *clientpb.Beacon
hasIntegrity bool
wantProcess string
}{
{
name: "windows path",
beacon: testWindowsBeacon(),
hasIntegrity: true,
wantProcess: "winterfell.exe (2020)",
},
{
name: "posix path",
beacon: testLinuxBeacon(),
hasIntegrity: false,
wantProcess: "winterfell-agent (2020)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
beacons := []*clientpb.Beacon{tt.beacon}
fullLayout := targettable.Layout{
IncludeIntegrity: tt.hasIntegrity,
IncludeLocale: true,
}
basenameLayout := fullLayout
basenameLayout.ProcessBasename = true
fullWidth := beaconCandidateWidth(t, beacons, fullLayout, tt.hasIntegrity)
basenameWidth := beaconCandidateWidth(t, beacons, basenameLayout, tt.hasIntegrity)
if basenameWidth >= fullWidth {
t.Fatalf("basename candidate width %d is not narrower than full-path width %d", basenameWidth, fullWidth)
}
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, basenameWidth)
if !strings.Contains(rendered, tt.wantProcess) {
t.Fatalf("process basename missing from table:\n%s", rendered)
}
if strings.Contains(rendered, tt.beacon.Filename) {
t.Fatalf("full process path remained in basename layout:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, basenameWidth)
})
}
}
func TestRenderBeaconsOmitsIntegrityBeforeLocale(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
withAll := targettable.Layout{
ProcessBasename: true,
IncludeIntegrity: true,
IncludeLocale: true,
}
withoutIntegrity := withAll
withoutIntegrity.IncludeIntegrity = false
withoutLocale := withoutIntegrity
withoutLocale.IncludeLocale = false
allWidth := beaconCandidateWidth(t, beacons, withAll, true)
withoutIntegrityWidth := beaconCandidateWidth(t, beacons, withoutIntegrity, true)
withoutLocaleWidth := beaconCandidateWidth(t, beacons, withoutLocale, true)
if !(withoutLocaleWidth < withoutIntegrityWidth && withoutIntegrityWidth < allWidth) {
t.Fatalf("candidate widths did not shrink in omission order: all=%d no-integrity=%d no-locale=%d", allWidth, withoutIntegrityWidth, withoutLocaleWidth)
}
withoutIntegrityRendered := renderBeaconTableAtWidth(t, beacons, "", nil, withoutIntegrityWidth)
assertHeaderPresence(t, withoutIntegrityRendered, "Integrity", false)
assertHeaderPresence(t, withoutIntegrityRendered, "Locale", true)
assertBeaconTableFits(t, withoutIntegrityRendered, withoutIntegrityWidth)
withoutLocaleRendered := renderBeaconTableAtWidth(t, beacons, "", nil, withoutLocaleWidth)
assertHeaderPresence(t, withoutLocaleRendered, "Integrity", false)
assertHeaderPresence(t, withoutLocaleRendered, "Locale", false)
assertBeaconTableFits(t, withoutLocaleRendered, withoutLocaleWidth)
}
func TestRenderBeaconsDoesNotAddIntegrityForNonWindowsTargets(t *testing.T) {
beacon := testLinuxBeacon()
beacons := []*clientpb.Beacon{beacon}
layout := targettable.Layout{IncludeLocale: true}
width := beaconCandidateWidth(t, beacons, layout, false)
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, width)
assertHeaderPresence(t, rendered, "Integrity", false)
assertHeaderPresence(t, rendered, "Locale", true)
if !strings.Contains(rendered, beacon.Filename+" (2020)") {
t.Fatalf("non-Windows full process path missing from table:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, width)
}
func TestRenderBeaconsFiltersCanonicalFullRows(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
basenameLayout := targettable.Layout{
ProcessBasename: true,
IncludeIntegrity: true,
IncludeLocale: true,
}
basenameWidth := beaconCandidateWidth(t, beacons, basenameLayout, true)
withoutLocale := basenameLayout
withoutLocale.IncludeIntegrity = false
withoutLocale.IncludeLocale = false
withoutLocaleWidth := beaconCandidateWidth(t, beacons, withoutLocale, true)
t.Run("full process path survives basename projection", func(t *testing.T) {
rendered := renderBeaconTableAtWidth(t, beacons, `AppData\Local\Temp`, nil, basenameWidth)
if !strings.Contains(rendered, beacon.Name) {
t.Fatalf("filter matching canonical process path removed beacon:\n%s", rendered)
}
if strings.Contains(rendered, `AppData\Local\Temp`) {
t.Fatalf("basename layout unexpectedly displayed filtered directory:\n%s", rendered)
}
})
t.Run("hidden integrity survives column projection", func(t *testing.T) {
rendered := renderBeaconTableAtWidth(t, beacons, "", regexp.MustCompile(`^High$`), withoutLocaleWidth)
if !strings.Contains(rendered, beacon.Name) {
t.Fatalf("filter matching hidden integrity removed beacon:\n%s", rendered)
}
assertHeaderPresence(t, rendered, "Integrity", false)
})
}
func TestRenderBeaconsDoesNotMutateEmptyIntegrity(t *testing.T) {
beacon := testWindowsBeacon()
beacon.Integrity = ""
layout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := beaconCandidateWidth(t, []*clientpb.Beacon{beacon}, layout, true)
_ = renderBeaconTableAtWidth(t, []*clientpb.Beacon{beacon}, "", nil, width)
if beacon.Integrity != "" {
t.Fatalf("beacon integrity mutated to %q", beacon.Integrity)
}
}
func TestRenderBeaconsCompactFallbackPreservesLegacyColumnsAndTimes(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
compactWidth := beaconCandidateWidth(t, beacons, targettable.Layout{Compact: true}, true)
withoutOptionalWidth := beaconCandidateWidth(t, beacons, targettable.Layout{ProcessBasename: true}, true)
if compactWidth >= withoutOptionalWidth {
t.Fatalf("test fixture does not require compact fallback: compact=%d detailed=%d", compactWidth, withoutOptionalWidth)
}
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, compactWidth)
for _, omitted := range []string{"Tasks", "Remote Address", "Process (PID)", "Integrity", "Locale"} {
assertHeaderPresence(t, rendered, omitted, false)
}
if !strings.Contains(rendered, "36s") || !strings.Contains(rendered, "24s") {
t.Fatalf("compact relative check-in times missing:\n%s", rendered)
}
if strings.Contains(rendered, "36s ago") || strings.Contains(rendered, "in 24s") {
t.Fatalf("compact fallback did not preserve legacy timestamp format:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, compactWidth)
}
func testWindowsBeacon() *clientpb.Beacon {
return &clientpb.Beacon{
ID: "0c9f6b27-1111-2222-3333-444444444444",
Name: "winterfell-session",
TasksCount: 3,
TasksCountCompleted: 2,
Transport: "mtls",
RemoteAddress: "10.13.37.11:49760",
Hostname: "winterfell",
Username: `winterfell\vagrant`,
Filename: `C:\Users\vagrant\AppData\Local\Temp\sliver-range\winterfell.exe`,
PID: 2020,
Integrity: "High",
OS: "windows",
Arch: "amd64",
Locale: "en-US",
LastCheckin: beaconTableTestNow.Add(-36 * time.Second).Unix(),
NextCheckin: beaconTableTestNow.Add(24 * time.Second).Unix(),
}
}
func testLinuxBeacon() *clientpb.Beacon {
beacon := testWindowsBeacon()
beacon.OS = "linux"
beacon.Hostname = "winterfell-linux"
beacon.Username = "vagrant"
beacon.Filename = "/opt/sliver/temporary/build/output/winterfell-agent"
beacon.Integrity = ""
return beacon
}
func testBeaconConsole() *console.SliverClient {
return &console.SliverClient{
Settings: &assets.ClientSettings{TableStyle: "SliverDefault"},
}
}
func beaconCandidateWidth(t *testing.T, beacons []*clientpb.Beacon, layout targettable.Layout, hasIntegrity bool) int {
t.Helper()
tw := buildBeaconTable(beacons, "", nil, testBeaconConsole(), layout, hasIntegrity, beaconTableTestNow)
return text.LongestLineLen(tw.Render())
}
func renderBeaconTableAtWidth(t *testing.T, beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, width int) string {
t.Helper()
tw := renderBeaconsAtWidth(beacons, filter, filterRegex, testBeaconConsole(), width, beaconTableTestNow)
return text.StripEscape(tw.Render())
}
func assertBeaconTableFits(t *testing.T, rendered string, width int) {
t.Helper()
if got := text.LongestLineLen(rendered); got > width {
t.Fatalf("rendered table width %d exceeds terminal width %d:\n%s", got, width, rendered)
}
}
func assertHeaderPresence(t *testing.T, rendered string, header string, want bool) {
t.Helper()
headerLine := strings.SplitN(rendered, "\n", 2)[0]
if got := strings.Contains(strings.ToLower(headerLine), strings.ToLower(header)); got != want {
t.Fatalf("header %q presence = %v, want %v; header line: %s", header, got, want, headerLine)
}
}
+3 -3
View File
@@ -28,7 +28,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
},
}
flags.Bind("beacons", true, beaconsCmd, func(f *pflag.FlagSet) {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("beacons", false, beaconsCmd, func(f *pflag.FlagSet) {
f.StringP("kill", "k", "", "kill the designated beacon")
@@ -145,8 +145,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+1 -1
View File
@@ -28,11 +28,11 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/settings"
-4
View File
@@ -1,7 +1,6 @@
package c2profiles
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/generate"
"github.com/bishopfox/sliver/client/command/help"
@@ -28,7 +27,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("file", "f", "", "Path to C2 configuration file to import")
f.BoolP("overwrite", "o", false, "Overwrite profile if it exists")
})
completers.RegisterLocalFilePathFlagCompletion(importC2ProfileCmd, "file")
exportC2ProfileCmd := &cobra.Command{
Use: consts.ExportC2ProfileStr,
@@ -42,7 +40,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("file", "f", "", "Path to file to export C2 configuration to")
f.StringP("name", "n", consts.DefaultC2Profile, "HTTP C2 Profile name")
})
completers.RegisterLocalFilePathFlagCompletion(exportC2ProfileCmd, "file")
flags.BindFlagCompletions(exportC2ProfileCmd, func(comp *carapace.ActionMap) {
(*comp)["name"] = generate.HTTPC2Completer(con)
})
@@ -79,7 +76,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "HTTP C2 Profile name to save C2Profile as")
f.StringP("template", "t", consts.DefaultC2Profile, "HTTP C2 Profile to use as a template for the new profile")
})
completers.RegisterLocalFilePathFlagCompletion(generateC2ProfileCmd, "file")
flags.BindFlagCompletions(generateC2ProfileCmd, func(comp *carapace.ActionMap) {
(*comp)["template"] = generate.HTTPC2Completer(con)
+13 -12
View File
@@ -2,6 +2,7 @@ package certificates
import (
"context"
"fmt"
"time"
"github.com/bishopfox/sliver/client/command/settings"
@@ -57,28 +58,28 @@ func printCertificateAuthorityInfo(con *console.SliverClient, authData []*client
}
for _, authority := range authData {
rowStyle := console.StyleNormal
rowColor := console.Normal
expiry, err := time.Parse(timeFormat, authority.ValidityExpiry)
if err == nil {
rowStyle = checkCertExpiry(expiry)
rowColor = checkCertExpiry(expiry)
}
if wideTermWidth {
tw.AppendRow(table.Row{
rowStyle.Render(authority.ID),
rowStyle.Render(authority.CN),
rowStyle.Render(authority.CreationTime),
rowStyle.Render(authority.Type),
rowStyle.Render(authority.KeyAlgorithm),
rowStyle.Render(authority.ValidityStart),
rowStyle.Render(authority.ValidityExpiry),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.ID),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.CN),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.CreationTime),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.Type),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.KeyAlgorithm),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.ValidityStart),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.ValidityExpiry),
})
} else {
tw.AppendRow(table.Row{
rowStyle.Render(authority.ID),
rowStyle.Render(authority.CN),
rowStyle.Render(authority.ValidityExpiry),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.ID),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.CN),
fmt.Sprintf(rowColor+"%s"+console.Normal, authority.ValidityExpiry),
})
}
}
+18 -17
View File
@@ -2,6 +2,7 @@ package certificates
import (
"context"
"fmt"
"time"
"github.com/bishopfox/sliver/client/command/settings"
@@ -94,22 +95,22 @@ func CertificateInfoCmd(cmd *cobra.Command, con *console.SliverClient, args []st
printCertificateInfo(con, certificateInfo.Info)
}
func checkCertExpiry(expiryTime time.Time) console.TextStyle {
func checkCertExpiry(expiryTime time.Time) string {
if expiryTime.Before(time.Now()) || expiryTime.Equal(time.Now()) {
return console.StyleBoldRed
return console.Bold + console.Red
}
// One week is 168 hours - this is bad
if expiryTime.Before(time.Now().Add(168 * time.Hour)) {
return console.StyleBoldRed
return console.Bold + console.Red
}
// One month is approximately 730 hours - this is a warning
if expiryTime.Before(time.Now().Add(730 * time.Hour)) {
return console.StyleBoldOrange
return console.Bold + console.Orange
}
return console.StyleNormal
return console.Normal
}
func printCertificateInfo(con *console.SliverClient, certData []*clientpb.CertificateData) {
@@ -147,28 +148,28 @@ func printCertificateInfo(con *console.SliverClient, certData []*clientpb.Certif
}
for _, cert := range certData {
rowStyle := console.StyleNormal
rowColor := console.Normal
expiry, err := time.Parse(timeFormat, cert.ValidityExpiry)
// This should not error out, but if it does, the row will not be colored
if err == nil {
rowStyle = checkCertExpiry(expiry)
rowColor = checkCertExpiry(expiry)
}
if wideTermWidth {
tw.AppendRow(table.Row{
rowStyle.Render(cert.ID),
rowStyle.Render(cert.CN),
rowStyle.Render(cert.CreationTime),
rowStyle.Render(cert.Type),
rowStyle.Render(cert.KeyAlgorithm),
rowStyle.Render(cert.ValidityStart),
rowStyle.Render(cert.ValidityExpiry),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ID),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.CN),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.CreationTime),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.Type),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.KeyAlgorithm),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ValidityStart),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ValidityExpiry),
})
} else {
tw.AppendRow(table.Row{
rowStyle.Render(cert.ID),
rowStyle.Render(cert.CN),
rowStyle.Render(cert.ValidityExpiry),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ID),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.CN),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ValidityExpiry),
})
}
+2 -2
View File
@@ -84,8 +84,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
-58
View File
@@ -22,7 +22,6 @@ import (
"net"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
)
// ClientInterfacesCompleter completes interface addresses on the client host.
@@ -85,60 +84,3 @@ func LocalProxyCompleter() carapace.Action {
}).Invoke(c).Prefix(prefix).ToA()
})
}
// LocalFilePathCompleter completes local file and directory paths.
func LocalFilePathCompleter() carapace.Action {
return carapace.ActionFiles().Tag("local file path")
}
// RegisterLocalFilePathFlagCompletion registers local file-path completion for a flag.
func RegisterLocalFilePathFlagCompletion(cmd *cobra.Command, name string) {
if cmd == nil {
return
}
if _, ok := cmd.GetFlagCompletionFunc(name); ok {
return
}
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
return
}
_ = cmd.RegisterFlagCompletionFunc(name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
})
}
// RegisterLocalFilePathFlagCompletions registers local file-path completion for a list of flags.
func RegisterLocalFilePathFlagCompletions(cmd *cobra.Command, names ...string) {
for _, name := range names {
RegisterLocalFilePathFlagCompletion(cmd, name)
}
}
// RegisterLocalFilePathPositionalCompletion registers local file-path completion for a positional argument index.
func RegisterLocalFilePathPositionalCompletion(cmd *cobra.Command, index int) {
if cmd == nil || index < 0 {
return
}
if cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == index {
return nil, cobra.ShellCompDirectiveDefault
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
// RegisterLocalFilePathPositionalAnyCompletion registers local file-path completion for all positional arguments.
func RegisterLocalFilePathPositionalAnyCompletion(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
}
}
+1 -6
View File
@@ -2,16 +2,11 @@
## Overview
Implements the 'crack' command group for the Sliver client console. Handlers map Cobra invocations to crack workflows such as cached benchmark inspection, durable jobs, real-time monitoring, and crack files.
Implements the 'crack' command group for the Sliver client console. Handlers map Cobra invocations to crack workflows such as crack files.
## Go Files
- `commands.go` – Defines the crack command hierarchy, attaching operations for stations and file management.
- `crack-benchmarks.go` – Displays bounded cached crackstation benchmark summaries, freshness metadata, and representative or complete hash-mode rates.
- `crack-files.go` – Manages cracking asset uploads, listings, and removals, handling streaming transfers and chunking.
- `crack-jobs.go` – Lists durable cracking jobs, renders detailed task progress, manages pause/resume/cancel lifecycle actions, and removes terminal jobs after confirmation.
- `top.go` – Preserves the crack command's compatibility entry point and delegates to the dedicated monitor package.
- `top/` – Owns the full-screen Bubble Tea monitor, its lean real-time snapshot loader, live telemetry, history, charts, actions, and tests.
- `internal/hashcatdisplay/` – Shares Hashcat mode names and rate formatting between the command handlers and monitor without coupling the packages.
- `crack.go` – Implements station status reporting, health checks, and list rendering for distributed cracking nodes.
- `helpers.go` – Supplies completion helpers and byte formatting utilities for crack subcommands.
-123
View File
@@ -1,9 +1,6 @@
package crack
import (
"time"
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -28,14 +25,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.Bind("", true, crackCmd, func(f *pflag.FlagSet) {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("", false, crackCmd, func(f *pflag.FlagSet) {
bindCrackFlags(f)
})
flags.BindFlagCompletions(crackCmd, func(comp *carapace.ActionMap) {
(*comp)["input"] = CrackWordlistCompleter(con)
(*comp)["rules-file"] = CrackRulesCompleter(con)
(*comp)["markov-hcstat2"] = CrackHcstat2Completer(con)
})
crackStationsCmd := &cobra.Command{
Use: consts.StationsStr,
@@ -45,117 +34,8 @@ func Commands(con *console.SliverClient) []*cobra.Command {
CrackStationsCmd(cmd, con, args)
},
}
flags.Bind("", false, crackStationsCmd, func(f *pflag.FlagSet) {
f.Bool("show-benchmarks", false, "show benchmark rates for crackstations")
})
crackCmd.AddCommand(crackStationsCmd)
crackBenchmarksCmd := &cobra.Command{
Use: consts.BenchmarksStr,
Short: "Show cached crackstation benchmark results",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
BenchmarksCmd(cmd, con, args)
},
}
flags.Bind("", false, crackBenchmarksCmd, func(f *pflag.FlagSet) {
f.BoolP("all", "a", false, "show every cached hash mode")
})
crackCmd.AddCommand(crackBenchmarksCmd)
crackJobsCmd := &cobra.Command{
Use: "jobs",
Short: "List hash cracking jobs",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
CrackJobsCmd(cmd, con, args)
},
}
flags.Bind("", true, crackJobsCmd, func(f *pflag.FlagSet) {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
crackCmd.AddCommand(crackJobsCmd)
crackTopCmd := &cobra.Command{
Use: "top",
Short: "Monitor crack jobs and crackstations in real time",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
CrackTopCmd(cmd, con, args)
},
}
flags.Bind("", false, crackTopCmd, func(f *pflag.FlagSet) {
f.Duration("poll-interval", time.Second, "crack job and crackstation refresh interval")
})
crackCmd.AddCommand(crackTopCmd)
crackJobCmd := &cobra.Command{
Use: "job [id]",
Short: "Show a hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobCmd(cmd, con, args)
},
}
flags.Bind("", true, crackJobCmd, func(f *pflag.FlagSet) {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("", false, crackJobCmd, func(f *pflag.FlagSet) {
f.BoolP("watch", "w", false, "watch until the job reaches a terminal state")
f.Duration("poll-interval", time.Second, "job status polling interval")
})
carapace.Gen(crackJobCmd).PositionalCompletion(CrackJobIDCompleter(con).Usage("crack job ID (leave empty to select)"))
registerCrackJobIDCompletion(crackJobCmd, con)
crackJobCancelCmd := &cobra.Command{
Use: consts.CancelStr + " [id]",
Short: "Cancel an active or paused hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobCancelCmd(cmd, con, args)
},
}
carapace.Gen(crackJobCancelCmd).PositionalCompletion(crackJobIDCompleter(con, crackJobCancellable).Usage("active or paused crack job ID (leave empty to select)"))
registerCrackJobIDCompletionFiltered(crackJobCancelCmd, con, crackJobCancellable)
crackJobCmd.AddCommand(crackJobCancelCmd)
crackJobPauseCmd := &cobra.Command{
Use: "pause [id]",
Short: "Pause an active hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobPauseCmd(cmd, con, args)
},
}
carapace.Gen(crackJobPauseCmd).PositionalCompletion(crackJobIDCompleter(con, crackJobPausable).Usage("active crack job ID (leave empty to select)"))
registerCrackJobIDCompletionFiltered(crackJobPauseCmd, con, crackJobPausable)
crackJobCmd.AddCommand(crackJobPauseCmd)
crackJobResumeCmd := &cobra.Command{
Use: "resume [id]",
Short: "Resume a paused hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobResumeCmd(cmd, con, args)
},
}
carapace.Gen(crackJobResumeCmd).PositionalCompletion(crackJobIDCompleter(con, crackJobResumable).Usage("paused crack job ID (leave empty to select)"))
registerCrackJobIDCompletionFiltered(crackJobResumeCmd, con, crackJobResumable)
crackJobCmd.AddCommand(crackJobResumeCmd)
crackJobRmCmd := &cobra.Command{
Use: consts.RmStr + " [id]",
Short: "Remove a completed, failed, or cancelled hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobRmCmd(cmd, con, args)
},
}
carapace.Gen(crackJobRmCmd).PositionalCompletion(crackTerminalJobIDCompleter(con).Usage("terminal crack job ID (leave empty to select)"))
registerCrackTerminalJobIDCompletion(crackJobRmCmd, con)
crackJobCmd.AddCommand(crackJobRmCmd)
crackCmd.AddCommand(crackJobCmd)
wordlistsCmd := &cobra.Command{
Use: consts.WordlistsStr,
Short: "Manage wordlists",
@@ -177,7 +57,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "wordlist name (blank = filename)")
})
carapace.Gen(wordlistsAddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local wordlist file"))
completers.RegisterLocalFilePathPositionalCompletion(wordlistsAddCmd, 0)
wordlistsCmd.AddCommand(wordlistsAddCmd)
wordlistsRmCmd := &cobra.Command{
@@ -212,7 +91,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "rules name (blank = filename)")
})
carapace.Gen(rulesAddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local rules file"))
completers.RegisterLocalFilePathPositionalCompletion(rulesAddCmd, 0)
rulesCmd.AddCommand(rulesAddCmd)
rulesRmCmd := &cobra.Command{
@@ -248,7 +126,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "hcstat2 name (blank = filename)")
})
carapace.Gen(hcstat2AddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local hcstat2 file"))
completers.RegisterLocalFilePathPositionalCompletion(hcstat2AddCmd, 0)
hcstat2Cmd.AddCommand(hcstat2AddCmd)
hcstat2RmCmd := &cobra.Command{
-216
View File
@@ -1,216 +0,0 @@
package crack
import (
"fmt"
"strconv"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/jedib0t/go-pretty/v6/table"
)
type backendDeviceDetails struct {
deviceType string
vendor string
vendorID int32
version string
runtimeVersion string
deviceID uint32
deviceAlias *uint32
preferredThreadSize *uint32
memoryUnified *bool
localMemory string
cacheSize string
memoryAllocPerBlock string
pciAddress string
}
func hasReportedBackendDevice(cracker *clientpb.Crackstation) bool {
for _, device := range cracker.CUDA {
if device != nil {
return true
}
}
for _, device := range cracker.HIP {
if device != nil {
return true
}
}
for _, device := range cracker.Metal {
if device != nil {
return true
}
}
for _, device := range cracker.OpenCL {
if device != nil {
return true
}
}
return false
}
func appendCrackstationDetailRows(tw table.Writer, cracker *clientpb.Crackstation) {
if cracker.HostUUID != "" {
tw.AppendRow(table.Row{console.StyleBold.Render("Host UUID"), safeCrackCell(cracker.HostUUID)})
}
if cracker.Version != "" {
tw.AppendRow(table.Row{console.StyleBold.Render("Crackstation Version"), safeCrackCell(cracker.Version)})
}
}
func appendCUDABackendRows(tw table.Writer, device *clientpb.CUDABackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "CUDA Device", formatCUDADevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.CUDAVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
cacheSize: device.CacheSize,
pciAddress: device.PCIAddress,
})
}
func appendHIPBackendRows(tw table.Writer, device *clientpb.HIPBackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "HIP Device", formatHIPDevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.HIPVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
cacheSize: device.CacheSize,
pciAddress: device.PCIAddress,
})
}
func appendMetalBackendRows(tw table.Writer, device *clientpb.MetalBackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "Metal Device", formatMetalDevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.MetalVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
cacheSize: device.CacheSize,
memoryAllocPerBlock: device.MemoryAllocPerBlock,
})
appendStringBackendDetailRow(tw, "Physical Location", device.PhysicalLocation)
appendUint32BackendDetailRow(tw, "Registry ID", device.RegistryID)
appendStringBackendDetailRow(tw, "Max TX Rate", device.MaxTXRate)
appendBoolBackendDetailRow(tw, "Headless", device.GPUHeadless)
appendBoolBackendDetailRow(tw, "Low Power", device.GPULowPower)
appendBoolBackendDetailRow(tw, "Removable", device.GPURemovable)
}
func appendOpenCLBackendRows(tw table.Writer, device *clientpb.OpenCLBackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "OpenCL Device", formatOpenCLDevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.OpenCLVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
memoryAllocPerBlock: device.MemoryAllocPerBlock,
pciAddress: device.PCIAddress,
})
appendStringBackendDetailRow(tw, "OpenCL Driver", device.OpenCLDriverVersion)
if device.PlatformID != 0 || device.PlatformVendor != "" || device.PlatformName != "" || device.PlatformVersion != "" {
tw.AppendRow(table.Row{console.StyleBold.Render("OpenCL Platform ID"), strconv.FormatUint(uint64(device.PlatformID), 10)})
}
appendStringBackendDetailRow(tw, "OpenCL Platform Vendor", device.PlatformVendor)
appendStringBackendDetailRow(tw, "OpenCL Platform", device.PlatformName)
appendStringBackendDetailRow(tw, "OpenCL Platform Version", device.PlatformVersion)
}
func appendBackendSummaryRows(tw table.Writer, label, name, memoryFree, memoryTotal string, clock, processors int32) {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render(label), console.StyleBoldGreen.Render(safeCrackCell(name))})
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", valueOrDash(memoryFree), valueOrDash(memoryTotal))})
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), strconv.FormatInt(int64(clock), 10)})
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), strconv.FormatInt(int64(processors), 10)})
}
func appendBackendDeviceDetailRows(tw table.Writer, details backendDeviceDetails) {
appendStringBackendDetailRow(tw, "Device Type", details.deviceType)
appendStringBackendDetailRow(tw, "Vendor", details.vendor)
if details.vendorID != 0 {
tw.AppendRow(table.Row{console.StyleBold.Render("Vendor ID"), strconv.FormatInt(int64(details.vendorID), 10)})
}
if details.version != "" && details.version != details.runtimeVersion {
appendStringBackendDetailRow(tw, "Device Version", details.version)
}
tw.AppendRow(table.Row{console.StyleBold.Render("Backend Device ID"), strconv.FormatUint(uint64(details.deviceID), 10)})
appendUint32BackendDetailRow(tw, "Backend Device Alias", details.deviceAlias)
appendUint32BackendDetailRow(tw, "Preferred Thread Size", details.preferredThreadSize)
appendBoolBackendDetailRow(tw, "Unified Memory", details.memoryUnified)
appendStringBackendDetailRow(tw, "Local Memory", details.localMemory)
appendStringBackendDetailRow(tw, "Cache Size", details.cacheSize)
appendStringBackendDetailRow(tw, "Memory Allocation per Block", details.memoryAllocPerBlock)
appendStringBackendDetailRow(tw, "PCI Address", details.pciAddress)
}
func appendStringBackendDetailRow(tw table.Writer, label, value string) {
if value == "" {
return
}
tw.AppendRow(table.Row{console.StyleBold.Render(label), safeCrackCell(value)})
}
func appendUint32BackendDetailRow(tw table.Writer, label string, value *uint32) {
if value == nil {
return
}
tw.AppendRow(table.Row{console.StyleBold.Render(label), strconv.FormatUint(uint64(*value), 10)})
}
func appendBoolBackendDetailRow(tw table.Writer, label string, value *bool) {
if value == nil {
return
}
tw.AppendRow(table.Row{console.StyleBold.Render(label), strconv.FormatBool(*value)})
}
-210
View File
@@ -1,210 +0,0 @@
package crack
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
)
var representativeCrackBenchmarkModes = map[int32]struct{}{
0: {}, // MD5
100: {}, // SHA1
1000: {}, // NTLM
1400: {}, // SHA2-256
3200: {}, // bcrypt
22000: {}, // WPA-PBKDF2
}
const (
crackBenchmarkOutputWidth = 80
crackBenchmarkHashTypeWidth = 48
crackBenchmarkMetadataValueWidth = 48
)
// BenchmarksCmd displays benchmark results persisted by the server.
func BenchmarksCmd(cmd *cobra.Command, con *console.SliverClient, _ []string) {
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
response, err := con.Rpc.CrackstationBenchmarks(ctx, &commonpb.Empty{})
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
showAll, _ := cmd.Flags().GetBool("all")
benchmarks := response.GetSnapshots()
rendered := renderCrackBenchmarks(benchmarks, settings.GetTableStyle(con), showAll)
if rendered == "" {
con.PrintInfof("No cached crackstation benchmarks\n")
return
}
con.Printf("%s\n", rendered)
}
func renderCrackBenchmarks(benchmarks []*clientpb.CrackBenchmarkSnapshot, style table.Style, showAll bool) string {
ordered := make([]*clientpb.CrackBenchmarkSnapshot, 0, len(benchmarks))
for _, benchmark := range benchmarks {
if benchmark != nil {
ordered = append(ordered, benchmark)
}
}
if len(ordered) == 0 {
return ""
}
sort.SliceStable(ordered, func(i, j int) bool {
leftName := safeCrackCell(ordered[i].GetName())
rightName := safeCrackCell(ordered[j].GetName())
if leftName == rightName {
return safeCrackCell(ordered[i].GetHostUUID()) < safeCrackCell(ordered[j].GetHostUUID())
}
return leftName < rightName
})
sections := make([]string, 0, len(ordered)*2+1)
for index, benchmark := range ordered {
identity := cachedBenchmarkIdentity(benchmark)
title := renderCachedBenchmarkTitle(index, identity)
metadata := table.NewWriter()
metadata.SetStyle(boundedCrackBenchmarkStyle(style))
metadata.SetColumnConfigs([]table.ColumnConfig{
{Number: 2, WidthMax: crackBenchmarkMetadataValueWidth},
})
metadata.AppendRow(table.Row{console.StyleBold.Render("Name"), valueOrDash(benchmark.GetName())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Host UUID"), console.StyleBoldPrimary.Render(valueOrDash(benchmark.GetHostUUID()))})
metadata.AppendRow(table.Row{console.StyleBold.Render("Operator"), valueOrDash(benchmark.GetOperatorName())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Connection"), renderCachedBenchmarkConnection(benchmark.GetOnline())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Cache"), renderCachedBenchmarkFreshness(benchmark.GetFresh())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Current Hashcat Version"), valueOrDash(benchmark.GetCurrentHashcatVersion())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Benchmark Hashcat Version"), valueOrDash(benchmark.GetBenchmarkHashcatVersion())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Benchmark Schema"), strconv.FormatUint(uint64(benchmark.GetBenchmarkSchemaVersion()), 10)})
metadata.AppendRow(table.Row{console.StyleBold.Render("Benchmarked At"), formatCachedBenchmarkTime(benchmark.GetBenchmarkedAt())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Cached Modes"), strconv.Itoa(len(benchmark.GetBenchmarks()))})
sections = append(sections, title+"\n"+metadata.Render())
rates := table.NewWriter()
rates.SetStyle(boundedCrackBenchmarkStyle(style))
rates.SetColumnConfigs([]table.ColumnConfig{
{Name: "Hash Type", WidthMax: crackBenchmarkHashTypeWidth},
})
if showAll {
rates.SetTitle(console.StyleBold.Render("All Cached Rates"))
} else {
rates.SetTitle(console.StyleBold.Render("Representative Rates"))
}
rates.AppendHeader(table.Row{"Mode", "Hash Type", "Rate"})
modes := cachedBenchmarkModes(benchmark.GetBenchmarks(), showAll)
if len(modes) == 0 {
rates.AppendRow(table.Row{"-", "No benchmark rates cached", "-"})
} else {
for _, mode := range modes {
rates.AppendRow(table.Row{
strconv.FormatInt(int64(mode), 10),
crackBenchmarkModeName(mode),
humanizeHashRate(benchmark.GetBenchmarks()[mode]),
})
}
}
sections = append(sections, rates.Render())
}
if !showAll {
sections = append(sections, console.StyleGray.Render("Showing representative rates; use --all for every cached mode."))
}
return strings.Join(sections, "\n\n")
}
func boundedCrackBenchmarkStyle(style table.Style) table.Style {
style.Size.WidthMax = crackBenchmarkOutputWidth
return style
}
func renderCachedBenchmarkTitle(index int, identity string) string {
prefix := fmt.Sprintf(">>> Cached Crackstation %02d - ", index+1)
maximumIdentityWidth := crackBenchmarkOutputWidth - ansi.StringWidth(prefix)
if maximumIdentityWidth < 1 {
return console.StyleBoldPrimary.Render(ansi.Truncate(prefix, crackBenchmarkOutputWidth, "…"))
}
identity = ansi.Truncate(identity, maximumIdentityWidth, "…")
return console.StyleBoldPrimary.Render(prefix + identity)
}
func cachedBenchmarkIdentity(benchmark *clientpb.CrackBenchmarkSnapshot) string {
if benchmark == nil {
return "Unknown crackstation"
}
if name := safeCrackCell(benchmark.GetName()); name != "" {
return name
}
if hostUUID := safeCrackCell(benchmark.GetHostUUID()); hostUUID != "" {
return hostUUID
}
return "Unknown crackstation"
}
func renderCachedBenchmarkConnection(online bool) string {
if online {
return console.StyleBoldSuccess.Render("Online")
}
return console.StyleBoldGray.Render("Offline")
}
func renderCachedBenchmarkFreshness(fresh bool) string {
if fresh {
return console.StyleBoldSuccess.Render("Fresh")
}
return console.StyleBoldWarning.Render("Stale")
}
func formatCachedBenchmarkTime(unix int64) string {
if unix == 0 {
return "-"
}
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
}
func cachedBenchmarkModes(benchmarks map[int32]uint64, showAll bool) []int32 {
modes := make([]int32, 0, len(benchmarks))
for mode := range benchmarks {
if showAll {
modes = append(modes, mode)
continue
}
if _, representative := representativeCrackBenchmarkModes[mode]; representative {
modes = append(modes, mode)
}
}
sort.Slice(modes, func(i, j int) bool { return modes[i] < modes[j] })
if showAll || len(modes) != 0 {
return modes
}
for mode := range benchmarks {
modes = append(modes, mode)
}
sort.Slice(modes, func(i, j int) bool { return modes[i] < modes[j] })
if len(modes) > 6 {
modes = modes[:6]
}
return modes
}
func crackBenchmarkModeName(mode int32) string {
if name, ok := hashcatHashTypeName(mode); ok {
return name
}
if enumName := clientpb.HashType(mode).String(); enumName != strconv.FormatInt(int64(mode), 10) {
return enumName
}
return "Unknown hash mode"
}
@@ -1,376 +0,0 @@
package crack
import (
"context"
"regexp"
"strconv"
"strings"
"testing"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/charmbracelet/x/ansi"
"github.com/spf13/cobra"
"google.golang.org/grpc"
)
func TestCrackBenchmarksCommandIsRegistered(t *testing.T) {
root := Commands(nil)[0]
benchmarks, _, err := root.Find([]string{"benchmarks"})
if err != nil || benchmarks == root {
t.Fatalf("find crack benchmarks: command=%v error=%v", benchmarks, err)
}
if benchmarks.Use != "benchmarks" {
t.Fatalf("crack benchmarks use = %q, want benchmarks", benchmarks.Use)
}
if err := benchmarks.Args(benchmarks, nil); err != nil {
t.Fatalf("crack benchmarks rejected no arguments: %v", err)
}
if err := benchmarks.Args(benchmarks, []string{"unexpected"}); err == nil {
t.Fatal("crack benchmarks accepted a positional argument")
}
allFlag := benchmarks.Flags().Lookup("all")
if allFlag == nil {
t.Fatal("crack benchmarks is missing --all")
}
if allFlag.Shorthand != "a" {
t.Fatalf("--all shorthand = %q, want %q", allFlag.Shorthand, "a")
}
if benchmarks.PersistentFlags().Lookup("all") != nil || root.PersistentFlags().Lookup("all") != nil {
t.Fatal("--all must remain local to crack benchmarks")
}
if benchmarks.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack benchmarks must inherit --timeout")
}
}
func TestRenderCrackBenchmarksSortsStationsAndRepresentativeModes(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{
nil,
{
Name: "zeta",
HostUUID: "host-z",
OperatorName: "operator-z",
CurrentHashcatVersion: "v7.1.3",
BenchmarkHashcatVersion: "v7.1.2",
BenchmarkSchemaVersion: 1,
BenchmarkedAt: 1_700_000_000,
Online: true,
Fresh: true,
Benchmarks: map[int32]uint64{
22000: 6_000_000_000_000,
0: 999,
3200: 5_000_000_000,
100: 1_000,
1400: 3_000_000,
1000: 2_000_000,
99999: 7_000_000,
},
},
{Name: "alpha", HostUUID: "host-b", Benchmarks: map[int32]uint64{0: 1}},
{Name: "alpha", HostUUID: "host-a", Benchmarks: map[int32]uint64{0: 2}},
}
raw := renderCrackBenchmarks(benchmarks, settings.SliverDefault, false)
output := ansi.Strip(raw)
requireOrderedCrackBenchmarkText(t, output, "host-a", "host-b", "host-z")
zetaStart := strings.Index(output, ">>> Cached Crackstation 03 - zeta")
if zetaStart < 0 {
t.Fatalf("rendered benchmarks are missing sorted zeta section:\n%s", output)
}
requireOrderedCrackBenchmarkText(t, output[zetaStart:],
"MD5", "SHA1", "NTLM", "SHA2-256", "bcrypt $2\\*$, Blowfish (Unix)", "WPA-PBKDF2-PMKID+EAPOL",
)
for _, expected := range []string{
"Name", "Host UUID", "Operator", "Connection", "Online", "Cache", "Fresh",
"Current Hashcat Version", "v7.1.3", "Benchmark Hashcat Version", "v7.1.2",
"Benchmark Schema", "Benchmarked At", "2023-11-14T22:13:20Z", "Cached Modes",
"operator-z", "Representative Rates", "Showing representative rates; use --all for every cached mode.",
} {
if !strings.Contains(output, expected) {
t.Errorf("rendered benchmarks do not contain %q:\n%s", expected, output)
}
}
requireCrackBenchmarkRow(t, output, 0, "MD5", "999 H/s")
requireCrackBenchmarkRow(t, output, 100, "SHA1", "1.00 kH/s")
requireCrackBenchmarkRow(t, output, 1000, "NTLM", "2.00 MH/s")
requireCrackBenchmarkRow(t, output, 1400, "SHA2-256", "3.00 MH/s")
requireCrackBenchmarkRow(t, output, 3200, "bcrypt $2\\*$, Blowfish (Unix)", "5.00 GH/s")
requireCrackBenchmarkRow(t, output, 22000, "WPA-PBKDF2-PMKID+EAPOL", "6.00 TH/s")
if hasCrackBenchmarkRow(output, 99999, "Plaintext", "7.00 MH/s") {
t.Fatalf("default benchmark view unexpectedly contains non-representative mode 99999:\n%s", output)
}
for _, styled := range []string{
console.StyleBoldPrimary.Render(">>> Cached Crackstation 01 - alpha"),
console.StyleBoldPrimary.Render("host-a"),
console.StyleBoldSuccess.Render("Online"),
console.StyleBoldSuccess.Render("Fresh"),
console.StyleBold.Render("Representative Rates"),
console.StyleGray.Render("Showing representative rates; use --all for every cached mode."),
} {
if !strings.Contains(raw, styled) {
t.Errorf("rendered benchmarks are missing shared Lip Gloss styling %q:\n%s", styled, raw)
}
}
}
func TestRenderCrackBenchmarksFallsBackToFirstSixModes(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{{
Name: "fallback",
Benchmarks: map[int32]uint64{
23: 23,
10: 10,
22: 22,
12: 12,
20: 20,
11: 11,
21: 21,
},
}}
output := ansi.Strip(renderCrackBenchmarks(benchmarks, settings.SliverDefault, false))
requireOrderedCrackBenchmarkText(t, output,
"md5($pass.$salt)",
"Joomla < 2.5.18",
"PostgreSQL",
"md5($salt.$pass)",
"osCommerce, xt:Commerce",
"Juniper NetScreen/SSG (ScreenOS)",
)
if hasCrackBenchmarkRow(output, 23, "Skype", "23 H/s") {
t.Fatalf("fallback benchmark view contains more than six modes:\n%s", output)
}
}
func TestRenderCrackBenchmarksAllShowsEveryModeAndUnknownFallback(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{{
Name: "all-modes",
Benchmarks: map[int32]uint64{
424242: 1_234,
36100: 3_600,
99999: 7_000_000,
0: 500,
},
}}
raw := renderCrackBenchmarks(benchmarks, settings.SliverDefault, true)
output := ansi.Strip(raw)
for _, row := range []struct {
mode int32
hashType string
rate string
}{
{mode: 0, hashType: "MD5", rate: "500 H/s"},
{mode: 36100, hashType: "YESCRYPT", rate: "3.60 kH/s"},
{mode: 99999, hashType: "Plaintext", rate: "7.00 MH/s"},
{mode: 424242, hashType: "Unknown hash mode", rate: "1.23 kH/s"},
} {
requireCrackBenchmarkRow(t, output, row.mode, row.hashType, row.rate)
}
if !strings.Contains(raw, console.StyleBold.Render("All Cached Rates")) {
t.Fatalf("all-modes view is missing styled title:\n%s", raw)
}
if strings.Contains(output, "Showing representative rates") {
t.Fatalf("all-modes view unexpectedly contains representative-rate hint:\n%s", output)
}
}
func TestRenderCrackBenchmarksSanitizesMetadata(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{{
Name: "alpha\nbeta\x1b]52;c;payload\a",
HostUUID: "host\r\nuuid",
OperatorName: "op\tname",
CurrentHashcatVersion: "current-v7\x00.2",
BenchmarkHashcatVersion: "benchmark-v7\x00.1",
Benchmarks: map[int32]uint64{0: 1},
}}
raw := renderCrackBenchmarks(benchmarks, settings.SliverDefault, false)
output := ansi.Strip(raw)
for _, expected := range []string{"alphabeta]52;c;payload", "hostuuid", "opname", "current-v7.2", "benchmark-v7.1"} {
if !strings.Contains(output, expected) {
t.Errorf("sanitized benchmark metadata does not contain %q:\n%s", expected, output)
}
}
for _, control := range []string{"\x1b]52;", "\a", "\r", "\x00", "\t"} {
if strings.Contains(raw, control) {
t.Errorf("rendered benchmark metadata retained control text %q:\n%q", control, raw)
}
}
}
func TestRenderCrackBenchmarksStylesConnectionAndFreshness(t *testing.T) {
raw := renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{
{Name: "fresh", Online: true, Fresh: true, Benchmarks: map[int32]uint64{0: 1}},
{Name: "stale", Online: false, Fresh: false, Benchmarks: map[int32]uint64{0: 1}},
}, settings.SliverDefault, false)
for _, styled := range []string{
console.StyleBoldSuccess.Render("Online"),
console.StyleBoldSuccess.Render("Fresh"),
console.StyleBoldGray.Render("Offline"),
console.StyleBoldWarning.Render("Stale"),
} {
if !strings.Contains(raw, styled) {
t.Errorf("rendered benchmarks are missing status styling %q:\n%s", styled, raw)
}
}
}
func TestRenderCrackBenchmarksBoundsEveryLine(t *testing.T) {
raw := renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{{
Name: strings.Repeat("very-long-crackstation-name-", 8),
HostUUID: "12345678-1234-1234-1234-123456789abc",
OperatorName: strings.Repeat("operator-", 16),
CurrentHashcatVersion: strings.Repeat("current-version-", 10),
BenchmarkHashcatVersion: strings.Repeat("benchmark-version-", 10),
BenchmarkedAt: 1_700_000_000,
Benchmarks: map[int32]uint64{6211: 1_000_000},
}}, settings.SliverDefault, true)
for lineNumber, line := range strings.Split(raw, "\n") {
if width := ansi.StringWidth(line); width > crackBenchmarkOutputWidth {
t.Errorf("rendered line %d width = %d, want <= %d:\n%s", lineNumber+1, width, crackBenchmarkOutputWidth, line)
}
}
if !strings.Contains(ansi.Strip(raw), "…") {
t.Fatalf("long benchmark heading was not truncated:\n%s", raw)
}
}
func TestFormatCachedBenchmarkTime(t *testing.T) {
if got := formatCachedBenchmarkTime(0); got != "-" {
t.Fatalf("zero benchmark time = %q, want -", got)
}
if got := formatCachedBenchmarkTime(1_700_000_000); got != "2023-11-14T22:13:20Z" {
t.Fatalf("benchmark time = %q, want UTC RFC3339", got)
}
}
func TestRenderCrackBenchmarksHandlesNilAndEmptyData(t *testing.T) {
for _, benchmarks := range [][]*clientpb.CrackBenchmarkSnapshot{nil, {nil, nil}} {
if got := renderCrackBenchmarks(benchmarks, settings.SliverDefault, false); got != "" {
t.Fatalf("renderCrackBenchmarks(%#v) = %q, want empty output", benchmarks, got)
}
}
raw := renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{{
HostUUID: "host-only",
}}, settings.SliverDefault, false)
output := ansi.Strip(raw)
for _, expected := range []string{
">>> Cached Crackstation 01 - host-only",
"No benchmark rates cached",
"Representative Rates",
} {
if !strings.Contains(output, expected) {
t.Errorf("empty benchmark snapshot does not contain %q:\n%s", expected, output)
}
}
unknown := ansi.Strip(renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{{}}, settings.SliverDefault, false))
if !strings.Contains(unknown, ">>> Cached Crackstation 01 - Unknown crackstation") {
t.Fatalf("missing unknown-station identity fallback:\n%s", unknown)
}
}
type crackBenchmarksRPCCapture struct {
rpcpb.SliverRPCClient
response *clientpb.CrackBenchmarkSnapshots
err error
benchmarkCalls int
crackstationsCalls int
hadDeadline bool
}
func (capture *crackBenchmarksRPCCapture) CrackstationBenchmarks(ctx context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.CrackBenchmarkSnapshots, error) {
capture.benchmarkCalls++
_, capture.hadDeadline = ctx.Deadline()
return capture.response, capture.err
}
func (capture *crackBenchmarksRPCCapture) Crackstations(_ context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.Crackstations, error) {
capture.crackstationsCalls++
return &clientpb.Crackstations{}, nil
}
func TestCrackBenchmarksCommandUsesDedicatedRPCWithDeadline(t *testing.T) {
capture := &crackBenchmarksRPCCapture{response: &clientpb.CrackBenchmarkSnapshots{Snapshots: []*clientpb.CrackBenchmarkSnapshot{{
Name: "station",
HostUUID: "host-id",
Benchmarks: map[int32]uint64{0: 1},
}}}}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
root := Commands(con)[0]
root.SetArgs([]string{"benchmarks", "--timeout=1", "-a"})
root.SilenceErrors = true
root.SilenceUsage = true
if err := root.Execute(); err != nil {
t.Fatalf("execute crack benchmarks: %v", err)
}
if capture.benchmarkCalls != 1 {
t.Fatalf("CrackstationBenchmarks calls = %d, want 1", capture.benchmarkCalls)
}
if capture.crackstationsCalls != 0 {
t.Fatalf("Crackstations calls = %d, want 0", capture.crackstationsCalls)
}
if !capture.hadDeadline {
t.Fatal("CrackstationBenchmarks context did not inherit --timeout")
}
}
func TestCrackBenchmarksCommandHandlesNilResponse(t *testing.T) {
capture := &crackBenchmarksRPCCapture{}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
root := Commands(con)[0]
root.SetArgs([]string{"benchmarks", "--timeout=1"})
root.SilenceErrors = true
root.SilenceUsage = true
if err := root.Execute(); err != nil {
t.Fatalf("execute crack benchmarks with nil response: %v", err)
}
if capture.benchmarkCalls != 1 {
t.Fatalf("CrackstationBenchmarks calls = %d, want 1", capture.benchmarkCalls)
}
}
func requireOrderedCrackBenchmarkText(t *testing.T, output string, values ...string) {
t.Helper()
remaining := output
for _, value := range values {
index := strings.Index(remaining, value)
if index < 0 {
t.Fatalf("rendered benchmarks do not contain %q in order:\n%s", value, output)
}
remaining = remaining[index+len(value):]
}
}
func requireCrackBenchmarkRow(t *testing.T, output string, mode int32, hashType string, rate string) {
t.Helper()
if !hasCrackBenchmarkRow(output, mode, hashType, rate) {
t.Fatalf("rendered benchmarks are missing mode %d, hash type %q, rate %q:\n%s", mode, hashType, rate, output)
}
}
func hasCrackBenchmarkRow(output string, mode int32, hashType string, rate string) bool {
pattern := `(?m)^[ \t]*` + regexp.QuoteMeta(strconv.FormatInt(int64(mode), 10)) + `[ \t]+` +
regexp.QuoteMeta(hashType) + `[ \t]+` + regexp.QuoteMeta(rate) + `[ \t]*$`
return regexp.MustCompile(pattern).MatchString(output)
}
-593
View File
@@ -1,593 +0,0 @@
package crack
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const maxKeyboardLayoutMappingBytes int64 = 1 << 20
func readKeyboardLayoutMapping(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open keyboard layout mapping %q: %w", path, err)
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat keyboard layout mapping %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("keyboard layout mapping %q is not a regular file", path)
}
if info.Size() > maxKeyboardLayoutMappingBytes {
return nil, fmt.Errorf("keyboard layout mapping %q exceeds %d bytes", path, maxKeyboardLayoutMappingBytes)
}
data, err := io.ReadAll(io.LimitReader(file, maxKeyboardLayoutMappingBytes+1))
if err != nil {
return nil, fmt.Errorf("read keyboard layout mapping %q: %w", path, err)
}
if int64(len(data)) > maxKeyboardLayoutMappingBytes {
return nil, fmt.Errorf("keyboard layout mapping %q exceeds %d bytes", path, maxKeyboardLayoutMappingBytes)
}
if len(data) == 0 {
return nil, fmt.Errorf("keyboard layout mapping %q is empty", path)
}
return data, nil
}
func shouldRunCrack(cmd *cobra.Command, args []string) bool {
if len(args) > 0 {
return true
}
hasFlag := false
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
if !flag.Changed || flag.Name == "timeout" {
return
}
// An explicitly disabled standalone mode is a no-op, not a crack job.
switch flag.Name {
case "backend-info", "keyspace", "total-candidates", "identify-mode", "hash-info":
enabled, _ := cmd.Flags().GetBool(flag.Name)
if !enabled {
return
}
}
hasFlag = true
})
return hasFlag
}
func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackCommand, error) {
flags := cmd.Flags()
req := &clientpb.CrackCommand{HashType: clientpb.HashType_INVALID}
if flags.Changed("attack-mode") {
raw, _ := flags.GetString("attack-mode")
attackMode, err := parseCrackAttackMode(raw)
if err != nil {
return nil, err
}
req.AttackMode = attackMode
}
if flags.Changed("hash-type") {
raw, _ := flags.GetString("hash-type")
hashType, err := parseHashType(raw)
if err != nil {
return nil, err
}
req.HashType = hashType
}
if flags.Changed("hash-mode") {
hashMode, _ := flags.GetUint32("hash-mode")
req.HashMode = &hashMode
}
hashes, _ := flags.GetStringArray("hash")
if len(args) > 0 {
hashes = append(hashes, args...)
}
req.Hashes = hashes
req.CredentialIDs, _ = flags.GetStringArray("credential")
for index, credentialID := range req.CredentialIDs {
req.CredentialIDs[index] = strings.TrimSpace(credentialID)
if req.CredentialIDs[index] == "" {
return nil, fmt.Errorf("--credential cannot be empty")
}
}
req.CredentialCollection, _ = flags.GetString("credential-collection")
req.CredentialCollection = strings.TrimSpace(req.CredentialCollection)
req.IncludeCrackedCredentials, _ = flags.GetBool("include-cracked-credentials")
if req.IncludeCrackedCredentials && len(req.CredentialIDs) == 0 && req.CredentialCollection == "" {
return nil, fmt.Errorf("--include-cracked-credentials requires --credential or --credential-collection")
}
req.PositionalArguments, _ = flags.GetStringArray("input")
req.Crackstation, _ = flags.GetString("crackstation")
req.Crackstation = strings.TrimSpace(req.Crackstation)
if stdin, _ := flags.GetString("stdin"); stdin != "" {
req.Stdin = []byte(stdin)
}
if flags.Changed("segment-size") {
return nil, fmt.Errorf("--segment-size was removed in Hashcat v7")
}
req.Quiet, _ = flags.GetBool("quiet")
req.HexCharset, _ = flags.GetBool("hex-charset")
req.HexSalt, _ = flags.GetBool("hex-salt")
req.HexWordlist, _ = flags.GetBool("hex-wordlist")
req.Force, _ = flags.GetBool("force")
req.DeprecatedCheckDisable, _ = flags.GetBool("deprecated-check-disable")
req.AdviceDisable, _ = flags.GetBool("advice-disable")
req.Status, _ = flags.GetBool("status")
req.StatusJSON, _ = flags.GetBool("status-json")
req.StatusTimer, _ = flags.GetUint32("status-timer")
if flags.Changed("status-timer") {
value := req.StatusTimer
req.StatusTimerV7 = &value
}
req.StdinTimeoutAbort, _ = flags.GetUint32("stdin-timeout-abort")
if flags.Changed("stdin-timeout-abort") {
value := req.StdinTimeoutAbort
req.StdinTimeoutAbortV7 = &value
}
req.MachineReadable, _ = flags.GetBool("machine-readable")
req.KeepGuessing, _ = flags.GetBool("keep-guessing")
req.SelfTestDisable, _ = flags.GetBool("self-test-disable")
req.Loopback, _ = flags.GetBool("loopback")
if raw, _ := flags.GetString("markov-hcstat2"); raw != "" {
req.MarkovHcstat2 = []byte(raw)
}
req.MarkovDisable, _ = flags.GetBool("markov-disable")
req.MarkovClassic, _ = flags.GetBool("markov-classic")
req.MarkovInverse, _ = flags.GetBool("markov-inverse")
req.MarkovThreshold, _ = flags.GetUint32("markov-threshold")
req.Runtime, _ = flags.GetUint32("runtime")
req.Session, _ = flags.GetString("session")
req.Restore, _ = flags.GetBool("restore")
req.RestoreDisable, _ = flags.GetBool("restore-disable")
if raw, _ := flags.GetString("restore-file"); raw != "" {
req.RestoreFile = []byte(raw)
}
req.RestorePosition, _ = flags.GetBool("restore-position")
req.RestoreShowCommand, _ = flags.GetBool("restore-show-command")
if req.RestoreShowCommand && (req.Restore || req.RestorePosition) { //nolint:staticcheck // Restore is required for legacy command compatibility.
return nil, fmt.Errorf("--restore-show-command and restore-position modes are mutually exclusive")
}
req.Outfile, _ = flags.GetString("outfile")
if formats, _ := flags.GetStringSlice("outfile-format"); len(formats) > 0 {
for _, raw := range formats {
format, err := parseCrackOutfileFormat(raw)
if err != nil {
return nil, err
}
req.OutfileFormat = append(req.OutfileFormat, format)
}
}
req.OutfileJSON, _ = flags.GetBool("outfile-json")
req.OutfileAutohexDisable, _ = flags.GetBool("outfile-autohex-disable")
req.OutfileCheckTimer, _ = flags.GetUint32("outfile-check-timer")
if flags.Changed("outfile-check-timer") {
value := req.OutfileCheckTimer
req.OutfileCheckTimerV7 = &value
}
req.WordlistAutohexDisable, _ = flags.GetBool("wordlist-autohex-disable")
req.Separator, _ = flags.GetString("separator")
req.Show, _ = flags.GetBool("show")
req.Left, _ = flags.GetBool("left")
req.Username, _ = flags.GetBool("username")
req.Remove, _ = flags.GetBool("remove")
req.RemoveTimer, _ = flags.GetUint32("remove-timer")
req.PotfileDisable, _ = flags.GetBool("potfile-disable")
if raw, _ := flags.GetString("potfile"); raw != "" {
req.Potfile = []byte(raw)
}
if flags.Changed("encoding-from") {
raw, _ := flags.GetString("encoding-from")
if enc, err := parseCrackEncoding(raw); err == nil {
req.EncodingFrom = enc
}
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
req.EncodingFromName = raw
}
}
if flags.Changed("encoding-to") {
raw, _ := flags.GetString("encoding-to")
if enc, err := parseCrackEncoding(raw); err == nil {
req.EncodingTo = enc
}
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
req.EncodingToName = raw
}
}
req.DebugMode, _ = flags.GetUint32("debug-mode")
req.DebugFile, _ = flags.GetString("debug-file")
req.InductionDir, _ = flags.GetString("induction-dir")
req.OutfileCheckDir, _ = flags.GetString("outfile-check-dir")
req.LogfileDisable, _ = flags.GetBool("logfile-disable")
req.HccapxMessagePair, _ = flags.GetUint32("hccapx-message-pair")
if flags.Changed("hccapx-message-pair") {
value := req.HccapxMessagePair
req.HccapxMessagePairV7 = &value
}
req.NonceErrorCorrections, _ = flags.GetUint32("nonce-error-corrections")
if flags.Changed("nonce-error-corrections") {
value := req.NonceErrorCorrections
req.NonceErrorCorrectionsV7 = &value
}
if raw, _ := flags.GetString("keyboard-layout-mapping"); raw != "" {
mapping, err := readKeyboardLayoutMapping(raw)
if err != nil {
return nil, err
}
req.KeyboardLayoutMapping = mapping
}
req.TruecryptKeyfiles, _ = flags.GetString("truecrypt-keyfiles")
req.VeracryptKeyfiles, _ = flags.GetString("veracrypt-keyfiles")
if flags.Changed("veracrypt-pim-start") {
value, _ := flags.GetUint32("veracrypt-pim-start")
req.VeracryptPimStart = &value
}
if flags.Changed("veracrypt-pim-stop") {
value, _ := flags.GetUint32("veracrypt-pim-stop")
req.VeracryptPimStop = &value
}
req.Benchmark, _ = flags.GetBool("benchmark")
req.BenchmarkAll, _ = flags.GetBool("benchmark-all")
req.BenchmarkMin, _ = flags.GetUint32("benchmark-min")
if flags.Changed("benchmark-max") {
value, _ := flags.GetUint32("benchmark-max")
req.BenchmarkMax = &value
}
req.SpeedOnly, _ = flags.GetBool("speed-only")
req.ProgressOnly, _ = flags.GetBool("progress-only")
req.PipelineStats, _ = flags.GetBool("pipeline-stats")
req.TaskTimeBreakdown, _ = flags.GetBool("task-time-breakdown")
req.BitmapMin, _ = flags.GetUint32("bitmap-min")
if flags.Changed("bitmap-min") {
value := req.BitmapMin
req.BitmapMinV7 = &value
}
req.BitmapMax, _ = flags.GetUint32("bitmap-max")
if flags.Changed("bitmap-max") {
value := req.BitmapMax
req.BitmapMaxV7 = &value
}
req.CPUAffinity = uintSliceToUint32(flags, "cpu-affinity")
req.HookThreads, _ = flags.GetUint32("hook-threads")
req.HashInfo, _ = flags.GetBool("hash-info")
if req.HashInfo {
req.HashInfoLevel = 1
}
if flags.Changed("hash-info-level") {
req.HashInfoLevel, _ = flags.GetUint32("hash-info-level")
}
req.BackendIgnoreCUDA, _ = flags.GetBool("backend-ignore-cuda")
req.BackendIgnoreHip, _ = flags.GetBool("backend-ignore-hip")
req.BackendIgnoreMetal, _ = flags.GetBool("backend-ignore-metal")
req.BackendIgnoreOpenCL, _ = flags.GetBool("backend-ignore-opencl")
req.BackendInfo, _ = flags.GetBool("backend-info")
if req.BackendInfo {
req.BackendInfoLevel = 1
}
if flags.Changed("backend-info-level") {
req.BackendInfoLevel, _ = flags.GetUint32("backend-info-level")
}
req.BackendDevices = uintSliceToUint32(flags, "backend-devices")
req.BackendDevicesVirtMulti, _ = flags.GetUint32("backend-devices-virtmulti")
req.BackendDevicesVirtHost, _ = flags.GetUint32("backend-devices-virthost")
req.OpenCLDeviceTypes = uintSliceToUint32(flags, "opencl-device-types")
req.MetalCompilerRuntime, _ = flags.GetUint32("metal-compiler-runtime")
req.OptimizedKernelEnable, _ = flags.GetBool("optimized-kernel-enable")
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disable")
if !req.MultiplyAccelDisabled {
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disabled")
}
if flags.Changed("workload-profile") {
raw, _ := flags.GetString("workload-profile")
profile, err := parseCrackWorkloadProfile(raw)
if err != nil {
return nil, err
}
req.WorkloadProfile = profile
}
req.KernelAccel, _ = flags.GetUint32("kernel-accel")
req.KernelLoops, _ = flags.GetUint32("kernel-loops")
req.KernelThreads, _ = flags.GetUint32("kernel-threads")
req.BackendVectorWidth, _ = flags.GetUint32("backend-vector-width")
req.SpinDamp, _ = flags.GetUint32("spin-damp")
req.HwmonDisable, _ = flags.GetBool("hwmon-disable")
req.HwmonTempAbort, _ = flags.GetUint32("hwmon-temp-abort")
if flags.Changed("hwmon-temp-abort") {
value := req.HwmonTempAbort
req.HwmonTempAbortV7 = &value
}
req.ScryptTMTO, _ = flags.GetUint32("scrypt-tmto")
if flags.Changed("scrypt-tmto") {
value := req.ScryptTMTO
req.ScryptTMTOV7 = &value
}
req.Skip, _ = flags.GetUint64("skip")
req.Limit, _ = flags.GetUint64("limit")
req.Keyspace, _ = flags.GetBool("keyspace")
req.TotalCandidates, _ = flags.GetBool("total-candidates")
req.Lookup, _ = flags.GetString("lookup")
req.DynamicX, _ = flags.GetBool("dynamic-x")
req.SeekDBPath, _ = flags.GetString("seekdb-path")
req.RuleLeft, _ = flags.GetString("rule-left")
req.RuleRight, _ = flags.GetString("rule-right")
if values, _ := flags.GetStringArray("rules-file"); len(values) > 0 {
req.RulesFile = []byte(values[0])
for _, value := range values {
req.RulesFilesV7 = append(req.RulesFilesV7, []byte(value))
}
}
req.GenerateRules, _ = flags.GetUint32("generate-rules")
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-func-min")
if !flags.Changed("generate-rules-func-min") {
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-fun-min")
}
if flags.Changed("generate-rules-func-min") || flags.Changed("generate-rules-fun-min") {
value := req.GenerateRulesFunMin
req.GenerateRulesFuncMinV7 = &value
}
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-func-max")
if !flags.Changed("generate-rules-func-max") {
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-fun-max")
}
if flags.Changed("generate-rules-func-max") || flags.Changed("generate-rules-fun-max") {
value := req.GenerateRulesFunMax
req.GenerateRulesFuncMaxV7 = &value
}
req.GenerateRulesFuncSel, _ = flags.GetString("generate-rules-func-sel")
req.GenerateRulesSeed, _ = flags.GetInt32("generate-rules-seed")
if flags.Changed("generate-rules-seed") && req.GenerateRulesSeed >= 0 {
value := uint32(req.GenerateRulesSeed)
req.GenerateRulesSeedV7 = &value
}
req.CustomCharset1, _ = flags.GetString("custom-charset1")
req.CustomCharset2, _ = flags.GetString("custom-charset2")
req.CustomCharset3, _ = flags.GetString("custom-charset3")
req.CustomCharset4, _ = flags.GetString("custom-charset4")
req.CustomCharset5, _ = flags.GetString("custom-charset5")
req.CustomCharset6, _ = flags.GetString("custom-charset6")
req.CustomCharset7, _ = flags.GetString("custom-charset7")
req.CustomCharset8, _ = flags.GetString("custom-charset8")
req.Identify, _ = flags.GetString("identify")
req.IdentifyMode, _ = flags.GetBool("identify-mode")
if req.IdentifyMode && (flags.Changed("hash-mode") || flags.Changed("hash-type")) {
return nil, fmt.Errorf("--identify-mode and hash mode/type are mutually exclusive")
}
req.Increment, _ = flags.GetBool("increment")
req.IncrementInverse, _ = flags.GetBool("increment-inverse")
req.IncrementMin, _ = flags.GetUint32("increment-min")
req.IncrementMax, _ = flags.GetUint32("increment-max")
if flags.Changed("bypass-delay") {
value, _ := flags.GetUint32("bypass-delay")
req.BypassDelay = &value
}
if flags.Changed("bypass-threshold") {
value, _ := flags.GetUint32("bypass-threshold")
req.BypassThreshold = &value
}
req.SlowCandidates, _ = flags.GetBool("slow-candidates")
req.BridgeParameter1, _ = flags.GetString("bridge-parameter1")
req.BridgeParameter2, _ = flags.GetString("bridge-parameter2")
req.BridgeParameter3, _ = flags.GetString("bridge-parameter3")
req.BridgeParameter4, _ = flags.GetString("bridge-parameter4")
req.BrainServer, _ = flags.GetBool("brain-server")
req.BrainServerTimer, _ = flags.GetUint32("brain-server-timer")
if flags.Changed("brain-server-timer") {
value := req.BrainServerTimer
req.BrainServerTimerV7 = &value
}
req.BrainClient, _ = flags.GetBool("brain-client")
req.BrainClientFeatures, _ = flags.GetString("brain-client-features")
if value, ok := parseUint32(req.BrainClientFeatures); ok {
req.BrainClientFeaturesV7 = value
}
req.BrainHost, _ = flags.GetString("brain-host")
req.BrainPort, _ = flags.GetUint32("brain-port")
req.BrainPassword, _ = flags.GetString("brain-password")
if flags.Changed("brain-password") {
value := req.BrainPassword
req.BrainPasswordV7 = &value
}
req.BrainSession, _ = flags.GetString("brain-session")
if value, ok := parseHexUint32(req.BrainSession); ok {
req.BrainSessionV7 = &value
}
req.BrainSessionWhitelist, _ = flags.GetString("brain-session-whitelist")
if values, ok := parseHexUint32List(req.BrainSessionWhitelist); ok {
req.BrainSessionWhitelistV7 = values
}
req.BrainFeed, _ = flags.GetBool("brain-feed")
req.ColorCracked, _ = flags.GetBool("color-cracked")
req.HashCopy, _ = flags.GetBool("hash-copy")
req.EncryptWithPubkey, _ = flags.GetString("encrypt-with-pubkey")
return req, nil
}
func uintSliceToUint32(flags *pflag.FlagSet, name string) []uint32 {
values, _ := flags.GetUintSlice(name)
if len(values) == 0 {
return nil
}
out := make([]uint32, len(values))
for i, value := range values {
out[i] = uint32(value)
}
return out
}
func parseUint32(raw string) (uint32, bool) {
if raw == "" {
return 0, false
}
value, err := strconv.ParseUint(raw, 10, 32)
return uint32(value), err == nil
}
func parseHexUint32(raw string) (uint32, bool) {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "0x") || strings.HasPrefix(raw, "0X") {
raw = raw[2:]
}
if raw == "" {
return 0, false
}
value, err := strconv.ParseUint(raw, 16, 32)
return uint32(value), err == nil
}
func parseHexUint32List(raw string) ([]uint32, bool) {
if raw == "" {
return nil, false
}
parts := strings.Split(raw, ",")
values := make([]uint32, 0, len(parts))
for _, part := range parts {
value, ok := parseHexUint32(part)
if !ok {
return nil, false
}
values = append(values, value)
}
return values, true
}
func parseCrackAttackMode(raw string) (clientpb.CrackAttackMode, error) {
if raw == "" {
return clientpb.CrackAttackMode_STRAIGHT, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackAttackMode(numeric), nil
}
normalized := strings.ToLower(strings.TrimSpace(raw))
normalized = strings.NewReplacer("-", "_", " ", "_").Replace(normalized)
switch normalized {
case "straight":
return clientpb.CrackAttackMode_STRAIGHT, nil
case "combination":
return clientpb.CrackAttackMode_COMBINATION, nil
case "bruteforce", "brute_force", "brute-force":
return clientpb.CrackAttackMode_BRUTEFORCE, nil
case "pcfg":
return clientpb.CrackAttackMode_PCFG, nil
case "hybrid_wordlist_mask":
return clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK, nil
case "hybrid_mask_wordlist":
return clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST, nil
case "generic":
return clientpb.CrackAttackMode_GENERIC, nil
case "association":
return clientpb.CrackAttackMode_ASSOCIATION, nil
case "no_attack", "none":
return clientpb.CrackAttackMode_NO_ATTACK, nil
case "hybrid":
return clientpb.CrackAttackMode_HYBRID, nil
default:
return clientpb.CrackAttackMode_STRAIGHT, fmt.Errorf("unknown attack mode: %s", raw)
}
}
func parseHashType(raw string) (clientpb.HashType, error) {
if raw == "" {
return clientpb.HashType_MD5, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.HashType(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.HashType_value[key]; ok {
return clientpb.HashType(value), nil
}
return clientpb.HashType_MD5, fmt.Errorf("unknown hash type: %s", raw)
}
func parseCrackEncoding(raw string) (clientpb.CrackEncoding, error) {
if raw == "" {
return clientpb.CrackEncoding_INVALID_ENCODING, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackEncoding(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.CrackEncoding_value[key]; ok {
return clientpb.CrackEncoding(value), nil
}
return clientpb.CrackEncoding_INVALID_ENCODING, fmt.Errorf("unknown encoding: %s", raw)
}
func parseCrackOutfileFormat(raw string) (clientpb.CrackOutfileFormat, error) {
if raw == "" {
return clientpb.CrackOutfileFormat_INVALID_FORMAT, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackOutfileFormat(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.CrackOutfileFormat_value[key]; ok {
return clientpb.CrackOutfileFormat(value), nil
}
return clientpb.CrackOutfileFormat_INVALID_FORMAT, fmt.Errorf("unknown outfile format: %s", raw)
}
func parseCrackWorkloadProfile(raw string) (clientpb.CrackWorkloadProfile, error) {
if raw == "" {
return clientpb.CrackWorkloadProfile_INVALID_WORKLOAD_PROFILE, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackWorkloadProfile(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.CrackWorkloadProfile_value[key]; ok {
return clientpb.CrackWorkloadProfile(value), nil
}
return clientpb.CrackWorkloadProfile_INVALID_WORKLOAD_PROFILE, fmt.Errorf("unknown workload profile: %s", raw)
}
func normalizeEnumKey(value string) string {
if value == "" {
return ""
}
var builder strings.Builder
builder.Grow(len(value))
underscore := false
for i := 0; i < len(value); i++ {
ch := value[i]
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
if ch >= 'a' && ch <= 'z' {
ch -= 'a' - 'A'
}
builder.WriteByte(ch)
underscore = false
continue
}
if !underscore {
builder.WriteByte('_')
underscore = true
}
}
return strings.Trim(builder.String(), "_")
}
-487
View File
@@ -1,487 +0,0 @@
package crack
import (
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/spf13/cobra"
"google.golang.org/protobuf/proto"
)
func TestBuildCrackCommandReadsKeyboardLayoutMapping(t *testing.T) {
mapping := []byte("a A\nb B\n")
path := filepath.Join(t.TempDir(), "keyboard.hckmap")
if err := os.WriteFile(path, mapping, 0600); err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--keyboard-layout-mapping=" + path}); err != nil {
t.Fatal(err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand() error = %v", err)
}
if !bytes.Equal(got.KeyboardLayoutMapping, mapping) {
t.Fatalf("KeyboardLayoutMapping = %q, want file contents %q", got.KeyboardLayoutMapping, mapping)
}
}
func TestBuildCrackCommandRejectsInvalidKeyboardLayoutMapping(t *testing.T) {
tests := []struct {
name string
path func(*testing.T) string
want string
}{
{
name: "missing",
path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing.hckmap") },
want: "open keyboard layout mapping",
},
{
name: "directory",
path: func(t *testing.T) string { return t.TempDir() },
want: "is not a regular file",
},
{
name: "empty",
path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "empty.hckmap")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
return path
},
want: "is empty",
},
{
name: "too large",
path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large.hckmap")
if err := os.WriteFile(path, make([]byte, maxKeyboardLayoutMappingBytes+1), 0600); err != nil {
t.Fatal(err)
}
return path
},
want: "exceeds",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--keyboard-layout-mapping=" + test.path(t)}); err != nil {
t.Fatal(err)
}
if _, err := buildCrackCommand(cmd, nil); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("buildCrackCommand() error = %v, want substring %q", err, test.want)
}
})
}
}
func TestParseCrackAttackMode(t *testing.T) {
tests := []struct {
name string
want clientpb.CrackAttackMode
}{
{name: "straight", want: clientpb.CrackAttackMode_STRAIGHT},
{name: "combination", want: clientpb.CrackAttackMode_COMBINATION},
{name: "bruteforce", want: clientpb.CrackAttackMode_BRUTEFORCE},
{name: "pcfg", want: clientpb.CrackAttackMode_PCFG},
{name: "hybrid-wordlist-mask", want: clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK},
{name: "hybrid-mask-wordlist", want: clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST},
{name: "generic", want: clientpb.CrackAttackMode_GENERIC},
{name: "association", want: clientpb.CrackAttackMode_ASSOCIATION},
{name: "no-attack", want: clientpb.CrackAttackMode_NO_ATTACK},
{name: "hybrid", want: clientpb.CrackAttackMode_HYBRID},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := parseCrackAttackMode(test.name)
if err != nil {
t.Fatalf("parseCrackAttackMode(%q) returned error: %v", test.name, err)
}
if got != test.want {
t.Fatalf("parseCrackAttackMode(%q) = %v, want %v", test.name, got, test.want)
}
})
}
}
func TestBuildCrackCommandHashcatShortFlags(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"-a", "3", "-m", "1000"}); err != nil {
t.Fatal(err)
}
got, err := buildCrackCommand(cmd, []string{"hash"})
if err != nil {
t.Fatalf("buildCrackCommand() error = %v", err)
}
if got.AttackMode != clientpb.CrackAttackMode_BRUTEFORCE {
t.Fatalf("AttackMode = %s, want BRUTEFORCE", got.AttackMode)
}
if got.HashType != clientpb.HashType(1000) {
t.Fatalf("HashType = %d, want 1000", got.HashType)
}
}
func TestBuildCrackCommandHashcatV7Options(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
args := []string{
"--attack-mode=hybrid",
"--hash-mode=0",
"--input=first-input",
"--input=second-input",
"--stdin=stdin-data",
"--advice-disable",
"--status-timer=0",
"--stdin-timeout-abort=0",
"--restore-position",
"--outfile=outfile.txt",
"--outfile-json",
"--debug-file=debug.txt",
"--induction-dir=induction",
"--outfile-check-dir=outfile-check",
"--outfile-check-timer=0",
"--truecrypt-keyfiles=truecrypt.keys",
"--veracrypt-keyfiles=veracrypt.keys",
"--veracrypt-pim-start=11",
"--veracrypt-pim-stop=12",
"--encoding-from=windows-1252",
"--encoding-to=utf-8",
"--hccapx-message-pair=0",
"--nonce-error-corrections=0",
"--benchmark-min=13",
"--benchmark-max=14",
"--pipeline-stats",
"--task-time-breakdown",
"--bitmap-min=0",
"--bitmap-max=0",
"--hash-info",
"--hash-info-level=2",
"--backend-info",
"--backend-info-level=2",
"--backend-devices-virtmulti=15",
"--backend-devices-virthost=16",
"--metal-compiler-runtime=17",
"--multiply-accel-disable",
"--hwmon-temp-abort=0",
"--total-candidates",
"--lookup=lookup-value",
"--crackstation= station-one ",
"--dynamic-x",
"--seekdb-path=seekdb",
"--rule-left=l",
"--rule-right=r",
"--rules-file=:",
"--rules-file=$1",
"--custom-charset5=cs5",
"--custom-charset6=cs6",
"--custom-charset7=cs7",
"--custom-charset8=cs8",
"--identify=legacy-input",
"--increment-inverse",
"--bypass-delay=18",
"--bypass-threshold=19",
"--bridge-parameter1=bridge1",
"--bridge-parameter2=bridge2",
"--bridge-parameter3=bridge3",
"--bridge-parameter4=bridge4",
"--scrypt-tmto=0",
"--generate-rules-func-min=4",
"--generate-rules-func-max=8",
"--generate-rules-seed=0",
"--brain-client-features=3",
"--brain-password=",
"--brain-server-timer=0",
"--brain-session=deadbeef",
"--brain-session-whitelist=1,2,0x3",
"--brain-feed",
"--color-cracked",
"--hash-copy",
"--encrypt-with-pubkey=key.pub",
}
if err := cmd.Flags().Parse(args); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
zero := uint32(0)
want := &clientpb.CrackCommand{
AttackMode: clientpb.CrackAttackMode_HYBRID,
HashType: clientpb.HashType_INVALID,
HashMode: &zero,
Stdin: []byte("stdin-data"),
AdviceDisable: true,
StatusTimerV7: &zero,
StdinTimeoutAbortV7: &zero,
Outfile: "outfile.txt",
DebugFile: "debug.txt",
InductionDir: "induction",
OutfileCheckDir: "outfile-check",
OutfileCheckTimerV7: &zero,
TruecryptKeyfiles: "truecrypt.keys",
VeracryptKeyfiles: "veracrypt.keys",
VeracryptPimStart: uint32Pointer(11),
VeracryptPimStop: uint32Pointer(12),
EncodingFromName: "windows-1252",
EncodingToName: "utf-8",
HccapxMessagePairV7: &zero,
NonceErrorCorrectionsV7: &zero,
RuleLeft: "l",
RuleRight: "r",
RulesFile: []byte(":"),
RulesFilesV7: [][]byte{[]byte(":"), []byte("$1")},
PipelineStats: true,
TaskTimeBreakdown: true,
BitmapMinV7: &zero,
BitmapMaxV7: &zero,
HashInfo: true,
HashInfoLevel: 2,
BackendInfo: true,
BackendInfoLevel: 2,
MetalCompilerRuntime: 17,
RestorePosition: true,
OutfileJSON: true,
DynamicX: true,
SeekDBPath: "seekdb",
BenchmarkMin: 13,
BenchmarkMax: uint32Pointer(14),
BridgeParameter1: "bridge1",
BridgeParameter2: "bridge2",
BridgeParameter3: "bridge3",
BridgeParameter4: "bridge4",
BackendDevicesVirtMulti: 15,
BackendDevicesVirtHost: 16,
MultiplyAccelDisabled: true,
HwmonTempAbortV7: &zero,
TotalCandidates: true,
Lookup: "lookup-value",
Crackstation: "station-one",
CustomCharset5: "cs5",
CustomCharset6: "cs6",
CustomCharset7: "cs7",
CustomCharset8: "cs8",
IncrementInverse: true,
BypassDelay: uint32Pointer(18),
BypassThreshold: uint32Pointer(19),
ScryptTMTOV7: &zero,
GenerateRulesFunMin: 4,
GenerateRulesFunMax: 8,
GenerateRulesFuncMinV7: uint32Pointer(4),
GenerateRulesFuncMaxV7: uint32Pointer(8),
GenerateRulesSeedV7: &zero,
BrainClientFeatures: "3",
BrainClientFeaturesV7: 3,
BrainPasswordV7: stringPointer(""),
BrainSession: "deadbeef",
BrainSessionV7: uint32Pointer(0xdeadbeef),
BrainServerTimerV7: &zero,
BrainSessionWhitelist: "1,2,0x3",
BrainSessionWhitelistV7: []uint32{1, 2, 3},
BrainFeed: true,
ColorCracked: true,
HashCopy: true,
EncryptWithPubkey: "key.pub",
Identify: "legacy-input", //nolint:staticcheck // Verify the legacy positional operand remains populated for wire compatibility.
PositionalArguments: []string{"first-input", "second-input"},
}
if !proto.Equal(got, want) {
t.Fatal("buildCrackCommand() did not map the Hashcat v7 options as expected")
}
}
func uint32Pointer(value uint32) *uint32 {
return &value
}
func stringPointer(value string) *string {
return &value
}
func TestBuildCrackCommandBrainSessionsAreHexadecimal(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--brain-session=10",
"--brain-session-whitelist=10,20",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
if got.BrainSessionV7 == nil || *got.BrainSessionV7 != 0x10 {
t.Fatalf("BrainSessionV7 = %v, want 0x10", got.BrainSessionV7)
}
wantWhitelist := []uint32{0x10, 0x20}
if !slices.Equal(got.BrainSessionWhitelistV7, wantWhitelist) {
t.Fatalf("BrainSessionWhitelistV7 = %#v, want %#v", got.BrainSessionWhitelistV7, wantWhitelist)
}
}
func TestBuildCrackCommandPreservesCommasInPositionalInputs(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--input=hashes,2026.txt",
"--input=?d?d,?l?l",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
want := []string{"hashes,2026.txt", "?d?d,?l?l"}
if !slices.Equal(got.PositionalArguments, want) {
t.Fatalf("PositionalArguments = %#v, want %#v", got.PositionalArguments, want)
}
}
func TestBuildCrackCommandPreservesCommasInHashes(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--hash=digest:salt,with,commas",
"--hash=second:digest,salt",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
want := []string{"digest:salt,with,commas", "second:digest,salt"}
if !slices.Equal(got.Hashes, want) {
t.Fatalf("Hashes = %#v, want %#v", got.Hashes, want)
}
}
func TestBuildCrackCommandRejectsConflictingRestoreModes(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--restore-position", "--restore-show-command"}); err != nil {
t.Fatalf("parse flags: %v", err)
}
_, err := buildCrackCommand(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("buildCrackCommand() error = %v, want restore conflict error", err)
}
}
func TestBuildCrackCommandIdentifyHasNoImplicitHashType(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--identify-mode", "--input=hashes.txt"}); err != nil {
t.Fatal(err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatal(err)
}
if got.HashType != clientpb.HashType_INVALID || got.HashMode != nil {
t.Fatalf("identify command inherited hash mode/type: %#v", got)
}
}
func TestBuildCrackCommandLegacyFlagAliases(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--multiply-accel-disabled",
"--generate-rules-fun-min=2",
"--generate-rules-fun-max=6",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
if !got.MultiplyAccelDisabled || got.GenerateRulesFunMin != 2 || got.GenerateRulesFunMax != 6 {
t.Fatalf("legacy aliases were not preserved: %#v", got)
}
}
func TestBuildCrackCommandRejectsSegmentSize(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Set("segment-size", "32"); err != nil {
t.Fatalf("set segment-size: %v", err)
}
_, err := buildCrackCommand(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "removed in Hashcat v7") {
t.Fatalf("buildCrackCommand() error = %v, want Hashcat v7 removal error", err)
}
}
func TestBuildCrackCommandCredentialSelectors(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--credential= first-id ",
"--credential=second-id",
"--credential-collection= domain-admins ",
"--include-cracked-credentials",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
if !slices.Equal(got.CredentialIDs, []string{"first-id", "second-id"}) {
t.Fatalf("CredentialIDs = %#v", got.CredentialIDs)
}
if got.CredentialCollection != "domain-admins" {
t.Fatalf("CredentialCollection = %q", got.CredentialCollection)
}
if !got.IncludeCrackedCredentials {
t.Fatal("IncludeCrackedCredentials is false")
}
}
func TestBuildCrackCommandRejectsEmptyCredential(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--credential= "}); err != nil {
t.Fatalf("parse flags: %v", err)
}
if _, err := buildCrackCommand(cmd, nil); err == nil || !strings.Contains(err.Error(), "cannot be empty") {
t.Fatalf("buildCrackCommand error = %v", err)
}
}
func TestBuildCrackCommandIncludeCrackedRequiresSelector(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--include-cracked-credentials"}); err != nil {
t.Fatalf("parse flags: %v", err)
}
if _, err := buildCrackCommand(cmd, nil); err == nil || !strings.Contains(err.Error(), "requires --credential") {
t.Fatalf("buildCrackCommand error = %v", err)
}
}
-270
View File
@@ -1,270 +0,0 @@
package crack
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const (
maxCrackBackendInfoLevel uint32 = 2
maxCrackHashInfoLevel uint32 = 2
)
type crackInvocationMode uint8
const (
crackInvocationDashboard crackInvocationMode = iota
crackInvocationJob
crackInvocationBackendInfo
crackInvocationKeyspace
crackInvocationTotalCandidates
crackInvocationLookup
crackInvocationIdentify
crackInvocationHashInfo
)
type crackInvocation struct {
mode crackInvocationMode
backendInfoLevel uint32
hashInfoLevel uint32
crackstation string
}
type crackQueryModeSelection struct {
mode crackInvocationMode
flagName string
}
var incompatibleCandidateQueryFlags = map[string]struct{}{
"benchmark": {},
"benchmark-all": {},
"benchmark-min": {},
"benchmark-max": {},
"brain-client": {},
"brain-client-features": {},
"brain-feed": {},
"brain-host": {},
"brain-password": {},
"brain-port": {},
"brain-server": {},
"brain-server-timer": {},
"brain-session": {},
"brain-session-whitelist": {},
"left": {},
"progress-only": {},
"restore": {},
"restore-file": {},
"restore-position": {},
"restore-show-command": {},
"show": {},
"speed-only": {},
}
// classifyCrackInvocation separates terminal command modes from work that
// belongs in the distributed crack queue. Backend information comes from the
// registration snapshots; the remaining query modes execute synchronously on
// one idle crackstation and never create a CrackJob.
//
//nolint:gocyclo // The ordered flag matrix keeps mutually exclusive query modes and their validation in one decision point.
func classifyCrackInvocation(cmd *cobra.Command, args []string) (crackInvocation, error) {
flags := cmd.Flags()
backendInfo, _ := flags.GetBool("backend-info")
backendInfoLevel, _ := flags.GetUint32("backend-info-level")
backendInfoLevelChanged := flags.Changed("backend-info-level")
hashInfo, _ := flags.GetBool("hash-info")
hashInfoLevel, _ := flags.GetUint32("hash-info-level")
hashInfoLevelChanged := flags.Changed("hash-info-level")
keyspace, _ := flags.GetBool("keyspace")
totalCandidates, _ := flags.GetBool("total-candidates")
lookup, _ := flags.GetString("lookup")
lookupChanged := flags.Changed("lookup")
identify, _ := flags.GetBool("identify-mode")
crackstation, _ := flags.GetString("crackstation")
crackstation = strings.TrimSpace(crackstation)
if flags.Changed("crackstation") && crackstation == "" {
return crackInvocation{}, fmt.Errorf("--crackstation cannot be empty")
}
if lookupChanged && lookup == "" {
return crackInvocation{}, fmt.Errorf("--lookup cannot be empty")
}
selections := make([]crackQueryModeSelection, 0, 6)
if backendInfo || backendInfoLevelChanged {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationBackendInfo, flagName: "--backend-info"})
}
if keyspace {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationKeyspace, flagName: "--keyspace"})
}
if totalCandidates {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationTotalCandidates, flagName: "--total-candidates"})
}
if lookupChanged {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationLookup, flagName: "--lookup"})
}
if identify {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationIdentify, flagName: "--identify-mode"})
}
if hashInfo || hashInfoLevelChanged {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationHashInfo, flagName: "--hash-info"})
}
if len(selections) > 1 {
names := make([]string, 0, len(selections))
for _, selection := range selections {
names = append(names, selection.flagName)
}
sort.Strings(names)
return crackInvocation{}, fmt.Errorf("crack query modes cannot be combined: %s", strings.Join(names, ", "))
}
if len(selections) == 0 {
if flags.Changed("crackstation") {
return crackInvocation{}, fmt.Errorf("--crackstation requires --keyspace, --total-candidates, --lookup, --identify-mode, or --hash-info")
}
if shouldRunCrack(cmd, args) {
return crackInvocation{mode: crackInvocationJob}, nil
}
return crackInvocation{mode: crackInvocationDashboard}, nil
}
invocation := crackInvocation{mode: selections[0].mode, crackstation: crackstation}
switch invocation.mode {
case crackInvocationBackendInfo:
if !backendInfoLevelChanged {
backendInfoLevel = 1
}
if backendInfoLevel == 0 || backendInfoLevel > maxCrackBackendInfoLevel {
return crackInvocation{}, fmt.Errorf("--backend-info-level must be 1 or %d", maxCrackBackendInfoLevel)
}
conflicts := changedCrackFlagConflicts(flags, map[string]struct{}{
"backend-info": {}, "backend-info-level": {}, "timeout": {},
})
if len(args) != 0 {
conflicts = append(conflicts, "positional hash arguments")
}
if len(conflicts) != 0 {
sort.Strings(conflicts)
return crackInvocation{}, fmt.Errorf("backend-info query cannot be combined with %s", strings.Join(conflicts, ", "))
}
invocation.backendInfoLevel = backendInfoLevel
case crackInvocationHashInfo:
if !hashInfoLevelChanged {
hashInfoLevel = 1
}
if hashInfoLevel == 0 || hashInfoLevel > maxCrackHashInfoLevel {
return crackInvocation{}, fmt.Errorf("--hash-info-level must be 1 or %d", maxCrackHashInfoLevel)
}
conflicts := changedCrackFlagConflicts(flags, map[string]struct{}{
"crackstation": {}, "hash-info": {}, "hash-info-level": {}, "hash-mode": {}, "hash-type": {}, "timeout": {},
})
if len(args) != 0 {
conflicts = append(conflicts, "positional hash arguments")
}
if len(conflicts) != 0 {
sort.Strings(conflicts)
return crackInvocation{}, fmt.Errorf("hash-info query cannot be combined with %s", strings.Join(conflicts, ", "))
}
invocation.hashInfoLevel = hashInfoLevel
case crackInvocationIdentify:
conflicts := changedCrackFlagConflicts(flags, map[string]struct{}{
"crackstation": {}, "hash": {}, "identify-mode": {}, "timeout": {},
})
if len(conflicts) != 0 {
return crackInvocation{}, fmt.Errorf("identify query cannot be combined with %s", strings.Join(conflicts, ", "))
}
if !hasCrackIdentifyInput(flags, args) {
return crackInvocation{}, fmt.Errorf("--identify-mode requires a positional hash or --hash")
}
case crackInvocationKeyspace, crackInvocationTotalCandidates, crackInvocationLookup:
if conflicts := candidateQueryConflicts(flags, args, invocation.mode); len(conflicts) != 0 {
return crackInvocation{}, fmt.Errorf("%s query cannot be combined with %s", crackInvocationName(invocation.mode), strings.Join(conflicts, ", "))
}
}
return invocation, nil
}
func crackInvocationName(mode crackInvocationMode) string {
switch mode {
case crackInvocationBackendInfo:
return "backend-info"
case crackInvocationKeyspace:
return "keyspace"
case crackInvocationTotalCandidates:
return "total-candidates"
case crackInvocationLookup:
return "lookup"
case crackInvocationIdentify:
return "identify"
case crackInvocationHashInfo:
return "hash-info"
default:
return "crack"
}
}
func changedCrackFlagConflicts(flags *pflag.FlagSet, allowed map[string]struct{}) []string {
conflicts := make([]string, 0)
flags.Visit(func(flag *pflag.Flag) {
if _, ok := allowed[flag.Name]; ok || changedBoolFlagIsFalse(flags, flag) {
return
}
conflicts = append(conflicts, "--"+flag.Name)
})
sort.Strings(conflicts)
return conflicts
}
func changedBoolFlagIsFalse(flags *pflag.FlagSet, flag *pflag.Flag) bool {
if flag.Value.Type() != "bool" {
return false
}
value, err := flags.GetBool(flag.Name)
return err == nil && !value
}
func hasCrackIdentifyInput(flags *pflag.FlagSet, args []string) bool {
if len(args) != 0 {
return true
}
hashes, _ := flags.GetStringArray("hash")
return len(hashes) != 0
}
func candidateQueryConflicts(flags *pflag.FlagSet, args []string, mode crackInvocationMode) []string {
conflicts := make([]string, 0)
if len(args) != 0 {
conflicts = append(conflicts, "positional hash arguments")
}
flags.Visit(func(flag *pflag.Flag) {
if changedBoolFlagIsFalse(flags, flag) {
return
}
switch flag.Name {
case "hash", "credential", "credential-collection":
conflicts = append(conflicts, "--"+flag.Name)
return
case "include-cracked-credentials":
includeCracked, _ := flags.GetBool(flag.Name)
if includeCracked {
conflicts = append(conflicts, "--"+flag.Name)
}
return
case "skip", "limit":
if mode != crackInvocationLookup {
value, _ := flags.GetUint64(flag.Name)
if value != 0 {
conflicts = append(conflicts, "--"+flag.Name)
}
}
return
}
if _, incompatible := incompatibleCandidateQueryFlags[flag.Name]; incompatible {
conflicts = append(conflicts, "--"+flag.Name)
}
})
sort.Strings(conflicts)
return conflicts
}
-527
View File
@@ -1,527 +0,0 @@
package crack
import (
"context"
"errors"
"strings"
"testing"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
func newCrackDispatchCommand(t *testing.T, flags ...string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "crack-test"}
cmd.SetContext(context.Background())
cmd.Flags().Int64("timeout", 0, "test timeout")
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse(flags); err != nil {
t.Fatalf("parse flags: %v", err)
}
return cmd
}
func TestClassifyCrackInvocation(t *testing.T) {
tests := []struct {
name string
flags []string
args []string
wantMode crackInvocationMode
wantBackendLevel uint32
wantHashLevel uint32
wantCrackstation string
wantError string
}{
{name: "dashboard", wantMode: crackInvocationDashboard},
{name: "timeout only", flags: []string{"--timeout=5"}, wantMode: crackInvocationDashboard},
{name: "positional hash job", args: []string{"hash"}, wantMode: crackInvocationJob},
{name: "job flag", flags: []string{"--attack-mode=3"}, wantMode: crackInvocationJob},
{name: "explicit false is a no-op", flags: []string{"--backend-info=false"}, wantMode: crackInvocationDashboard},
{name: "explicit false with job flag", flags: []string{"--backend-info=false", "--attack-mode=3"}, wantMode: crackInvocationJob},
{name: "keyspace false is a no-op", flags: []string{"--keyspace=false"}, wantMode: crackInvocationDashboard},
{name: "keyspace false with job flag", flags: []string{"--keyspace=false", "--attack-mode=3"}, wantMode: crackInvocationJob},
{name: "keyspace", flags: []string{"--keyspace"}, wantMode: crackInvocationKeyspace},
{name: "keyspace candidate flags", flags: []string{"--keyspace", "--attack-mode=3", "--input=?d?d", "--increment"}, wantMode: crackInvocationKeyspace},
{name: "keyspace selected station", flags: []string{"--keyspace", "--crackstation= station-one "}, wantMode: crackInvocationKeyspace, wantCrackstation: "station-one"},
{name: "keyspace timeout", flags: []string{"--keyspace", "--timeout=5"}, wantMode: crackInvocationKeyspace},
{name: "keyspace skip conflict", flags: []string{"--keyspace", "--skip=1"}, wantError: "--skip"},
{name: "keyspace zero skip no-op", flags: []string{"--keyspace", "--skip=0"}, wantMode: crackInvocationKeyspace},
{name: "keyspace positional hash conflict", flags: []string{"--keyspace"}, args: []string{"hash"}, wantError: "positional hash arguments"},
{name: "keyspace hash flag conflict", flags: []string{"--keyspace", "--hash=digest"}, wantError: "--hash"},
{name: "keyspace credential conflict", flags: []string{"--keyspace", "--credential=deadbeef"}, wantError: "--credential"},
{name: "keyspace collection conflict", flags: []string{"--keyspace", "--credential-collection=secrets"}, wantError: "--credential-collection"},
{name: "keyspace include cracked conflict", flags: []string{"--keyspace", "--include-cracked-credentials"}, wantError: "--include-cracked-credentials"},
{name: "keyspace and backend info conflict", flags: []string{"--keyspace", "--backend-info"}, wantError: "--keyspace"},
{name: "backend info ignores disabled keyspace", flags: []string{"--backend-info", "--keyspace=false"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "backend info", flags: []string{"--backend-info"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "backend info and timeout", flags: []string{"--backend-info", "--timeout=5"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "level one", flags: []string{"--backend-info-level=1"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "level two", flags: []string{"--backend-info-level=2"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 2},
{name: "explicit level wins", flags: []string{"--backend-info", "--backend-info-level=2"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 2},
{name: "level independently enables mode", flags: []string{"--backend-info=false", "--backend-info-level=2"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 2},
{name: "zero level", flags: []string{"--backend-info-level=0"}, wantError: "must be 1 or 2"},
{name: "unsupported level", flags: []string{"--backend-info-level=3"}, wantError: "must be 1 or 2"},
{name: "positional conflict", flags: []string{"--backend-info"}, args: []string{"hash"}, wantError: "positional hash arguments"},
{name: "job flag conflict", flags: []string{"--backend-info", "--benchmark"}, wantError: "--benchmark"},
{name: "input conflict", flags: []string{"--backend-info-level=2", "--input=wordlist"}, wantError: "--input"},
{name: "backend info station conflict", flags: []string{"--backend-info", "--crackstation=station-one"}, wantError: "--crackstation"},
{name: "total candidates", flags: []string{"--total-candidates", "--attack-mode=3", "--input=?d"}, wantMode: crackInvocationTotalCandidates},
{name: "total candidates false", flags: []string{"--total-candidates=false"}, wantMode: crackInvocationDashboard},
{name: "total candidates hash conflict", flags: []string{"--total-candidates", "--hash=digest"}, wantError: "--hash"},
{name: "total candidates limit conflict", flags: []string{"--total-candidates", "--limit=1"}, wantError: "--limit"},
{name: "lookup", flags: []string{"--lookup=candidate", "--attack-mode=3", "--input=?d"}, wantMode: crackInvocationLookup},
{name: "lookup allows window", flags: []string{"--lookup=candidate", "--skip=1", "--limit=2"}, wantMode: crackInvocationLookup},
{name: "lookup empty", flags: []string{"--lookup="}, wantError: "cannot be empty"},
{name: "query modes conflict", flags: []string{"--keyspace", "--total-candidates"}, wantError: "cannot be combined"},
{name: "candidate query terminal conflict", flags: []string{"--keyspace", "--show"}, wantError: "--show"},
{name: "identify positional", flags: []string{"--identify-mode"}, args: []string{"digest"}, wantMode: crackInvocationIdentify},
{name: "identify hash flag", flags: []string{"--identify-mode", "--hash=digest"}, wantMode: crackInvocationIdentify},
{name: "identify false", flags: []string{"--identify-mode=false"}, wantMode: crackInvocationDashboard},
{name: "identify missing hash", flags: []string{"--identify-mode"}, wantError: "requires a positional hash or --hash"},
{name: "identify credential conflict", flags: []string{"--identify-mode", "--credential=deadbeef"}, wantError: "--credential"},
{name: "identify hash mode conflict", flags: []string{"--identify-mode", "--hash=digest", "--hash-mode=0"}, wantError: "--hash-mode"},
{name: "hash info", flags: []string{"--hash-info"}, wantMode: crackInvocationHashInfo, wantHashLevel: 1},
{name: "hash info false", flags: []string{"--hash-info=false"}, wantMode: crackInvocationDashboard},
{name: "hash info level", flags: []string{"--hash-info=false", "--hash-info-level=2", "--hash-mode=0", "--crackstation=station-one"}, wantMode: crackInvocationHashInfo, wantHashLevel: 2, wantCrackstation: "station-one"},
{name: "hash info zero level", flags: []string{"--hash-info-level=0"}, wantError: "must be 1 or 2"},
{name: "hash info unsupported level", flags: []string{"--hash-info-level=3"}, wantError: "must be 1 or 2"},
{name: "hash info hash conflict", flags: []string{"--hash-info", "--hash=digest"}, wantError: "--hash"},
{name: "crackstation requires query", flags: []string{"--crackstation=station-one"}, wantError: "requires --keyspace"},
{name: "crackstation cannot be empty", flags: []string{"--keyspace", "--crackstation= "}, wantError: "cannot be empty"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
invocation, err := classifyCrackInvocation(newCrackDispatchCommand(t, test.flags...), test.args)
if test.wantError != "" {
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("classifyCrackInvocation() error = %v, want substring %q", err, test.wantError)
}
return
}
if err != nil {
t.Fatalf("classifyCrackInvocation() error = %v", err)
}
if invocation.mode != test.wantMode || invocation.backendInfoLevel != test.wantBackendLevel ||
invocation.hashInfoLevel != test.wantHashLevel || invocation.crackstation != test.wantCrackstation {
t.Fatalf("classifyCrackInvocation() = %#v, want mode %d backend level %d hash level %d station %q",
invocation, test.wantMode, test.wantBackendLevel, test.wantHashLevel, test.wantCrackstation)
}
})
}
}
type crackDispatchRPCCapture struct {
rpcpb.SliverRPCClient
crackstationsCalls int
crackCalls int
crackFilesCalls int
hadDeadline bool
crackHadDeadline bool
err error
response *clientpb.Crackstations
crackResponse *clientpb.CrackResponse
crackFilesResponse *clientpb.CrackFiles
crackRequest *clientpb.CrackCommand
}
func (capture *crackDispatchRPCCapture) Crackstations(ctx context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.Crackstations, error) {
capture.crackstationsCalls++
_, capture.hadDeadline = ctx.Deadline()
if capture.err != nil {
return nil, capture.err
}
if capture.response != nil {
return capture.response, nil
}
return &clientpb.Crackstations{}, nil
}
func (capture *crackDispatchRPCCapture) Crack(ctx context.Context, request *clientpb.CrackCommand, _ ...grpc.CallOption) (*clientpb.CrackResponse, error) {
capture.crackCalls++
_, capture.crackHadDeadline = ctx.Deadline()
capture.crackRequest = request
if capture.crackResponse != nil {
return capture.crackResponse, nil
}
return &clientpb.CrackResponse{}, nil
}
func (capture *crackDispatchRPCCapture) CrackFilesList(_ context.Context, _ *clientpb.CrackFile, _ ...grpc.CallOption) (*clientpb.CrackFiles, error) {
capture.crackFilesCalls++
if capture.crackFilesResponse != nil {
return capture.crackFilesResponse, nil
}
return &clientpb.CrackFiles{}, nil
}
func TestCrackBackendInfoQueriesStationsWithoutCreatingJob(t *testing.T) {
capture := &crackDispatchRPCCapture{}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
CrackCmd(newCrackDispatchCommand(t, "--backend-info", "--timeout=1"), con, nil)
if capture.crackstationsCalls != 1 {
t.Fatalf("Crackstations calls = %d, want 1", capture.crackstationsCalls)
}
if capture.crackCalls != 0 || capture.crackFilesCalls != 0 {
t.Fatalf("backend-info used job path: Crack=%d CrackFilesList=%d", capture.crackCalls, capture.crackFilesCalls)
}
if !capture.hadDeadline {
t.Fatal("Crackstations context did not inherit --timeout")
}
}
func TestCrackKeyspaceUsesSynchronousCrackResponse(t *testing.T) {
digest := strings.Repeat("a", 64)
capture := &crackDispatchRPCCapture{
crackResponse: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE, Value: "100",
}},
crackFilesResponse: &clientpb.CrackFiles{
Files: []*clientpb.CrackFile{{
ID: "wordlist-id", Name: "words.txt", Type: clientpb.CrackFileType_WORDLIST, Sha2_256: digest,
}},
},
}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
CrackCmd(newCrackDispatchCommand(t, "--keyspace", "--input=words.txt", "--crackstation=station-one", "--timeout=1"), con, nil)
if capture.crackCalls != 1 {
t.Fatalf("Crack calls = %d, want 1", capture.crackCalls)
}
if capture.crackstationsCalls != 0 {
t.Fatalf("keyspace queried crackstations directly %d times", capture.crackstationsCalls)
}
if capture.crackFilesCalls != 1 {
t.Fatalf("CrackFilesList calls = %d, want 1", capture.crackFilesCalls)
}
if !capture.crackHadDeadline {
t.Fatal("Crack context did not inherit --timeout")
}
if capture.crackRequest == nil || !capture.crackRequest.Keyspace {
t.Fatalf("Crack request = %#v, want keyspace mode", capture.crackRequest)
}
if capture.crackRequest.Crackstation != "station-one" {
t.Fatalf("Crack request station = %q, want station-one", capture.crackRequest.Crackstation)
}
wantInput := "crackfile://wordlist/" + digest
if len(capture.crackRequest.PositionalArguments) != 1 || capture.crackRequest.PositionalArguments[0] != wantInput {
t.Fatalf("resolved keyspace inputs = %#v, want %q", capture.crackRequest.PositionalArguments, wantInput)
}
if len(capture.crackRequest.Hashes) != 0 || len(capture.crackRequest.CredentialIDs) != 0 {
t.Fatalf("keyspace request contains hash selectors: %#v", capture.crackRequest)
}
}
func TestCrackSynchronousQueriesUseCrackRPCWithoutCreatingJob(t *testing.T) {
tests := []struct {
name string
flags []string
args []string
resultMode clientpb.CrackQueryMode
result string
check func(*testing.T, *clientpb.CrackCommand)
}{
{
name: "total candidates",
flags: []string{"--total-candidates", "--attack-mode=3", "--input=?d", "--crackstation=station-one"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_TOTAL_CANDIDATES,
result: "100",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if !command.TotalCandidates || command.Crackstation != "station-one" {
t.Fatalf("total-candidates request = %#v", command)
}
},
},
{
name: "lookup",
flags: []string{"--lookup=candidate", "--attack-mode=3", "--input=?d"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP,
result: "lookup output",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if command.Lookup != "candidate" {
t.Fatalf("lookup request = %#v", command)
}
},
},
{
name: "identify",
flags: []string{"--identify-mode"},
args: []string{"digest"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_IDENTIFY,
result: "identify output",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if !command.IdentifyMode || len(command.Hashes) != 1 || command.Hashes[0] != "digest" {
t.Fatalf("identify request = %#v", command)
}
},
},
{
name: "hash info",
flags: []string{"--hash-info-level=2", "--hash-mode=1000"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_HASH_INFO,
result: "hash-info output",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if command.HashInfoLevel != 2 || command.HashMode == nil || *command.HashMode != 1000 {
t.Fatalf("hash-info request = %#v", command)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
capture := &crackDispatchRPCCapture{crackResponse: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: test.resultMode, Value: test.result,
}}}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
CrackCmd(newCrackDispatchCommand(t, test.flags...), con, test.args)
if capture.crackCalls != 1 || capture.crackstationsCalls != 0 {
t.Fatalf("RPC calls: Crack=%d Crackstations=%d", capture.crackCalls, capture.crackstationsCalls)
}
if capture.crackRequest == nil {
t.Fatal("Crack request was not captured")
}
test.check(t, capture.crackRequest)
})
}
}
func TestCanonicalCrackKeyspace(t *testing.T) {
tests := []struct {
name string
response *clientpb.CrackResponse
want string
wantErr bool
}{
{name: "canonical", response: &clientpb.CrackResponse{Keyspace: "100"}, want: "100"},
{name: "normalizes", response: &clientpb.CrackResponse{Keyspace: " 00100 "}, want: "100"},
{name: "zero", response: &clientpb.CrackResponse{Keyspace: "0"}, want: "0"},
{name: "missing response", wantErr: true},
{name: "missing value", response: &clientpb.CrackResponse{}, wantErr: true},
{name: "negative", response: &clientpb.CrackResponse{Keyspace: "-1"}, wantErr: true},
{name: "overflow", response: &clientpb.CrackResponse{Keyspace: "18446744073709551616"}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := canonicalCrackKeyspace(test.response)
if (err != nil) != test.wantErr {
t.Fatalf("canonicalCrackKeyspace() error = %v, wantErr %v", err, test.wantErr)
}
if got != test.want {
t.Fatalf("canonicalCrackKeyspace() = %q, want %q", got, test.want)
}
})
}
}
func TestCrackQueryResponseValue(t *testing.T) {
tests := []struct {
name string
invocation crackInvocation
response *clientpb.CrackResponse
want string
wantError string
}{
{
name: "typed keyspace",
invocation: crackInvocation{mode: crackInvocationKeyspace},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE, Value: " 00100 ",
}},
want: "100",
},
{
name: "typed total candidates",
invocation: crackInvocation{mode: crackInvocationTotalCandidates},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_TOTAL_CANDIDATES, Value: "42\n",
}},
want: "42",
},
{
name: "text remains verbatim",
invocation: crackInvocation{mode: crackInvocationLookup},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP, Value: "line one\nline two\n",
}},
want: "line one\nline two\n",
},
{
name: "wrong typed mode",
invocation: crackInvocation{mode: crackInvocationLookup},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_IDENTIFY, Value: "output",
}},
wantError: "for lookup query",
},
{
name: "new mode cannot use legacy keyspace",
invocation: crackInvocation{mode: crackInvocationTotalCandidates},
response: &clientpb.CrackResponse{Keyspace: "42"},
wantError: "empty total-candidates query result",
},
{
name: "selected keyspace cannot use legacy response",
invocation: crackInvocation{mode: crackInvocationKeyspace, crackstation: "station-one"},
response: &clientpb.CrackResponse{Keyspace: "42"},
wantError: "empty keyspace query result",
},
{
name: "empty text",
invocation: crackInvocation{mode: crackInvocationHashInfo},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_HASH_INFO, Value: " \n",
}},
wantError: "empty hash-info query result",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := crackQueryResponseValue(test.response, test.invocation)
if test.wantError != "" {
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("crackQueryResponseValue() error = %v, want substring %q", err, test.wantError)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != test.want {
t.Fatalf("crackQueryResponseValue() = %q, want %q", got, test.want)
}
})
}
}
func TestCrackQueryResponsePreservesStderr(t *testing.T) {
response := &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP,
Value: "lookup output",
Stderr: "hashcat warning\n",
}}
value, stderr, err := crackQueryResponse(response, crackInvocation{mode: crackInvocationLookup})
if err != nil {
t.Fatal(err)
}
if value != "lookup output" || stderr != "hashcat warning\n" {
t.Fatalf("crackQueryResponse() = (%q, %q), want preserved value and stderr", value, stderr)
}
}
func TestCrackResponseKeyspaceFieldNumberAndRoundTrip(t *testing.T) {
field := (&clientpb.CrackResponse{}).ProtoReflect().Descriptor().Fields().ByName("Keyspace")
if field == nil {
t.Fatal("CrackResponse is missing field Keyspace")
}
if got, want := field.Number(), protoreflect.FieldNumber(2); got != want {
t.Fatalf("CrackResponse.Keyspace tag = %d, want %d", got, want)
}
queryField := (&clientpb.CrackResponse{}).ProtoReflect().Descriptor().Fields().ByName("Query")
if queryField == nil {
t.Fatal("CrackResponse is missing field Query")
}
if got, want := queryField.Number(), protoreflect.FieldNumber(3); got != want {
t.Fatalf("CrackResponse.Query tag = %d, want %d", got, want)
}
stderrField := (&clientpb.CrackQueryResult{}).ProtoReflect().Descriptor().Fields().ByName("Stderr")
if stderrField == nil {
t.Fatal("CrackQueryResult is missing field Stderr")
}
if got, want := stderrField.Number(), protoreflect.FieldNumber(6); got != want {
t.Fatalf("CrackQueryResult.Stderr tag = %d, want %d", got, want)
}
want := &clientpb.CrackResponse{
Job: &clientpb.CrackJob{ID: "job-id"},
Keyspace: "18446744073709551615",
Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE, CrackstationHostUUID: "host-id", Value: "42", Stderr: "warning\n",
},
}
raw, err := proto.Marshal(want)
if err != nil {
t.Fatalf("marshal crack response: %v", err)
}
got := &clientpb.CrackResponse{}
if err := proto.Unmarshal(raw, got); err != nil {
t.Fatalf("unmarshal crack response: %v", err)
}
if !proto.Equal(got, want) {
t.Fatalf("crack response round trip = %v, want %v", got, want)
}
}
func TestConnectedCrackstationsReturnsRPCError(t *testing.T) {
want := errors.New("query failed")
capture := &crackDispatchRPCCapture{err: want}
if _, err := connectedCrackstations(context.Background(), capture); !errors.Is(err, want) {
t.Fatalf("connectedCrackstations() error = %v, want %v", err, want)
}
if capture.crackstationsCalls != 1 {
t.Fatalf("Crackstations calls = %d, want 1", capture.crackstationsCalls)
}
}
func TestConnectedCrackstationsReturnsEveryNonNilStation(t *testing.T) {
capture := &crackDispatchRPCCapture{response: &clientpb.Crackstations{Crackstations: []*clientpb.Crackstation{
nil,
{Name: "one"},
{Name: "two"},
}}}
stations, err := connectedCrackstations(context.Background(), capture)
if err != nil {
t.Fatal(err)
}
if len(stations) != 2 || stations[0].Name != "one" || stations[1].Name != "two" {
t.Fatalf("connectedCrackstations() = %#v, want one and two", stations)
}
}
func TestCrackHashcatFlagsAreRootLocal(t *testing.T) {
root := Commands(nil)[0]
if root.Flags().Lookup("backend-info") == nil {
t.Fatal("root crack command is missing --backend-info")
}
if root.Flags().Lookup("crackstation") == nil {
t.Fatal("root crack command is missing --crackstation")
}
if root.PersistentFlags().Lookup("backend-info") != nil {
t.Fatal("--backend-info must not be inherited by crack subcommands")
}
if root.Flags().Lookup("stdout") != nil {
t.Fatal("root crack command unexpectedly exposes removed --stdout")
}
stations, _, err := root.Find([]string{"stations"})
if err != nil {
t.Fatalf("find crack stations: %v", err)
}
if stations.InheritedFlags().Lookup("backend-info") != nil {
t.Fatal("crack stations unexpectedly inherits --backend-info")
}
if stations.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack stations must continue to inherit --timeout")
}
}
@@ -1,215 +0,0 @@
package crack
import (
"context"
"encoding/hex"
"fmt"
"strings"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
const crackFileScheme = "crackfile://"
type crackFilesListFunc func(context.Context, *clientpb.CrackFile) (*clientpb.CrackFiles, error)
// resolveManagedCrackFileReferences replaces exact server-side crack file
// names, IDs, and digests with the content-addressed URI understood by a
// crackstation. Inputs that do not match a managed file remain ordinary
// Hashcat operands, masks, paths, or inline rule content.
//
//nolint:gocyclo // Resolve every managed-file role together in one deterministic pass.
func resolveManagedCrackFileReferences(ctx context.Context, command *clientpb.CrackCommand, list crackFilesListFunc) error {
if command == nil {
return fmt.Errorf("missing crack command")
}
if list == nil {
return fmt.Errorf("missing crack file lister")
}
wordlistIndexes := make([]int, 0, len(command.PositionalArguments))
wordlistInputs := make([]string, 0, len(command.PositionalArguments))
for index, argument := range command.PositionalArguments {
// Only wordlist-role operands may be resolved by alias. An inline mask can
// legitimately have the same text as a managed file name and must remain
// an inline mask. Explicit crackfile URIs are still validated here so a
// mistyped managed reference fails at the client boundary.
if !crackOperandIsWordlist(command.AttackMode, index) && !strings.HasPrefix(argument, crackFileScheme) {
continue
}
wordlistIndexes = append(wordlistIndexes, index)
wordlistInputs = append(wordlistInputs, argument)
}
if len(wordlistInputs) > 0 {
resolved, err := resolveCrackFileValues(ctx, wordlistInputs, clientpb.CrackFileType_WORDLIST, list)
if err != nil {
return err
}
for index, argument := range resolved {
command.PositionalArguments[wordlistIndexes[index]] = argument
}
}
legacyIdentify := command.Identify //nolint:staticcheck // Identify remains required for legacy wire compatibility.
if len(command.PositionalArguments) == 0 && legacyIdentify != "" &&
(crackOperandIsWordlist(command.AttackMode, 0) || strings.HasPrefix(legacyIdentify, crackFileScheme)) {
resolved, err := resolveCrackFileValues(ctx, []string{legacyIdentify}, clientpb.CrackFileType_WORDLIST, list)
if err != nil {
return err
}
command.Identify = resolved[0] //nolint:staticcheck // Keep the resolved legacy operand synchronized for older servers.
}
rules := command.RulesFilesV7
if len(rules) == 0 && len(command.RulesFile) > 0 {
rules = [][]byte{command.RulesFile}
}
if len(rules) > 0 {
values := make([]string, 0, len(rules))
for _, rule := range rules {
values = append(values, string(rule))
}
resolved, err := resolveCrackFileValues(ctx, values, clientpb.CrackFileType_RULES, list)
if err != nil {
return err
}
command.RulesFilesV7 = make([][]byte, 0, len(resolved))
for _, rule := range resolved {
command.RulesFilesV7 = append(command.RulesFilesV7, []byte(rule))
}
command.RulesFile = append([]byte(nil), command.RulesFilesV7[0]...)
}
if len(command.MarkovHcstat2) > 0 {
resolved, err := resolveCrackFileValues(ctx, []string{string(command.MarkovHcstat2)}, clientpb.CrackFileType_MARKOV_HCSTAT2, list)
if err != nil {
return err
}
command.MarkovHcstat2 = []byte(resolved[0])
}
return nil
}
func crackOperandIsWordlist(attackMode clientpb.CrackAttackMode, index int) bool {
switch attackMode {
case clientpb.CrackAttackMode_STRAIGHT, clientpb.CrackAttackMode_COMBINATION:
return true
case clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK:
return index == 0
case clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST:
return index == 1
default:
return false
}
}
//nolint:gocyclo // Validation and alias disambiguation intentionally share the manifest lookup and preserve input ordering.
func resolveCrackFileValues(ctx context.Context, values []string, fileType clientpb.CrackFileType, list crackFilesListFunc) ([]string, error) {
needsManifest := false
for _, value := range values {
if strings.HasPrefix(value, crackFileScheme) {
if err := validateCrackFileURI(value, fileType); err != nil {
return nil, err
}
continue
}
needsManifest = true
}
if !needsManifest {
return append([]string(nil), values...), nil
}
manifest, err := list(ctx, &clientpb.CrackFile{Type: fileType})
if err != nil {
return nil, fmt.Errorf("list managed %s files: %w", crackFileTypeToken(fileType), err)
}
files := []*clientpb.CrackFile(nil)
if manifest != nil {
files = manifest.Files
}
resolved := make([]string, 0, len(values))
for _, value := range values {
if strings.HasPrefix(value, crackFileScheme) {
resolved = append(resolved, value)
continue
}
matches := make([]*clientpb.CrackFile, 0, 1)
for _, file := range files {
if file == nil || file.Type != fileType {
continue
}
if value == file.Name || value == file.ID || strings.EqualFold(value, file.Sha2_256) {
matches = append(matches, file)
}
}
if len(matches) > 1 {
return nil, fmt.Errorf("managed %s file reference %q is ambiguous", crackFileTypeToken(fileType), value)
}
if len(matches) == 0 {
resolved = append(resolved, value)
continue
}
uri, err := crackFileURI(matches[0])
if err != nil {
return nil, err
}
resolved = append(resolved, uri)
}
return resolved, nil
}
func crackFileURI(file *clientpb.CrackFile) (string, error) {
if file == nil {
return "", fmt.Errorf("missing managed crack file")
}
digest := strings.ToLower(strings.TrimSpace(file.Sha2_256))
if err := validateCrackFileDigest(digest); err != nil {
return "", fmt.Errorf("managed crack file %q has invalid SHA-256: %w", file.Name, err)
}
fileType := crackFileTypeToken(file.Type)
if fileType == "" {
return "", fmt.Errorf("managed crack file %q has unsupported type %s", file.Name, file.Type.String())
}
return crackFileScheme + fileType + "/" + digest, nil
}
func validateCrackFileURI(value string, expectedType clientpb.CrackFileType) error {
remainder := strings.TrimPrefix(value, crackFileScheme)
parts := strings.Split(remainder, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf("invalid managed crack file URI %q", value)
}
if parts[0] != crackFileTypeToken(expectedType) {
return fmt.Errorf("managed crack file URI %q has type %q, expected %q", value, parts[0], crackFileTypeToken(expectedType))
}
if parts[1] != strings.ToLower(parts[1]) {
return fmt.Errorf("managed crack file URI %q must use a lowercase SHA-256", value)
}
if err := validateCrackFileDigest(parts[1]); err != nil {
return fmt.Errorf("invalid managed crack file URI %q: %w", value, err)
}
return nil
}
func validateCrackFileDigest(value string) error {
if len(value) != 64 {
return fmt.Errorf("SHA-256 must contain 64 hexadecimal characters")
}
decoded, err := hex.DecodeString(value)
if err != nil || len(decoded) != 32 {
return fmt.Errorf("SHA-256 must contain 64 hexadecimal characters")
}
return nil
}
func crackFileTypeToken(fileType clientpb.CrackFileType) string {
switch fileType {
case clientpb.CrackFileType_WORDLIST:
return "wordlist"
case clientpb.CrackFileType_RULES:
return "rules"
case clientpb.CrackFileType_MARKOV_HCSTAT2:
return "hcstat2"
default:
return ""
}
}
@@ -1,190 +0,0 @@
package crack
import (
"context"
"errors"
"slices"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestResolveManagedCrackFileReferences(t *testing.T) {
wordlistDigest := strings.Repeat("a", 64)
rulesDigest := strings.Repeat("b", 64)
hcstat2Digest := strings.Repeat("c", 64)
files := map[clientpb.CrackFileType][]*clientpb.CrackFile{
clientpb.CrackFileType_WORDLIST: {{ID: "wordlist-id", Name: "rockyou.txt", Sha2_256: wordlistDigest, Type: clientpb.CrackFileType_WORDLIST}},
clientpb.CrackFileType_RULES: {{ID: "rules-id", Name: "best64.rule", Sha2_256: rulesDigest, Type: clientpb.CrackFileType_RULES}},
clientpb.CrackFileType_MARKOV_HCSTAT2: {{
ID: "hcstat2-id", Name: "hashcat.hcstat2", Sha2_256: hcstat2Digest, Type: clientpb.CrackFileType_MARKOV_HCSTAT2,
}},
}
requested := make([]clientpb.CrackFileType, 0)
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
requested = append(requested, request.Type)
return &clientpb.CrackFiles{Files: files[request.Type]}, nil
}
command := &clientpb.CrackCommand{
AttackMode: clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK,
PositionalArguments: []string{"rockyou.txt", "?d?d?d"},
RulesFile: []byte("best64.rule"),
RulesFilesV7: [][]byte{[]byte("best64.rule"), []byte(":")},
MarkovHcstat2: []byte("hcstat2-id"),
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatalf("resolveManagedCrackFileReferences: %v", err)
}
if want := []string{"crackfile://wordlist/" + wordlistDigest, "?d?d?d"}; !slices.Equal(command.PositionalArguments, want) {
t.Fatalf("PositionalArguments = %#v, want %#v", command.PositionalArguments, want)
}
wantRules := [][]byte{[]byte("crackfile://rules/" + rulesDigest), []byte(":")}
if !slices.EqualFunc(command.RulesFilesV7, wantRules, func(left, right []byte) bool { return slices.Equal(left, right) }) {
t.Fatalf("RulesFilesV7 = %#v, want %#v", command.RulesFilesV7, wantRules)
}
if !slices.Equal(command.RulesFile, wantRules[0]) {
t.Fatalf("RulesFile = %q", command.RulesFile)
}
if want := "crackfile://hcstat2/" + hcstat2Digest; string(command.MarkovHcstat2) != want {
t.Fatalf("MarkovHcstat2 = %q, want %q", command.MarkovHcstat2, want)
}
if want := []clientpb.CrackFileType{clientpb.CrackFileType_WORDLIST, clientpb.CrackFileType_RULES, clientpb.CrackFileType_MARKOV_HCSTAT2}; !slices.Equal(requested, want) {
t.Fatalf("requested types = %#v, want %#v", requested, want)
}
}
func TestResolveManagedCrackFileReferencesRespectsAttackOperandRoles(t *testing.T) {
digestA := strings.Repeat("a", 64)
digestB := strings.Repeat("b", 64)
files := []*clientpb.CrackFile{
{ID: "wordlist-a", Name: "first", Sha2_256: digestA, Type: clientpb.CrackFileType_WORDLIST},
{ID: "wordlist-b", Name: "second", Sha2_256: digestB, Type: clientpb.CrackFileType_WORDLIST},
}
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
if request.Type != clientpb.CrackFileType_WORDLIST {
t.Fatalf("requested file type = %s, want WORDLIST", request.Type)
}
return &clientpb.CrackFiles{Files: files}, nil
}
tests := []struct {
name string
attackMode clientpb.CrackAttackMode
inputs []string
want []string
}{
{
name: "bruteforce mask collision",
attackMode: clientpb.CrackAttackMode_BRUTEFORCE,
inputs: []string{"first"},
want: []string{"first"},
},
{
name: "wordlist mask hybrid",
attackMode: clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK,
inputs: []string{"first", "second"},
want: []string{"crackfile://wordlist/" + digestA, "second"},
},
{
name: "mask wordlist hybrid",
attackMode: clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST,
inputs: []string{"first", "second"},
want: []string{"first", "crackfile://wordlist/" + digestB},
},
{
name: "straight wordlists",
attackMode: clientpb.CrackAttackMode_STRAIGHT,
inputs: []string{"first", "second"},
want: []string{"crackfile://wordlist/" + digestA, "crackfile://wordlist/" + digestB},
},
{
name: "combination wordlists",
attackMode: clientpb.CrackAttackMode_COMBINATION,
inputs: []string{"first", "second"},
want: []string{"crackfile://wordlist/" + digestA, "crackfile://wordlist/" + digestB},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
command := &clientpb.CrackCommand{
AttackMode: test.attackMode,
PositionalArguments: append([]string(nil), test.inputs...),
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatalf("resolveManagedCrackFileReferences: %v", err)
}
if !slices.Equal(command.PositionalArguments, test.want) {
t.Fatalf("PositionalArguments = %#v, want %#v", command.PositionalArguments, test.want)
}
})
}
}
func TestResolveManagedCrackFileReferencesPreservesInlineBytes(t *testing.T) {
inline := []byte{0, 1, 2, 0xff}
command := &clientpb.CrackCommand{RulesFilesV7: [][]byte{append([]byte(nil), inline...)}}
list := func(_ context.Context, _ *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return &clientpb.CrackFiles{}, nil
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatal(err)
}
if len(command.RulesFilesV7) != 1 || !slices.Equal(command.RulesFilesV7[0], inline) {
t.Fatalf("inline rules changed: %#v", command.RulesFilesV7)
}
}
func TestResolveManagedCrackFileReferencesAcceptsCanonicalURIWithoutListing(t *testing.T) {
uri := "crackfile://wordlist/" + strings.Repeat("d", 64)
command := &clientpb.CrackCommand{PositionalArguments: []string{uri}}
list := func(_ context.Context, _ *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
t.Fatal("canonical URI should not require a manifest lookup")
return nil, nil
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatal(err)
}
if command.PositionalArguments[0] != uri {
t.Fatalf("URI changed to %q", command.PositionalArguments[0])
}
}
func TestResolveManagedCrackFileReferencesRejectsWrongURIType(t *testing.T) {
command := &clientpb.CrackCommand{RulesFilesV7: [][]byte{[]byte("crackfile://wordlist/" + strings.Repeat("e", 64))}}
err := resolveManagedCrackFileReferences(context.Background(), command, func(context.Context, *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return nil, errors.New("must not be called")
})
if err == nil || !strings.Contains(err.Error(), "expected \"rules\"") {
t.Fatalf("error = %v", err)
}
}
func TestResolveManagedCrackFileReferencesRejectsAmbiguousName(t *testing.T) {
command := &clientpb.CrackCommand{PositionalArguments: []string{"duplicate.txt"}}
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return &clientpb.CrackFiles{Files: []*clientpb.CrackFile{
{Name: "duplicate.txt", Sha2_256: strings.Repeat("a", 64), Type: request.Type},
{Name: "duplicate.txt", Sha2_256: strings.Repeat("b", 64), Type: request.Type},
}}, nil
}
err := resolveManagedCrackFileReferences(context.Background(), command, list)
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("error = %v", err)
}
}
func TestResolveManagedCrackFileReferencesRejectsInvalidDigest(t *testing.T) {
command := &clientpb.CrackCommand{MarkovHcstat2: []byte("broken.hcstat2")}
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return &clientpb.CrackFiles{Files: []*clientpb.CrackFile{{
Name: "broken.hcstat2", Sha2_256: "not-a-digest", Type: request.Type,
}}}, nil
}
err := resolveManagedCrackFileReferences(context.Background(), command, list)
if err == nil || !strings.Contains(err.Error(), "invalid SHA-256") {
t.Fatalf("error = %v", err)
}
}
+71 -163
View File
@@ -22,36 +22,18 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/util"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/klauspost/compress/zstd"
"github.com/spf13/cobra"
)
func localCrackFileInfo(path string) (os.FileInfo, error) {
return localCrackFileInfoWith(path, os.Stat)
}
func localCrackFileInfoWith(path string, stat func(string) (os.FileInfo, error)) (os.FileInfo, error) {
info, err := stat(path)
if err != nil {
return nil, fmt.Errorf("stat local crack file %q: %w", path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("local crack file %q is a directory", path)
}
return info, nil
}
// CrackWordlistsCmd - Manage GPU cracking stations.
func CrackWordlistsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
wordlists, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_WORDLIST})
@@ -124,17 +106,17 @@ func PrintCrackFiles(crackFiles *clientpb.CrackFiles, con *console.SliverClient)
func PrintCrackFilesByType(crackFiles *clientpb.CrackFiles, con *console.SliverClient) {
wordlistTable := table.NewWriter()
wordlistTable.SetTitle(console.StyleBold.Render("Wordlists"))
wordlistTable.SetTitle(console.Bold + "Wordlists" + console.Normal)
wordlistTable.SetStyle(settings.GetTableStyle(con))
wordlistTable.AppendHeader(table.Row{"Name", "Size"})
rulesTable := table.NewWriter()
rulesTable.SetTitle(console.StyleBold.Render("Rules"))
rulesTable.SetTitle(console.Bold + "Rules" + console.Normal)
rulesTable.SetStyle(settings.GetTableStyle(con))
rulesTable.AppendHeader(table.Row{"Name", "Size"})
hcTable := table.NewWriter()
hcTable.SetTitle(console.StyleBold.Render("Markov Hcstat2"))
hcTable.SetTitle(console.Bold + "Markov Hcstat2" + console.Normal)
hcTable.SetStyle(settings.GetTableStyle(con))
hcTable.AppendHeader(table.Row{"Name", "Size"})
@@ -191,9 +173,9 @@ func CrackWordlistsAddCmd(cmd *cobra.Command, con *console.SliverClient, args []
con.PrintErrorf("No path specified, see --help\n")
return
}
wordlistStat, err := localCrackFileInfo(localPath)
if err != nil {
con.PrintErrorf("Invalid wordlist file: %s\n", err)
wordlistStat, err := os.Stat(localPath)
if os.IsNotExist(err) || wordlistStat.IsDir() {
con.PrintErrorf("File does not exist: %s\n", localPath)
return
}
if name == "" {
@@ -235,9 +217,9 @@ func CrackRulesAddCmd(cmd *cobra.Command, con *console.SliverClient, args []stri
con.PrintErrorf("No path specified, see --help\n")
return
}
rulesStat, err := localCrackFileInfo(localPath)
if err != nil {
con.PrintErrorf("Invalid rules file: %s\n", err)
rulesStat, err := os.Stat(localPath)
if os.IsNotExist(err) || rulesStat.IsDir() {
con.PrintErrorf("File does not exist: %s\n", localPath)
return
}
if name == "" {
@@ -279,9 +261,9 @@ func CrackHcstat2AddCmd(cmd *cobra.Command, con *console.SliverClient, args []st
con.PrintErrorf("No path specified, see --help\n")
return
}
hcstat2Stat, err := localCrackFileInfo(localPath)
if err != nil {
con.PrintErrorf("Invalid hcstat2 file: %s\n", err)
hcstat2Stat, err := os.Stat(localPath)
if os.IsNotExist(err) || hcstat2Stat.IsDir() {
con.PrintErrorf("File does not exist: %s\n", localPath)
return
}
if name == "" {
@@ -312,176 +294,102 @@ func CrackHcstat2AddCmd(cmd *cobra.Command, con *console.SliverClient, args []st
}
func addCrackFile(localFile *os.File, crackFile *clientpb.CrackFile, con *console.SliverClient) {
total, err := uploadCrackFile(localFile, crackFile, con.Rpc, func(total int64, n uint32, chunkSize int) {
digest := sha256.New()
wordlistReader := io.TeeReader(localFile, digest)
chunks := make(chan []byte, 1) // Chunks are in-memory!
var chunkReaderErr error
go func() {
chunkReaderErr = chunkReader(wordlistReader, crackFile.ChunkSize, chunks)
}()
n := uint32(0)
total := int64(0)
errors := []error{}
for chunk := range chunks {
total += int64(len(chunk))
con.PrintInfof("Uploading %s (chunk %d - %s) ...",
util.ByteCountBinary(total),
n,
util.ByteCountBinary(int64(chunkSize)),
util.ByteCountBinary(int64(len(chunk))),
)
})
_, err := con.Rpc.CrackFileChunkUpload(context.Background(), &clientpb.CrackFileChunk{
CrackFileID: crackFile.ID,
N: n,
Data: chunk,
})
n++
if err != nil {
errors = append(errors, err)
continue
}
}
con.Printf(console.Clearln + "\r")
if chunkReaderErr != nil {
con.PrintErrorf("Failed to read file: %s\n", chunkReaderErr)
return
}
if len(errors) > 0 {
for _, err := range errors {
con.PrintErrorf("Failed to upload chunk: %s\n", err)
}
return
}
_, err := con.Rpc.CrackFileComplete(context.Background(), &clientpb.CrackFile{
ID: crackFile.ID,
Sha2_256: hex.EncodeToString(digest.Sum(nil)),
})
if err != nil {
con.PrintErrorf("Failed to upload crack file: %s\n", err)
con.PrintErrorf("Failed to complete file upload: %s\n", err)
return
}
con.PrintInfof("Upload completed (compressed: %s)\n", util.ByteCountBinary(total))
}
func uploadCrackFile(localFile io.Reader, crackFile *clientpb.CrackFile, rpc rpcpb.SliverRPCClient, progress func(total int64, n uint32, chunkSize int)) (total int64, retErr error) {
if localFile == nil {
return 0, errors.New("local crack file reader is nil")
}
if crackFile == nil || crackFile.ID == "" {
return 0, errors.New("crack file reservation is missing an ID")
}
if rpc == nil {
return 0, errors.New("crack file RPC client is nil")
}
completed := false
defer func() {
if retErr == nil || completed {
return
}
_, cleanupErr := rpc.CrackFileDelete(context.Background(), &clientpb.CrackFile{ID: crackFile.ID})
if cleanupErr != nil {
retErr = errors.Join(retErr, fmt.Errorf("failed to remove crack file reservation %s: %w", crackFile.ID, cleanupErr))
}
}()
digest := sha256.New()
wordlistReader := io.TeeReader(localFile, digest)
chunks, chunkReaderErrors := readCrackFileChunks(wordlistReader, crackFile.ChunkSize)
n := uint32(0)
var uploadErr error
for chunk := range chunks {
total += int64(len(chunk))
if uploadErr == nil {
if progress != nil {
progress(total, n, len(chunk))
}
_, err := rpc.CrackFileChunkUpload(context.Background(), &clientpb.CrackFileChunk{
CrackFileID: crackFile.ID,
N: n,
Data: chunk,
})
if err != nil {
uploadErr = fmt.Errorf("upload chunk %d: %w", n, err)
}
}
n++
}
chunkReaderErr := <-chunkReaderErrors
if chunkReaderErr != nil {
uploadErr = errors.Join(uploadErr, fmt.Errorf("read and compress crack file: %w", chunkReaderErr))
}
if uploadErr != nil {
return total, uploadErr
}
_, err := rpc.CrackFileComplete(context.Background(), &clientpb.CrackFile{
ID: crackFile.ID,
Sha2_256: hex.EncodeToString(digest.Sum(nil)),
})
if err != nil {
return total, fmt.Errorf("complete crack file upload: %w", err)
}
completed = true
return total, nil
}
func readCrackFileChunks(reader io.Reader, chunkSize int64) (<-chan []byte, <-chan error) {
chunks := make(chan []byte, 1) // Chunks are in-memory!
errors := make(chan error, 1)
go func() {
defer close(errors)
errors <- chunkReader(reader, chunkSize, chunks)
}()
return chunks, errors
}
//nolint:gocyclo // Compression, fixed-size emission, final remainder handling, and cleanup form one streaming state machine.
func chunkReader(wordlistReader io.Reader, chunkSize int64, chunks chan []byte) (retErr error) {
func chunkReader(wordlistReader io.Reader, chunkSize int64, chunks chan []byte) error {
defer close(chunks)
if chunkSize < 1 {
return fmt.Errorf("invalid crack file chunk size %d", chunkSize)
}
start := int64(0)
tmpFile, err := os.CreateTemp("", "sliver-wordlist")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
defer func() {
if err := tmpFile.Close(); retErr == nil && err != nil {
retErr = err
}
_ = os.Remove(tmpPath)
}()
compressor, err := zstd.NewWriter(tmpFile, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
if err != nil {
return err
}
compressorClosed := false
defer func() {
if !compressorClosed {
_ = compressor.Close()
}
}()
readBuf := make([]byte, 32*1024*1024) // 32MB
defer tmpFile.Close()
compressor, _ := zstd.NewWriter(tmpFile, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
for {
readBuf := make([]byte, 32*1024*1024) // 32MB
readN, readErr := wordlistReader.Read(readBuf)
if readErr != nil && readErr != io.EOF {
return err
}
if readN != 0 {
_, err := compressor.Write(readBuf[:readN])
if err != nil {
return err
}
if err := compressor.Flush(); err != nil {
return err
}
tmpFileStat, err := tmpFile.Stat()
compressor.Flush()
tmpFileStat, err := os.Stat(tmpFile.Name())
if err != nil {
return err
}
for tmpFileStat.Size()-start >= chunkSize {
if tmpFileStat.Size()-start >= chunkSize {
stop := start + chunkSize
chunk, err := readChunkAt(tmpFile, start, chunkSize)
if err != nil {
return err
}
chunks <- chunk
start += int64(len(chunk))
start = stop
}
}
if readErr != nil && readErr != io.EOF {
return readErr
}
if readErr == io.EOF {
break
chunk, err := readChunkAt(tmpFile, start, chunkSize)
if err != nil {
return err
}
chunks <- chunk
return nil
}
}
if err := compressor.Close(); err != nil {
return err
}
compressorClosed = true
tmpFileStat, err := tmpFile.Stat()
if err != nil {
return err
}
for start < tmpFileStat.Size() {
remaining := tmpFileStat.Size() - start
readSize := chunkSize
if remaining < readSize {
readSize = remaining
}
chunk, err := readChunkAt(tmpFile, start, readSize)
if err != nil {
return err
}
chunks <- chunk
start += int64(len(chunk))
}
return nil
}
func readChunkAt(tmpFile *os.File, offset int64, chunkSize int64) ([]byte, error) {
-372
View File
@@ -1,372 +0,0 @@
package crack
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"math/rand"
"os"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/klauspost/compress/zstd"
"google.golang.org/grpc"
)
func TestLocalCrackFileInfoHandlesStatErrors(t *testing.T) {
wantErr := errors.New("permission denied")
info, err := localCrackFileInfoWith("/restricted/file", func(string) (os.FileInfo, error) {
return nil, wantErr
})
if info != nil {
t.Fatalf("localCrackFileInfoWith info = %#v, want nil", info)
}
if !errors.Is(err, wantErr) {
t.Fatalf("localCrackFileInfoWith error = %v, want wrapped %v", err, wantErr)
}
if !strings.Contains(err.Error(), "/restricted/file") {
t.Fatalf("localCrackFileInfoWith error = %q, want path context", err)
}
}
func TestLocalCrackFileInfoRejectsDirectories(t *testing.T) {
_, err := localCrackFileInfo(t.TempDir())
if err == nil || !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("localCrackFileInfo directory error = %v, want directory error", err)
}
}
func TestLocalCrackFileInfoAcceptsRegularFile(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "crack-file-")
if err != nil {
t.Fatalf("create local crack file: %v", err)
}
const payload = "hashcat input"
if _, err := file.WriteString(payload); err != nil {
t.Fatalf("write local crack file: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close local crack file: %v", err)
}
info, err := localCrackFileInfo(file.Name())
if err != nil {
t.Fatalf("localCrackFileInfo: %v", err)
}
if info.Size() != int64(len(payload)) {
t.Fatalf("localCrackFileInfo size = %d, want %d", info.Size(), len(payload))
}
}
func TestUploadCrackFileCleansUpPostCreateFailures(t *testing.T) {
readErr := errors.New("read failed")
chunkErr := errors.New("chunk upload failed")
completeErr := errors.New("complete failed")
tests := []struct {
name string
reader io.Reader
configureRPC func(*fakeCrackFileUploadRPC)
wantErr error
wantChunkCalls int
wantCompleteCalls int
}{
{
name: "read failure",
reader: errorReader{err: readErr},
wantErr: readErr,
},
{
name: "chunk upload failure",
reader: bytes.NewReader([]byte("payload")),
configureRPC: func(rpc *fakeCrackFileUploadRPC) {
rpc.chunkErr = chunkErr
},
wantErr: chunkErr,
wantChunkCalls: 1,
},
{
name: "completion failure",
reader: bytes.NewReader([]byte("payload")),
configureRPC: func(rpc *fakeCrackFileUploadRPC) {
rpc.completeErr = completeErr
},
wantErr: completeErr,
wantChunkCalls: 1,
wantCompleteCalls: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rpc := &fakeCrackFileUploadRPC{}
if test.configureRPC != nil {
test.configureRPC(rpc)
}
reservation := &clientpb.CrackFile{ID: "reserved-file", ChunkSize: 1024}
_, err := uploadCrackFile(test.reader, reservation, rpc, nil)
if !errors.Is(err, test.wantErr) {
t.Fatalf("uploadCrackFile error = %v, want wrapped %v", err, test.wantErr)
}
if rpc.chunkCalls != test.wantChunkCalls {
t.Fatalf("CrackFileChunkUpload calls = %d, want %d", rpc.chunkCalls, test.wantChunkCalls)
}
if rpc.completeCalls != test.wantCompleteCalls {
t.Fatalf("CrackFileComplete calls = %d, want %d", rpc.completeCalls, test.wantCompleteCalls)
}
if rpc.deleteCalls != 1 {
t.Fatalf("CrackFileDelete calls = %d, want 1", rpc.deleteCalls)
}
if rpc.deleted == nil || rpc.deleted.ID != reservation.ID {
t.Fatalf("CrackFileDelete request = %#v, want ID %q", rpc.deleted, reservation.ID)
}
})
}
}
func TestUploadCrackFileReportsCleanupFailure(t *testing.T) {
uploadErr := errors.New("chunk upload failed")
cleanupErr := errors.New("delete failed")
rpc := &fakeCrackFileUploadRPC{chunkErr: uploadErr, deleteErr: cleanupErr}
reservation := &clientpb.CrackFile{ID: "reserved-file", ChunkSize: 1024}
_, err := uploadCrackFile(bytes.NewReader([]byte("payload")), reservation, rpc, nil)
if !errors.Is(err, uploadErr) {
t.Fatalf("uploadCrackFile error = %v, want wrapped upload error %v", err, uploadErr)
}
if !errors.Is(err, cleanupErr) {
t.Fatalf("uploadCrackFile error = %v, want wrapped cleanup error %v", err, cleanupErr)
}
if !strings.Contains(err.Error(), reservation.ID) {
t.Fatalf("uploadCrackFile error = %q, want reservation ID", err)
}
if rpc.deleteCalls != 1 {
t.Fatalf("CrackFileDelete calls = %d, want 1", rpc.deleteCalls)
}
}
func TestUploadCrackFileSuccessCompletesWithoutCleanup(t *testing.T) {
payload := []byte("correct horse battery staple\n")
rpc := &fakeCrackFileUploadRPC{}
reservation := &clientpb.CrackFile{ID: "reserved-file", ChunkSize: 8}
progressCalls := 0
total, err := uploadCrackFile(bytes.NewReader(payload), reservation, rpc, func(int64, uint32, int) {
progressCalls++
})
if err != nil {
t.Fatalf("uploadCrackFile: %v", err)
}
if total == 0 {
t.Fatal("uploadCrackFile compressed byte count = 0, want non-zero")
}
if rpc.chunkCalls == 0 {
t.Fatal("CrackFileChunkUpload calls = 0, want at least 1")
}
if progressCalls != rpc.chunkCalls {
t.Fatalf("progress calls = %d, want %d", progressCalls, rpc.chunkCalls)
}
if rpc.completeCalls != 1 {
t.Fatalf("CrackFileComplete calls = %d, want 1", rpc.completeCalls)
}
if rpc.deleteCalls != 0 {
t.Fatalf("CrackFileDelete calls = %d, want 0", rpc.deleteCalls)
}
wantDigest := sha256.Sum256(payload)
if rpc.completed == nil || rpc.completed.ID != reservation.ID {
t.Fatalf("CrackFileComplete request = %#v, want ID %q", rpc.completed, reservation.ID)
}
if rpc.completed.Sha2_256 != hex.EncodeToString(wantDigest[:]) {
t.Fatalf("CrackFileComplete digest = %q, want %q", rpc.completed.Sha2_256, hex.EncodeToString(wantDigest[:]))
}
}
func TestChunkReaderZstdRoundTrip(t *testing.T) {
largePayload := make([]byte, 256*1024)
if _, err := rand.New(rand.NewSource(42)).Read(largePayload); err != nil {
t.Fatalf("create test payload: %v", err)
}
tests := []struct {
name string
payload []byte
chunkSize int64
wantMinChunks int
}{
{
name: "single chunk",
payload: []byte("correct horse battery staple\n"),
chunkSize: 1024,
wantMinChunks: 1,
},
{
name: "many chunks from one read",
payload: largePayload,
chunkSize: 1024,
wantMinChunks: 3,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
compressed, chunkCount, err := collectCompressedCrackFile(bytes.NewReader(test.payload), test.chunkSize)
if err != nil {
t.Fatalf("chunkReader: %v", err)
}
if chunkCount < test.wantMinChunks {
t.Fatalf("chunk count = %d, want at least %d", chunkCount, test.wantMinChunks)
}
decoder, err := zstd.NewReader(bytes.NewReader(compressed))
if err != nil {
t.Fatalf("open zstd stream: %v", err)
}
decoded, err := io.ReadAll(decoder)
decoder.Close()
if err != nil {
t.Fatalf("decode zstd stream: %v", err)
}
if !bytes.Equal(decoded, test.payload) {
t.Fatalf("decoded payload does not match input: got %d bytes, want %d", len(decoded), len(test.payload))
}
})
}
}
func TestChunkReaderHandlesDataWithEOF(t *testing.T) {
payload := []byte("reader returns data and EOF together")
compressed, _, err := collectCompressedCrackFile(&dataEOFReader{data: payload}, 8)
if err != nil {
t.Fatalf("chunkReader: %v", err)
}
decoder, err := zstd.NewReader(bytes.NewReader(compressed))
if err != nil {
t.Fatalf("open zstd stream: %v", err)
}
decoded, err := io.ReadAll(decoder)
decoder.Close()
if err != nil {
t.Fatalf("decode zstd stream: %v", err)
}
if !bytes.Equal(decoded, payload) {
t.Fatalf("decoded payload = %q, want %q", decoded, payload)
}
}
func TestChunkReaderRejectsInvalidChunkSize(t *testing.T) {
for _, chunkSize := range []int64{0, -1} {
compressed, chunkCount, err := collectCompressedCrackFile(bytes.NewReader([]byte("payload")), chunkSize)
if err == nil {
t.Fatalf("chunkReader accepted chunk size %d", chunkSize)
}
if chunkCount != 0 || len(compressed) != 0 {
t.Fatalf("invalid chunk size %d emitted %d chunks (%d bytes)", chunkSize, chunkCount, len(compressed))
}
}
}
func TestChunkReaderPropagatesReadError(t *testing.T) {
wantErr := errors.New("read failed")
compressed, chunkCount, err := collectCompressedCrackFile(errorReader{err: wantErr}, 1024)
if !errors.Is(err, wantErr) {
t.Fatalf("chunkReader error = %v, want %v", err, wantErr)
}
if chunkCount != 0 || len(compressed) != 0 {
t.Fatalf("failed read emitted %d chunks (%d bytes)", chunkCount, len(compressed))
}
}
func collectCompressedCrackFile(reader io.Reader, chunkSize int64) ([]byte, int, error) {
chunks, errCh := readCrackFileChunks(reader, chunkSize)
compressed := bytes.Buffer{}
chunkCount := 0
invalidChunk := false
for chunk := range chunks {
if len(chunk) == 0 || int64(len(chunk)) > chunkSize {
invalidChunk = true
}
_, _ = compressed.Write(chunk)
chunkCount++
}
readerErr := <-errCh
if invalidChunk {
return nil, chunkCount, errors.New("chunkReader emitted an invalid chunk size")
}
return compressed.Bytes(), chunkCount, readerErr
}
type dataEOFReader struct {
data []byte
done bool
}
func (r *dataEOFReader) Read(buffer []byte) (int, error) {
if r.done {
return 0, io.EOF
}
r.done = true
return copy(buffer, r.data), io.EOF
}
type errorReader struct {
err error
}
func (r errorReader) Read([]byte) (int, error) {
return 0, r.err
}
type fakeCrackFileUploadRPC struct {
rpcpb.SliverRPCClient
chunkErr error
completeErr error
deleteErr error
chunkCalls int
completeCalls int
deleteCalls int
uploaded []*clientpb.CrackFileChunk
completed *clientpb.CrackFile
deleted *clientpb.CrackFile
}
func (f *fakeCrackFileUploadRPC) CrackFileChunkUpload(_ context.Context, request *clientpb.CrackFileChunk, _ ...grpc.CallOption) (*commonpb.Empty, error) {
f.chunkCalls++
f.uploaded = append(f.uploaded, &clientpb.CrackFileChunk{
CrackFileID: request.CrackFileID,
N: request.N,
Data: append([]byte(nil), request.Data...),
})
if f.chunkErr != nil {
return nil, f.chunkErr
}
return &commonpb.Empty{}, nil
}
func (f *fakeCrackFileUploadRPC) CrackFileComplete(_ context.Context, request *clientpb.CrackFile, _ ...grpc.CallOption) (*commonpb.Empty, error) {
f.completeCalls++
f.completed = &clientpb.CrackFile{ID: request.ID, Sha2_256: request.Sha2_256}
if f.completeErr != nil {
return nil, f.completeErr
}
return &commonpb.Empty{}, nil
}
func (f *fakeCrackFileUploadRPC) CrackFileDelete(_ context.Context, request *clientpb.CrackFile, _ ...grpc.CallOption) (*commonpb.Empty, error) {
f.deleteCalls++
f.deleted = &clientpb.CrackFile{ID: request.ID}
if f.deleteErr != nil {
return nil, f.deleteErr
}
return &commonpb.Empty{}, nil
}
-166
View File
@@ -1,166 +0,0 @@
package crack
import "github.com/spf13/pflag"
func bindCrackFlags(f *pflag.FlagSet) {
f.StringP("attack-mode", "a", "", "crack attack mode (straight, combination, bruteforce, pcfg, hybrid-wordlist-mask, hybrid-mask-wordlist, generic, association, no-attack, hybrid, or numeric)")
f.StringP("hash-type", "m", "", "hashcat hash type (number or enum name)")
f.Uint32("hash-mode", 0, "canonical numeric hashcat mode (overrides hash-type)")
f.StringArray("hash", nil, "hashes to crack (repeatable)")
f.StringArray("credential", nil, "server credential ID or ID prefix to crack (repeatable)")
f.String("credential-collection", "", "server credential collection to crack")
f.Bool("include-cracked-credentials", false, "include credentials already marked as cracked")
f.StringArray("input", nil, "ordered positional hashcat inputs (repeatable)")
f.String("stdin", "", "data supplied to hashcat standard input")
f.Bool("quiet", false, "quiet mode")
f.Bool("hex-charset", false, "treat charset as hex")
f.Bool("hex-salt", false, "treat salt as hex")
f.Bool("hex-wordlist", false, "treat wordlist as hex")
f.Bool("force", false, "ignore warnings")
f.Bool("deprecated-check-disable", false, "disable deprecated check")
f.Bool("advice-disable", false, "disable hashcat advice")
f.Bool("status", false, "enable status")
f.Bool("status-json", false, "emit status in JSON")
f.Uint32("status-timer", 0, "status timer interval (seconds)")
f.Uint32("stdin-timeout-abort", 0, "abort after stdin timeout (seconds)")
f.Bool("machine-readable", false, "machine readable output")
f.Bool("keep-guessing", false, "keep guessing")
f.Bool("self-test-disable", false, "disable self-test")
f.Bool("loopback", false, "use loopback mode")
f.String("markov-hcstat2", "", "markov hcstat2 filename or identifier")
f.Bool("markov-disable", false, "disable markov mode")
f.Bool("markov-classic", false, "use classic markov")
f.Bool("markov-inverse", false, "use inverse markov")
f.Uint32("markov-threshold", 0, "markov threshold")
f.Uint32("runtime", 0, "runtime in seconds")
f.String("session", "", "session name")
f.Bool("restore", false, "restore session")
f.Bool("restore-disable", false, "disable restore")
f.String("restore-file", "", "restore file path or identifier")
f.Bool("restore-position", false, "restore from the stored position")
f.Bool("restore-show-command", false, "display the stored restore command and stop")
f.String("outfile", "", "outfile path or identifier")
f.StringSlice("outfile-format", nil, "outfile formats (hash-salt, plain, hex-plain, crack-pos, timestamp-absolute, timestamp-relative, or numeric)")
f.Bool("outfile-json", false, "write outfile records as JSON")
f.Bool("outfile-autohex-disable", false, "disable outfile autohex")
f.Uint32("outfile-check-timer", 0, "outfile check timer interval (seconds)")
f.Bool("wordlist-autohex-disable", false, "disable wordlist autohex")
f.String("separator", "", "separator character")
f.Bool("show", false, "show cracked hashes")
f.Bool("left", false, "show uncracked hashes")
f.Bool("username", false, "enable username handling")
f.Bool("remove", false, "remove cracked hashes")
f.Uint32("remove-timer", 0, "remove timer interval (seconds)")
f.Bool("potfile-disable", false, "disable potfile")
f.String("potfile", "", "potfile path or identifier")
f.String("encoding-from", "", "input encoding name (legacy enum names and numbers are also accepted)")
f.String("encoding-to", "", "output encoding name (legacy enum names and numbers are also accepted)")
f.Uint32("debug-mode", 0, "debug mode mask")
f.String("debug-file", "", "debug file path or identifier")
f.String("induction-dir", "", "induction directory path or identifier")
f.String("outfile-check-dir", "", "outfile-check directory path or identifier")
f.Bool("logfile-disable", false, "disable logfile")
f.Uint32("hccapx-message-pair", 0, "hccapx message pair")
f.Uint32("nonce-error-corrections", 0, "nonce error corrections")
f.String("keyboard-layout-mapping", "", "local keyboard layout mapping file")
f.String("truecrypt-keyfiles", "", "TrueCrypt keyfiles path or identifier")
f.String("veracrypt-keyfiles", "", "VeraCrypt keyfiles path or identifier")
f.Uint32("veracrypt-pim-start", 0, "VeraCrypt PIM start")
f.Uint32("veracrypt-pim-stop", 0, "VeraCrypt PIM stop")
f.Bool("benchmark", false, "run benchmark")
f.Bool("benchmark-all", false, "run full benchmark")
f.Uint32("benchmark-min", 0, "minimum benchmark runtime")
f.Uint32("benchmark-max", 0, "maximum benchmark runtime")
f.Bool("speed-only", false, "show speed only")
f.Bool("progress-only", false, "show progress only")
f.Uint32("segment-size", 0, "deprecated: removed in Hashcat v7")
f.Bool("pipeline-stats", false, "show pipeline statistics")
f.Bool("task-time-breakdown", false, "show task time breakdown")
f.Uint32("bitmap-min", 0, "bitmap min")
f.Uint32("bitmap-max", 0, "bitmap max")
f.UintSlice("cpu-affinity", nil, "CPU affinity list")
f.Uint32("hook-threads", 0, "hook threads")
f.Bool("hash-info", false, "show hash information from an idle crackstation (does not create a job)")
f.Uint32("hash-info-level", 0, "hash information detail level: 1 or 2 (does not create a job)")
f.Bool("backend-ignore-cuda", false, "ignore CUDA backend")
f.Bool("backend-ignore-hip", false, "ignore HIP backend")
f.Bool("backend-ignore-metal", false, "ignore Metal backend")
f.Bool("backend-ignore-opencl", false, "ignore OpenCL backend")
f.Bool("backend-info", false, "show registered backend info for all connected crackstations (does not create a job)")
f.Uint32("backend-info-level", 0, "registered backend information detail level: 1 or 2 (does not create a job)")
f.UintSlice("backend-devices", nil, "backend device IDs")
f.Uint32("backend-devices-virtmulti", 0, "virtual backend device multiplier")
f.Uint32("backend-devices-virthost", 0, "virtual backend host count")
f.UintSlice("opencl-device-types", nil, "OpenCL device types")
f.Uint32("metal-compiler-runtime", 0, "Metal compiler runtime")
f.Bool("optimized-kernel-enable", false, "enable optimized kernels")
f.Bool("multiply-accel-disable", false, "disable multiply accel")
f.Bool("multiply-accel-disabled", false, "deprecated alias for multiply-accel-disable")
_ = f.MarkDeprecated("multiply-accel-disabled", "use --multiply-accel-disable")
_ = f.MarkHidden("multiply-accel-disabled")
f.String("workload-profile", "", "deprecated: accepted but ignored by Hashcat v7")
f.Uint32("kernel-accel", 0, "kernel acceleration")
f.Uint32("kernel-loops", 0, "kernel loops")
f.Uint32("kernel-threads", 0, "kernel threads")
f.Uint32("backend-vector-width", 0, "backend vector width")
f.Uint32("spin-damp", 0, "spin damp")
f.Bool("hwmon-disable", false, "disable hardware monitoring")
f.Uint32("hwmon-temp-abort", 0, "hardware monitor temperature abort")
f.Uint32("scrypt-tmto", 0, "scrypt tmto")
f.Uint64("skip", 0, "skip count")
f.Uint64("limit", 0, "limit count")
f.Bool("keyspace", false, "calculate keyspace on an idle crackstation and return it (does not create a job)")
f.Bool("total-candidates", false, "calculate the total candidate count on an idle crackstation (does not create a job)")
f.String("lookup", "", "locate a candidate in the attack on an idle crackstation (does not create a job)")
f.String("crackstation", "", "target an exact crackstation name or host UUID for a synchronous query")
f.Bool("dynamic-x", false, "enable dynamic-X mode")
f.String("seekdb-path", "", "seek database path or identifier")
f.String("rule-left", "", "left combinator rule")
f.String("rule-right", "", "right combinator rule")
f.StringArray("rules-file", nil, "rules file content or identifier (repeatable)")
f.Uint32("generate-rules", 0, "generate rules")
f.Uint32("generate-rules-func-min", 0, "generate rules function min")
f.Uint32("generate-rules-func-max", 0, "generate rules function max")
f.Uint32("generate-rules-fun-min", 0, "deprecated alias for generate-rules-func-min")
f.Uint32("generate-rules-fun-max", 0, "deprecated alias for generate-rules-func-max")
_ = f.MarkDeprecated("generate-rules-fun-min", "use --generate-rules-func-min")
_ = f.MarkHidden("generate-rules-fun-min")
_ = f.MarkDeprecated("generate-rules-fun-max", "use --generate-rules-func-max")
_ = f.MarkHidden("generate-rules-fun-max")
f.String("generate-rules-func-sel", "", "generate rules function selection")
f.Int32("generate-rules-seed", 0, "generate rules seed")
f.String("custom-charset1", "", "custom charset 1")
f.String("custom-charset2", "", "custom charset 2")
f.String("custom-charset3", "", "custom charset 3")
f.String("custom-charset4", "", "custom charset 4")
f.String("custom-charset5", "", "custom charset 5")
f.String("custom-charset6", "", "custom charset 6")
f.String("custom-charset7", "", "custom charset 7")
f.String("custom-charset8", "", "custom charset 8")
f.String("identify", "", "deprecated: legacy positional input")
f.Bool("identify-mode", false, "identify hash types on an idle crackstation (does not create a job)")
f.Bool("increment", false, "incremental mode")
f.Bool("increment-inverse", false, "inverse incremental mode")
f.Uint32("increment-min", 0, "increment min")
f.Uint32("increment-max", 0, "increment max")
f.Uint32("bypass-delay", 0, "bypass delay")
f.Uint32("bypass-threshold", 0, "bypass threshold")
f.Bool("slow-candidates", false, "slow candidates")
f.String("bridge-parameter1", "", "bridge parameter 1")
f.String("bridge-parameter2", "", "bridge parameter 2")
f.String("bridge-parameter3", "", "bridge parameter 3")
f.String("bridge-parameter4", "", "bridge parameter 4")
f.Bool("brain-server", false, "brain server mode")
f.Uint32("brain-server-timer", 0, "brain server timer")
f.Bool("brain-client", false, "brain client mode")
f.String("brain-client-features", "", "brain client features")
f.String("brain-host", "", "brain host")
f.Uint32("brain-port", 0, "brain port")
f.String("brain-password", "", "brain password")
f.String("brain-session", "", "brain session")
f.String("brain-session-whitelist", "", "brain session whitelist")
f.Bool("brain-feed", false, "brain feed mode")
f.Bool("color-cracked", false, "color cracked output")
f.Bool("hash-copy", false, "copy hashes")
f.String("encrypt-with-pubkey", "", "public key path or identifier used to encrypt output")
}
@@ -1,388 +0,0 @@
package crack
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/spf13/cobra"
)
func TestCrackJobLifecycleCommandsAreRegisteredAtExactHierarchy(t *testing.T) {
root := Commands(nil)[0]
for _, name := range []string{"cancel", "pause", "resume"} {
t.Run(name, func(t *testing.T) {
command, args, err := root.Find([]string{"job", name, "job-id"})
if err != nil {
t.Fatalf("find crack job %s: %v", name, err)
}
if got, want := command.CommandPath(), "crack job "+name; got != want {
t.Fatalf("command path = %q, want %q", got, want)
}
if !reflect.DeepEqual(args, []string{"job-id"}) {
t.Fatalf("remaining args = %#v, want [job-id]", args)
}
if got, want := command.Use, name+" [id]"; got != want {
t.Fatalf("use = %q, want %q", got, want)
}
if err := command.Args(command, nil); err != nil {
t.Fatalf("interactive selection rejected: %v", err)
}
if err := command.Args(command, []string{"job-id"}); err != nil {
t.Fatalf("one job ID rejected: %v", err)
}
if err := command.Args(command, []string{"first", "second"}); err == nil {
t.Fatal("more than one job ID was accepted")
}
if command.ValidArgsFunction == nil {
t.Fatal("job ID completion is missing")
}
if command.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("--timeout is not inherited")
}
if command.InheritedFlags().Lookup("watch") != nil || command.InheritedFlags().Lookup("poll-interval") != nil {
t.Fatal("crack job view flags leaked into lifecycle command")
}
})
}
}
func TestCrackJobLifecycleCompletionOnlyOffersEligibleFullIDs(t *testing.T) {
activeID := "aaaaaaaa-1111-4111-8111-111111111111"
pausedID := "bbbbbbbb-2222-4222-8222-222222222222"
completedID := "cccccccc-3333-4333-8333-333333333333"
cancelledID := "dddddddd-4444-4444-8444-444444444444"
tests := []struct {
name string
want []string
}{
{name: "cancel", want: []string{activeID, pausedID}},
{name: "pause", want: []string{activeID}},
{name: "resume", want: []string{pausedID}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{
{ID: activeID, Status: clientpb.CrackJobStatus_IN_PROGRESS},
{ID: pausedID, Status: clientpb.CrackJobStatus_PAUSED},
{ID: completedID, Status: clientpb.CrackJobStatus_COMPLETED},
{ID: cancelledID, Status: clientpb.CrackJobStatus_CANCELLED},
nil,
{},
}}}
root := Commands(&console.SliverClient{Rpc: capture})[0]
command, _, err := root.Find([]string{"job", test.name})
if err != nil {
t.Fatalf("find crack job %s: %v", test.name, err)
}
values, directive := command.ValidArgsFunction(command, nil, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion directive = %v, want no-file-completion", directive)
}
got := make([]string, 0, len(values))
for _, value := range values {
got = append(got, strings.SplitN(value, "\t", 2)[0])
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("completion IDs = %#v, want %#v", got, test.want)
}
if capture.calls != 1 {
t.Fatalf("CrackJobs calls = %d, want 1", capture.calls)
}
})
}
}
func TestCrackJobLifecycleEligibility(t *testing.T) {
tests := []struct {
name string
job *clientpb.CrackJob
pausable bool
resumable bool
cancellable bool
}{
{name: "nil"},
{name: "in progress", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_IN_PROGRESS}, pausable: true, cancellable: true},
{name: "paused", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_PAUSED}, resumable: true, cancellable: true},
{name: "completed", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_COMPLETED}},
{name: "failed", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_FAILED}},
{name: "cancelled", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_CANCELLED}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := crackJobPausable(test.job); got != test.pausable {
t.Errorf("pausable = %v, want %v", got, test.pausable)
}
if got := crackJobResumable(test.job); got != test.resumable {
t.Errorf("resumable = %v, want %v", got, test.resumable)
}
if got := crackJobCancellable(test.job); got != test.cancellable {
t.Errorf("cancellable = %v, want %v", got, test.cancellable)
}
})
}
}
func TestCrackJobLifecycleEmptySelectionMessagesAreActionSpecific(t *testing.T) {
for _, test := range []struct {
name string
action crackJobLifecycleAction
want string
}{
{name: "cancel", action: newCrackJobCancelAction(nil), want: "No cancellable crack jobs"},
{name: "pause", action: newCrackJobPauseAction(nil), want: "No pausable crack jobs"},
{name: "resume", action: newCrackJobResumeAction(nil), want: "No resumable crack jobs"},
} {
t.Run(test.name, func(t *testing.T) {
if test.action.noEligibleJobsMessage != test.want {
t.Fatalf("empty-selection message = %q, want %q", test.action.noEligibleJobsMessage, test.want)
}
})
}
}
func TestApplyCrackJobLifecycleActionsConfirmAndMutateByID(t *testing.T) {
type contextKey struct{}
ctx := context.WithValue(t.Context(), contextKey{}, "expected")
jobID := "aaaaaaaa-1111-4111-8111-111111111111"
tests := []struct {
name string
status clientpb.CrackJobStatus
updatedStatus clientpb.CrackJobStatus
newAction func(crackJobMutationFunc) crackJobLifecycleAction
wantConfirmation bool
promptParts []string
}{
{
name: "cancel active",
status: clientpb.CrackJobStatus_IN_PROGRESS,
updatedStatus: clientpb.CrackJobStatus_CANCELLED,
newAction: newCrackJobCancelAction,
wantConfirmation: true,
promptParts: []string{"Cancel crack job", jobID, "Unfinished work will be discarded"},
},
{
name: "cancel paused",
status: clientpb.CrackJobStatus_PAUSED,
updatedStatus: clientpb.CrackJobStatus_CANCELLED,
newAction: newCrackJobCancelAction,
wantConfirmation: true,
promptParts: []string{"Cancel crack job", jobID, "PAUSED"},
},
{
name: "pause",
status: clientpb.CrackJobStatus_IN_PROGRESS,
updatedStatus: clientpb.CrackJobStatus_PAUSED,
newAction: newCrackJobPauseAction,
wantConfirmation: true,
promptParts: []string{"Pause crack job", jobID, "stopped", "beginning of their shards when resumed"},
},
{
name: "resume",
status: clientpb.CrackJobStatus_PAUSED,
updatedStatus: clientpb.CrackJobStatus_IN_PROGRESS,
newAction: newCrackJobResumeAction,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mutateCalls := 0
confirmCalls := 0
action := test.newAction(func(gotCtx context.Context, request *clientpb.CrackJob) (*clientpb.CrackJob, error) {
mutateCalls++
if gotCtx.Value(contextKey{}) != "expected" {
t.Fatal("mutation did not receive the caller context")
}
if request.GetID() != jobID || request.GetCommand() != nil || len(request.GetTasks()) != 0 || len(request.GetResults()) != 0 {
t.Fatalf("mutation request = %#v, want ID-only request", request)
}
return &clientpb.CrackJob{ID: jobID, Status: test.updatedStatus, UpdatedAt: 1}, nil
})
updated, changed, err := applyCrackJobLifecycleAction(ctx, &clientpb.CrackJob{ID: " " + jobID + " ", Status: test.status}, action,
func(prompt string) (bool, error) {
confirmCalls++
for _, part := range test.promptParts {
if !strings.Contains(prompt, part) {
t.Errorf("confirmation prompt %q does not contain %q", prompt, part)
}
}
return true, nil
})
if err != nil {
t.Fatalf("apply lifecycle action: %v", err)
}
if !changed || updated.GetID() != jobID || updated.GetStatus() != test.updatedStatus {
t.Fatalf("updated=%#v changed=%v", updated, changed)
}
wantConfirmCalls := 0
if test.wantConfirmation {
wantConfirmCalls = 1
}
if confirmCalls != wantConfirmCalls || mutateCalls != 1 {
t.Fatalf("confirm calls=%d mutate calls=%d, want %d/1", confirmCalls, mutateCalls, wantConfirmCalls)
}
})
}
}
func TestApplyFetchedCrackJobLifecycleActionRejectsMismatchedIDBeforeConfirmation(t *testing.T) {
requestedID := "aaaaaaaa-1111-4111-8111-111111111111"
returnedID := "bbbbbbbb-2222-4222-8222-222222222222"
confirmCalls := 0
mutationCalls := 0
action := newCrackJobPauseAction(func(context.Context, *clientpb.CrackJob) (*clientpb.CrackJob, error) {
mutationCalls++
return nil, nil
})
updated, changed, err := applyFetchedCrackJobLifecycleAction(
t.Context(),
requestedID,
&clientpb.CrackJob{ID: returnedID, Status: clientpb.CrackJobStatus_IN_PROGRESS},
action,
func(string) (bool, error) {
confirmCalls++
return true, nil
},
)
if err == nil || !strings.Contains(err.Error(), requestedID) || !strings.Contains(err.Error(), returnedID) {
t.Fatalf("mismatched detail error = %v, want both job IDs", err)
}
if updated != nil || changed || confirmCalls != 0 || mutationCalls != 0 {
t.Fatalf("updated=%#v changed=%v confirm calls=%d mutation calls=%d, want nil/false/0/0", updated, changed, confirmCalls, mutationCalls)
}
}
func TestApplyCrackJobLifecycleActionDeclineAndValidationErrorsDoNotMutate(t *testing.T) {
sentinel := errors.New("sentinel")
mutationCalls := 0
mutation := func(context.Context, *clientpb.CrackJob) (*clientpb.CrackJob, error) {
mutationCalls++
return nil, sentinel
}
for _, test := range []struct {
name string
job *clientpb.CrackJob
action crackJobLifecycleAction
confirm crackJobConfirmFunc
wantErr string
}{
{
name: "cancel declined",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS},
action: newCrackJobCancelAction(mutation),
confirm: func(string) (bool, error) { return false, nil },
},
{
name: "pause confirmation unavailable",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS},
action: newCrackJobPauseAction(mutation),
wantErr: "confirmation is unavailable",
},
{
name: "cannot pause completed",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_COMPLETED},
action: newCrackJobPauseAction(mutation),
confirm: func(string) (bool, error) { return true, nil },
wantErr: "only in-progress jobs can be paused",
},
{
name: "cannot resume active",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS},
action: newCrackJobResumeAction(mutation),
wantErr: "only paused jobs can be resumed",
},
{
name: "cannot cancel terminal",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_CANCELLED},
action: newCrackJobCancelAction(mutation),
confirm: func(string) (bool, error) { return true, nil },
wantErr: "only in-progress or paused jobs can be cancelled",
},
{name: "empty job", action: newCrackJobResumeAction(mutation), wantErr: "empty crack job"},
{name: "empty ID", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_PAUSED}, action: newCrackJobResumeAction(mutation), wantErr: "ID cannot be empty"},
} {
t.Run(test.name, func(t *testing.T) {
before := mutationCalls
updated, changed, err := applyCrackJobLifecycleAction(t.Context(), test.job, test.action, test.confirm)
if updated != nil || changed {
t.Fatalf("updated=%#v changed=%v, want nil/false", updated, changed)
}
if test.wantErr == "" && err != nil {
t.Fatalf("error = %v, want nil", err)
}
if test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
if mutationCalls != before {
t.Fatal("mutation ran despite decline or validation error")
}
})
}
}
func TestApplyCrackJobLifecycleActionValidatesAuthoritativeResponse(t *testing.T) {
job := &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_PAUSED}
for _, test := range []struct {
name string
result *clientpb.CrackJob
resultErr error
wantErr string
}{
{name: "RPC error", resultErr: errors.New("sentinel"), wantErr: "sentinel"},
{name: "nil", wantErr: "empty crack job after resume"},
{name: "empty ID", result: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_IN_PROGRESS}, wantErr: "without an ID after resume"},
{name: "wrong ID", result: &clientpb.CrackJob{ID: "another-job", Status: clientpb.CrackJobStatus_IN_PROGRESS, UpdatedAt: 1}, wantErr: "after resume request for job-id"},
{name: "missing timestamp", result: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}, wantErr: "without an update timestamp after resume"},
{name: "wrong status", result: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 1}, wantErr: "unexpected crack job status PAUSED after resume"},
} {
t.Run(test.name, func(t *testing.T) {
action := newCrackJobResumeAction(func(context.Context, *clientpb.CrackJob) (*clientpb.CrackJob, error) {
return test.result, test.resultErr
})
updated, changed, err := applyCrackJobLifecycleAction(t.Context(), job, action, nil)
if updated != nil || changed || err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("updated=%#v changed=%v error=%v, want error containing %q", updated, changed, err, test.wantErr)
}
})
}
}
func TestCrackJobLifecycleMutationUsesCommandTimeout(t *testing.T) {
root := Commands(nil)[0]
pause, _, err := root.Find([]string{"job", "pause"})
if err != nil {
t.Fatalf("find crack job pause: %v", err)
}
if err := pause.InheritedFlags().Set("timeout", "2"); err != nil {
t.Fatalf("set timeout: %v", err)
}
started := time.Now()
var deadline time.Time
action := newCrackJobPauseAction(func(parent context.Context, request *clientpb.CrackJob) (*clientpb.CrackJob, error) {
ctx, cancel := crackCommandContext(parent, pause)
defer cancel()
var ok bool
deadline, ok = ctx.Deadline()
if !ok {
t.Fatal("mutation context has no deadline")
}
return &clientpb.CrackJob{ID: request.GetID(), Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 1}, nil
})
if _, changed, err := applyCrackJobLifecycleAction(t.Context(), &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}, action,
func(string) (bool, error) { return true, nil }); err != nil || !changed {
t.Fatalf("apply lifecycle action changed=%v error=%v", changed, err)
}
if deadline.Before(started.Add(1500*time.Millisecond)) || deadline.After(started.Add(2500*time.Millisecond)) {
t.Fatalf("mutation deadline = %s, want about two seconds after %s", deadline, started)
}
}
-249
View File
@@ -1,249 +0,0 @@
package crack
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/spf13/cobra"
)
func TestCrackJobRmCommandIsRegisteredAtExactHierarchy(t *testing.T) {
root := Commands(nil)[0]
rm, args, err := root.Find([]string{"job", "rm", "job-id"})
if err != nil {
t.Fatalf("find crack job rm: %v", err)
}
if got := rm.CommandPath(); got != "crack job rm" {
t.Fatalf("command path = %q, want %q", got, "crack job rm")
}
if !reflect.DeepEqual(args, []string{"job-id"}) {
t.Fatalf("remaining args = %#v, want [job-id]", args)
}
if rm.Use != "rm [id]" {
t.Fatalf("crack job rm use = %q, want %q", rm.Use, "rm [id]")
}
if err := rm.Args(rm, nil); err != nil {
t.Fatalf("crack job rm rejected interactive selection: %v", err)
}
if err := rm.Args(rm, []string{"job-id"}); err != nil {
t.Fatalf("crack job rm rejected one job ID: %v", err)
}
if err := rm.Args(rm, []string{"first", "second"}); err == nil {
t.Fatal("crack job rm accepted more than one job ID")
}
if rm.ValidArgsFunction == nil {
t.Fatal("crack job rm is missing ID completion")
}
if rm.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack job rm must inherit --timeout")
}
if rm.InheritedFlags().Lookup("watch") != nil || rm.InheritedFlags().Lookup("poll-interval") != nil {
t.Fatal("crack job view flags leaked into crack job rm")
}
}
func TestCrackJobRmCompletionOnlyOffersTerminalJobs(t *testing.T) {
activeID := "aaaaaaaa-1111-4111-8111-111111111111"
completedID := "bbbbbbbb-2222-4222-8222-222222222222"
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{
{ID: activeID, Status: clientpb.CrackJobStatus_IN_PROGRESS},
{ID: completedID, Status: clientpb.CrackJobStatus_COMPLETED},
}}}
root := Commands(&console.SliverClient{Rpc: capture})[0]
rm, _, err := root.Find([]string{"job", "rm"})
if err != nil {
t.Fatalf("find crack job rm: %v", err)
}
values, directive := rm.ValidArgsFunction(rm, nil, "")
if len(values) != 1 || !strings.HasPrefix(values[0], completedID+"\t") {
t.Fatalf("completion values = %#v, want only terminal job %q", values, completedID)
}
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion directive = %v, want no-file-completion", directive)
}
if capture.calls != 1 {
t.Fatalf("CrackJobs calls = %d, want 1", capture.calls)
}
}
func TestRemoveCrackJobConfirmsCascadeAndDeletesByID(t *testing.T) {
type contextKey struct{}
ctx := context.WithValue(t.Context(), contextKey{}, "expected")
job := &clientpb.CrackJob{
ID: " aaaaaaaa-1111-4111-8111-111111111111 ",
Status: clientpb.CrackJobStatus_COMPLETED,
Tasks: []*clientpb.CrackTask{{ID: "one"}, {ID: "two"}},
ResultCount: 1,
}
confirmCalls := 0
deleteCalls := 0
removed, err := removeCrackJob(ctx, job, func(prompt string) (bool, error) {
confirmCalls++
want := "Permanently delete crack job \"aaaaaaaa-1111-4111-8111-111111111111\" (COMPLETED), including 2 tasks and 1 result?"
if prompt != want {
t.Fatalf("confirmation prompt = %q, want %q", prompt, want)
}
return true, nil
}, func(gotCtx context.Context, request *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
if gotCtx.Value(contextKey{}) != "expected" {
t.Fatal("delete did not receive the caller context")
}
if request.GetID() != "aaaaaaaa-1111-4111-8111-111111111111" || request.GetCommand() != nil || len(request.GetTasks()) != 0 || len(request.GetResults()) != 0 {
t.Fatalf("delete request = %#v, want ID-only request", request)
}
return &commonpb.Empty{}, nil
})
if err != nil {
t.Fatalf("removeCrackJob: %v", err)
}
if !removed || confirmCalls != 1 || deleteCalls != 1 {
t.Fatalf("removed=%v confirm calls=%d delete calls=%d, want true/1/1", removed, confirmCalls, deleteCalls)
}
}
func TestRemoveFetchedCrackJobRejectsMismatchedIDBeforeConfirmation(t *testing.T) {
requestedID := "aaaaaaaa-1111-4111-8111-111111111111"
returnedID := "bbbbbbbb-2222-4222-8222-222222222222"
confirmCalls := 0
deleteCalls := 0
removed, err := removeFetchedCrackJob(
t.Context(),
requestedID,
&clientpb.CrackJob{ID: returnedID, Status: clientpb.CrackJobStatus_COMPLETED},
func(string) (bool, error) {
confirmCalls++
return true, nil
},
func(context.Context, *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
return &commonpb.Empty{}, nil
},
)
if err == nil || !strings.Contains(err.Error(), requestedID) || !strings.Contains(err.Error(), returnedID) {
t.Fatalf("mismatched detail error = %v, want both job IDs", err)
}
if removed || confirmCalls != 0 || deleteCalls != 0 {
t.Fatalf("removed=%v confirm calls=%d delete calls=%d, want false/0/0", removed, confirmCalls, deleteCalls)
}
}
func TestRemoveCrackJobDeclineAndErrorsDoNotReportRemoval(t *testing.T) {
sentinel := errors.New("sentinel")
tests := []struct {
name string
job *clientpb.CrackJob
confirm crackJobConfirmFunc
deleteJob crackJobDeleteFunc
wantErr string
wantConfirm int
wantDelete int
}{
{
name: "active job rejected before confirmation",
job: &clientpb.CrackJob{ID: "active", Status: clientpb.CrackJobStatus_IN_PROGRESS},
wantErr: "only completed, failed, or cancelled jobs can be removed",
},
{
name: "declined",
job: &clientpb.CrackJob{ID: "completed", Status: clientpb.CrackJobStatus_COMPLETED},
confirm: func(string) (bool, error) { return false, nil },
wantConfirm: 1,
},
{
name: "confirmation error",
job: &clientpb.CrackJob{ID: "failed", Status: clientpb.CrackJobStatus_FAILED},
confirm: func(string) (bool, error) { return false, sentinel },
wantErr: "sentinel",
wantConfirm: 1,
},
{
name: "delete error",
job: &clientpb.CrackJob{ID: "cancelled", Status: clientpb.CrackJobStatus_CANCELLED},
confirm: func(string) (bool, error) { return true, nil },
deleteJob: func(context.Context, *clientpb.CrackJob) (*commonpb.Empty, error) { return nil, sentinel },
wantErr: "sentinel",
wantConfirm: 1,
wantDelete: 1,
},
{
name: "empty response",
wantErr: "server returned an empty crack job",
},
{
name: "empty ID",
job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_COMPLETED},
wantErr: "crack job ID cannot be empty",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
confirmCalls := 0
deleteCalls := 0
confirm := test.confirm
if confirm == nil {
confirm = func(string) (bool, error) {
confirmCalls++
return true, nil
}
} else {
wrapped := confirm
confirm = func(prompt string) (bool, error) {
confirmCalls++
return wrapped(prompt)
}
}
deleteJob := test.deleteJob
if deleteJob == nil {
deleteJob = func(context.Context, *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
return &commonpb.Empty{}, nil
}
} else {
wrapped := deleteJob
deleteJob = func(ctx context.Context, job *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
return wrapped(ctx, job)
}
}
removed, err := removeCrackJob(t.Context(), test.job, confirm, deleteJob)
if removed {
t.Fatal("removeCrackJob reported removal")
}
if test.wantErr == "" && err != nil {
t.Fatalf("removeCrackJob error = %v, want nil", err)
}
if test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)) {
t.Fatalf("removeCrackJob error = %v, want substring %q", err, test.wantErr)
}
if confirmCalls != test.wantConfirm || deleteCalls != test.wantDelete {
t.Fatalf("confirm calls=%d delete calls=%d, want %d/%d", confirmCalls, deleteCalls, test.wantConfirm, test.wantDelete)
}
})
}
}
func TestTerminalCrackJobsFiltersWithoutReordering(t *testing.T) {
jobs := []*clientpb.CrackJob{
{ID: "active", Status: clientpb.CrackJobStatus_IN_PROGRESS},
nil,
{ID: "failed", Status: clientpb.CrackJobStatus_FAILED},
{ID: "completed", Status: clientpb.CrackJobStatus_COMPLETED},
{ID: "cancelled", Status: clientpb.CrackJobStatus_CANCELLED},
}
got := terminalCrackJobs(jobs)
want := []*clientpb.CrackJob{jobs[2], jobs[3], jobs[4]}
if !reflect.DeepEqual(got, want) {
t.Fatalf("terminal jobs = %#v, want %#v", got, want)
}
}
File diff suppressed because it is too large. Load diff
-463
View File
@@ -1,463 +0,0 @@
package crack
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
"google.golang.org/protobuf/reflect/protoreflect"
)
func TestCrackJobCommandsAreRegistered(t *testing.T) {
root := Commands(nil)[0]
jobs, _, err := root.Find([]string{"jobs"})
if err != nil || jobs == root {
t.Fatalf("find crack jobs: command=%v error=%v", jobs, err)
}
top, _, err := root.Find([]string{"top"})
if err != nil || top == root {
t.Fatalf("find crack top: command=%v error=%v", top, err)
}
if top.Use != "top" {
t.Fatalf("crack top use = %q, want top", top.Use)
}
if err := top.Args(top, nil); err != nil {
t.Fatalf("crack top rejected no arguments: %v", err)
}
if err := top.Args(top, []string{"unexpected"}); err == nil {
t.Fatal("crack top accepted a positional argument")
}
pollInterval := top.Flags().Lookup("poll-interval")
if pollInterval == nil {
t.Fatal("crack top is missing --poll-interval")
}
if got, err := top.Flags().GetDuration("poll-interval"); err != nil || got != time.Second {
t.Fatalf("crack top --poll-interval = %s, %v; want %s", got, err, time.Second)
}
if top.PersistentFlags().Lookup("poll-interval") != nil || root.PersistentFlags().Lookup("poll-interval") != nil {
t.Fatal("--poll-interval must remain local to crack top")
}
if top.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack top must inherit --timeout")
}
job, _, err := root.Find([]string{"job", "job-id"})
if err != nil || job == root {
t.Fatalf("find crack job: command=%v error=%v", job, err)
}
if job.Flags().Lookup("watch") == nil || job.Flags().Lookup("poll-interval") == nil {
t.Fatal("crack job is missing watch flags")
}
if job.Use != "job [id]" {
t.Fatalf("crack job use = %q, want %q", job.Use, "job [id]")
}
if job.ValidArgsFunction == nil {
t.Fatal("crack job is missing ID completion")
}
if err := job.Args(job, nil); err != nil {
t.Fatalf("crack job rejected interactive selection: %v", err)
}
if err := job.Args(job, []string{"job-id"}); err != nil {
t.Fatalf("crack job rejected one job ID: %v", err)
}
if err := job.Args(job, []string{"first", "second"}); err == nil {
t.Fatal("crack job accepted more than one job ID")
}
}
func TestResolveCrackJobIDUsesExplicitIDWithoutListing(t *testing.T) {
listed := false
selected := false
jobID, err := resolveCrackJobID([]string{" full-job-id "}, func() (*clientpb.CrackJobs, error) {
listed = true
return nil, nil
}, func([]*clientpb.CrackJob) (string, error) {
selected = true
return "", nil
})
if err != nil {
t.Fatalf("resolveCrackJobID: %v", err)
}
if jobID != "full-job-id" {
t.Fatalf("job ID = %q, want full-job-id", jobID)
}
if listed || selected {
t.Fatalf("explicit ID unexpectedly listed=%v selected=%v", listed, selected)
}
}
func TestResolveCrackJobIDSelectsFromListedJobs(t *testing.T) {
wantJobs := []*clientpb.CrackJob{{ID: "first"}, {ID: "selected-full-id"}}
listCalls := 0
selectCalls := 0
jobID, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
listCalls++
return &clientpb.CrackJobs{Jobs: wantJobs}, nil
}, func(jobs []*clientpb.CrackJob) (string, error) {
selectCalls++
if len(jobs) != len(wantJobs) || jobs[1] != wantJobs[1] {
t.Fatalf("selector jobs = %#v, want %#v", jobs, wantJobs)
}
return "selected-full-id", nil
})
if err != nil {
t.Fatalf("resolveCrackJobID: %v", err)
}
if jobID != "selected-full-id" {
t.Fatalf("job ID = %q, want selected-full-id", jobID)
}
if listCalls != 1 || selectCalls != 1 {
t.Fatalf("list calls = %d, select calls = %d; want 1 each", listCalls, selectCalls)
}
}
func TestResolveCrackJobIDHandlesEmptyAndInvalidSelections(t *testing.T) {
selectorCalled := false
for _, listedJobs := range [][]*clientpb.CrackJob{nil, {nil, {ID: " "}}} {
_, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
return &clientpb.CrackJobs{Jobs: listedJobs}, nil
}, func([]*clientpb.CrackJob) (string, error) {
selectorCalled = true
return "unused", nil
})
if !errors.Is(err, errNoCrackJobs) {
t.Fatalf("empty job error = %v, want %v", err, errNoCrackJobs)
}
}
if selectorCalled {
t.Fatal("selector called with no jobs")
}
_, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
return &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{{ID: "job-id"}}}, nil
}, func([]*clientpb.CrackJob) (string, error) {
return " ", nil
})
if err == nil || !strings.Contains(err.Error(), "no crack job selected") {
t.Fatalf("blank selection error = %v", err)
}
_, err = resolveCrackJobID([]string{" "}, nil, nil)
if err == nil || !strings.Contains(err.Error(), "job ID cannot be empty") {
t.Fatalf("blank explicit ID error = %v", err)
}
}
func TestResolveCrackJobIDPropagatesUserAbort(t *testing.T) {
_, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
return &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{{ID: "job-id"}}}, nil
}, func([]*clientpb.CrackJob) (string, error) {
return "", forms.ErrUserAborted
})
if !errors.Is(err, forms.ErrUserAborted) {
t.Fatalf("selection error = %v, want user abort", err)
}
}
//nolint:gocyclo // One fixture verifies the complete newest-status telemetry contract and all presentation helpers.
func TestParseCrackStatusJSONUsesNewestStatus(t *testing.T) {
raw := []byte("{\"status\":1,\"progress\":[1,10]}\n" +
"{\"session\":\"distributed-job\",\"status\":2,\"target\":\"hashes\",\"progress\":[5,10]," +
"\"restore_point\":4,\"recovered_hashes\":[1,3],\"recovered_salts\":[1,2],\"rejected\":7," +
"\"time_start\":1700000000,\"estimated_stop\":1700000100," +
"\"guess\":{\"guess_mode\":3,\"guess_base\":\"?d?d\",\"guess_base_offset\":1,\"guess_base_count\":10," +
"\"guess_base_percent\":25.5,\"guess_mask_length\":2,\"guess_mod\":\"rules\",\"guess_mod_offset\":2,\"guess_mod_count\":4,\"guess_mod_percent\":50},\"devices\":[" +
"{\"device_id\":1,\"device_name\":\"GPU 0\",\"device_type\":\"GPU\",\"speed\":1500,\"temp\":65,\"util\":98,\"corespeed\":2100,\"memoryspeed\":9500,\"buslanes\":16}," +
"{\"id\":2,\"name\":\"GPU 1\",\"type\":\"GPU\",\"speed\":500,\"temperature\":70,\"utilization\":97}]}\n")
status, err := parseCrackStatusJSON(raw)
if err != nil {
t.Fatalf("parseCrackStatusJSON: %v", err)
}
if status.Status != "2" || status.ProgressCurrent != "5" || status.ProgressTotal != "10" {
t.Fatalf("unexpected status: %#v", status)
}
if got := status.progressText(); got != "5/10 (50.0%)" {
t.Fatalf("progressText = %q", got)
}
if got := status.speedText(); got != "2.00 kH/s" {
t.Fatalf("speedText = %q", got)
}
if got := status.temperatureText(); got != "70 C" {
t.Fatalf("temperatureText = %q", got)
}
if len(status.Devices) != 2 || status.Devices[1].Name != "GPU 1" {
t.Fatalf("devices = %#v", status.Devices)
}
if status.Session != "distributed-job" || status.Target != "hashes" || status.RestorePoint != "4" ||
status.RecoveredHashesCurrent != "1" || status.RecoveredHashesTotal != "3" ||
status.RecoveredSaltsCurrent != "1" || status.RecoveredSaltsTotal != "2" || status.Rejected != "7" ||
status.TimeStart != "1700000000" || status.EstimatedStop != "1700000100" {
t.Fatalf("full status fields were not decoded: %#v", status)
}
if status.Guess.Mode != "3" || status.Guess.Base != "?d?d" || status.Guess.BasePercent != "25.5" || status.Guess.MaskLength != "2" || status.Guess.Mod != "rules" || status.Guess.ModPercent != "50" {
t.Fatalf("guess = %#v", status.Guess)
}
if got := status.guessText(); !strings.Contains(got, "base=?d?d [1/10] (25.5%)") || !strings.Contains(got, "mask-length=2") || !strings.Contains(got, "mod=rules [2/4] (50%)") {
t.Fatalf("guessText = %q", got)
}
if status.Devices[0].CoreSpeed != "2100" || status.Devices[0].MemorySpeed != "9500" || status.Devices[0].BusLanes != "16" {
t.Fatalf("extended device telemetry = %#v", status.Devices[0])
}
}
func TestParseCrackStatusJSONRejectsMalformedOutput(t *testing.T) {
if _, err := parseCrackStatusJSON([]byte("hashcat is still starting")); err == nil {
t.Fatal("malformed status output was accepted")
}
}
func TestUnavailableDeviceTelemetryRendersAsUnknown(t *testing.T) {
status := &crackStatusView{Devices: []crackDeviceView{{Temperature: "-1"}}}
if got := status.temperatureText(); got != "-" {
t.Fatalf("temperatureText = %q, want -", got)
}
if got := unitValue("-1", " C"); got != "-" {
t.Fatalf("unitValue = %q, want -", got)
}
}
func TestRenderCrackJobIncludesTasksProgressDevicesAndResults(t *testing.T) {
status := []byte(`{"session":"job-session","status":2,"target":"hash-file","progress":[25,100],"restore_point":24,"recovered_hashes":[1,2],"recovered_salts":[1,1],"rejected":3,"time_start":1700000000,"estimated_stop":1700000100,"guess":{"guess_mode":3,"guess_base":"?d?d","guess_base_offset":0,"guess_base_count":1},"devices":[{"device_id":1,"device_name":"GPU 0","device_type":"GPU","speed":2500000,"temp":61,"util":99,"fanspeed":45,"corespeed":2100,"memoryspeed":9000,"buslanes":16,"power":125000}]}`)
recovered := []byte(`[{"hash":"hash-one","plaintext":"cGxhaW4tb25l"}]`)
task := &clientpb.CrackTask{
ID: "task-id",
HostUUID: "station-id",
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
UpdatedAt: 1700000002,
LatestStatusJSON: status,
RecoveredJSON: recovered,
ShardSkip: 100,
ShardLimit: 50,
}
job := &clientpb.CrackJob{
ID: "job-id",
CreatedAt: "2026-09-09T00:00:00Z",
UpdatedAt: 1700000003,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Keyspace: "1000000",
Tasks: []*clientpb.CrackTask{task},
Results: []*clientpb.CrackResult{{
Hash: "hash-one",
Plaintext: []byte("plain-one"),
}, {
CredentialID: "credential-id",
Hash: "hash-two",
Plaintext: []byte("plain-two"),
}},
}
rawOutput := renderCrackJob(job, settings.SliverDefault)
output := ansi.Strip(rawOutput)
for _, expected := range []string{
"job-id", "1000000", "task-id", "station-id", "100+50", "25/100 (25.0%)",
"2.50 MH/s", "61 C", "GPU 0", "credential-id", "hash-one", "plain-one", "hash-two", "plain-two",
"Hashcat Statistics", "job-session", "hash-file", "mode=3", "base=?d?d", "1/2", "1/1",
"2100 MHz", "9000 MHz", "16 lanes", "125000 mW", "2023-11-14T22:13:20Z", "2023-11-14T22:15:00Z",
} {
if !strings.Contains(output, expected) {
t.Errorf("rendered job does not contain %q:\n%s", expected, output)
}
}
for _, styled := range []string{
console.StyleBoldPrimary.Render("Crack Job"),
console.StyleBoldWarning.Render(clientpb.CrackJobStatus_IN_PROGRESS.String()),
console.StyleBoldPrimary.Render("Tasks"),
console.StyleBoldWarning.Render(clientpb.CrackTaskState_CRACK_TASK_RUNNING.String()),
console.StyleBoldPrimary.Render("Hashcat Statistics"),
console.StyleBoldPrimary.Render("Devices"),
console.StyleBoldSuccess.Render("Recovered Credentials"),
} {
if !strings.Contains(rawOutput, styled) {
t.Fatalf("rendered job is missing Lip Gloss styling %q:\n%s", styled, rawOutput)
}
}
if strings.Contains(rawOutput, console.StylePrimary.Render("task-id")) {
t.Fatalf("rendered task ID should use the default foreground color:\n%s", rawOutput)
}
}
func TestRenderCrackJobsSummarizesExtendedFields(t *testing.T) {
job := &clientpb.CrackJob{
ID: "job-id",
CreatedAt: "2026-09-09T00:00:00Z",
UpdatedAt: 1700000003,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Keyspace: "1000",
Tasks: []*clientpb.CrackTask{{ID: "task-id"}},
Results: []*clientpb.CrackResult{{Hash: "hash", Plaintext: []byte("plain")}},
}
rawOutput := renderCrackJobs([]*clientpb.CrackJob{job}, settings.SliverDefault)
output := ansi.Strip(rawOutput)
for _, expected := range []string{"job-id", "IN_PROGRESS", "1000", "1"} {
if !strings.Contains(output, expected) {
t.Errorf("rendered jobs do not contain %q:\n%s", expected, output)
}
}
if !strings.Contains(rawOutput, console.StyleBoldPrimary.Render("job-id")) ||
!strings.Contains(rawOutput, console.StyleBoldWarning.Render(clientpb.CrackJobStatus_IN_PROGRESS.String())) {
t.Fatalf("rendered jobs are missing Lip Gloss ID or status styling:\n%s", rawOutput)
}
}
func TestCrackJobStatusStyleUsesSemanticPalette(t *testing.T) {
tests := []struct {
status string
want console.TextStyle
}{
{status: "IN_PROGRESS", want: console.StyleBoldWarning},
{status: "CRACK_TASK_RUNNING", want: console.StyleBoldWarning},
{status: "CRACK_TASK_QUEUED", want: console.StyleBold},
{status: "CRACK_TASK_LEASED", want: console.StyleBoldWarning},
{status: "COMPLETED", want: console.StyleBoldSuccess},
{status: "CRACK_TASK_COMPLETED", want: console.StyleBoldSuccess},
{status: "CRACK_TASK_FAILED", want: console.StyleBoldDanger},
{status: "CANCELLED", want: console.StyleBoldGray},
{status: "CANCELED", want: console.StyleBoldGray},
{status: "PAUSED", want: console.StyleBoldGray},
{status: "CRACK_TASK_CANCELLED", want: console.StyleBoldGray},
{status: "UNKNOWN", want: console.StyleBold},
}
for _, test := range tests {
t.Run(test.status, func(t *testing.T) {
got := crackJobStatusStyle(test.status)
if got.GetBold() != test.want.GetBold() {
t.Fatalf("bold = %v, want %v", got.GetBold(), test.want.GetBold())
}
gotColor := got.GetForeground()
wantColor := test.want.GetForeground()
if (gotColor == nil) != (wantColor == nil) {
t.Fatalf("foreground = %v, want %v", gotColor, wantColor)
}
if gotColor == nil {
return
}
gotR, gotG, gotB, gotA := gotColor.RGBA()
wantR, wantG, wantB, wantA := wantColor.RGBA()
if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA {
t.Fatalf("foreground RGBA = (%d,%d,%d,%d), want (%d,%d,%d,%d)", gotR, gotG, gotB, gotA, wantR, wantG, wantB, wantA)
}
})
}
}
func TestRenderCrackJobStylesErrors(t *testing.T) {
rawOutput := renderCrackJob(&clientpb.CrackJob{
ID: "job-id",
Status: clientpb.CrackJobStatus_FAILED,
Err: "worker failed",
}, settings.SliverDefault)
for _, styled := range []string{
console.StyleBoldDanger.Render(clientpb.CrackJobStatus_FAILED.String()),
console.StyleBoldDanger.Render("Error"),
console.StyleDanger.Render("worker failed"),
} {
if !strings.Contains(rawOutput, styled) {
t.Fatalf("rendered error is missing Lip Gloss styling %q:\n%s", styled, rawOutput)
}
}
}
func TestCrackJobResultCountUsesSummaryField(t *testing.T) {
job := &clientpb.CrackJob{Results: []*clientpb.CrackResult{{Plaintext: []byte("not-a-summary-count")}}}
field := findProtoField(job.ProtoReflect(), "ResultCount")
want := "-"
if field != nil {
job.ProtoReflect().Set(field, protoreflect.ValueOfUint64(7))
want = "7"
}
if got := crackJobResultCount(job); got != want {
t.Fatalf("crackJobResultCount() = %q, want %q", got, want)
}
}
func TestCrackJobResultsRenderAuthoritativePlaintextBytes(t *testing.T) {
job := &clientpb.CrackJob{Results: []*clientpb.CrackResult{
{Hash: "valid", Plaintext: []byte("line\ntext\x1b[31m")},
{Hash: "binary", Plaintext: []byte{0xff, 0x00, 0x1b}},
{Hash: "nul", Plaintext: []byte{'a', 0, 'b'}},
}}
results := crackJobResults(job)
if len(results) != 3 {
t.Fatalf("crackJobResults() = %#v", results)
}
if results[0].Plaintext != "linetext[31m" {
t.Fatalf("valid UTF-8 plaintext = %q, want sanitized text", results[0].Plaintext)
}
if results[1].Plaintext != "0xff001b" {
t.Fatalf("binary plaintext = %q, want 0xff001b", results[1].Plaintext)
}
if results[2].Plaintext != "0x610062" {
t.Fatalf("NUL plaintext = %q, want 0x610062", results[2].Plaintext)
}
}
func TestWatchCrackJobPrintsChangesAndStopsAtTerminalState(t *testing.T) {
inProgress := &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}
completed := &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_COMPLETED}
responses := []*clientpb.CrackJob{inProgress, inProgress, completed}
requests := 0
fetch := func(_ context.Context, id string) (*clientpb.CrackJob, error) {
if id != "job-id" {
t.Fatalf("job ID = %q", id)
}
response := responses[requests]
requests++
return response, nil
}
updates := make([]clientpb.CrackJobStatus, 0)
err := watchCrackJob(context.Background(), "job-id", time.Millisecond, fetch, func(job *clientpb.CrackJob) {
updates = append(updates, job.Status)
})
if err != nil {
t.Fatalf("watchCrackJob: %v", err)
}
if requests != 3 {
t.Fatalf("requests = %d, want 3", requests)
}
if len(updates) != 2 || updates[0] != clientpb.CrackJobStatus_IN_PROGRESS || updates[1] != clientpb.CrackJobStatus_COMPLETED {
t.Fatalf("updates = %#v", updates)
}
}
func TestWatchCrackJobCancellationAndValidation(t *testing.T) {
fetch := func(_ context.Context, _ string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}, nil
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := watchCrackJob(ctx, "job-id", time.Hour, fetch, nil); !errors.Is(err, context.Canceled) {
t.Fatalf("watchCrackJob cancellation error = %v", err)
}
if err := watchCrackJob(context.Background(), "job-id", 0, fetch, nil); err == nil {
t.Fatal("watchCrackJob accepted zero poll interval")
}
}
func TestRecoveredResultsStripTerminalControlCharacters(t *testing.T) {
results := parseRecoveredResults([]byte(`[{"hash":"hash\u001b[31m","plaintext":"cGxhaW4KdGV4dA=="}]`))
if len(results) != 1 {
t.Fatalf("results = %#v", results)
}
if strings.ContainsAny(results[0].Hash, "\x1b\n\r") || strings.ContainsAny(results[0].Plaintext, "\x1b\n\r") {
t.Fatalf("control characters were not stripped: %#v", results[0])
}
}
func TestRecoveredResultsRenderNULPlaintextAsHex(t *testing.T) {
results := parseRecoveredResults([]byte(`[{"hash":"hash","plaintext":"YQBi"}]`))
if len(results) != 1 {
t.Fatalf("results = %#v", results)
}
if results[0].Plaintext != "0x610062" {
t.Fatalf("plaintext = %q, want 0x610062", results[0].Plaintext)
}
}
+46 -279
View File
@@ -22,8 +22,6 @@ import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
@@ -32,75 +30,10 @@ import (
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
)
// CrackCmd - GPU password cracking interface
func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
invocation, err := classifyCrackInvocation(cmd, args)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if invocation.mode == crackInvocationBackendInfo {
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
crackers, err := connectedCrackstations(ctx, con.Rpc)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if len(crackers) == 0 {
PrintNoCrackstations(con)
return
}
printCrackersWithBackendInfoLevel(crackers, con, false, invocation.backendInfoLevel)
return
}
if invocation.mode == crackInvocationJob || invocation.isSynchronousQuery() {
crackCmd, err := buildCrackCommand(cmd, args)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
if err := resolveManagedCrackFileReferences(ctx, crackCmd, func(ctx context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return con.Rpc.CrackFilesList(ctx, request)
}); err != nil {
con.PrintErrorf("%s\n", err)
return
}
resp, err := con.Rpc.Crack(ctx, crackCmd)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if invocation.isSynchronousQuery() {
value, stderr, err := crackQueryResponse(resp, invocation)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
printCrackQueryStderr(con, stderr)
printCrackQueryValue(con, value)
return
}
if resp == nil || resp.Job == nil {
con.PrintInfof("Crack request submitted\n")
return
}
con.PrintInfof("Crack job %s created (status: %s)\n", resp.Job.ID, resp.Job.Status.String())
if resp.Job.Err != "" {
con.PrintErrorf("Crack job error: %s\n", resp.Job.Err)
}
return
}
if !AreCrackersOnline(con) {
PrintNoCrackstations(con)
} else {
@@ -124,139 +57,20 @@ func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
}
}
func canonicalCrackKeyspace(response *clientpb.CrackResponse) (string, error) {
return crackQueryResponseValue(response, crackInvocation{mode: crackInvocationKeyspace})
}
func (invocation crackInvocation) isSynchronousQuery() bool {
switch invocation.mode {
case crackInvocationKeyspace, crackInvocationTotalCandidates, crackInvocationLookup, crackInvocationIdentify, crackInvocationHashInfo:
return true
default:
return false
}
}
func (invocation crackInvocation) queryMode() clientpb.CrackQueryMode {
switch invocation.mode {
case crackInvocationKeyspace:
return clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE
case crackInvocationTotalCandidates:
return clientpb.CrackQueryMode_CRACK_QUERY_TOTAL_CANDIDATES
case crackInvocationLookup:
return clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP
case crackInvocationIdentify:
return clientpb.CrackQueryMode_CRACK_QUERY_IDENTIFY
case crackInvocationHashInfo:
return clientpb.CrackQueryMode_CRACK_QUERY_HASH_INFO
default:
return clientpb.CrackQueryMode_CRACK_QUERY_UNSPECIFIED
}
}
func crackQueryResponseValue(response *clientpb.CrackResponse, invocation crackInvocation) (string, error) {
value, _, err := crackQueryResponse(response, invocation)
return value, err
}
func crackQueryResponse(response *clientpb.CrackResponse, invocation crackInvocation) (string, string, error) {
if response == nil {
return "", "", fmt.Errorf("server returned an empty %s response", crackInvocationName(invocation.mode))
}
expectedMode := invocation.queryMode()
if expectedMode == clientpb.CrackQueryMode_CRACK_QUERY_UNSPECIFIED {
return "", "", fmt.Errorf("invalid synchronous crack query mode")
}
query := response.GetQuery()
if query == nil {
// Servers predating typed query results returned only Keyspace. Do not use
// that legacy field for a selected station because it cannot prove which
// station produced the result.
if invocation.mode == crackInvocationKeyspace && invocation.crackstation == "" {
value, err := canonicalCrackDecimal(response.GetKeyspace(), "keyspace")
return value, "", err
}
return "", "", fmt.Errorf("server returned an empty %s query result", crackInvocationName(invocation.mode))
}
if query.GetMode() != expectedMode {
return "", "", fmt.Errorf("server returned %s for %s query", query.GetMode().String(), crackInvocationName(invocation.mode))
}
value := query.GetValue()
switch invocation.mode {
case crackInvocationKeyspace, crackInvocationTotalCandidates:
value, err := canonicalCrackDecimal(value, crackInvocationName(invocation.mode))
return value, query.GetStderr(), err
default:
if strings.TrimSpace(value) == "" {
return "", "", fmt.Errorf("server returned an empty %s query result", crackInvocationName(invocation.mode))
}
return value, query.GetStderr(), nil
}
}
func canonicalCrackDecimal(value string, name string) (string, error) {
raw := strings.TrimSpace(value)
keyspace, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return "", fmt.Errorf("server returned invalid %s %q", name, value)
}
return strconv.FormatUint(keyspace, 10), nil
}
func printCrackQueryValue(con *console.SliverClient, value string) {
con.Printf("%s", value)
if !strings.HasSuffix(value, "\n") {
con.Printf("\n")
}
}
func printCrackQueryStderr(con *console.SliverClient, value string) {
if value == "" {
return
}
if !strings.HasSuffix(value, "\n") {
value += "\n"
}
con.PrintWarnf("%s", value)
}
// CrackStationsCmd - Manage GPU cracking stations
func CrackStationsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
crackers, err := connectedCrackstations(ctx, con.Rpc)
crackers, err := con.Rpc.Crackstations(context.Background(), &commonpb.Empty{})
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
showBenchmarks, _ := cmd.Flags().GetBool("show-benchmarks")
if len(crackers) == 0 {
if len(crackers.Crackstations) == 0 {
PrintNoCrackstations(con)
} else {
PrintCrackers(crackers, con, showBenchmarks)
PrintCrackers(crackers.Crackstations, con)
}
}
func connectedCrackstations(ctx context.Context, rpc rpcpb.SliverRPCClient) ([]*clientpb.Crackstation, error) {
if ctx == nil {
ctx = context.Background()
}
response, err := rpc.Crackstations(ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
if response == nil {
return nil, nil
}
crackers := make([]*clientpb.Crackstation, 0, len(response.Crackstations))
for _, cracker := range response.Crackstations {
if cracker != nil {
crackers = append(crackers, cracker)
}
}
return crackers, nil
}
func PrintNoCrackstations(con *console.SliverClient) {
con.PrintInfof("No crackstations connected to server\n")
}
@@ -269,113 +83,66 @@ func AreCrackersOnline(con *console.SliverClient) bool {
return len(crackers.Crackstations) > 0
}
func PrintCrackers(crackers []*clientpb.Crackstation, con *console.SliverClient, showBenchmarks bool) {
printCrackersWithBackendInfoLevel(crackers, con, showBenchmarks, 1)
}
func printCrackersWithBackendInfoLevel(crackers []*clientpb.Crackstation, con *console.SliverClient, showBenchmarks bool, backendInfoLevel uint32) {
ordered := make([]*clientpb.Crackstation, 0, len(crackers))
for _, cracker := range crackers {
if cracker != nil {
ordered = append(ordered, cracker)
}
}
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].Name == ordered[j].Name {
return ordered[i].HostUUID < ordered[j].HostUUID
}
return ordered[i].Name < ordered[j].Name
func PrintCrackers(crackers []*clientpb.Crackstation, con *console.SliverClient) {
sort.Slice(crackers, func(i, j int) bool {
return crackers[i].Name < crackers[j].Name
})
for index, cracker := range ordered {
printCracker(cracker, index, con, showBenchmarks, backendInfoLevel)
if index < len(ordered)-1 {
for index, cracker := range crackers {
printCracker(cracker, index, con)
if index < len(crackers)-1 {
con.Println()
con.Println()
}
}
}
func printCracker(cracker *clientpb.Crackstation, index int, con *console.SliverClient, showBenchmarks bool, backendInfoLevel uint32) {
con.Printf("%s\n", renderCracker(cracker, index, settings.GetTableStyle(con), backendInfoLevel))
if showBenchmarks {
con.Println()
printBenchmarks(cracker, con)
}
}
func renderCracker(cracker *clientpb.Crackstation, index int, style table.Style, backendInfoLevel uint32) string {
if cracker == nil {
return ""
}
func printCracker(cracker *clientpb.Crackstation, index int, con *console.SliverClient) {
tw := table.NewWriter()
tw.SetStyle(style)
tw.SetTitle(console.StyleBoldOrange.Render(fmt.Sprintf(">>> Crackstation %02d - %s (%s)", index+1, safeCrackCell(cracker.Name), safeCrackCell(cracker.OperatorName))) + "\n")
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.Bold + console.Orange + fmt.Sprintf(">>> Crackstation %02d - %s (%s)", index+1, cracker.Name, cracker.OperatorName) + console.Normal + "\n")
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("Operating System"), fmt.Sprintf("%s/%s", valueOrDash(cracker.GOOS), valueOrDash(cracker.GOARCH))})
tw.AppendRow(table.Row{console.StyleBold.Render("Hashcat Version"), valueOrDash(cracker.HashcatVersion)})
if backendInfoLevel >= 2 {
appendCrackstationDetailRows(tw, cracker)
}
for _, cuda := range cracker.CUDA {
appendCUDABackendRows(tw, cuda, backendInfoLevel)
}
for _, hip := range cracker.HIP {
appendHIPBackendRows(tw, hip, backendInfoLevel)
}
for _, metal := range cracker.Metal {
appendMetalBackendRows(tw, metal, backendInfoLevel)
}
for _, openCL := range cracker.OpenCL {
appendOpenCLBackendRows(tw, openCL, backendInfoLevel)
}
if !hasReportedBackendDevice(cracker) {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("Backend Devices"), "No backend devices reported"})
}
return tw.Render()
}
func formatCUDADevice(device *clientpb.CUDABackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetCUDAVersion(), device.GetVersion())
}
func formatHIPDevice(device *clientpb.HIPBackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetHIPVersion(), device.GetVersion())
}
func formatMetalDevice(device *clientpb.MetalBackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetMetalVersion(), device.GetVersion())
}
func formatOpenCLDevice(device *clientpb.OpenCLBackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetOpenCLVersion(), device.GetVersion())
}
func formatBackendDevice(name string, versions ...string) string {
for _, version := range versions {
if version != "" {
return fmt.Sprintf("%s (%s)", name, version)
tw.AppendRow(table.Row{console.Bold + "Operating System" + console.Normal, fmt.Sprintf("%s/%s", cracker.GOOS, cracker.GOARCH)})
tw.AppendRow(table.Row{console.Bold + "Hashcat Version" + console.Normal, cracker.HashcatVersion})
if 0 < len(cracker.CUDA) {
for _, cuda := range cracker.CUDA {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.Bold + "CUDA Device" + console.Normal, fmt.Sprintf(console.Bold+console.Green+"%s (%s)"+console.Normal, cuda.Name, cuda.Version)})
tw.AppendRow(table.Row{console.Bold + "Memory" + console.Normal, fmt.Sprintf("%s free of %s", cuda.MemoryFree, cuda.MemoryTotal)})
tw.AppendRow(table.Row{console.Bold + "Clock" + console.Normal, fmt.Sprintf("%d", cuda.Clock)})
tw.AppendRow(table.Row{console.Bold + "Processors" + console.Normal, fmt.Sprintf("%d", cuda.Processors)})
}
}
return name
if 0 < len(cracker.Metal) {
for _, metal := range cracker.Metal {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.Bold + "Metal Device" + console.Normal, fmt.Sprintf(console.Bold+console.Green+"%s (%s)"+console.Normal, metal.Name, metal.Version)})
tw.AppendRow(table.Row{console.Bold + "Memory" + console.Normal, fmt.Sprintf("%s free of %s", metal.MemoryFree, metal.MemoryTotal)})
tw.AppendRow(table.Row{console.Bold + "Clock" + console.Normal, fmt.Sprintf("%d", metal.Clock)})
tw.AppendRow(table.Row{console.Bold + "Processors" + console.Normal, fmt.Sprintf("%d", metal.Processors)})
}
}
if 0 < len(cracker.OpenCL) {
for _, openCL := range cracker.OpenCL {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.Bold + "OpenCL Device" + console.Normal, fmt.Sprintf(console.Bold+console.Green+console.Bold+"%s (%s)"+console.Normal, openCL.Name, openCL.Version)})
tw.AppendRow(table.Row{console.Bold + "Memory" + console.Normal, fmt.Sprintf("%s free of %s", openCL.MemoryFree, openCL.MemoryTotal)})
tw.AppendRow(table.Row{console.Bold + "Clock" + console.Normal, fmt.Sprintf("%d", openCL.Clock)})
tw.AppendRow(table.Row{console.Bold + "Processors" + console.Normal, fmt.Sprintf("%d", openCL.Processors)})
}
}
con.Printf("%s\n", tw.Render())
con.Println()
printBenchmarks(cracker, con)
}
func printBenchmarks(cracker *clientpb.Crackstation, con *console.SliverClient) {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.StyleBold.Render("Benchmarks"))
tw.SetTitle(console.Bold + "Benchmarks" + console.Normal)
tw.SortBy([]table.SortBy{{Name: "Hash Type"}})
tw.AppendHeader(table.Row{"Hash Type", "Rate"})
if len(cracker.Benchmarks) == 0 {
tw.AppendRow(table.Row{"No benchmarks reported", "-"})
} else {
for hashType, speed := range cracker.Benchmarks {
name, ok := hashcatHashTypeName(hashType)
if !ok {
name = clientpb.HashType(hashType).String()
}
tw.AppendRow(table.Row{name, humanizeHashRate(speed)})
}
tw.AppendHeader(table.Row{"Hash Type", "Rate (H/s)"})
for hashType, speed := range cracker.Benchmarks {
tw.AppendRow(table.Row{clientpb.HashType(hashType), fmt.Sprintf("%d", speed)})
}
con.Printf("%s\n", tw.Render())
}
@@ -1,188 +0,0 @@
package crack
import (
"strings"
"testing"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestFormatBackendDevice(t *testing.T) {
tests := []struct {
name string
device string
versions []string
want string
}{
{
name: "specific runtime version wins",
device: "Apple M5",
versions: []string{"Metal 410.7", "legacy-generic"},
want: "Apple M5 (Metal 410.7)",
},
{
name: "legacy generic fallback",
device: "NVIDIA GPU",
versions: []string{"", "13.0"},
want: "NVIDIA GPU (13.0)",
},
{
name: "no empty version suffix",
device: "AMD GPU",
versions: []string{"", ""},
want: "AMD GPU",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatBackendDevice(test.device, test.versions...); got != test.want {
t.Fatalf("formatBackendDevice() = %q, want %q", got, test.want)
}
})
}
}
func TestBackendSpecificDisplayVersions(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{
name: "CUDA version",
got: formatCUDADevice(&clientpb.CUDABackendInfo{
Name: "NVIDIA GPU", CUDAVersion: "13.0", Version: "legacy",
}),
want: "NVIDIA GPU (13.0)",
},
{
name: "HIP version",
got: formatHIPDevice(&clientpb.HIPBackendInfo{
Name: "AMD GPU", HIPVersion: "7.0.51831", Version: "legacy",
}),
want: "AMD GPU (7.0.51831)",
},
{
name: "Metal version",
got: formatMetalDevice(&clientpb.MetalBackendInfo{
Name: "Apple M5", MetalVersion: "Metal 410.7", Version: "legacy",
}),
want: "Apple M5 (Metal 410.7)",
},
{
name: "legacy CUDA fallback",
got: formatCUDADevice(&clientpb.CUDABackendInfo{
Name: "NVIDIA GPU", Version: "12.9",
}),
want: "NVIDIA GPU (12.9)",
},
{
name: "OpenCL version",
got: formatOpenCLDevice(&clientpb.OpenCLBackendInfo{
Name: "Portable GPU", OpenCLVersion: "OpenCL 3.0", Version: "legacy",
}),
want: "Portable GPU (OpenCL 3.0)",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.got != test.want {
t.Fatalf("backend display = %q, want %q", test.got, test.want)
}
})
}
}
func TestRenderCrackerBackendInfoLevelTwo(t *testing.T) {
zero := uint32(0)
falseValue := false
station := &clientpb.Crackstation{
Name: "station",
OperatorName: "operator",
HostUUID: "host-uuid",
Version: "station-v1",
GOOS: "linux",
GOARCH: "amd64",
HashcatVersion: "v7.1.2",
CUDA: []*clientpb.CUDABackendInfo{{
Name: "CUDA GPU", CUDAVersion: "CUDA 13", Version: "device-v1", Type: "GPU",
Vendor: "NVIDIA", VendorID: 4318, BackendDeviceID: 1, BackendDeviceAlias: &zero,
PreferredThreadSize: &zero, MemoryUnified: &falseValue, LocalMemory: "64 KB",
CacheSize: "8 MB", PCIAddress: "01:00.0", MemoryFree: "8 GB", MemoryTotal: "12 GB",
}},
HIP: []*clientpb.HIPBackendInfo{{Name: "HIP GPU", HIPVersion: "HIP 7", BackendDeviceID: 2}},
Metal: []*clientpb.MetalBackendInfo{{
Name: "Metal GPU", MetalVersion: "Metal 410", BackendDeviceID: 3,
RegistryID: &zero, GPUHeadless: &falseValue, GPULowPower: &falseValue, GPURemovable: &falseValue,
PhysicalLocation: "built-in", MaxTXRate: "100", MemoryAllocPerBlock: "4 GB",
}},
OpenCL: []*clientpb.OpenCLBackendInfo{{
Name: "OpenCL GPU", OpenCLVersion: "OpenCL 3.0", OpenCLDriverVersion: "driver-v1",
BackendDeviceID: 4, PlatformVendor: "Portable", PlatformName: "Platform",
PlatformVersion: "platform-v1", MemoryAllocPerBlock: "2 GB",
}},
}
output := renderCracker(station, 0, settings.SliverDefault, 2)
for _, expected := range []string{
"station", "operator", "host-uuid", "station-v1", "linux/amd64", "v7.1.2",
"CUDA GPU", "HIP GPU", "Metal GPU", "OpenCL GPU", "OpenCL 3.0", "driver-v1",
"Backend Device ID", "Backend Device Alias", "Preferred Thread Size", "Unified Memory",
"NVIDIA", "4318", "device-v1", "64 KB", "8 MB", "01:00.0",
"Registry ID", "Headless", "Low Power", "Removable", "built-in", "100",
"OpenCL Platform ID", "Portable", "Platform", "platform-v1", "2 GB",
} {
if !strings.Contains(output, expected) {
t.Errorf("level-2 backend info does not contain %q:\n%s", expected, output)
}
}
}
func TestRenderCrackerBackendInfoLevelOneOmitsDetails(t *testing.T) {
station := &clientpb.Crackstation{
Name: "station",
CUDA: []*clientpb.CUDABackendInfo{{
Name: "GPU", BackendDeviceID: 7, PCIAddress: "01:00.0",
}},
}
output := renderCracker(station, 0, settings.SliverDefault, 1)
for _, unexpected := range []string{"Host UUID", "Backend Device ID", "PCI Address"} {
if strings.Contains(output, unexpected) {
t.Errorf("level-1 backend info unexpectedly contains %q:\n%s", unexpected, output)
}
}
}
func TestRenderCrackerPreservesOptionalPresence(t *testing.T) {
zero := uint32(0)
falseValue := false
withPresence := renderCracker(&clientpb.Crackstation{
CUDA: []*clientpb.CUDABackendInfo{{
BackendDeviceAlias: &zero,
MemoryUnified: &falseValue,
}},
}, 0, settings.SliverDefault, 2)
if !strings.Contains(withPresence, "Backend Device Alias") || !strings.Contains(withPresence, "Unified Memory") {
t.Fatalf("present zero/false optional fields were omitted:\n%s", withPresence)
}
withoutPresence := renderCracker(&clientpb.Crackstation{
CUDA: []*clientpb.CUDABackendInfo{{}},
}, 0, settings.SliverDefault, 2)
if strings.Contains(withoutPresence, "Backend Device Alias") || strings.Contains(withoutPresence, "Unified Memory") {
t.Fatalf("absent optional fields were rendered:\n%s", withoutPresence)
}
}
func TestRenderCrackerReportsNoBackendDevices(t *testing.T) {
output := renderCracker(&clientpb.Crackstation{
Name: "station",
CUDA: []*clientpb.CUDABackendInfo{nil},
}, 0, settings.SliverDefault, 1)
if !strings.Contains(output, "No backend devices reported") {
t.Fatalf("missing no-device message:\n%s", output)
}
}
-11
View File
@@ -1,11 +0,0 @@
package crack
import "github.com/bishopfox/sliver/client/command/crack/internal/hashcatdisplay"
func hashcatHashTypeName(mode int32) (string, bool) {
return hashcatdisplay.TypeName(mode)
}
func humanizeHashRate(rate uint64) string {
return hashcatdisplay.HumanizeRate(rate)
}
+6 -120
View File
@@ -3,127 +3,13 @@ package crack
import (
"context"
"fmt"
"strings"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
)
type crackJobCompletionEntry struct {
id string
description string
}
// CrackJobIDCompleter completes durable crack job IDs.
//
//nolint:revive // Keep the established exported completer name for compatibility.
func CrackJobIDCompleter(con *console.SliverClient) carapace.Action {
return crackJobIDCompleter(con, nil)
}
func crackTerminalJobIDCompleter(con *console.SliverClient) carapace.Action {
return crackJobIDCompleter(con, crackJobTerminal)
}
func crackJobPausable(job *clientpb.CrackJob) bool {
return job != nil && job.GetStatus() == clientpb.CrackJobStatus_IN_PROGRESS
}
func crackJobResumable(job *clientpb.CrackJob) bool {
return job != nil && job.GetStatus() == clientpb.CrackJobStatus_PAUSED
}
func crackJobCancellable(job *clientpb.CrackJob) bool {
return crackJobPausable(job) || crackJobResumable(job)
}
func crackJobIDCompleter(con *console.SliverClient, include func(*clientpb.CrackJob) bool) carapace.Action {
return carapace.ActionCallback(func(_ carapace.Context) carapace.Action {
entries, err := crackJobCompletionEntriesFiltered(con, include)
if err != nil {
return carapace.ActionMessage("failed to fetch crack jobs: %s", err.Error())
}
values := make([]string, 0, len(entries)*2)
for _, entry := range entries {
values = append(values, entry.id, entry.description)
}
return carapace.ActionValuesDescribed(values...).Tag("crack jobs")
})
}
func registerCrackJobIDCompletion(cmd *cobra.Command, con *console.SliverClient) {
registerCrackJobIDCompletionFiltered(cmd, con, nil)
}
func registerCrackTerminalJobIDCompletion(cmd *cobra.Command, con *console.SliverClient) {
registerCrackJobIDCompletionFiltered(cmd, con, crackJobTerminal)
}
func registerCrackJobIDCompletionFiltered(cmd *cobra.Command, con *console.SliverClient, include func(*clientpb.CrackJob) bool) {
if cmd == nil || cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
entries, err := crackJobCompletionEntriesFiltered(con, include)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
values := make([]string, 0, len(entries))
for _, entry := range entries {
if strings.HasPrefix(entry.id, toComplete) {
values = append(values, fmt.Sprintf("%s\t%s", entry.id, entry.description))
}
}
return values, cobra.ShellCompDirectiveNoFileComp
}
}
func crackJobCompletionEntries(con *console.SliverClient) ([]crackJobCompletionEntry, error) {
return crackJobCompletionEntriesFiltered(con, nil)
}
func crackJobCompletionEntriesFiltered(con *console.SliverClient, include func(*clientpb.CrackJob) bool) ([]crackJobCompletionEntry, error) {
if con == nil || con.Rpc == nil {
return nil, nil
}
jobs, err := con.Rpc.CrackJobs(context.Background(), &commonpb.Empty{})
if err != nil {
return nil, err
}
if jobs == nil {
return nil, nil
}
entries := make([]crackJobCompletionEntry, 0, len(jobs.GetJobs()))
for _, job := range jobs.GetJobs() {
if job == nil || strings.TrimSpace(job.GetID()) == "" || (include != nil && !include(job)) {
continue
}
entries = append(entries, crackJobCompletionEntry{
id: job.GetID(),
description: fmt.Sprintf(
"%s, created %s, keyspace %s, %d task(s), %d result(s)",
safeCrackCell(job.GetStatus().String()),
formatCrackTime(job.GetCreatedAt()),
valueOrDash(job.GetKeyspace()),
len(job.GetTasks()),
job.GetResultCount(),
),
})
}
return entries, nil
}
func CrackHcstat2Completer(con *console.SliverClient) carapace.Action {
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
hcstat2, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_MARKOV_HCSTAT2})
@@ -139,7 +25,7 @@ func CrackHcstat2Completer(con *console.SliverClient) carapace.Action {
}
results = append(results, file.Name)
desc := fmt.Sprintf("%s (%d chunks, chunk size: %s)", byteCountSI(file.UncompressedSize), len(file.Chunks), byteCountSI(file.ChunkSize))
desc := fmt.Sprintf("%s (%s chunks, chunk size: %s)", byteCountSI(file.UncompressedSize), len(file.Chunks), byteCountSI(file.ChunkSize))
results = append(results, desc)
}
@@ -149,14 +35,14 @@ func CrackHcstat2Completer(con *console.SliverClient) carapace.Action {
func CrackWordlistCompleter(con *console.SliverClient) carapace.Action {
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
wordlists, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_WORDLIST})
hcstat2, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_MARKOV_HCSTAT2})
if err != nil {
return carapace.ActionMessage("failed to fetch crack files: %s", err.Error())
}
results := make([]string, 0)
for _, file := range wordlists.Files {
for _, file := range hcstat2.Files {
if file.Type != clientpb.CrackFileType_WORDLIST {
continue
}
@@ -173,14 +59,14 @@ func CrackWordlistCompleter(con *console.SliverClient) carapace.Action {
func CrackRulesCompleter(con *console.SliverClient) carapace.Action {
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
rules, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_RULES})
hcstat2, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_MARKOV_HCSTAT2})
if err != nil {
return carapace.ActionMessage("failed to fetch crack files: %s", err.Error())
}
results := make([]string, 0)
for _, file := range rules.Files {
for _, file := range hcstat2.Files {
if file.Type != clientpb.CrackFileType_RULES {
continue
}
@@ -191,7 +77,7 @@ func CrackRulesCompleter(con *console.SliverClient) carapace.Action {
results = append(results, desc)
}
return carapace.ActionValuesDescribed(results...).Tag("rules")
return carapace.ActionValuesDescribed(results...).Tag("wordlists")
})
}
-145
View File
@@ -1,145 +0,0 @@
package crack
import (
"context"
"errors"
"strings"
"testing"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"google.golang.org/grpc"
)
type crackFilesListCapture struct {
rpcpb.SliverRPCClient
requests []clientpb.CrackFileType
}
type crackJobsListCapture struct {
rpcpb.SliverRPCClient
jobs *clientpb.CrackJobs
err error
calls int
}
func (capture *crackJobsListCapture) CrackJobs(_ context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.CrackJobs, error) {
capture.calls++
return capture.jobs, capture.err
}
func (capture *crackFilesListCapture) CrackFilesList(_ context.Context, request *clientpb.CrackFile, _ ...grpc.CallOption) (*clientpb.CrackFiles, error) {
capture.requests = append(capture.requests, request.GetType())
return &clientpb.CrackFiles{}, nil
}
func TestCrackFileCompletersRequestTheirOwnFileType(t *testing.T) {
tests := []struct {
name string
want clientpb.CrackFileType
action func(*console.SliverClient) carapace.Action
}{
{name: "wordlists", want: clientpb.CrackFileType_WORDLIST, action: CrackWordlistCompleter},
{name: "rules", want: clientpb.CrackFileType_RULES, action: CrackRulesCompleter},
{name: "hcstat2", want: clientpb.CrackFileType_MARKOV_HCSTAT2, action: CrackHcstat2Completer},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
capture := &crackFilesListCapture{}
client := &console.SliverClient{Rpc: capture}
_ = test.action(client).Invoke(carapace.NewContext(""))
if len(capture.requests) != 1 || capture.requests[0] != test.want {
t.Fatalf("completion requests = %v, want [%s]", capture.requests, test.want)
}
})
}
}
func TestCrackJobCompleterRequestsDurableJobs(t *testing.T) {
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{{
ID: "12345678-1234-1234-1234-123456789abc",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
CreatedAt: "2026-09-11T12:00:00Z",
Keyspace: "1000",
Tasks: []*clientpb.CrackTask{{ID: "task-id"}},
ResultCount: 2,
}}}}
client := &console.SliverClient{Rpc: capture}
_ = CrackJobIDCompleter(client).Invoke(carapace.NewContext(""))
if capture.calls != 1 {
t.Fatalf("CrackJobs calls = %d, want 1", capture.calls)
}
}
func TestCrackJobCobraCompletionUsesFullIDsAndFiltersPrefix(t *testing.T) {
firstID := "12345678-1234-1234-1234-123456789abc"
secondID := "abcdefab-cdef-cdef-cdef-abcdefabcdef"
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{
{
ID: firstID,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
CreatedAt: "2026-09-11T12:00:00Z",
Keyspace: "1000",
Tasks: []*clientpb.CrackTask{{ID: "task-id"}},
ResultCount: 2,
},
{ID: secondID, Status: clientpb.CrackJobStatus_COMPLETED},
nil,
{},
}}}
client := &console.SliverClient{Rpc: capture}
root := Commands(client)[0]
job, _, err := root.Find([]string{"job"})
if err != nil {
t.Fatalf("find crack job: %v", err)
}
values, directive := job.ValidArgsFunction(job, nil, "1234")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion directive = %v, want no-file-completion", directive)
}
if len(values) != 1 {
t.Fatalf("completion values = %#v, want one filtered result", values)
}
if !strings.HasPrefix(values[0], firstID+"\t") {
t.Fatalf("completion = %q, want full ID %q", values[0], firstID)
}
for _, want := range []string{"IN_PROGRESS", "2026-09-11T12:00:00Z", "keyspace 1000", "1 task(s)", "2 result(s)"} {
if !strings.Contains(values[0], want) {
t.Errorf("completion %q does not contain %q", values[0], want)
}
}
values, directive = job.ValidArgsFunction(job, []string{firstID}, "")
if len(values) != 0 || directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("second positional completion = %#v, %v", values, directive)
}
}
func TestCrackJobCompletionHandlesRPCFailuresAndNilData(t *testing.T) {
for _, test := range []struct {
name string
capture *crackJobsListCapture
}{
{name: "rpc error", capture: &crackJobsListCapture{err: errors.New("rpc failed")}},
{name: "nil response", capture: &crackJobsListCapture{}},
} {
t.Run(test.name, func(t *testing.T) {
client := &console.SliverClient{Rpc: test.capture}
root := Commands(client)[0]
job, _, err := root.Find([]string{"job"})
if err != nil {
t.Fatalf("find crack job: %v", err)
}
values, directive := job.ValidArgsFunction(job, nil, "")
if len(values) != 0 || directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion = %#v, %v", values, directive)
}
})
}
}
@@ -1,422 +0,0 @@
// Package hashcatdisplay provides shared Hashcat presentation helpers for crack commands.
package hashcatdisplay
import "fmt"
// Hashcat mode names sourced from client/credentials/EXAMPLES.md.
var hashcatHashTypes = map[int32]string{
0: "MD5",
10: "md5($pass.$salt)",
11: "Joomla < 2.5.18",
12: "PostgreSQL",
20: "md5($salt.$pass)",
21: "osCommerce, xt:Commerce",
22: "Juniper NetScreen/SSG (ScreenOS)",
23: "Skype",
24: "SolarWinds Serv-U",
30: "md5(utf16le($pass).$salt)",
40: "md5($salt.utf16le($pass))",
50: "HMAC-MD5 (key = $pass)",
60: "HMAC-MD5 (key = $salt)",
70: "md5(utf16le($pass))",
100: "SHA1",
101: "nsldap, SHA-1(Base64), Netscape LDAP SHA",
110: "sha1($pass.$salt)",
111: "nsldaps, SSHA-1(Base64), Netscape LDAP SSHA",
112: "Oracle S: Type (Oracle 11+)",
120: "sha1($salt.$pass)",
121: "SMF (Simple Machines Forum) > v1.1",
122: "macOS v10.4, macOS v10.5, macOS v10.6",
123: "EPi",
124: "Django (SHA-1)",
125: "ArubaOS",
130: "sha1(utf16le($pass).$salt)",
131: "MSSQL (2000)",
132: "MSSQL (2005)",
133: "PeopleSoft",
140: "sha1($salt.utf16le($pass))",
141: "Episerver 6.x < .NET 4",
150: "HMAC-SHA1 (key = $pass)",
160: "HMAC-SHA1 (key = $salt)",
170: "sha1(utf16le($pass))",
190: "sha1(LinkedIn)",
200: "MySQL323",
300: "MySQL4.1/MySQL5",
400: "phpass, phpBB3 (MD5)",
500: "md5crypt, MD5 (Unix), Cisco-IOS $1$ (MD5)",
501: "Juniper IVE",
600: "BLAKE2b-512",
900: "MD4",
1000: "NTLM",
1100: "Domain Cached Credentials (DCC), MS Cache",
1300: "SHA2-224",
1400: "SHA2-256",
1410: "sha256($pass.$salt)",
1411: "SSHA-256(Base64), LDAP {SSHA256}",
1420: "sha256($salt.$pass)",
1421: "hMailServer",
1430: "sha256(utf16le($pass).$salt)",
1431: "base64(sha256(unicode($pass)))",
1440: "sha256($salt.utf16le($pass))",
1441: "Episerver 6.x >= .NET 4",
1450: "HMAC-SHA256 (key = $pass)",
1460: "HMAC-SHA256 (key = $salt)",
1470: "sha256(utf16le($pass))",
1500: "descrypt, DES (Unix), Traditional DES",
1600: "Apache $apr1$ MD5, md5apr1, MD5 (APR)",
1700: "SHA2-512",
1710: "sha512($pass.$salt)",
1711: "SSHA-512(Base64), LDAP {SSHA512}",
1720: "sha512($salt.$pass)",
1722: "macOS v10.7",
1730: "sha512(utf16le($pass).$salt)",
1731: "MSSQL (2012, 2014)",
1740: "sha512($salt.utf16le($pass))",
1750: "HMAC-SHA512 (key = $pass)",
1760: "HMAC-SHA512 (key = $salt)",
1770: "sha512(utf16le($pass))",
1800: "sha512crypt $6$, SHA512 (Unix)",
2000: "STDOUT",
2100: "Domain Cached Credentials 2 (DCC2), MS Cache 2",
2400: "Cisco-PIX MD5",
2410: "Cisco-ASA MD5",
2500: "WPA-EAPOL-PBKDF2",
2501: "WPA-EAPOL-PMK",
2600: "md5(md5($pass))",
2611: "vBulletin < v3.8.5",
2612: "PHPS",
2711: "vBulletin >= v3.8.5",
2811: "MyBB 1.2+, IPB2+ (Invision Power Board)",
3000: "LM",
3100: "Oracle H: Type (Oracle 7+)",
3200: "bcrypt $2\\*$, Blowfish (Unix)",
3300: "MD5(Sun)",
3500: "md5(md5(md5($pass)))",
3610: "md5(md5($salt).$pass )",
3710: "md5($salt.md5($pass))",
3711: "MediaWiki B type",
3720: "md5($pass.md5($salt))",
3721: "WebEdition CMS",
3800: "md5($salt.$pass.$salt)",
3910: "md5(md5($pass).md5($salt))",
4010: "md5($salt.md5($salt.$pass))",
4110: "md5($salt.md5($pass.$salt))",
4210: "md5($username.0.$pass)",
4300: "md5(strtoupper(md5($pass)))",
4400: "md5(sha1($pass))",
4500: "sha1(sha1($pass))",
4510: "sha1(sha1($pass).$salt)",
4520: "sha1($salt.sha1($pass))",
4521: "Redmine",
4522: "PunBB",
4600: "sha1(sha1(sha1($pass)))",
4700: "sha1(md5($pass))",
4710: "sha1(md5($pass).$salt)",
4711: "Huawei sha1(md5($pass).$salt)",
4800: "iSCSI CHAP authentication, MD5(CHAP)",
4900: "sha1($salt.$pass.$salt)",
5000: "sha1(sha1($salt.$pass.$salt))",
5100: "Half MD5",
5200: "Password Safe v3",
5300: "IKE-PSK MD5",
5400: "IKE-PSK SHA1",
5500: "NetNTLMv1 / NetNTLMv1+ESS",
5600: "NetNTLMv2",
5700: "Cisco-IOS type 4 (SHA256)",
5800: "Samsung Android Password/PIN",
6000: "RIPEMD-160",
6100: "Whirlpool",
6211: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish",
6212: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-AES / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent",
6213: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish-Serpent / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES",
6221: "TrueCrypt 5.0+ SHA512 + AES / TrueCrypt 5.0+ SHA512 + Serpent / TrueCrypt 5.0+ SHA512 + Twofish",
6222: "TrueCrypt 5.0+ SHA512 + AES-Twofish / TrueCrypt 5.0+ SHA512 + Serpent-AES / TrueCrypt 5.0+ SHA512 + Twofish-Serpent",
6223: "TrueCrypt 5.0+ SHA512 + AES-Twofish-Serpent / TrueCrypt 5.0+ SHA512 + Serpent-Twofish-AES",
6231: "TrueCrypt 5.0+ Whirlpool + AES / TrueCrypt 5.0+ Whirlpool + Serpent / TrueCrypt 5.0+ Whirlpool + Twofish",
6232: "TrueCrypt 5.0+ Whirlpool + AES-Twofish / TrueCrypt 5.0+ Whirlpool + Serpent-AES / TrueCrypt 5.0+ Whirlpool + Twofish-Serpent",
6233: "TrueCrypt 5.0+ Whirlpool + AES-Twofish-Serpent / TrueCrypt 5.0+ Whirlpool + Serpent-Twofish-AES",
6241: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish + boot",
6242: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-AES + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent + boot",
6243: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish-Serpent + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES + boot",
6300: "AIX {smd5}",
6400: "AIX {ssha256}",
6500: "AIX {ssha512}",
6600: "1Password, agilekeychain",
6700: "AIX {ssha1}",
6800: "LastPass + LastPass sniffed",
6900: "GOST R 34.11-94",
7000: "FortiGate (FortiOS)",
7100: "macOS v10.8+ (PBKDF2-SHA512)",
7200: "GRUB 2",
7300: "IPMI2 RAKP HMAC-SHA1",
7400: "sha256crypt $5$, SHA256 (Unix)",
7401: "MySQL $A$ (sha256crypt)",
7500: "Kerberos 5, etype 23, AS-REQ Pre-Auth",
7700: "SAP CODVN B (BCODE)",
7701: "SAP CODVN B (BCODE) from RFC_READ_TABLE",
7800: "SAP CODVN F/G (PASSCODE)",
7801: "SAP CODVN F/G (PASSCODE) from RFC_READ_TABLE",
7900: "Drupal7",
8000: "Sybase ASE",
8100: "Citrix NetScaler (SHA1)",
8200: "1Password, cloudkeychain",
8300: "DNSSEC (NSEC3)",
8400: "WBB3 (Woltlab Burning Board)",
8500: "RACF",
8600: "Lotus Notes/Domino 5",
8700: "Lotus Notes/Domino 6",
8800: "Android FDE <= 4.3",
8900: "scrypt",
9000: "Password Safe v2",
9100: "Lotus Notes/Domino 8",
9200: "Cisco-IOS $8$ (PBKDF2-SHA256)",
9300: "Cisco-IOS $9$ (scrypt)",
9400: "MS Office 2007",
9500: "MS Office 2010",
9600: "MS Office 2013",
9700: "MS Office <= 2003 MD5 + RC4, oldoffice$0, oldoffice$1",
9710: "MS Office <= 2003 $0/$1, MD5 + RC4, collider #1",
9720: "MS Office <= 2003 $0/$1, MD5 + RC4, collider #2",
9800: "MS Office <= 2003 SHA1 + RC4, oldoffice$3, oldoffice$4",
9810: "MS Office <= 2003 $3, SHA1 + RC4, collider #1",
9820: "MS Office <= 2003 $3, SHA1 + RC4, collider #2",
9900: "Radmin2",
10000: "Django (PBKDF2-SHA256)",
10100: "SipHash",
10200: "CRAM-MD5",
10300: "SAP CODVN H (PWDSALTEDHASH) iSSHA-1",
10400: "PDF 1.1 - 1.3 (Acrobat 2 - 4)",
10410: "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #1",
10420: "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #2",
10500: "PDF 1.4 - 1.6 (Acrobat 5 - 8)",
10600: "PDF 1.7 Level 3 (Acrobat 9)",
10700: "PDF 1.7 Level 8 (Acrobat 10 - 11)",
10800: "SHA2-384",
10810: "sha384($pass.$salt)",
10820: "sha384($salt.$pass)",
10830: "sha384(utf16le($pass).$salt)",
10840: "sha384($salt.utf16le($pass))",
10870: "sha384(utf16le($pass))",
10900: "PBKDF2-HMAC-SHA256",
10901: "RedHat 389-DS LDAP (PBKDF2-HMAC-SHA256)",
11000: "PrestaShop",
11100: "PostgreSQL CRAM (MD5)",
11200: "MySQL CRAM (SHA1)",
11300: "Bitcoin/Litecoin wallet.dat",
11400: "SIP digest authentication (MD5)",
11500: "CRC32",
11600: "7-Zip",
11700: "GOST R 34.11-2012 (Streebog) 256-bit, big-endian",
11750: "HMAC-Streebog-256 (key = $pass), big-endian",
11760: "HMAC-Streebog-256 (key = $salt), big-endian",
11800: "GOST R 34.11-2012 (Streebog) 512-bit, big-endian",
11850: "HMAC-Streebog-512 (key = $pass), big-endian",
11860: "HMAC-Streebog-512 (key = $salt), big-endian",
11900: "PBKDF2-HMAC-MD5",
12000: "PBKDF2-HMAC-SHA1",
12001: "Atlassian (PBKDF2-HMAC-SHA1)",
12100: "PBKDF2-HMAC-SHA512",
12200: "eCryptfs",
12300: "Oracle T: Type (Oracle 12+)",
12400: "BSDi Crypt, Extended DES",
12500: "RAR3-hp",
12600: "ColdFusion 10+",
12700: "Blockchain, My Wallet",
12800: "MS-AzureSync PBKDF2-HMAC-SHA256",
12900: "Android FDE (Samsung DEK)",
13000: "RAR5",
13100: "Kerberos 5, etype 23, TGS-REP",
13200: "AxCrypt 1",
13300: "AxCrypt 1 in-memory SHA1",
13400: "KeePass 1 AES / without keyfile / KeePass 2 AES / without keyfile / KeePass 1 Twofish / with keyfile / Keepass 2 AES / with keyfile",
13500: "PeopleSoft PS_TOKEN",
13600: "WinZip",
13711: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + AES / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Twofish",
13712: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + AES-Twofish / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent-AES / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent",
13713: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES",
13721: "VeraCrypt PBKDF2-HMAC-SHA512 + AES / VeraCrypt PBKDF2-HMAC-SHA512 + Serpent / VeraCrypt PBKDF2-HMAC-SHA512 + Twofish",
13722: "VeraCrypt PBKDF2-HMAC-SHA512 + AES-Twofish / VeraCrypt PBKDF2-HMAC-SHA512 + Serpent-AES / VeraCrypt PBKDF2-HMAC-SHA512 + Twofish-Serpent",
13723: "VeraCrypt PBKDF2-HMAC-SHA512 + Serpent-Twofish-AES",
13731: "VeraCrypt PBKDF2-HMAC-Whirlpool + AES / VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent / VeraCrypt PBKDF2-HMAC-Whirlpool + Twofish",
13732: "VeraCrypt PBKDF2-HMAC-Whirlpool + AES-Twofish / VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent-AES / VeraCrypt PBKDF2-HMAC-Whirlpool + Twofish-Serpent",
13733: "VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent-Twofish-AES",
13741: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES",
13742: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES-Twofish",
13743: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES-Twofish-Serpent",
13751: "VeraCrypt PBKDF2-HMAC-SHA256 + AES / VeraCrypt PBKDF2-HMAC-SHA256 + Serpent / VeraCrypt PBKDF2-HMAC-SHA256 + Twofish",
13752: "VeraCrypt PBKDF2-HMAC-SHA256 + AES-Twofish / VeraCrypt PBKDF2-HMAC-SHA256 + Serpent-AES / VeraCrypt PBKDF2-HMAC-SHA256 + Twofish-Serpent",
13753: "VeraCrypt PBKDF2-HMAC-SHA256 + Serpent-Twofish-AES",
13761: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Twofish / VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + PIM + AES",
13762: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Serpent-AES",
13763: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Serpent-Twofish-AES",
13771: "VeraCrypt Streebog-512 + XTS 512 bit",
13772: "VeraCrypt Streebog-512 + XTS 1024 bit",
13773: "VeraCrypt Streebog-512 + XTS 1536 bit",
13800: "Windows Phone 8+ PIN/password",
13900: "OpenCart",
14000: "DES (PT = $salt, key = $pass)",
14100: "3DES (PT = $salt, key = $pass)",
14400: "sha1(CX)",
14500: "Linux Kernel Crypto API (2.4)",
14600: "LUKS",
14700: "iTunes backup < 10.0",
14800: "iTunes backup >= 10.0",
14900: "Skip32 (PT = $salt, key = $pass)",
15000: "FileZilla Server >= 0.9.55",
15100: "Juniper/NetBSD sha1crypt",
15200: "Blockchain, My Wallet, V2",
15300: "DPAPI masterkey file v1 + local context",
15400: "ChaCha20",
15500: "JKS Java Key Store Private Keys (SHA1)",
15600: "Ethereum Wallet, PBKDF2-HMAC-SHA256",
15700: "Ethereum Wallet, SCRYPT",
15900: "DPAPI masterkey file v2 + Active Directory domain context",
16000: "Tripcode",
16100: "TACACS+",
16200: "Apple Secure Notes",
16300: "Ethereum Pre-Sale Wallet, PBKDF2-HMAC-SHA256",
16400: "CRAM-MD5 Dovecot",
16500: "JWT (JSON Web Token)",
16600: "Electrum Wallet (Salt-Type 1-3)",
16700: "FileVault 2",
16800: "WPA-PMKID-PBKDF2",
16801: "WPA-PMKID-PMK",
16900: "Ansible Vault",
17200: "PKZIP (Compressed)",
17210: "PKZIP (Uncompressed)",
17220: "PKZIP (Compressed Multi-File)",
17225: "PKZIP (Mixed Multi-File)",
17230: "PKZIP (Mixed Multi-File Checksum-Only)",
17300: "SHA3-224",
17400: "SHA3-256",
17500: "SHA3-384",
17600: "SHA3-512",
17700: "Keccak-224",
17800: "Keccak-256",
17900: "Keccak-384",
18000: "Keccak-512",
18100: "TOTP (HMAC-SHA1)",
18200: "Kerberos 5, etype 23, AS-REP",
18300: "Apple File System (APFS)",
18400: "Open Document Format (ODF) 1.2 (SHA-256, AES)",
18500: "sha1(md5(md5($pass)))",
18600: "Open Document Format (ODF) 1.1 (SHA-1, Blowfish)",
18700: "Java Object hashCode()",
18800: "Blockchain, My Wallet, Second Password (SHA256)",
18900: "Android Backup",
19000: "QNX /etc/shadow (MD5)",
19100: "QNX /etc/shadow (SHA256)",
19200: "QNX /etc/shadow (SHA512)",
19300: "sha1($salt1.$pass.$salt2)",
19500: "Ruby on Rails Restful-Authentication",
19600: "Kerberos 5, etype 17, TGS-REP (AES128-CTS-HMAC-SHA1-96)",
19700: "Kerberos 5, etype 18, TGS-REP (AES256-CTS-HMAC-SHA1-96)",
19800: "Kerberos 5, etype 17, Pre-Auth",
19900: "Kerberos 5, etype 18, Pre-Auth",
20011: "DiskCryptor SHA512 + XTS 512 bit (AES) / DiskCryptor SHA512 + XTS 512 bit (Twofish) / DiskCryptor SHA512 + XTS 512 bit (Serpent)",
20012: "DiskCryptor SHA512 + XTS 1024 bit (AES-Twofish) / DiskCryptor SHA512 + XTS 1024 bit (Twofish-Serpent) / DiskCryptor SHA512 + XTS 1024 bit (Serpent-AES)",
20013: "DiskCryptor SHA512 + XTS 1536 bit (AES-Twofish-Serpent)",
20200: "Python passlib pbkdf2-sha512",
20300: "Python passlib pbkdf2-sha256",
20400: "Python passlib pbkdf2-sha1",
20500: "PKZIP Master Key",
20510: "PKZIP Master Key (6 byte optimization)",
20600: "Oracle Transportation Management (SHA256)",
20710: "sha256(sha256($pass).$salt)",
20711: "AuthMe sha256",
20720: "sha256($salt.sha256($pass))",
20800: "sha256(md5($pass))",
20900: "md5(sha1($pass).md5($pass).sha1($pass))",
21000: "BitShares v0.x - sha512(sha512_bin(pass))",
21100: "sha1(md5($pass.$salt))",
21200: "md5(sha1($salt).md5($pass))",
21300: "md5($salt.sha1($salt.$pass))",
21400: "sha256(sha256_bin($pass))",
21500: "SolarWinds Orion",
21501: "SolarWinds Orion v2",
21600: "Web2py pbkdf2-sha512",
21700: "Electrum Wallet (Salt-Type 4)",
21800: "Electrum Wallet (Salt-Type 5)",
22000: "WPA-PBKDF2-PMKID+EAPOL",
22001: "WPA-PMK-PMKID+EAPOL",
22100: "BitLocker",
22200: "Citrix NetScaler (SHA512)",
22300: "sha256($salt.$pass.$salt)",
22301: "Telegram Mobile App Passcode (SHA256)",
22400: "AES Crypt (SHA256)",
22500: "MultiBit Classic .key (MD5)",
22600: "Telegram Desktop < v2.1.14 (PBKDF2-HMAC-SHA1)",
22700: "MultiBit HD (scrypt)",
22911: "RSA/DSA/EC/OpenSSH Private Keys ($0$)",
22921: "RSA/DSA/EC/OpenSSH Private Keys ($6$)",
22931: "RSA/DSA/EC/OpenSSH Private Keys ($1, $3$)",
22941: "RSA/DSA/EC/OpenSSH Private Keys ($4$)",
22951: "RSA/DSA/EC/OpenSSH Private Keys ($5$)",
23001: "SecureZIP AES-128",
23002: "SecureZIP AES-192",
23003: "SecureZIP AES-256",
23100: "Apple Keychain",
23200: "XMPP SCRAM PBKDF2-SHA1",
23300: "Apple iWork",
23400: "Bitwarden",
23500: "AxCrypt 2 AES-128",
23600: "AxCrypt 2 AES-256",
23700: "RAR3-p (Uncompressed)",
23800: "RAR3-p (Compressed)",
23900: "BestCrypt v3 Volume Encryption",
24100: "MongoDB ServerKey SCRAM-SHA-1",
24200: "MongoDB ServerKey SCRAM-SHA-256",
24300: "sha1($salt.sha1($pass.$salt))",
24410: "PKCS#8 Private Keys (PBKDF2-HMAC-SHA1 + 3DES/AES)",
24420: "PKCS#8 Private Keys (PBKDF2-HMAC-SHA256 + 3DES/AES)",
24500: "Telegram Desktop >= v2.1.14 (PBKDF2-HMAC-SHA512)",
24600: "SQLCipher",
24700: "Stuffit5",
24800: "Umbraco HMAC-SHA1",
24900: "Dahua Authentication MD5",
25300: "MS Office 2016 - SheetProtection",
25400: "PDF 1.4 - 1.6 (Acrobat 5 - 8) - edit password",
25500: "Stargazer Stellar Wallet XLM",
25600: "bcrypt(md5($pass)) / bcryptmd5",
25700: "MurmurHash",
25800: "bcrypt(sha1($pass)) / bcryptsha1",
25900: "KNX IP Secure - Device Authentication Code",
26000: "Mozilla key3.db",
26100: "Mozilla key4.db",
26200: "OpenEdge Progress Encode",
26300: "FortiGate256 (FortiOS256)",
26401: "AES-128-ECB NOKDF (PT = $salt, key = $pass)",
26402: "AES-192-ECB NOKDF (PT = $salt, key = $pass)",
26403: "AES-256-ECB NOKDF (PT = $salt, key = $pass)",
26500: "iPhone passcode (UID key + System Keybag)",
26600: "MetaMask Wallet",
99999: "Plaintext",
}
// TypeName returns the display name for a Hashcat mode.
func TypeName(mode int32) (string, bool) {
name, ok := hashcatHashTypes[mode]
return name, ok
}
// HumanizeRate formats a hashes-per-second rate for terminal display.
func HumanizeRate(rate uint64) string {
const unit = 1000
if rate < unit {
return fmt.Sprintf("%d H/s", rate)
}
value := float64(rate)
exp := 0
for value >= unit && exp < 5 {
value /= unit
exp++
}
suffixes := []string{"kH/s", "MH/s", "GH/s", "TH/s", "PH/s"}
if exp == 0 {
return fmt.Sprintf("%d H/s", rate)
}
if exp > len(suffixes) {
exp = len(suffixes)
}
return fmt.Sprintf("%.2f %s", value, suffixes[exp-1])
}
-16
View File
@@ -1,16 +0,0 @@
package crack
import (
cracktop "github.com/bishopfox/sliver/client/command/crack/top"
"github.com/bishopfox/sliver/client/console"
"github.com/spf13/cobra"
)
// CrackTopCmd launches the full-screen real-time crack cluster monitor.
// The implementation lives in the top subpackage; this facade preserves the
// existing command-handler API for callers of client/command/crack.
//
//nolint:revive // Preserve the established exported command-handler API.
func CrackTopCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
cracktop.CrackTopCmd(cmd, con, args)
}
-518
View File
@@ -1,518 +0,0 @@
package top
import (
"context"
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
)
const crackTopActionTestJobID = "dddddddd-4444-4444-8444-444444444444"
func newCrackTopJobActionTestModel(
status clientpb.CrackJobStatus,
actions map[crackTopJobAction]crackTopJobActionFunc,
) *crackTopModel {
snapshot := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{
ID: crackTopActionTestJobID,
Status: status,
UpdatedAt: 100,
Tasks: []*clientpb.CrackTask{{
ID: "action-task",
HostUUID: "action-station",
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
LastHeartbeatAt: 1_700_000_000,
}},
}},
Stations: []*clientpb.Crackstation{{
ID: "action-station",
HostUUID: "action-station",
Status: &clientpb.CrackstationStatus{
HostUUID: "action-station",
State: clientpb.States_CRACKING,
CurrentCrackJobID: crackTopActionTestJobID,
},
}},
RefreshedAt: time.Unix(1_700_000_000, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{
"action-task": {status: &crackStatusView{Devices: []crackDeviceView{{Speed: "100"}}}},
},
}
model := newCrackTopModel(context.Background(), nil, nil, time.Second)
model.jobActions = actions
model.snapshot = snapshot
model.dashboard = buildCrackTopDashboard(snapshot)
model.refreshing = false
model.width = 100
model.height = 28
model.normalizeJobSelection()
return model
}
func singleCrackTopAction(action crackTopJobAction, actionJob crackTopJobActionFunc) map[crackTopJobAction]crackTopJobActionFunc {
return map[crackTopJobAction]crackTopJobActionFunc{action: actionJob}
}
func TestCrackTopDeleteConfirmsAndRemovesTerminalJob(t *testing.T) {
deletedID := ""
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
deletedID = jobID
return nil, nil
}),
)
_, openCommand := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if openCommand != nil || model.pendingJobAction != (crackTopPendingJobAction{action: crackTopJobActionDelete, jobID: crackTopActionTestJobID}) {
t.Fatalf("delete prompt = command:%v prompt:%#v", openCommand != nil, model.pendingJobAction)
}
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"Delete crack job?", crackTopActionTestJobID, "This cannot be undone", "y delete"} {
if !strings.Contains(plain, expected) {
t.Fatalf("delete confirmation is missing %q:\n%s", expected, plain)
}
}
if got := lipgloss.Width(model.View().Content); got > model.width {
t.Fatalf("delete modal width = %d, want <= %d", got, model.width)
}
_, deleteCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if deleteCommand == nil || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != crackTopActionTestJobID {
t.Fatalf("confirmed delete = command:%v pending:%#v running:%#v", deleteCommand != nil, model.pendingJobAction, model.runningJobAction)
}
rawMessage := deleteCommand()
message, ok := rawMessage.(crackTopJobActionCompletedMsg)
if !ok {
t.Fatalf("delete command returned %T, want crackTopJobActionCompletedMsg", rawMessage)
}
if deletedID != crackTopActionTestJobID || message.action != crackTopJobActionDelete || message.jobID != crackTopActionTestJobID || message.err != nil {
t.Fatalf("delete call/message = called:%q message:%#v", deletedID, message)
}
_, refreshCommand := model.Update(message)
if refreshCommand == nil || !model.refreshing {
t.Fatalf("successful delete refresh = command:%v refreshing:%v", refreshCommand != nil, model.refreshing)
}
if len(model.snapshot.Jobs) != 0 || len(model.dashboard.Jobs) != 0 || model.selectedJobID != "" {
t.Fatalf("deleted job remains in dashboard: snapshot=%#v dashboard=%#v selected=%q", model.snapshot.Jobs, model.dashboard.Jobs, model.selectedJobID)
}
if _, ok := model.snapshot.taskTelemetry["action-task"]; ok {
t.Fatal("deleted job telemetry remains cached")
}
if model.toastLevel != "success" || !strings.Contains(model.toast, "Deleted crack job dddddddd") {
t.Fatalf("success toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopDeleteRejectsInProgressJob(t *testing.T) {
deleteCalls := 0
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_IN_PROGRESS,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
deleteCalls++
return nil, nil
}),
)
_, command := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if command == nil {
t.Fatal("in-progress delete did not schedule a warning")
}
if model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" || deleteCalls != 0 {
t.Fatalf("in-progress delete changed state: pending=%#v running=%#v calls=%d", model.pendingJobAction, model.runningJobAction, deleteCalls)
}
if model.toastLevel != "warning" || !strings.Contains(model.toast, "Only completed, failed, or cancelled crack jobs can be deleted") {
t.Fatalf("in-progress warning = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopDeleteFailureRetainsJobAndEscapeCancels(t *testing.T) {
wantErr := errors.New("database is busy\ntry later")
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_FAILED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
return nil, wantErr
}),
)
_, _ = model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
_, cancelCommand := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
if cancelCommand != nil || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" {
t.Fatalf("escaped delete prompt = command:%v pending=%#v running=%#v", cancelCommand != nil, model.pendingJobAction, model.runningJobAction)
}
_, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDelete})
_, enterCommand := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if enterCommand != nil || model.pendingJobAction.jobID != crackTopActionTestJobID || model.runningJobAction.jobID != "" {
t.Fatalf("bare enter confirmed destructive action: command=%v pending=%#v running=%#v", enterCommand != nil, model.pendingJobAction, model.runningJobAction)
}
_, deleteCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if deleteCommand == nil {
t.Fatal("y did not confirm deletion")
}
message := deleteCommand().(crackTopJobActionCompletedMsg)
_, toastCommand := model.Update(message)
if toastCommand == nil || model.refreshing {
t.Fatalf("failed delete = toast command:%v refreshing:%v", toastCommand != nil, model.refreshing)
}
if len(model.snapshot.Jobs) != 1 || model.snapshot.Jobs[0].GetID() != crackTopActionTestJobID {
t.Fatalf("failed delete removed job: %#v", model.snapshot.Jobs)
}
if model.toastLevel != "error" || !strings.Contains(model.toast, "database is busy try later") || strings.Contains(model.toast, "\n") {
t.Fatalf("failure toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopResizeBelowMinimumCancelsHiddenConfirmation(t *testing.T) {
deleteCalls := 0
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
deleteCalls++
return nil, nil
}),
)
_, _ = model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if model.pendingJobAction.jobID == "" {
t.Fatal("delete confirmation did not open")
}
_, resizeCommand := model.Update(tea.WindowSizeMsg{Width: crackTopMinWidth - 1, Height: crackTopMinHeight})
if resizeCommand != nil || model.pendingJobAction.jobID != "" {
t.Fatalf("small resize left hidden confirmation actionable: command=%v pending=%#v", resizeCommand != nil, model.pendingJobAction)
}
_, confirmCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if confirmCommand != nil || model.runningJobAction.jobID != "" || deleteCalls != 0 {
t.Fatalf("hidden confirmation executed: command=%v running=%#v calls=%d", confirmCommand != nil, model.runningJobAction, deleteCalls)
}
}
func TestCrackTopBelowMinimumCannotOpenHiddenConfirmation(t *testing.T) {
deleteCalls := 0
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
deleteCalls++
return nil, nil
}),
)
_, _ = model.Update(tea.WindowSizeMsg{Width: crackTopMinWidth, Height: crackTopMinHeight - 1})
_, openCommand := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
_, confirmCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if openCommand != nil || confirmCommand != nil || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" || deleteCalls != 0 {
t.Fatalf("undersized dashboard accepted hidden delete: open=%v confirm=%v pending=%#v running=%#v calls=%d",
openCommand != nil, confirmCommand != nil, model.pendingJobAction, model.runningJobAction, deleteCalls)
}
}
func TestCrackTopDeleteAllowsEveryTerminalStatus(t *testing.T) {
for _, status := range []clientpb.CrackJobStatus{
clientpb.CrackJobStatus_COMPLETED,
clientpb.CrackJobStatus_FAILED,
clientpb.CrackJobStatus_CANCELLED,
} {
t.Run(status.String(), func(t *testing.T) {
model := newCrackTopJobActionTestModel(
status,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) { return nil, nil }),
)
_, command := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if command != nil || model.pendingJobAction.jobID != crackTopActionTestJobID {
t.Fatalf("terminal status %s did not open delete confirmation", status)
}
})
}
}
func TestCrackTopDeleteSuppressesStaleRefreshUntilServerOmitsJob(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) { return nil, nil }),
)
model.removeJob(crackTopActionTestJobID)
stale := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{ID: crackTopActionTestJobID, Status: clientpb.CrackJobStatus_COMPLETED, Tasks: []*clientpb.CrackTask{{ID: "stale-task"}}}},
RefreshedAt: time.Unix(1_700_000_001, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{"stale-task": {}},
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: stale})
if len(model.snapshot.Jobs) != 0 || len(model.dashboard.Jobs) != 0 {
t.Fatalf("stale refresh restored deleted job: snapshot=%#v dashboard=%#v", model.snapshot.Jobs, model.dashboard.Jobs)
}
if _, ok := model.snapshot.taskTelemetry["stale-task"]; ok {
t.Fatal("stale refresh retained deleted job telemetry")
}
if _, ok := model.deletedJobIDs[crackTopActionTestJobID]; !ok {
t.Fatal("stale refresh cleared the deletion tombstone")
}
fresh := &crackTopSnapshot{RefreshedAt: time.Unix(1_700_000_002, 0)}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: fresh})
if _, ok := model.deletedJobIDs[crackTopActionTestJobID]; ok {
t.Fatal("server-confirmed refresh did not clear the deletion tombstone")
}
}
func TestCrackTopPauseConfirmsAppliesAuthoritativeStatusAndRefreshes(t *testing.T) {
pausedID := ""
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_IN_PROGRESS,
singleCrackTopAction(crackTopJobActionPause, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
pausedID = jobID
return &clientpb.CrackJob{ID: jobID, Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 200}, nil
}),
)
_, openCommand := model.Update(tea.KeyPressMsg{Text: "p", Code: 'p'})
if openCommand != nil || model.pendingJobAction != (crackTopPendingJobAction{action: crackTopJobActionPause, jobID: crackTopActionTestJobID}) {
t.Fatalf("pause prompt = command:%v prompt:%#v", openCommand != nil, model.pendingJobAction)
}
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"Pause crack job?", "release its current crackstation work", "may restart", "y pause"} {
if !strings.Contains(plain, expected) {
t.Fatalf("pause confirmation is missing %q:\n%s", expected, plain)
}
}
_, enterCommand := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if enterCommand != nil || model.runningJobAction.jobID != "" {
t.Fatal("enter confirmed pause without an explicit y")
}
_, pauseCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if pauseCommand == nil || model.runningJobAction.action != crackTopJobActionPause {
t.Fatalf("pause confirmation = command:%v running:%#v", pauseCommand != nil, model.runningJobAction)
}
message := pauseCommand().(crackTopJobActionCompletedMsg)
if pausedID != crackTopActionTestJobID || message.status != clientpb.CrackJobStatus_PAUSED || !message.hasStatus {
t.Fatalf("pause call/message = called:%q message:%#v", pausedID, message)
}
_, refreshCommand := model.Update(message)
if refreshCommand == nil || !model.refreshing {
t.Fatalf("successful pause refresh = command:%v refreshing:%v", refreshCommand != nil, model.refreshing)
}
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("local pause status = %s, want PAUSED", got)
}
if model.dashboard.Jobs[0].RateKnown || model.dashboard.ActiveJobs != 0 {
t.Fatalf("paused job retained active throughput: row=%#v active=%d", model.dashboard.Jobs[0], model.dashboard.ActiveJobs)
}
if model.toastLevel != "success" || !strings.Contains(model.toast, "Paused crack job dddddddd") {
t.Fatalf("pause toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopCancelConfirmsActiveAndPausedJobs(t *testing.T) {
for _, status := range []clientpb.CrackJobStatus{clientpb.CrackJobStatus_IN_PROGRESS, clientpb.CrackJobStatus_PAUSED} {
t.Run(status.String(), func(t *testing.T) {
model := newCrackTopJobActionTestModel(
status,
singleCrackTopAction(crackTopJobActionCancel, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: jobID, Status: clientpb.CrackJobStatus_CANCELLED, UpdatedAt: 200}, nil
}),
)
_, openCommand := model.Update(tea.KeyPressMsg{Text: "c", Code: 'c'})
if openCommand != nil || model.pendingJobAction.action != crackTopJobActionCancel {
t.Fatalf("cancel prompt = command:%v prompt:%#v", openCommand != nil, model.pendingJobAction)
}
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"Cancel crack job?", "Cancelled jobs cannot be resumed", "y cancel"} {
if !strings.Contains(plain, expected) {
t.Fatalf("cancel confirmation is missing %q:\n%s", expected, plain)
}
}
_, cancelCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
message := cancelCommand().(crackTopJobActionCompletedMsg)
_, refreshCommand := model.Update(message)
if refreshCommand == nil || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_CANCELLED {
t.Fatalf("cancel result = command:%v job:%#v", refreshCommand != nil, model.snapshot.Jobs[0])
}
})
}
}
func TestCrackTopResumePausedJobWithoutDestructiveConfirmation(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_PAUSED,
singleCrackTopAction(crackTopJobActionResume, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: jobID, Status: clientpb.CrackJobStatus_IN_PROGRESS, UpdatedAt: 300}, nil
}),
)
_, resumeCommand := model.Update(tea.KeyPressMsg{Text: "u", Code: 'u'})
if resumeCommand == nil || model.pendingJobAction.jobID != "" || model.runningJobAction.action != crackTopJobActionResume {
t.Fatalf("resume start = command:%v pending:%#v running:%#v", resumeCommand != nil, model.pendingJobAction, model.runningJobAction)
}
plain := strings.ToLower(ansi.Strip(model.View().Content))
if !strings.Contains(plain, "resuming crack job dddddddd") {
t.Fatalf("resume progress footer missing:\n%s", plain)
}
message := resumeCommand().(crackTopJobActionCompletedMsg)
_, refreshCommand := model.Update(message)
if refreshCommand == nil || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_IN_PROGRESS {
t.Fatalf("resume result = command:%v job:%#v", refreshCommand != nil, model.snapshot.Jobs[0])
}
if model.toastLevel != "success" || !strings.Contains(model.toast, "Resumed crack job dddddddd") {
t.Fatalf("resume toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopLifecycleActionsRejectIneligibleStatuses(t *testing.T) {
tests := []struct {
name string
key rune
action crackTopJobAction
status clientpb.CrackJobStatus
want string
}{
{name: "pause paused", key: 'p', action: crackTopJobActionPause, status: clientpb.CrackJobStatus_PAUSED, want: "Only in-progress crack jobs can be paused"},
{name: "resume active", key: 'u', action: crackTopJobActionResume, status: clientpb.CrackJobStatus_IN_PROGRESS, want: "Only paused crack jobs can be resumed"},
{name: "cancel complete", key: 'c', action: crackTopJobActionCancel, status: clientpb.CrackJobStatus_COMPLETED, want: "Only in-progress or paused crack jobs can be cancelled"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
calls := 0
model := newCrackTopJobActionTestModel(
test.status,
singleCrackTopAction(test.action, func(context.Context, string) (*clientpb.CrackJob, error) {
calls++
return nil, nil
}),
)
_, command := model.Update(tea.KeyPressMsg{Text: string(test.key), Code: test.key})
if command == nil || calls != 0 || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" {
t.Fatalf("ineligible action = command:%v calls:%d pending:%#v running:%#v", command != nil, calls, model.pendingJobAction, model.runningJobAction)
}
if model.toastLevel != "warning" || !strings.Contains(model.toast, test.want) {
t.Fatalf("ineligible toast = level:%q text:%q", model.toastLevel, model.toast)
}
})
}
}
func TestCrackTopLifecycleRejectsNonAuthoritativeSuccessResponse(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_PAUSED,
singleCrackTopAction(crackTopJobActionResume, func(context.Context, string) (*clientpb.CrackJob, error) {
return nil, nil
}),
)
_, resumeCommand := model.Update(tea.KeyPressMsg{Text: "u", Code: 'u'})
message := resumeCommand().(crackTopJobActionCompletedMsg)
if message.err == nil || !strings.Contains(message.err.Error(), "no job state") {
t.Fatalf("empty resume response error = %v", message.err)
}
_, toastCommand := model.Update(message)
if toastCommand == nil || model.refreshing || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("empty resume response mutated state: command=%v refreshing=%v job=%#v", toastCommand != nil, model.refreshing, model.snapshot.Jobs[0])
}
if model.toastLevel != "error" || !strings.Contains(model.toast, "server returned no job state") {
t.Fatalf("empty resume toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopLifecycleRejectsIDOnlyResumeResponse(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_PAUSED,
singleCrackTopAction(crackTopJobActionResume, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: jobID}, nil
}),
)
_, resumeCommand := model.Update(tea.KeyPressMsg{Text: "u", Code: 'u'})
message := resumeCommand().(crackTopJobActionCompletedMsg)
if message.err == nil || !strings.Contains(message.err.Error(), "without an update timestamp") {
t.Fatalf("ID-only resume response error = %v", message.err)
}
_, toastCommand := model.Update(message)
if toastCommand == nil || model.refreshing || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("ID-only resume response mutated state: command=%v refreshing=%v job=%#v", toastCommand != nil, model.refreshing, model.snapshot.Jobs[0])
}
}
func TestCrackTopLifecycleStatusOverrideSuppressesStaleRefresh(t *testing.T) {
model := newCrackTopJobActionTestModel(clientpb.CrackJobStatus_IN_PROGRESS, nil)
model.overrideJobStatus(crackTopActionTestJobID, clientpb.CrackJobStatus_PAUSED, 200)
stale := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{ID: crackTopActionTestJobID, Status: clientpb.CrackJobStatus_IN_PROGRESS, UpdatedAt: 100}},
RefreshedAt: time.Unix(1_700_000_001, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: stale})
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("stale refresh status = %s, want PAUSED", got)
}
if _, ok := model.jobStatusOverrides[crackTopActionTestJobID]; !ok {
t.Fatal("stale refresh cleared authoritative status override")
}
observed := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{ID: crackTopActionTestJobID, Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 200}},
RefreshedAt: time.Unix(1_700_000_002, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: observed})
if _, ok := model.jobStatusOverrides[crackTopActionTestJobID]; ok {
t.Fatal("server-confirmed refresh did not clear status override")
}
}
func TestCrackTopLifecycleStatusOverrideYieldsToRepeatedSameSecondConflict(t *testing.T) {
model := newCrackTopJobActionTestModel(clientpb.CrackJobStatus_IN_PROGRESS, nil)
model.overrideJobStatus(crackTopActionTestJobID, clientpb.CrackJobStatus_PAUSED, 200)
conflictingSnapshot := func(refreshedAt int64) *crackTopSnapshot {
return &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{
ID: crackTopActionTestJobID,
Status: clientpb.CrackJobStatus_CANCELLED,
UpdatedAt: 200,
}},
RefreshedAt: time.Unix(refreshedAt, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: conflictingSnapshot(1_700_000_001)})
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("first same-second conflict status = %s, want PAUSED stale-snapshot mask", got)
}
override, ok := model.jobStatusOverrides[crackTopActionTestJobID]
if !ok || !override.equalVersionDisagreementSeen {
t.Fatalf("first same-second conflict override = %#v, present=%v", override, ok)
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: conflictingSnapshot(1_700_000_002)})
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_CANCELLED {
t.Fatalf("repeated same-second conflict status = %s, want CANCELLED", got)
}
if _, ok := model.jobStatusOverrides[crackTopActionTestJobID]; ok {
t.Fatal("repeated same-second conflict did not release status override")
}
}
func TestCrackTopActiveFilterRetainsPausedJob(t *testing.T) {
model := newCrackTopJobActionTestModel(clientpb.CrackJobStatus_PAUSED, nil)
model.filter = crackTopFilterActive
model.normalizeJobSelection()
jobs := model.filteredJobs()
if len(jobs) != 1 || jobs[0].ID != crackTopActionTestJobID || model.selectedJobID != crackTopActionTestJobID {
t.Fatalf("active filter lost paused job: jobs=%#v selected=%q", jobs, model.selectedJobID)
}
if model.dashboard.ActiveJobs != 0 {
t.Fatalf("paused job counted as active throughput work: %d", model.dashboard.ActiveJobs)
}
}
-78
View File
@@ -1,78 +0,0 @@
package top
import "time"
const crackTopHistoryLimit = 120
// crackTopHistorySample captures the real telemetry available at one refresh
// observation. Known fields distinguish an observed zero from telemetry that
// was unavailable, including when the refresh itself failed.
type crackTopHistorySample struct {
At time.Time
ClusterRate uint64
ClusterRateKnown bool
ClusterComplete bool
Progress float64
ProgressKnown bool
Utilization float64
UtilizationKnown bool
Temperature float64
TemperatureKnown bool
CountsKnown bool
ActiveJobs int
OnlineWorkers int
TotalWorkers int
Recovered uint64
}
func crackTopHistorySampleFromDashboard(dashboard crackTopDashboard, at time.Time) crackTopHistorySample {
sample := crackTopHistorySample{
At: at,
ActiveJobs: dashboard.ActiveJobs,
OnlineWorkers: dashboard.OnlineWorkers,
TotalWorkers: dashboard.OnlineWorkers + dashboard.Unavailable,
Recovered: dashboard.Recovered,
ClusterRateKnown: dashboard.ClusterRateKnown,
ClusterComplete: dashboard.ClusterComplete,
ProgressKnown: dashboard.ProgressKnown,
CountsKnown: true,
}
if sample.ClusterRateKnown {
sample.ClusterRate = dashboard.ClusterRate
}
if sample.ProgressKnown {
sample.Progress = dashboard.Progress
}
var utilizationTotal float64
utilizationSamples := 0
for _, worker := range dashboard.Workers {
if !worker.TelemetryLive {
continue
}
if worker.HasUtil {
utilizationTotal += worker.Utilization
utilizationSamples++
}
if worker.HasTemp && (!sample.TemperatureKnown || worker.Temperature > sample.Temperature) {
sample.Temperature = worker.Temperature
sample.TemperatureKnown = true
}
}
if utilizationSamples > 0 {
sample.Utilization = utilizationTotal / float64(utilizationSamples)
sample.UtilizationKnown = true
}
return sample
}
func appendCrackTopHistory(history []crackTopHistorySample, sample crackTopHistorySample) []crackTopHistorySample {
if len(history) >= crackTopHistoryLimit {
start := len(history) - (crackTopHistoryLimit - 1)
retained := copy(history, history[start:])
history = history[:retained]
}
return append(history, sample)
}
-99
View File
@@ -1,99 +0,0 @@
package top
import (
"testing"
"time"
)
//nolint:gocyclo // This test validates every derived history field in one complete sample.
func TestCrackTopHistorySampleFromDashboardDerivesLiveTelemetry(t *testing.T) {
at := time.Unix(1_700_000_000, 123)
dashboard := crackTopDashboard{
ActiveJobs: 2,
OnlineWorkers: 3,
Unavailable: 2,
Recovered: 9,
ClusterRate: 12_345,
ClusterRateKnown: true,
ClusterComplete: true,
Progress: 0.625,
ProgressKnown: true,
Workers: []crackTopWorkerRow{
{TelemetryLive: true, Utilization: 20, HasUtil: true, Temperature: 60, HasTemp: true},
{TelemetryLive: true, Utilization: 80, HasUtil: true, Temperature: 75, HasTemp: true},
{TelemetryLive: true},
{TelemetryLive: false, Utilization: 100, HasUtil: true, Temperature: 99, HasTemp: true},
},
}
sample := crackTopHistorySampleFromDashboard(dashboard, at)
if !sample.At.Equal(at) {
t.Fatalf("sample time = %s, want %s", sample.At, at)
}
if !sample.ClusterRateKnown || sample.ClusterRate != 12_345 {
t.Fatalf("cluster rate = %d known %v, want 12345 known", sample.ClusterRate, sample.ClusterRateKnown)
}
if !sample.ClusterComplete {
t.Fatal("complete cluster snapshot was recorded as incomplete")
}
if !sample.ProgressKnown || sample.Progress != 0.625 {
t.Fatalf("progress = %v known %v, want 0.625 known", sample.Progress, sample.ProgressKnown)
}
if !sample.UtilizationKnown || sample.Utilization != 50 {
t.Fatalf("utilization = %v known %v, want live-worker mean 50", sample.Utilization, sample.UtilizationKnown)
}
if !sample.TemperatureKnown || sample.Temperature != 75 {
t.Fatalf("temperature = %v known %v, want live-worker maximum 75", sample.Temperature, sample.TemperatureKnown)
}
if sample.ActiveJobs != 2 || sample.OnlineWorkers != 3 || sample.TotalWorkers != 5 || sample.Recovered != 9 {
t.Fatalf("sample counts = active:%d online:%d total:%d recovered:%d", sample.ActiveJobs, sample.OnlineWorkers, sample.TotalWorkers, sample.Recovered)
}
if !sample.CountsKnown {
t.Fatal("successful dashboard sample marked counts unknown")
}
}
func TestCrackTopHistorySampleFromDashboardPreservesUnknowns(t *testing.T) {
dashboard := crackTopDashboard{
ClusterRate: 999,
Progress: 0.9,
Workers: []crackTopWorkerRow{
{TelemetryLive: false, Utilization: 88, HasUtil: true, Temperature: 70, HasTemp: true},
{TelemetryLive: true},
},
}
sample := crackTopHistorySampleFromDashboard(dashboard, time.Time{})
if sample.ClusterRateKnown || sample.ClusterRate != 0 {
t.Fatalf("unknown cluster rate = %d known %v, want zero unknown", sample.ClusterRate, sample.ClusterRateKnown)
}
if sample.ClusterComplete {
t.Fatal("incomplete cluster snapshot was recorded as complete")
}
if sample.ProgressKnown || sample.Progress != 0 {
t.Fatalf("unknown progress = %v known %v, want zero unknown", sample.Progress, sample.ProgressKnown)
}
if sample.UtilizationKnown || sample.Utilization != 0 {
t.Fatalf("unknown utilization = %v known %v, want zero unknown", sample.Utilization, sample.UtilizationKnown)
}
if sample.TemperatureKnown || sample.Temperature != 0 {
t.Fatalf("unknown temperature = %v known %v, want zero unknown", sample.Temperature, sample.TemperatureKnown)
}
}
func TestAppendCrackTopHistoryCapsAndRetainsNewestSamples(t *testing.T) {
history := make([]crackTopHistorySample, 0, crackTopHistoryLimit)
for index := 0; index < crackTopHistoryLimit+5; index++ {
history = appendCrackTopHistory(history, crackTopHistorySample{ActiveJobs: index})
}
if len(history) != crackTopHistoryLimit {
t.Fatalf("history length = %d, want %d", len(history), crackTopHistoryLimit)
}
if history[0].ActiveJobs != 5 {
t.Fatalf("oldest retained sample = %d, want 5", history[0].ActiveJobs)
}
if history[len(history)-1].ActiveJobs != crackTopHistoryLimit+4 {
t.Fatalf("newest retained sample = %d, want %d", history[len(history)-1].ActiveJobs, crackTopHistoryLimit+4)
}
}
-245
View File
@@ -1,245 +0,0 @@
package top
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/charmbracelet/x/ansi"
"google.golang.org/grpc"
)
const crackTopWarningRunes = 256
// crackTopRPC is the complete wire-level dependency of the crack top client.
// Keeping it narrow also makes it difficult for the dashboard to accidentally
// fall back to RPCs that expose crack targets or recovered material.
type crackTopRPC interface {
CrackTop(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.CrackTopSnapshot, error)
}
type crackTopTaskTelemetry struct {
status *crackStatusView
parseErr bool
progressExact bool
}
// crackTopSnapshot is the client-side representation consumed by the existing
// dashboard telemetry code. Jobs and stations contain only fields supplied by
// the narrow CrackTop RPC; parsed status is kept separately so no sensitive raw
// status JSON has to be reconstructed.
type crackTopSnapshot struct {
Jobs []*clientpb.CrackJob
Stations []*clientpb.Crackstation
RefreshedAt time.Time
taskTelemetry map[string]crackTopTaskTelemetry
}
// CrackTop snapshots are atomic. A successful refresh replaces all previously
// displayed server state, while a failed refresh returns no snapshot and leaves
// the model's existing value untouched.
func mergeCrackTopSnapshot(_ *crackTopSnapshot, incoming *crackTopSnapshot) *crackTopSnapshot {
return incoming
}
func loadCrackTopSnapshot(ctx context.Context, rpc crackTopRPC, perCallTimeout time.Duration, completionTime time.Time) (*crackTopSnapshot, error) {
if rpc == nil {
return nil, crackTopContextError("load crack top snapshot", errors.New("rpc client is unavailable"))
}
if ctx == nil {
ctx = context.Background()
}
var cancel context.CancelFunc
if perCallTimeout > 0 {
ctx, cancel = context.WithTimeout(ctx, perCallTimeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
wire, err := rpc.CrackTop(ctx, &commonpb.Empty{})
if completionTime.IsZero() {
completionTime = time.Now()
}
if err != nil {
return nil, crackTopContextError("load crack top snapshot", err)
}
if wire == nil {
return nil, crackTopContextError("load crack top snapshot", errors.New("server returned an empty response"))
}
refreshedAt := completionTime
if wire.GetObservedAt() != 0 {
refreshedAt = time.Unix(wire.GetObservedAt(), 0)
}
snapshot := &crackTopSnapshot{
Jobs: make([]*clientpb.CrackJob, len(wire.GetJobs())),
Stations: make([]*clientpb.Crackstation, len(wire.GetCrackstations())),
RefreshedAt: refreshedAt,
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
for index, job := range wire.GetJobs() {
snapshot.Jobs[index] = crackTopJobFromWire(job, snapshot.taskTelemetry)
}
for index, station := range wire.GetCrackstations() {
snapshot.Stations[index] = crackTopStationFromWire(station)
}
return snapshot, nil
}
func crackTopJobFromWire(wire *clientpb.CrackTopJob, telemetry map[string]crackTopTaskTelemetry) *clientpb.CrackJob {
if wire == nil {
return nil
}
command := &clientpb.CrackCommand{
AttackMode: wire.GetAttackMode(),
HashType: wire.GetHashType(),
}
if wire.HashMode != nil {
hashMode := wire.GetHashMode()
command.HashMode = &hashMode
}
job := &clientpb.CrackJob{
ID: wire.GetID(),
CreatedAt: wire.GetCreatedAt(),
CompletedAt: wire.GetCompletedAt(),
Status: wire.GetStatus(),
Command: command,
UpdatedAt: wire.GetUpdatedAt(),
ResultCount: wire.GetResultCount(),
Tasks: make([]*clientpb.CrackTask, len(wire.GetTasks())),
}
for index, task := range wire.GetTasks() {
job.Tasks[index] = crackTopTaskFromWire(task, telemetry)
}
return job
}
func crackTopTaskFromWire(wire *clientpb.CrackTopTask, telemetry map[string]crackTopTaskTelemetry) *clientpb.CrackTask {
if wire == nil {
return nil
}
task := &clientpb.CrackTask{
ID: wire.GetID(),
HostUUID: wire.GetHostUUID(),
CreatedAt: wire.GetCreatedAt(),
StartedAt: wire.GetStartedAt(),
CompletedAt: wire.GetCompletedAt(),
Kind: wire.GetKind(),
State: wire.GetState(),
Attempt: wire.GetAttempt(),
LeaseExpiresAt: wire.GetLeaseExpiresAt(),
UpdatedAt: wire.GetUpdatedAt(),
LastHeartbeatAt: wire.GetLastHeartbeatAt(),
ShardSkip: wire.GetShardSkip(),
ShardLimit: wire.GetShardLimit(),
}
taskID := strings.TrimSpace(task.GetID())
if telemetry == nil || taskID == "" {
return task
}
metadata := crackTopTaskTelemetry{
parseErr: wire.GetStatusParseError(),
progressExact: wire.GetProgressExact(),
}
if wire.GetStatusAvailable() && !metadata.parseErr {
metadata.status = &crackStatusView{Devices: crackTopDevicesFromWire(wire.GetDevices())}
if metadata.progressExact || wire.GetShardSkip() == 0 {
metadata.status.ProgressCurrent = wire.GetProgressCurrent()
metadata.status.ProgressTotal = wire.GetProgressTotal()
}
}
if metadata.status != nil || metadata.parseErr {
telemetry[taskID] = metadata
}
return task
}
func crackTopDevicesFromWire(devices []*clientpb.CrackTopDevice) []crackDeviceView {
converted := make([]crackDeviceView, 0, len(devices))
for _, device := range devices {
if device == nil {
continue
}
view := crackDeviceView{ID: device.GetID()}
if device.GetSpeedAvailable() {
view.Speed = strconv.FormatUint(device.GetSpeed(), 10)
}
if device.GetTemperatureAvailable() {
view.Temperature = strconv.FormatFloat(device.GetTemperature(), 'f', -1, 64)
}
if device.GetUtilizationAvailable() {
view.Utilization = strconv.FormatFloat(device.GetUtilization(), 'f', -1, 64)
}
converted = append(converted, view)
}
return converted
}
func crackTopStationFromWire(wire *clientpb.CrackTopStation) *clientpb.Crackstation {
if wire == nil {
return nil
}
station := &clientpb.Crackstation{
ID: wire.GetID(),
Name: wire.GetName(),
HostUUID: wire.GetHostUUID(),
}
if wire.GetStatusAvailable() {
station.Status = &clientpb.CrackstationStatus{
Name: wire.GetName(),
HostUUID: wire.GetHostUUID(),
State: wire.GetState(),
CurrentCrackJobID: wire.GetCurrentCrackJobID(),
IsSyncing: wire.GetIsSyncing(),
}
}
return station
}
func crackTopContextError(scope string, err error) error {
message := crackTopSanitizeError(err)
if strings.TrimSpace(scope) == "" {
return errors.New(message)
}
return fmt.Errorf("%s: %s", strings.TrimSpace(scope), message)
}
func crackTopWarningText(message string) string {
return crackTopSanitizeError(errors.New(message))
}
func crackTopSanitizeError(err error) string {
if err == nil {
return "unknown error"
}
message := ansi.Strip(err.Error())
message = strings.Map(func(character rune) rune {
if unicode.IsSpace(character) {
return ' '
}
if !unicode.IsPrint(character) {
return -1
}
return character
}, message)
message = strings.Join(strings.Fields(message), " ")
if message == "" {
return "unknown error"
}
runes := []rune(message)
if len(runes) > crackTopWarningRunes {
message = string(runes[:crackTopWarningRunes-1]) + "…"
}
return message
}
-424
View File
@@ -1,424 +0,0 @@
package top
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
type crackTopRPCTestClient struct {
calls atomic.Int32
call func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error)
}
func (client *crackTopRPCTestClient) CrackTop(ctx context.Context, request *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.CrackTopSnapshot, error) {
client.calls.Add(1)
if client.call == nil {
return nil, nil
}
return client.call(ctx, request)
}
//nolint:gocyclo // This test intentionally validates every field in one complete lean-snapshot conversion.
func TestLoadCrackTopSnapshotUsesOneRPCAndConvertsLeanSnapshot(t *testing.T) {
hashMode := uint32(5600)
wire := &clientpb.CrackTopSnapshot{
ObservedAt: 1_700_000_123,
Jobs: []*clientpb.CrackTopJob{
{
ID: "job-one",
CreatedAt: "2026-09-12T10:00:00Z",
CompletedAt: "",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
UpdatedAt: 1_700_000_120,
ResultCount: 7,
AttackMode: clientpb.CrackAttackMode_BRUTEFORCE,
HashType: clientpb.HashType_NET_NTLM_V2,
HashMode: &hashMode,
Tasks: []*clientpb.CrackTopTask{
{
ID: "task-live",
HostUUID: "worker-one",
CreatedAt: 11,
StartedAt: 12,
CompletedAt: 13,
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
Attempt: 2,
LeaseExpiresAt: 1_700_000_500,
UpdatedAt: 14,
LastHeartbeatAt: 15,
ShardSkip: 100,
ShardLimit: 200,
ProgressCurrent: "125",
ProgressTotal: "300",
StatusAvailable: true,
StatusParseError: false,
ProgressExact: true,
Devices: []*clientpb.CrackTopDevice{
{
ID: "gpu-0",
Speed: 9_876_543,
SpeedAvailable: true,
Temperature: 71.5,
TemperatureAvailable: true,
Utilization: 99.25,
UtilizationAvailable: true,
},
nil,
{ID: "gpu-1", Speed: 123, Temperature: 45, Utilization: 67},
},
},
nil,
{
ID: "task-malformed",
HostUUID: "worker-two",
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
ProgressCurrent: "sensitive-raw-data-must-not-be-rebuilt",
StatusAvailable: true,
StatusParseError: true,
},
},
},
nil,
{ID: "job-two", Status: clientpb.CrackJobStatus_COMPLETED},
},
Crackstations: []*clientpb.CrackTopStation{
{
ID: "station-one",
Name: "rig-one",
HostUUID: "worker-one",
State: clientpb.States_CRACKING,
CurrentCrackJobID: "job-one",
IsSyncing: false,
StatusAvailable: true,
},
nil,
{ID: "station-two", Name: "rig-two", HostUUID: "worker-two"},
},
}
wireBefore := proto.Clone(wire).(*clientpb.CrackTopSnapshot)
client := &crackTopRPCTestClient{
call: func(ctx context.Context, request *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
if ctx == nil {
t.Fatal("CrackTop received a nil context")
}
if request == nil {
t.Fatal("CrackTop received a nil request")
}
return wire, nil
},
}
fallback := time.Unix(1_600_000_000, 987_654_321)
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, fallback)
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
if want := time.Unix(wire.GetObservedAt(), 0); !snapshot.RefreshedAt.Equal(want) {
t.Fatalf("RefreshedAt = %s, want server observation time %s", snapshot.RefreshedAt, want)
}
if !proto.Equal(wire, wireBefore) {
t.Fatal("loader mutated the wire snapshot")
}
if len(snapshot.Jobs) != 3 || snapshot.Jobs[1] != nil || snapshot.Jobs[2].GetID() != "job-two" {
t.Fatalf("jobs did not preserve wire order and nil entries: %#v", snapshot.Jobs)
}
job := snapshot.Jobs[0]
if job.GetID() != "job-one" || job.GetCreatedAt() != wire.Jobs[0].GetCreatedAt() || job.GetCompletedAt() != wire.Jobs[0].GetCompletedAt() ||
job.GetStatus() != clientpb.CrackJobStatus_IN_PROGRESS || job.GetUpdatedAt() != 1_700_000_120 || job.GetResultCount() != 7 {
t.Fatalf("converted job metadata = %#v", job)
}
if job.GetCommand() == nil || job.GetCommand().GetAttackMode() != clientpb.CrackAttackMode_BRUTEFORCE ||
job.GetCommand().GetHashType() != clientpb.HashType_NET_NTLM_V2 || job.GetCommand().HashMode == nil || job.GetCommand().GetHashMode() != hashMode {
t.Fatalf("converted job command = %#v", job.GetCommand())
}
if job.GetErr() != "" || job.GetResultFileID() != "" || job.GetKeyspace() != "" || len(job.GetResults()) != 0 ||
len(job.GetCommand().GetHashes()) != 0 || len(job.GetCommand().GetPositionalArguments()) != 0 || len(job.GetCommand().GetCredentialIDs()) != 0 {
t.Fatalf("lean job conversion populated target or result fields: %#v", job)
}
if len(job.GetTasks()) != 3 || job.GetTasks()[1] != nil {
t.Fatalf("tasks did not preserve wire order and nil entries: %#v", job.GetTasks())
}
task := job.GetTasks()[0]
if task.GetID() != "task-live" || task.GetHostUUID() != "worker-one" || task.GetCreatedAt() != 11 || task.GetStartedAt() != 12 ||
task.GetCompletedAt() != 13 || task.GetKind() != clientpb.CrackTaskKind_CRACK_TASK_CRACK ||
task.GetState() != clientpb.CrackTaskState_CRACK_TASK_RUNNING || task.GetAttempt() != 2 ||
task.GetLeaseExpiresAt() != 1_700_000_500 || task.GetUpdatedAt() != 14 || task.GetLastHeartbeatAt() != 15 ||
task.GetShardSkip() != 100 || task.GetShardLimit() != 200 {
t.Fatalf("converted task metadata = %#v", task)
}
if task.GetErr() != "" || len(task.GetStdout()) != 0 || len(task.GetStderr()) != 0 || task.GetCommand() != nil ||
task.GetLeaseToken() != "" || task.GetKeyspace() != "" || len(task.GetLatestStatusJSON()) != 0 || len(task.GetRecoveredJSON()) != 0 {
t.Fatalf("lean task conversion populated execution or raw telemetry fields: %#v", task)
}
metadata, ok := snapshot.taskTelemetry["task-live"]
if !ok || metadata.parseErr || metadata.status == nil {
t.Fatalf("live task telemetry = %#v, present=%v", metadata, ok)
}
if !metadata.progressExact {
t.Fatal("server-declared exact progress was discarded")
}
if metadata.status.ProgressCurrent != "125" || metadata.status.ProgressTotal != "300" || len(metadata.status.Devices) != 2 {
t.Fatalf("converted status = %#v", metadata.status)
}
firstDevice := metadata.status.Devices[0]
if firstDevice.ID != "gpu-0" || firstDevice.Speed != "9876543" || firstDevice.Temperature != "71.5" || firstDevice.Utilization != "99.25" {
t.Fatalf("converted available device telemetry = %#v", firstDevice)
}
secondDevice := metadata.status.Devices[1]
if secondDevice.ID != "gpu-1" || secondDevice.Speed != "" || secondDevice.Temperature != "" || secondDevice.Utilization != "" {
t.Fatalf("unavailable device telemetry was treated as available: %#v", secondDevice)
}
malformed, ok := snapshot.taskTelemetry["task-malformed"]
if !ok || !malformed.parseErr || malformed.status != nil {
t.Fatalf("malformed task telemetry = %#v, present=%v", malformed, ok)
}
if len(snapshot.Stations) != 3 || snapshot.Stations[1] != nil {
t.Fatalf("stations did not preserve wire order and nil entries: %#v", snapshot.Stations)
}
station := snapshot.Stations[0]
if station.GetID() != "station-one" || station.GetName() != "rig-one" || station.GetHostUUID() != "worker-one" || station.GetStatus() == nil ||
station.GetStatus().GetName() != "rig-one" || station.GetStatus().GetHostUUID() != "worker-one" ||
station.GetStatus().GetState() != clientpb.States_CRACKING || station.GetStatus().GetCurrentCrackJobID() != "job-one" || station.GetStatus().GetIsSyncing() {
t.Fatalf("converted station = %#v", station)
}
if station.GetOperatorName() != "" || station.GetGOOS() != "" || station.GetGOARCH() != "" || station.GetVersion() != "" ||
len(station.GetBenchmarks()) != 0 || len(station.GetCUDA()) != 0 || len(station.GetMetal()) != 0 || len(station.GetOpenCL()) != 0 ||
len(station.GetHIP()) != 0 || len(station.GetCapabilities()) != 0 {
t.Fatalf("lean station conversion populated private inventory fields: %#v", station)
}
if snapshot.Stations[2].GetStatus() != nil {
t.Fatalf("station without available status got a fabricated status: %#v", snapshot.Stations[2])
}
*snapshot.Jobs[0].Command.HashMode = 1000
snapshot.Jobs[0].Tasks[0].ID = "changed-task"
snapshot.Stations[0].Status.Name = "changed-station"
metadata.status.Devices[0].ID = "changed-device"
if wire.Jobs[0].GetHashMode() != 5600 || wire.Jobs[0].Tasks[0].GetID() != "task-live" ||
wire.Crackstations[0].GetName() != "rig-one" || wire.Jobs[0].Tasks[0].Devices[0].GetID() != "gpu-0" {
t.Fatal("converted snapshot aliases mutable wire data")
}
}
//nolint:gocyclo // This test intentionally covers the full loader-to-dashboard telemetry path.
func TestLoadCrackTopSnapshotFeedsDashboardTelemetry(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
client := &crackTopRPCTestClient{
call: func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
return &clientpb.CrackTopSnapshot{
ObservedAt: now.Unix(),
Jobs: []*clientpb.CrackTopJob{{
ID: crackTopTestJobID,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: []*clientpb.CrackTopTask{{
ID: "task-wire",
HostUUID: "worker-wire",
Kind: clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
LeaseExpiresAt: now.Add(time.Minute).Unix(),
LastHeartbeatAt: now.Unix(),
ProgressCurrent: "25",
ProgressTotal: "100",
StatusAvailable: true,
ProgressExact: true,
Devices: []*clientpb.CrackTopDevice{{
ID: "gpu-0",
Speed: 125_000,
SpeedAvailable: true,
Temperature: 67,
TemperatureAvailable: true,
Utilization: 98,
UtilizationAvailable: true,
}},
}},
}},
Crackstations: []*clientpb.CrackTopStation{{
ID: "station-wire",
Name: "Wire Worker",
HostUUID: "worker-wire",
State: clientpb.States_CRACKING,
CurrentCrackJobID: crackTopTestJobID,
StatusAvailable: true,
}},
}, nil
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, time.Time{})
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
dashboard := buildCrackTopDashboard(snapshot)
if len(dashboard.Jobs) != 1 || !dashboard.Jobs[0].ProgressKnown || dashboard.Jobs[0].Progress != 0.25 {
t.Fatalf("job progress from narrow telemetry = %#v", dashboard.Jobs)
}
if !dashboard.ClusterRateKnown || !dashboard.ClusterComplete || dashboard.ClusterRate != 125_000 {
t.Fatalf("cluster telemetry = rate:%d known:%v complete:%v", dashboard.ClusterRate, dashboard.ClusterRateKnown, dashboard.ClusterComplete)
}
if len(dashboard.Workers) != 1 {
t.Fatalf("workers = %#v, want one", dashboard.Workers)
}
worker := dashboard.Workers[0]
if !worker.TelemetryLive || !worker.RateKnown || worker.Rate != 125_000 || worker.Devices != 1 ||
!worker.HasTemp || worker.Temperature != 67 || !worker.HasUtil || worker.Utilization != 98 {
t.Fatalf("worker telemetry from narrow snapshot = %#v", worker)
}
}
func TestLoadCrackTopSnapshotUsesCompletionTimeOnlyWithoutObservedAt(t *testing.T) {
fallback := time.Unix(1_700_000_000, 123_456_789)
client := &crackTopRPCTestClient{
call: func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
return &clientpb.CrackTopSnapshot{}, nil
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, 0, fallback)
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
if !snapshot.RefreshedAt.Equal(fallback) {
t.Fatalf("RefreshedAt = %s, want deterministic completion time %s", snapshot.RefreshedAt, fallback)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
}
func TestLoadCrackTopSnapshotPropagatesDeadlineAndCancelsCallContext(t *testing.T) {
var callContext context.Context
var deadline time.Time
client := &crackTopRPCTestClient{
call: func(ctx context.Context, _ *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
callContext = ctx
var ok bool
deadline, ok = ctx.Deadline()
if !ok {
t.Fatal("CrackTop context has no deadline")
}
return &clientpb.CrackTopSnapshot{ObservedAt: 1}, nil
},
}
started := time.Now()
timeout := time.Second
_, err := loadCrackTopSnapshot(t.Context(), client, timeout, time.Time{})
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
if deadline.Before(started) || deadline.After(started.Add(timeout+100*time.Millisecond)) {
t.Fatalf("RPC deadline = %s, want about %s after %s", deadline, timeout, started)
}
select {
case <-callContext.Done():
if !errors.Is(callContext.Err(), context.Canceled) {
t.Fatalf("completed call context error = %v, want context canceled", callContext.Err())
}
default:
t.Fatal("completed call context was not canceled")
}
}
func TestLoadCrackTopSnapshotReturnsNilOnTimeout(t *testing.T) {
client := &crackTopRPCTestClient{
call: func(ctx context.Context, _ *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
<-ctx.Done()
return nil, ctx.Err()
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, 10*time.Millisecond, time.Time{})
if snapshot != nil {
t.Fatalf("timed-out load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: context deadline exceeded" {
t.Fatalf("timed-out load error = %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
}
func TestLoadCrackTopSnapshotPropagatesParentCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
client := &crackTopRPCTestClient{
call: func(ctx context.Context, _ *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
<-ctx.Done()
return nil, ctx.Err()
},
}
snapshot, err := loadCrackTopSnapshot(ctx, client, time.Second, time.Time{})
if snapshot != nil {
t.Fatalf("canceled load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: context canceled" {
t.Fatalf("canceled load error = %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
}
func TestLoadCrackTopSnapshotSanitizesRPCErrorAndRejectsEmptyResponse(t *testing.T) {
t.Run("rpc error", func(t *testing.T) {
raw := errors.New("bad\r\n\x1b[31mserver\x1b[0m\tfailure")
client := &crackTopRPCTestClient{
call: func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
return nil, raw
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, time.Time{})
if snapshot != nil {
t.Fatalf("failed load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: bad server failure" {
t.Fatalf("sanitized error = %v", err)
}
if errors.Is(err, raw) {
t.Fatal("sanitized error retained the raw RPC error in its unwrap chain")
}
if strings.ContainsAny(err.Error(), "\r\n\t\x1b") {
t.Fatalf("error still contains terminal controls: %q", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
})
t.Run("nil response", func(t *testing.T) {
client := &crackTopRPCTestClient{}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, time.Time{})
if snapshot != nil {
t.Fatalf("empty load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: server returned an empty response" {
t.Fatalf("empty response error = %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
})
}
File diff suppressed because it is too large. Load diff
-598
View File
@@ -1,598 +0,0 @@
package top
import (
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
)
//nolint:gocyclo // This test validates all history fields across success, gap, failure, and recovery snapshots.
func TestCrackTopAcceptedSnapshotsAppendRealOverviewHistory(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
first := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: first})
if len(model.history) != 1 {
t.Fatalf("history length after first accepted snapshot = %d, want 1", len(model.history))
}
if sample := model.history[0]; !sample.At.Equal(first.RefreshedAt) ||
!sample.ClusterRateKnown || sample.ClusterRate != 2_502_000 ||
!sample.ProgressKnown || sample.Progress != 0.3125 ||
!sample.UtilizationKnown || sample.Utilization != 98.5 ||
!sample.TemperatureKnown || sample.Temperature != 70 {
t.Fatalf("first history sample does not reflect live fixture telemetry: %#v", sample)
}
gapTime := first.RefreshedAt.Add(time.Second)
unknownGap := &crackTopSnapshot{
Stations: []*clientpb.Crackstation{{
HostUUID: "worker-without-status",
Name: "Awaiting Status",
}},
RefreshedAt: gapTime,
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: unknownGap})
if len(model.history) != 2 {
t.Fatalf("history length after second accepted snapshot = %d, want 2", len(model.history))
}
gap := model.history[1]
if !gap.At.Equal(gapTime) {
t.Fatalf("unknown-gap time = %s, want %s", gap.At, gapTime)
}
if gap.ClusterRateKnown || gap.ProgressKnown || gap.UtilizationKnown || gap.TemperatureKnown {
t.Fatalf("unreported telemetry was recorded as known instead of an unknown gap: %#v", gap)
}
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("refresh failed")})
if len(model.history) != 3 {
t.Fatalf("failed refresh history length = %d, want an explicit third gap sample", len(model.history))
}
failure := model.history[2]
if failure.ClusterRateKnown || failure.ProgressKnown || failure.UtilizationKnown || failure.TemperatureKnown || failure.CountsKnown {
t.Fatalf("failed refresh did not produce an unknown chart gap: %#v", failure)
}
recovered := crackTopLiveTestSnapshot()
recovered.RefreshedAt = gapTime.Add(time.Second)
_, _ = model.Update(crackTopSnapshotMsg{snapshot: recovered})
if len(model.history) != 4 || !model.history[3].ClusterRateKnown {
t.Fatalf("recovered refresh did not resume live history after the gap: %#v", model.history)
}
}
func TestCrackTopWideOverviewRendersRealDataCardsChartsAndJournal(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
model.refreshing = false
_, _ = model.Update(crackTopSnapshotMsg{snapshot: crackTopLiveTestSnapshot()})
view := model.View()
if !view.AltScreen {
t.Fatal("wide crack top overview did not request the alternate screen")
}
plain := ansi.Strip(view.Content)
for _, expected := range []string{
"CLUSTER / STATE",
"THROUGHPUT",
"SAMPLE AVG",
"DIAGNOSTICS",
"KEYSPACE / PROGRESS",
"OBSERVED WORKERS",
"DEVICE LOAD",
"rate",
"progress",
"worker max util",
"RECENT JOURNAL",
"ACTIVE 1",
"JOBS 1 loaded",
"2/2 online",
"2.50 MH/s",
"RECOVERED 7 loaded",
"31.2%",
"98% avg worker max",
"70°C max",
"QUEUE 2 run 0 leased 0 waiting",
"Alpha Rig",
"Beta Rig",
"snapshot ready",
} {
if !strings.Contains(plain, expected) {
t.Errorf("wide overview does not contain %q:\n%s", expected, plain)
}
}
if got := ansi.StringWidth(strings.Split(view.Content, "\n")[0]); got > model.width {
t.Fatalf("wide overview first-line width = %d, want <= %d", got, model.width)
}
}
func TestCrackTopOverviewDoesNotPresentDefaultsAsLiveBeforeSnapshot(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = crackTopOverviewWidth
model.height = crackTopOverviewHeight
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"WAITING", "first live snapshot", "ACTIVE —", "RECOVERED —", "QUEUE —"} {
if !strings.Contains(plain, expected) {
t.Errorf("pre-snapshot overview is missing %q:\n%s", expected, plain)
}
}
for _, unsupported := range []string{"HEALTHY", "OK LIVE", "ACTIVE 0", "RECOVERED 0", "QUEUE 0"} {
if strings.Contains(plain, unsupported) {
t.Errorf("pre-snapshot overview presented %q as observed:\n%s", unsupported, plain)
}
}
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("rpc unavailable")})
failed := ansi.Strip(model.View().Content)
for _, expected := range []string{"NO SNAPSHOT", "refresh failed", "ERROR", "no live snapshot"} {
if !strings.Contains(failed, expected) {
t.Errorf("initial-failure overview is missing %q:\n%s", expected, failed)
}
}
}
func TestCrackTopCompactViewDoesNotPresentDefaultsAsLiveBeforeSnapshot(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 90
model.height = 28
model.refreshing = false
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"waiting for data", "ACTIVE —", "JOBS —", "WORKERS —", "RECOVERED —", "awaiting first snapshot"} {
if !strings.Contains(plain, expected) {
t.Errorf("compact pre-snapshot view is missing %q:\n%s", expected, plain)
}
}
for _, unsupported := range []string{"ACTIVE 0", "JOBS 0 loaded", "WORKERS 0/0", "RECOVERED 0", "no active jobs"} {
if strings.Contains(plain, unsupported) {
t.Errorf("compact pre-snapshot view presented %q as observed:\n%s", unsupported, plain)
}
}
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("rpc unavailable")})
failed := ansi.Strip(model.View().Content)
for _, expected := range []string{"no snapshot", "ACTIVE —", "current data unavailable"} {
if !strings.Contains(failed, expected) {
t.Errorf("compact initial-failure view is missing %q:\n%s", expected, failed)
}
}
for _, unsupported := range []string{"ACTIVE 0", "JOBS 0 loaded", "WORKERS 0/0", "RECOVERED 0", "no active jobs"} {
if strings.Contains(failed, unsupported) {
t.Errorf("compact initial-failure view presented %q as observed:\n%s", unsupported, failed)
}
}
}
func TestCrackTopOverviewMarksRetainedSnapshotMetricsUnavailableAfterRefreshFailure(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
_, _ = model.Update(crackTopSnapshotMsg{snapshot: crackTopLiveTestSnapshot()})
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("temporary outage")})
meters := ansi.Strip(model.renderOverviewMeters(150, crackTopOverviewMetersHeight))
if count := strings.Count(meters, "current data unavailable"); count != 3 {
t.Fatalf("failed refresh meters did not expose progress/worker gaps:\n%s", meters)
}
if strings.Contains(meters, "no active jobs") || strings.Contains(meters, "no observed workers") {
t.Fatalf("failed refresh meters converted unknown counts to zero:\n%s", meters)
}
throughput := ansi.Strip(model.renderOverviewThroughputCard(55, crackTopOverviewSummaryHeight))
if !strings.Contains(throughput, "CLUSTER last 2.50 MH/s") || !strings.Contains(throughput, "RECOVERED 7 last snapshot") || !strings.Contains(throughput, "VIEW —") {
t.Fatalf("retained throughput was not labeled as stale:\n%s", throughput)
}
diagnostics := ansi.Strip(model.renderOverviewDiagnosticsCard(55, crackTopOverviewSummaryHeight))
if !strings.Contains(diagnostics, "QUEUE last 2 run") {
t.Fatalf("retained queue counts were presented as current:\n%s", diagnostics)
}
jobs := ansi.Strip(model.renderJobsPane(90, 8))
workers := ansi.Strip(model.renderWorkersPane(59, 8))
if !strings.Contains(jobs, "LAST SNAPSHOT") || !strings.Contains(workers, "LAST SNAPSHOT") {
t.Fatalf("retained detail panes were not labeled stale:\njobs:\n%s\nworkers:\n%s", jobs, workers)
}
}
func TestCrackTopOverviewRefreshInFlightIsSilent(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
_, _ = model.Update(crackTopSnapshotMsg{snapshot: crackTopLiveTestSnapshot()})
if command := model.startRefresh(); command == nil || !model.refreshing {
t.Fatalf("refresh did not start: command=%v refreshing=%v", command != nil, model.refreshing)
}
top := ansi.Strip(model.renderOverviewTopBar(model.width))
diagnostics := ansi.Strip(model.renderOverviewDiagnosticsCard(50, crackTopOverviewSummaryHeight))
footer := ansi.Strip(model.renderOverviewFooter(model.width))
header := ansi.Strip(model.renderHeader(model.width))
for name, rendered := range map[string]string{
"top bar": top,
"diagnostics": diagnostics,
"footer": footer,
"header": header,
} {
lower := strings.ToLower(rendered)
if strings.Contains(lower, "refreshing") || strings.Contains(lower, "live snapshot in flight") {
t.Errorf("%s exposed an in-flight refresh:\n%s", name, rendered)
}
}
if !strings.Contains(diagnostics, "OK LIVE DATA COMPLETE") {
t.Errorf("silent refresh changed snapshot diagnostics:\n%s", diagnostics)
}
if !strings.Contains(footer, "JOBS focus") {
t.Errorf("silent refresh displaced the normal footer:\n%s", footer)
}
if !strings.Contains(header, "updated ") {
t.Errorf("silent refresh hid the current snapshot timestamp:\n%s", header)
}
}
func TestCrackTopOverviewLabelsPartialRateHistoryAsLowerBounds(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
model.snapshot = &crackTopSnapshot{RefreshedAt: time.Now()}
model.dashboard = crackTopDashboard{ClusterRate: 2_000, ClusterRateKnown: true, ClusterComplete: false}
model.history = []crackTopHistorySample{
{At: time.Now().Add(-time.Second), ClusterRate: 1_000, ClusterRateKnown: true, ClusterComplete: true},
{At: time.Now(), ClusterRate: 2_000, ClusterRateKnown: true, ClusterComplete: false},
}
average, peak, known, complete := crackTopRateStats(model.history)
if !known || complete || average != 1_500 || peak != 2_000 {
t.Fatalf("partial rate stats = avg:%d peak:%d known:%v complete:%v", average, peak, known, complete)
}
plain := ansi.Strip(model.renderOverviewThroughputCard(55, crackTopOverviewSummaryHeight))
if !strings.Contains(plain, "CLUSTER ≥ 2.00 kH/s") || !strings.Contains(plain, "AVG ≥ 1.50 kH/s") || !strings.Contains(plain, "PEAK ≥ 2.00 kH/s") {
t.Fatalf("partial rate card lost lower-bound semantics:\n%s", plain)
}
rateChart := ansi.Strip(model.renderOverviewCharts(150, 8))
if !strings.Contains(rateChart, "rate ≥ 2.00 kH/s") {
t.Fatalf("partial rate chart lost current lower-bound semantics:\n%s", rateChart)
}
}
func TestCrackTopOverviewPausedOnlyWorkloadIsNotIdle(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.snapshot = &crackTopSnapshot{RefreshedAt: time.Now()}
model.dashboard = crackTopDashboard{Jobs: []crackTopJobRow{{ID: "paused", Status: clientpb.CrackJobStatus_PAUSED}}}
plain := ansi.Strip(model.renderOverviewStateCard(50, crackTopOverviewSummaryHeight))
if !strings.Contains(plain, "PAUSED") || strings.Contains(plain, "IDLE") {
t.Fatalf("paused-only workload state is misleading:\n%s", plain)
}
}
func TestCrackTopPlotPreservesUnknownGapAndBounds(t *testing.T) {
const (
width = 4
height = 4
)
plot := crackTopRenderPlot([]crackTopChartPoint{
{value: 100, known: true},
{value: 100, known: true},
{known: false},
{value: 100, known: true},
}, width, height, 100)
lines := strings.Split(plot, "\n")
if len(lines) != height {
t.Fatalf("plot height = %d, want %d:\n%s", len(lines), height, plot)
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > width {
t.Fatalf("plot line %d width = %d, want <= %d: %q", index+1, got, width, line)
}
}
top := []rune(lines[0])
if len(top) != width {
t.Fatalf("plot top row width = %d runes, want %d: %q", len(top), width, lines[0])
}
if top[0] != '╶' || top[1] != '╴' || top[2] != ' ' || top[3] != '●' {
t.Fatalf("plot did not preserve the unknown sample as a visible gap: %q", lines[0])
}
}
func TestCrackTopPlotConnectsVerticalTransitions(t *testing.T) {
testCases := []struct {
name string
points []crackTopChartPoint
height int
want string
}{
{
name: "rising",
points: []crackTopChartPoint{
{value: 0, known: true},
{value: 50, known: true},
{value: 100, known: true},
},
height: 3,
want: " ╷\n·┌┘\n╶┘·",
},
{
name: "falling",
points: []crackTopChartPoint{
{value: 100, known: true},
{value: 50, known: true},
{value: 0, known: true},
},
height: 3,
want: "╶┐ \n·└┐\n··╵",
},
{
name: "flat",
points: []crackTopChartPoint{
{value: 50, known: true},
{value: 50, known: true},
{value: 50, known: true},
},
height: 3,
want: " \n╶─╴\n···",
},
{
name: "full range rise",
points: []crackTopChartPoint{
{value: 0, known: true},
{value: 100, known: true},
},
height: 4,
want: " ╷\n │\n·│\n╶┘",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got := crackTopRenderPlot(testCase.points, len(testCase.points), testCase.height, 100)
if got != testCase.want {
t.Fatalf("plot =\n%q\nwant\n%q", got, testCase.want)
}
})
}
}
func TestCrackTopChartStatsDoesNotPresentStaleLatestAsCurrent(t *testing.T) {
latest, average, peak, latestKnown, historyKnown, latestLowerBound, historyLowerBound := crackTopChartStats([]crackTopChartPoint{
{value: 25, known: true},
{value: 75, known: true},
{known: false},
})
if latestKnown || !historyKnown {
t.Fatalf("latest/history known = %v/%v, want false/true", latestKnown, historyKnown)
}
if latestLowerBound || historyLowerBound {
t.Fatalf("unexpected lower-bound flags = latest:%v history:%v", latestLowerBound, historyLowerBound)
}
if latest != 0 || average != 50 || peak != 75 {
t.Fatalf("latest/average/peak = %v/%v/%v, want 0/50/75", latest, average, peak)
}
}
func TestCrackTopChartStatsUseOnlyVisiblePlotWindow(t *testing.T) {
points := []crackTopChartPoint{
{value: 1_000, known: true},
{value: 10, known: true},
{value: 20, known: true},
{value: 30, known: true},
}
visible := crackTopVisibleChartPoints(points, 3)
latest, average, peak, latestKnown, historyKnown, _, _ := crackTopChartStats(visible)
if len(visible) != 3 || !latestKnown || !historyKnown || latest != 30 || average != 20 || peak != 30 {
t.Fatalf("visible chart stats = len:%d latest:%v avg:%v peak:%v latest-known:%v history-known:%v", len(visible), latest, average, peak, latestKnown, historyKnown)
}
}
func TestCrackTopRecoveredDeltaUsesOnlyObservedCountEndpoints(t *testing.T) {
history := []crackTopHistorySample{
{Recovered: 0, CountsKnown: false},
{Recovered: 10, CountsKnown: true},
{Recovered: 0, CountsKnown: false},
{Recovered: 13, CountsKnown: true},
}
if delta, known := crackTopRecoveredDelta(history); !known || delta != 3 {
t.Fatalf("recovered delta across boundary gaps = %d known:%v, want 3 known", delta, known)
}
if delta, known := crackTopRecoveredDelta([]crackTopHistorySample{{Recovered: 13}, {Recovered: 14}}); known || delta != 0 {
t.Fatalf("unobserved recovered endpoints = %d known:%v, want unknown", delta, known)
}
if delta, known := crackTopRecoveredDelta([]crackTopHistorySample{{Recovered: 13, CountsKnown: true}}); known || delta != 0 {
t.Fatalf("single recovered endpoint = %d known:%v, want unknown", delta, known)
}
if delta, known := crackTopRecoveredDelta([]crackTopHistorySample{{Recovered: 13, CountsKnown: true}, {Recovered: 12, CountsKnown: true}}); known || delta != 0 {
t.Fatalf("decreasing recovered counter = %d known:%v, want unknown", delta, known)
}
}
func TestCrackTopChartSpanDisclosesSampleIndexedHistory(t *testing.T) {
points := []crackTopChartPoint{
{at: time.Unix(1_700_000_000, 0)},
{at: time.Unix(1_700_000_001, 0)},
{at: time.Unix(1_700_000_061, 0)},
}
if got := crackTopChartSpan(points); got != "1m1s/3 samples" {
t.Fatalf("chart span = %q, want elapsed time and sample count", got)
}
}
func TestCrackTopPlotDistinguishesUnknownFromObservedZero(t *testing.T) {
plot := crackTopRenderPlot([]crackTopChartPoint{
{value: 0, known: true},
{known: false},
{value: 0, known: true},
}, 3, 3, 100)
bottom := strings.Split(plot, "\n")[2]
if bottom != "●·●" {
t.Fatalf("zero/unknown/zero baseline = %q, want observed points around a grid gap", bottom)
}
}
func TestCrackTopAuthoritativeJobActionIsJournaled(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.runningJobAction = crackTopPendingJobAction{action: crackTopJobActionPause, jobID: crackTopTestJobID}
_, _ = model.Update(crackTopJobActionCompletedMsg{
action: crackTopJobActionPause,
jobID: crackTopTestJobID,
status: clientpb.CrackJobStatus_PAUSED,
updatedAt: 1_700_000_100,
hasStatus: true,
})
if len(model.journal) != 1 || model.journal[0].label != "JOB" || model.journal[0].message != "aaaaaaaa PAUSED" {
t.Fatalf("authoritative action journal = %#v", model.journal)
}
}
//nolint:gocyclo // This test keeps the complete action-to-authoritative-snapshot freshness transition together.
func TestCrackTopActionMarksTaskTelemetryStaleUntilAuthoritativeSnapshot(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
initial := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: initial})
actionUpdatedAt := initial.RefreshedAt.Add(time.Second).Unix()
model.runningJobAction = crackTopPendingJobAction{action: crackTopJobActionPause, jobID: crackTopTestJobID}
_, _ = model.Update(crackTopJobActionCompletedMsg{
action: crackTopJobActionPause,
jobID: crackTopTestJobID,
status: clientpb.CrackJobStatus_PAUSED,
updatedAt: actionUpdatedAt,
hasStatus: true,
})
if !model.authoritativeSnapshotPending() {
t.Fatal("successful lifecycle action did not mark task telemetry as awaiting an authoritative snapshot")
}
latest := model.history[len(model.history)-1]
if latest.ClusterRateKnown || latest.ProgressKnown || latest.UtilizationKnown || latest.CountsKnown {
t.Fatalf("post-action history sample presented stale task telemetry as current: %#v", latest)
}
plain := ansi.Strip(strings.Join([]string{
model.renderOverviewStateCard(50, crackTopOverviewSummaryHeight),
model.renderOverviewThroughputCard(50, crackTopOverviewSummaryHeight),
model.renderOverviewDiagnosticsCard(50, crackTopOverviewSummaryHeight),
model.renderOverviewMeters(150, crackTopOverviewMetersHeight),
model.renderJobsPane(90, 8),
model.renderWorkersPane(59, 8),
}, "\n"))
for _, expected := range []string{"SYNCING", "TASK DATA LAST", "RECOVERED 7 last snapshot", "task data pending", "QUEUE last 2 run", "last snapshot • syncing", "TASK DATA LAST/SYNCING", "LAST SNAPSHOT/SYNCING"} {
if !strings.Contains(plain, expected) {
t.Errorf("post-action overview is missing %q:\n%s", expected, plain)
}
}
if strings.Contains(strings.ToLower(plain), "refreshing") {
t.Errorf("post-action overview exposed refresh progress:\n%s", plain)
}
stale := crackTopLiveTestSnapshot()
stale.RefreshedAt = stale.RefreshedAt.Add(500 * time.Millisecond)
stale.Jobs[0].UpdatedAt = actionUpdatedAt
stale.Stations = stale.Stations[:1]
_, _ = model.Update(crackTopSnapshotMsg{snapshot: stale})
if !model.authoritativeSnapshotPending() {
t.Fatal("equal-version pre-action snapshot prematurely cleared lifecycle synchronization state")
}
staleSample := model.history[len(model.history)-1]
if staleSample.ClusterRateKnown || staleSample.ProgressKnown || staleSample.UtilizationKnown || staleSample.CountsKnown {
t.Fatalf("client-overridden pre-action snapshot was recorded as authoritative telemetry: %#v", staleSample)
}
foundUnrelatedTransition := false
for _, entry := range model.journal {
if entry.label == "WORKER" && entry.message == "Beta Rig disconnected" {
foundUnrelatedTransition = true
break
}
}
if !foundUnrelatedTransition {
t.Fatalf("pending-action snapshot discarded an unrelated worker transition: %#v", model.journal)
}
authoritative := crackTopLiveTestSnapshot()
authoritative.RefreshedAt = authoritative.RefreshedAt.Add(2 * time.Second)
authoritative.Jobs[0].Status = clientpb.CrackJobStatus_PAUSED
authoritative.Jobs[0].UpdatedAt = actionUpdatedAt
for _, task := range authoritative.Jobs[0].Tasks {
task.State = clientpb.CrackTaskState_CRACK_TASK_QUEUED
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: authoritative})
if model.authoritativeSnapshotPending() {
t.Fatal("matching authoritative snapshot did not clear lifecycle synchronization state")
}
diagnostics := ansi.Strip(model.renderOverviewDiagnosticsCard(50, crackTopOverviewSummaryHeight))
if strings.Contains(diagnostics, "QUEUE last") || strings.Contains(diagnostics, "SYNC action") {
t.Fatalf("authoritative snapshot remained labeled as stale:\n%s", diagnostics)
}
}
func TestCrackTopOverviewSelectionMarkerFollowsFocusedPane(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.dashboard = buildCrackTopDashboard(crackTopLiveTestSnapshot())
model.normalizeJobSelection()
model.normalizeWorkerSelection()
jobs := ansi.Strip(model.renderJobsPane(75, 8))
workers := ansi.Strip(model.renderWorkersPane(55, 8))
if !strings.Contains(jobs, "› aaaaaaaa") || strings.Contains(workers, "› ") {
t.Fatalf("job focus markers are ambiguous:\njobs:\n%s\nworkers:\n%s", jobs, workers)
}
_, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
jobs = ansi.Strip(model.renderJobsPane(75, 8))
workers = ansi.Strip(model.renderWorkersPane(55, 8))
if strings.Contains(jobs, "› ") || !strings.Contains(workers, "› ") {
t.Fatalf("worker focus markers are ambiguous:\njobs:\n%s\nworkers:\n%s", jobs, workers)
}
}
func TestCrackTopJournalRecordsRecoveredJobAndWorkerTransitions(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
first := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: first})
second := crackTopLiveTestSnapshot()
second.RefreshedAt = first.RefreshedAt.Add(time.Second)
second.Jobs[0].Status = clientpb.CrackJobStatus_COMPLETED
second.Jobs[0].ResultCount = 10
second.Stations = second.Stations[:1]
_, _ = model.Update(crackTopSnapshotMsg{snapshot: second})
wants := map[string]string{
"JOB": "aaaaaaaa COMPLETED",
"WORKER": "Beta Rig disconnected",
"RECOVERED": "+3 across loaded jobs",
}
for label, message := range wants {
found := false
for _, entry := range model.journal {
if entry.label == label && entry.message == message {
found = true
if !entry.at.Equal(second.RefreshedAt) {
t.Errorf("%s journal time = %s, want %s", label, entry.at, second.RefreshedAt)
}
break
}
}
if !found {
t.Errorf("journal missing %s transition %q: %#v", label, message, model.journal)
}
}
}
func TestCrackTopJournalRecordsActiveJobRemovedByAnotherOperator(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
first := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: first})
second := &crackTopSnapshot{RefreshedAt: first.RefreshedAt.Add(time.Second)}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: second})
for _, entry := range model.journal {
if entry.label == "JOB" && entry.message == "aaaaaaaa removed from live set" {
return
}
}
t.Fatalf("journal did not record externally removed active job: %#v", model.journal)
}
-123
View File
@@ -1,123 +0,0 @@
package top
import (
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
type crackTopQueueCounts struct {
queued int
leased int
running int
}
func TestBuildCrackTopDashboardCountsActiveJobTaskQueueStates(t *testing.T) {
crackTasks := func(states ...clientpb.CrackTaskState) []*clientpb.CrackTask {
tasks := make([]*clientpb.CrackTask, 0, len(states))
for _, state := range states {
tasks = append(tasks, &clientpb.CrackTask{
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: state,
})
}
return tasks
}
testCases := []struct {
name string
jobs []*clientpb.CrackJob
want crackTopQueueCounts
}{
{
name: "in-progress job",
jobs: []*clientpb.CrackJob{{
ID: "in-progress",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: crackTasks(
clientpb.CrackTaskState_CRACK_TASK_QUEUED,
clientpb.CrackTaskState_CRACK_TASK_LEASED,
clientpb.CrackTaskState_CRACK_TASK_RUNNING,
),
}},
want: crackTopQueueCounts{queued: 1, leased: 1, running: 1},
},
{
name: "paused job",
jobs: []*clientpb.CrackJob{{
ID: "paused",
Status: clientpb.CrackJobStatus_PAUSED,
Tasks: crackTasks(
clientpb.CrackTaskState_CRACK_TASK_QUEUED,
clientpb.CrackTaskState_CRACK_TASK_LEASED,
clientpb.CrackTaskState_CRACK_TASK_RUNNING,
),
}},
want: crackTopQueueCounts{queued: 1, leased: 1, running: 1},
},
{
name: "legacy unspecified crack task with shard telemetry",
jobs: []*clientpb.CrackJob{{
ID: "legacy-crack",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: []*clientpb.CrackTask{{
Kind: clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
ShardLimit: 100,
}},
}},
want: crackTopQueueCounts{running: 1},
},
{
name: "keyspace and auxiliary scheduler stages",
jobs: []*clientpb.CrackJob{{
ID: "queries",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: []*clientpb.CrackTask{
{Kind: clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED, State: clientpb.CrackTaskState_CRACK_TASK_QUEUED},
{Kind: clientpb.CrackTaskKind_CRACK_TASK_KEYSPACE, State: clientpb.CrackTaskState_CRACK_TASK_QUEUED},
{Kind: clientpb.CrackTaskKind_CRACK_TASK_BENCHMARK, State: clientpb.CrackTaskState_CRACK_TASK_LEASED},
{Kind: clientpb.CrackTaskKind_CRACK_TASK_QUERY, State: clientpb.CrackTaskState_CRACK_TASK_RUNNING},
},
}},
want: crackTopQueueCounts{queued: 2, leased: 1, running: 1},
},
{
name: "terminal jobs",
jobs: []*clientpb.CrackJob{
{ID: "completed", Status: clientpb.CrackJobStatus_COMPLETED, Tasks: crackTasks(clientpb.CrackTaskState_CRACK_TASK_QUEUED)},
{ID: "failed", Status: clientpb.CrackJobStatus_FAILED, Tasks: crackTasks(clientpb.CrackTaskState_CRACK_TASK_LEASED)},
{ID: "cancelled", Status: clientpb.CrackJobStatus_CANCELLED, Tasks: crackTasks(clientpb.CrackTaskState_CRACK_TASK_RUNNING)},
},
},
{
name: "terminal task states",
jobs: []*clientpb.CrackJob{{
ID: "active-with-terminal-tasks",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: append(
crackTasks(
clientpb.CrackTaskState_CRACK_TASK_COMPLETED,
clientpb.CrackTaskState_CRACK_TASK_FAILED,
clientpb.CrackTaskState_CRACK_TASK_CANCELLED,
),
nil,
),
}},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
dashboard := buildCrackTopDashboard(&crackTopSnapshot{Jobs: testCase.jobs})
got := crackTopQueueCounts{
queued: dashboard.QueuedTasks,
leased: dashboard.LeasedTasks,
running: dashboard.RunningTasks,
}
if got != testCase.want {
t.Fatalf("task queue counts = %+v, want %+v", got, testCase.want)
}
})
}
}
-172
View File
@@ -1,172 +0,0 @@
package top
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/bishopfox/sliver/client/command/crack/internal/hashcatdisplay"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
// These views intentionally contain only the status fields used by Crack Top.
// The production loader fills them from the lean CrackTop RPC; JSON decoding is
// retained for compatibility with locally constructed snapshots and fixtures.
type crackStatusView struct {
ProgressCurrent string
ProgressTotal string
Devices []crackDeviceView
}
type crackDeviceView struct {
ID string
Name string
Type string
Speed string
Temperature string
Utilization string
}
func hashcatHashTypeName(mode int32) (string, bool) {
return hashcatdisplay.TypeName(mode)
}
func humanizeHashRate(rate uint64) string {
return hashcatdisplay.HumanizeRate(rate)
}
func crackJobSortTime(job *clientpb.CrackJob) time.Time {
if job == nil {
return time.Time{}
}
if updatedAt := job.GetUpdatedAt(); updatedAt > 0 {
return time.Unix(updatedAt, 0)
}
createdAt := strings.TrimSpace(job.GetCreatedAt())
if unix, err := strconv.ParseInt(createdAt, 10, 64); err == nil && unix > 0 {
return time.Unix(unix, 0)
}
parsed, _ := time.Parse(time.RFC3339, createdAt)
return parsed
}
func crackJobTasks(job *clientpb.CrackJob) []*clientpb.CrackTask {
if job == nil {
return nil
}
return job.GetTasks()
}
func crackTaskStatus(task *clientpb.CrackTask) (*crackStatusView, error) {
if task == nil || len(bytes.TrimSpace(task.GetLatestStatusJSON())) == 0 {
return nil, nil
}
return parseCrackStatusJSON(task.GetLatestStatusJSON())
}
func parseCrackStatusJSON(raw []byte) (*crackStatusView, error) {
root, err := decodeLastCrackTopJSONObject(raw)
if err != nil {
return nil, err
}
status := &crackStatusView{}
status.ProgressCurrent, status.ProgressTotal = crackTopJSONPair(root["progress"])
if devices, ok := root["devices"].([]any); ok {
for _, item := range devices {
values, ok := item.(map[string]any)
if !ok {
continue
}
status.Devices = append(status.Devices, crackDeviceView{
ID: crackTopJSONText(firstCrackTopJSONValue(values, "device_id", "id")),
Name: crackTopSafeCell(crackTopJSONText(firstCrackTopJSONValue(values, "device_name", "name"))),
Type: crackTopSafeCell(crackTopJSONText(firstCrackTopJSONValue(values, "device_type", "type"))),
Speed: crackTopJSONText(firstCrackTopJSONValue(values, "speed")),
Temperature: crackTopJSONText(firstCrackTopJSONValue(values, "temp", "temperature")),
Utilization: crackTopJSONText(firstCrackTopJSONValue(values, "util", "utilization")),
})
}
}
return status, nil
}
func decodeLastCrackTopJSONObject(raw []byte) (map[string]any, error) {
decode := func(candidate []byte) (map[string]any, error) {
decoder := json.NewDecoder(bytes.NewReader(candidate))
decoder.UseNumber()
var last map[string]any
for {
value := map[string]any{}
if err := decoder.Decode(&value); err != nil {
break
}
last = value
}
if last == nil {
return nil, errors.New("no JSON object")
}
return last, nil
}
trimmed := bytes.TrimSpace(raw)
if value, err := decode(trimmed); err == nil {
return value, nil
}
lines := bytes.Split(trimmed, []byte{'\n'})
for index := len(lines) - 1; index >= 0; index-- {
candidate := bytes.TrimSpace(lines[index])
if len(candidate) == 0 {
continue
}
if value, err := decode(candidate); err == nil {
return value, nil
}
}
return nil, errors.New("hashcat status did not contain a JSON object")
}
func firstCrackTopJSONValue(values map[string]any, names ...string) any {
for _, name := range names {
if value, ok := values[name]; ok {
return value
}
}
return nil
}
func crackTopJSONPair(value any) (string, string) {
items, ok := value.([]any)
if !ok || len(items) < 2 {
return "", ""
}
return crackTopJSONText(items[0]), crackTopJSONText(items[1])
}
func crackTopJSONText(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return crackTopSafeCell(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case bool:
return strconv.FormatBool(typed)
default:
return crackTopSafeCell(fmt.Sprint(typed))
}
}
func valueOrDash(value string) string {
value = crackTopSafeCell(value)
if value == "" {
return "-"
}
return value
}
-669
View File
@@ -1,669 +0,0 @@
package top
import (
"errors"
"math"
"math/big"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
)
const crackTopMaxProgressTextSize = 148
type crackTopDashboard struct {
Jobs []crackTopJobRow
Workers []crackTopWorkerRow
ActiveJobs int
QueuedTasks int
LeasedTasks int
RunningTasks int
OnlineWorkers int
Unavailable int
Recovered uint64
ClusterRate uint64
ClusterRateKnown bool
ClusterComplete bool
ExpectedWorkers int
ReportingWorkers int
Progress float64
ProgressKnown bool
ProgressJobs int
}
type crackTopJobRow struct {
ID string
Status clientpb.CrackJobStatus
CreatedAt time.Time
UpdatedAt time.Time
Progress float64
ProgressKnown bool
ProgressComplete bool
Rate uint64
RateKnown bool
Workers int
Reporting int
Results uint64
Mode string
TelemetryErrors int
}
type crackTopWorkerRow struct {
ID string
Name string
State string
Online bool
Syncing bool
JobID string
Rate uint64
RateKnown bool
Devices int
Temperature float64
HasTemp bool
Utilization float64
HasUtil bool
LastSeen time.Time
TelemetryLive bool
}
type crackTopTaskSample struct {
jobID string
task *clientpb.CrackTask
status *crackStatusView
statusErr error
rate uint64
rateKnown bool
devices int
temperature float64
hasTemp bool
utilization float64
hasUtil bool
lastSeen time.Time
}
type crackTopProgressAccumulator struct {
completed *big.Rat
total *big.Rat
}
func newCrackTopProgressAccumulator() *crackTopProgressAccumulator {
return &crackTopProgressAccumulator{completed: new(big.Rat), total: new(big.Rat)}
}
func (a *crackTopProgressAccumulator) add(fraction, weight *big.Rat) {
if a == nil || fraction == nil || weight == nil || weight.Sign() <= 0 {
return
}
clamped := new(big.Rat).Set(fraction)
if clamped.Sign() < 0 {
clamped.SetInt64(0)
}
if clamped.Cmp(big.NewRat(1, 1)) > 0 {
clamped.SetInt64(1)
}
a.completed.Add(a.completed, new(big.Rat).Mul(clamped, weight))
a.total.Add(a.total, weight)
}
func (a *crackTopProgressAccumulator) value() (float64, bool) {
if a == nil || a.total.Sign() <= 0 {
return 0, false
}
fraction, _ := new(big.Rat).Quo(a.completed, a.total).Float64()
if math.IsNaN(fraction) || math.IsInf(fraction, 0) {
return 0, false
}
return math.Max(0, math.Min(1, fraction)), true
}
//nolint:gocyclo // Jobs, task telemetry, and connected workers are normalized in one immutable snapshot pass.
func buildCrackTopDashboard(snapshot *crackTopSnapshot) crackTopDashboard {
dashboard := crackTopDashboard{}
if snapshot == nil {
return dashboard
}
stations := make(map[string]*clientpb.Crackstation, len(snapshot.Stations))
for _, station := range snapshot.Stations {
if station == nil {
continue
}
hostID := crackTopStationID(station)
if hostID == "" {
continue
}
if _, exists := stations[hostID]; !exists {
stations[hostID] = station
}
}
dashboard.OnlineWorkers = len(stations)
jobs := make([]*clientpb.CrackJob, 0, len(snapshot.Jobs))
for _, job := range snapshot.Jobs {
if job != nil && strings.TrimSpace(job.GetID()) != "" {
jobs = append(jobs, job)
}
}
sort.SliceStable(jobs, func(i, j int) bool {
left := crackJobSortTime(jobs[i])
right := crackJobSortTime(jobs[j])
if left.Equal(right) {
return jobs[i].GetID() < jobs[j].GetID()
}
return left.After(right)
})
globalProgress := newCrackTopProgressAccumulator()
workerSamples := map[string]crackTopTaskSample{}
for _, job := range jobs {
row, samples, progress := crackTopJobTelemetry(job, stations, snapshot.RefreshedAt, snapshot.taskTelemetry)
dashboard.Jobs = append(dashboard.Jobs, row)
dashboard.Recovered = saturatingUint64Add(dashboard.Recovered, row.Results)
jobStatus := job.GetStatus()
if jobStatus == clientpb.CrackJobStatus_IN_PROGRESS || jobStatus == clientpb.CrackJobStatus_PAUSED {
for _, task := range crackJobTasks(job) {
if task == nil {
continue
}
switch task.GetState() {
case clientpb.CrackTaskState_CRACK_TASK_QUEUED:
dashboard.QueuedTasks++
case clientpb.CrackTaskState_CRACK_TASK_LEASED:
dashboard.LeasedTasks++
case clientpb.CrackTaskState_CRACK_TASK_RUNNING:
dashboard.RunningTasks++
}
}
}
if jobStatus == clientpb.CrackJobStatus_IN_PROGRESS {
dashboard.ActiveJobs++
if row.ProgressComplete {
dashboard.ProgressJobs++
}
if progress != nil {
globalProgress.completed.Add(globalProgress.completed, progress.completed)
globalProgress.total.Add(globalProgress.total, progress.total)
}
for hostID, sample := range samples {
if hostID == "" {
continue
}
if current, ok := workerSamples[hostID]; !ok || crackTopSamplePreferred(sample, current, stations[hostID], snapshot.RefreshedAt) {
workerSamples[hostID] = sample
}
}
}
}
dashboard.Progress, dashboard.ProgressKnown = globalProgress.value()
dashboard.ProgressKnown = dashboard.ProgressKnown && dashboard.ProgressJobs == dashboard.ActiveJobs
if dashboard.ActiveJobs == 0 {
dashboard.Progress = 0
dashboard.ProgressKnown = false
}
workerIDs := make(map[string]struct{}, len(stations)+len(workerSamples))
for hostID := range stations {
workerIDs[hostID] = struct{}{}
}
for hostID := range workerSamples {
workerIDs[hostID] = struct{}{}
}
for hostID := range workerIDs {
station := stations[hostID]
sample, hasSample := workerSamples[hostID]
worker := crackTopWorkerRow{ID: hostID, Online: station != nil}
if station != nil {
worker.Name = crackTopSafeCell(station.GetName())
if worker.Name == "" && station.GetStatus() != nil {
worker.Name = crackTopSafeCell(station.GetStatus().GetName())
}
if status := station.GetStatus(); status != nil {
worker.State = status.GetState().String()
worker.Syncing = status.GetIsSyncing()
worker.JobID = strings.TrimSpace(status.GetCurrentCrackJobID())
}
}
if worker.Name == "" {
worker.Name = crackTopShortID(hostID)
}
if worker.State == "" {
if worker.Online {
worker.State = "ONLINE"
} else {
worker.State = "OFFLINE"
}
}
if crackTopStationExpectsTelemetry(station) {
dashboard.ExpectedWorkers++
}
if hasSample {
if worker.JobID == "" && !worker.Online {
worker.JobID = sample.jobID
}
worker.LastSeen = sample.lastSeen
if crackTopTaskSampleIsLive(sample, station, snapshot.RefreshedAt) {
worker.TelemetryLive = true
worker.Rate = sample.rate
worker.RateKnown = sample.rateKnown
worker.Devices = sample.devices
worker.Temperature = sample.temperature
worker.HasTemp = sample.hasTemp
worker.Utilization = sample.utilization
worker.HasUtil = sample.hasUtil
}
}
if !worker.Online {
dashboard.Unavailable++
}
if worker.RateKnown {
dashboard.ClusterRate = saturatingUint64Add(dashboard.ClusterRate, worker.Rate)
dashboard.ClusterRateKnown = true
dashboard.ReportingWorkers++
}
dashboard.Workers = append(dashboard.Workers, worker)
}
dashboard.ClusterComplete = dashboard.ReportingWorkers == dashboard.ExpectedWorkers
dashboard.ClusterRateKnown = dashboard.ReportingWorkers > 0 || dashboard.ClusterComplete
sort.SliceStable(dashboard.Workers, func(i, j int) bool {
left, right := dashboard.Workers[i], dashboard.Workers[j]
if left.Online != right.Online {
return left.Online
}
if left.RateKnown != right.RateKnown {
return left.RateKnown
}
if left.Rate != right.Rate {
return left.Rate > right.Rate
}
leftName := strings.ToLower(left.Name)
rightName := strings.ToLower(right.Name)
if leftName != rightName {
return leftName < rightName
}
return left.ID < right.ID
})
return dashboard
}
//nolint:gocyclo // Job telemetry combines task state, liveness, progress, and device metrics in one aggregation pass.
func crackTopJobTelemetry(
job *clientpb.CrackJob,
stations map[string]*clientpb.Crackstation,
now time.Time,
taskTelemetry map[string]crackTopTaskTelemetry,
) (crackTopJobRow, map[string]crackTopTaskSample, *crackTopProgressAccumulator) {
row := crackTopJobRow{
ID: strings.TrimSpace(job.GetID()),
Status: job.GetStatus(),
CreatedAt: crackTopParseTime(job.GetCreatedAt()),
UpdatedAt: crackTopParseUnix(job.GetUpdatedAt()),
Results: job.GetResultCount(),
Mode: crackTopJobMode(job),
}
progress := newCrackTopProgressAccumulator()
samples := map[string]crackTopTaskSample{}
workers := map[string]struct{}{}
workTasks := 0
knownProgressTasks := 0
allProgressWeightsKnown := true
for _, task := range crackJobTasks(job) {
if !crackTopCrackTask(task, taskTelemetry) {
continue
}
workTasks++
hostID := strings.TrimSpace(task.GetHostUUID())
if hostID != "" {
workers[hostID] = struct{}{}
}
status, statusErr := crackTaskStatus(task)
progressExact := task.GetShardSkip() == 0
if metadata, ok := taskTelemetry[strings.TrimSpace(task.GetID())]; ok {
status = metadata.status
statusErr = nil
progressExact = metadata.progressExact || task.GetShardSkip() == 0
if metadata.parseErr {
statusErr = errors.New("crack status telemetry could not be parsed")
}
}
if statusErr != nil {
row.TelemetryErrors++
}
fraction, weight, known := crackTopTaskProgress(task, status, progressExact)
if known {
progress.add(fraction, weight)
knownProgressTasks++
} else if weight := crackTopAssignedShardWeight(task); weight != nil {
// Unknown work may already be complete, so zero is only a lower
// bound. Including its weight prevents known small shards from
// overstating the job's completion percentage.
progress.add(new(big.Rat), weight)
} else {
allProgressWeightsKnown = false
}
sample := crackTopTaskSample{
jobID: row.ID,
task: task,
status: status,
statusErr: statusErr,
lastSeen: crackTopTaskTime(task),
}
if status != nil {
sample.rate, sample.rateKnown = crackTopStatusRate(status)
sample.devices = len(status.Devices)
sample.temperature, sample.hasTemp = crackTopStatusMaximum(status, func(device crackDeviceView) string { return device.Temperature })
sample.utilization, sample.hasUtil = crackTopStatusMaximum(status, func(device crackDeviceView) string { return device.Utilization })
}
if hostID != "" {
if current, ok := samples[hostID]; !ok || crackTopSamplePreferred(sample, current, stations[hostID], now) {
samples[hostID] = sample
}
}
}
row.Workers = len(workers)
row.Progress, row.ProgressKnown = progress.value()
row.ProgressKnown = row.ProgressKnown && workTasks > 0 && allProgressWeightsKnown
row.ProgressComplete = row.ProgressKnown && knownProgressTasks == workTasks
if job.GetStatus() == clientpb.CrackJobStatus_COMPLETED {
row.Progress = 1
row.ProgressKnown = true
row.ProgressComplete = true
}
if job.GetStatus() == clientpb.CrackJobStatus_IN_PROGRESS {
for hostID, sample := range samples {
if crackTopTaskSampleIsLive(sample, stations[hostID], now) && sample.rateKnown {
row.Rate = saturatingUint64Add(row.Rate, sample.rate)
row.RateKnown = true
row.Reporting++
}
}
}
return row, samples, progress
}
func crackTopTaskProgress(task *clientpb.CrackTask, status *crackStatusView, progressExact bool) (*big.Rat, *big.Rat, bool) {
if task == nil {
return nil, nil, false
}
weight := crackTopAssignedShardWeight(task)
if weight == nil {
weight = new(big.Rat)
}
state := task.GetState()
if state == clientpb.CrackTaskState_CRACK_TASK_COMPLETED {
if weight.Sign() <= 0 && status != nil {
_, total, ok := crackTopProgressPair(status)
if ok {
weight.Set(total)
}
}
if weight.Sign() > 0 {
return big.NewRat(1, 1), weight, true
}
}
current, total, ok := crackTopProgressPair(status)
if ok && (task.GetShardSkip() == 0 || progressExact) {
fraction := new(big.Rat).Quo(current, total)
if weight.Sign() <= 0 {
weight.Set(total)
}
return fraction, weight, weight.Sign() > 0
}
if weight.Sign() > 0 && (state == clientpb.CrackTaskState_CRACK_TASK_QUEUED || state == clientpb.CrackTaskState_CRACK_TASK_LEASED) {
return new(big.Rat), weight, true
}
return nil, nil, false
}
func crackTopAssignedShardWeight(task *clientpb.CrackTask) *big.Rat {
if task == nil || task.GetShardLimit() == 0 {
return nil
}
return new(big.Rat).SetInt(new(big.Int).SetUint64(task.GetShardLimit()))
}
func crackTopProgressPair(status *crackStatusView) (*big.Rat, *big.Rat, bool) {
if status == nil {
return nil, nil, false
}
current, currentOK := crackTopRat(status.ProgressCurrent)
total, totalOK := crackTopRat(status.ProgressTotal)
if !currentOK || !totalOK || total.Sign() <= 0 {
return nil, nil, false
}
return current, total, true
}
func crackTopRat(value string) (*big.Rat, bool) {
value = strings.TrimSpace(value)
if value == "" || len(value) > crackTopMaxProgressTextSize {
return nil, false
}
for _, character := range value {
if character < '0' || character > '9' {
return nil, false
}
}
result := new(big.Rat)
if _, ok := result.SetString(value); !ok {
return nil, false
}
return result, true
}
func crackTopStatusRate(status *crackStatusView) (uint64, bool) {
if status == nil {
return 0, false
}
var total uint64
found := false
for _, device := range status.Devices {
rate, err := strconv.ParseUint(strings.TrimSpace(device.Speed), 10, 64)
if err != nil {
continue
}
found = true
total = saturatingUint64Add(total, rate)
}
return total, found
}
func crackTopStatusMaximum(status *crackStatusView, value func(crackDeviceView) string) (float64, bool) {
if status == nil || value == nil {
return 0, false
}
maximum := 0.0
found := false
for _, device := range status.Devices {
number, err := strconv.ParseFloat(strings.TrimSpace(value(device)), 64)
if err != nil || number < 0 || math.IsNaN(number) || math.IsInf(number, 0) {
continue
}
if !found || number > maximum {
maximum = number
found = true
}
}
return maximum, found
}
func crackTopTaskSampleIsLive(sample crackTopTaskSample, station *clientpb.Crackstation, now time.Time) bool {
if station == nil || sample.task == nil || sample.statusErr != nil || sample.task.GetState() != clientpb.CrackTaskState_CRACK_TASK_RUNNING {
return false
}
leaseExpiresAt := sample.task.GetLeaseExpiresAt()
if now.IsZero() || leaseExpiresAt <= now.Unix() {
return false
}
status := station.GetStatus()
if status == nil {
return false
}
if status.GetIsSyncing() || status.GetState() != clientpb.States_CRACKING {
return false
}
currentJobID := strings.TrimSpace(status.GetCurrentCrackJobID())
return currentJobID != "" && currentJobID == sample.jobID
}
func crackTopStationExpectsTelemetry(station *clientpb.Crackstation) bool {
if station == nil {
return false
}
status := station.GetStatus()
if status == nil {
// The station is connected, but without status we cannot prove it is
// idle. Keep the cluster total unknown instead of reporting a false zero.
return true
}
if status.GetIsSyncing() {
return false
}
return status.GetState() == clientpb.States_CRACKING
}
func crackTopSamplePreferred(left, right crackTopTaskSample, station *clientpb.Crackstation, now time.Time) bool {
leftLive := crackTopTaskSampleIsLive(left, station, now)
rightLive := crackTopTaskSampleIsLive(right, station, now)
if leftLive != rightLive {
return leftLive
}
return crackTopSampleNewer(left, right)
}
func crackTopSampleNewer(left, right crackTopTaskSample) bool {
if !left.lastSeen.Equal(right.lastSeen) {
return left.lastSeen.After(right.lastSeen)
}
leftRunning := left.task != nil && left.task.GetState() == clientpb.CrackTaskState_CRACK_TASK_RUNNING
rightRunning := right.task != nil && right.task.GetState() == clientpb.CrackTaskState_CRACK_TASK_RUNNING
if leftRunning != rightRunning {
return leftRunning
}
if left.task == nil || right.task == nil {
return left.task != nil
}
return left.task.GetAttempt() > right.task.GetAttempt()
}
func crackTopCrackTask(task *clientpb.CrackTask, taskTelemetry map[string]crackTopTaskTelemetry) bool {
if task == nil {
return false
}
switch task.GetKind() {
case clientpb.CrackTaskKind_CRACK_TASK_CRACK:
return true
case clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED:
if task.GetShardLimit() != 0 || len(task.GetLatestStatusJSON()) != 0 {
return true
}
metadata, ok := taskTelemetry[strings.TrimSpace(task.GetID())]
return ok && (metadata.status != nil || metadata.parseErr)
default:
return false
}
}
func crackTopStationID(station *clientpb.Crackstation) string {
if station == nil {
return ""
}
if hostID := strings.TrimSpace(station.GetHostUUID()); hostID != "" {
return hostID
}
if status := station.GetStatus(); status != nil {
if hostID := strings.TrimSpace(status.GetHostUUID()); hostID != "" {
return hostID
}
}
return strings.TrimSpace(station.GetID())
}
func crackTopTaskTime(task *clientpb.CrackTask) time.Time {
if task == nil {
return time.Time{}
}
for _, value := range []int64{task.GetLastHeartbeatAt(), task.GetUpdatedAt(), task.GetStartedAt(), task.GetCreatedAt()} {
if value > 0 {
return time.Unix(value, 0)
}
}
return time.Time{}
}
func crackTopParseTime(value string) time.Time {
parsed, _ := time.Parse(time.RFC3339, strings.TrimSpace(value))
return parsed
}
func crackTopParseUnix(value int64) time.Time {
if value <= 0 {
return time.Time{}
}
return time.Unix(value, 0)
}
func crackTopJobMode(job *clientpb.CrackJob) string {
if job == nil || job.GetCommand() == nil {
return ""
}
mode := int32(job.GetCommand().GetHashType())
if job.GetCommand().HashMode != nil {
mode = int32(job.GetCommand().GetHashMode())
}
if name, ok := hashcatHashTypeName(mode); ok {
return name
}
return "mode " + strconv.FormatInt(int64(mode), 10)
}
func crackTopShortID(value string) string {
value = crackTopSafeCell(value)
if value == "" {
return "unknown"
}
if index := strings.IndexByte(value, '-'); index > 0 {
value = value[:index]
}
runes := []rune(value)
if len(runes) > 8 {
value = string(runes[:8])
}
return value
}
func crackTopSafeCell(value string) string {
value = ansi.Strip(value)
value = strings.Map(func(r rune) rune {
if !unicode.IsPrint(r) {
return -1
}
if unicode.IsSpace(r) && r != ' ' {
return ' '
}
return r
}, value)
value = strings.TrimSpace(value)
const maximumRunes = 96
runes := []rune(value)
if len(runes) > maximumRunes {
value = string(runes[:maximumRunes-1]) + "…"
}
return value
}
func saturatingUint64Add(left, right uint64) uint64 {
if math.MaxUint64-left < right {
return math.MaxUint64
}
return left + right
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-2
View File
@@ -1,7 +1,6 @@
package creds
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -66,7 +65,6 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["hash-type"] = CredsHashTypeCompleter(con)
})
carapace.Gen(credsAddFileCmd).PositionalCompletion(carapace.ActionFiles().Tag("credential file"))
completers.RegisterLocalFilePathPositionalCompletion(credsAddFileCmd, 0)
credsAddCmd.AddCommand(credsAddFileCmd)
credsRmCmd := &cobra.Command{
Loaded 100 of 5659 files, more files were not shown because too many files have changed in this diff. Show more