From afa1c34dfc18b42209bef3e5ccde770f3931fba9 Mon Sep 17 00:00:00 2001 From: jettwang Date: Wed, 12 Aug 2026 16:39:24 +0800 Subject: [PATCH] fix(security): harden v0.1.0 release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 15 +++++-- .github/workflows/release.yml | 27 ++++++----- AGENT.md | 27 +++++------ CHANGELOG.md | 17 ++++++- Makefile | 2 +- README.md | 3 ++ README_CN.md | 3 ++ RELEASE.md | 16 +++---- go.mod | 10 ++--- go.sum | 16 +++---- install.sh | 39 +++++++++++----- internal/app/usage.go | 2 +- internal/app/usage_test.go | 2 +- internal/sshclient/remote_state.go | 67 ++++++++++++++++++++-------- scripts/tag.sh | 46 ++++++++++++++----- tests/e2e/inspect_plugin_e2e_test.go | 20 +++++++++ 16 files changed, 216 insertions(+), 96 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83e1572..6d5ad0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - go: ["1.24"] + go: ["1.25.10"] steps: - name: Checkout code @@ -66,7 +66,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25.10" - name: Download dependencies run: go mod download @@ -132,7 +132,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25.10" - name: Run golangci-lint uses: golangci/golangci-lint-action@v7 @@ -151,7 +151,14 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25.10" + + - name: Run Go vulnerability scanner + uses: golang/govulncheck-action@v1 + with: + go-version-input: "1.25.10" + go-package: ./... + repo-checkout: false - name: Run Gosec Security Scanner uses: securego/gosec@master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e81dba7..7c05574 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,11 +22,11 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.25.10" - name: Get version id: get_version - run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - name: Extract changelog id: changelog @@ -42,10 +42,10 @@ jobs: flag { print } ' CHANGELOG.md > /tmp/release_notes.md - # 如果没有提取到内容,使用默认消息 + # A release without versioned notes is incomplete; fail before publishing. if [ ! -s /tmp/release_notes.md ]; then - echo "No changelog entry found for version $VERSION_NUMBER" > /tmp/release_notes.md - echo "Please check CHANGELOG.md for details." >> /tmp/release_notes.md + echo "No changelog entry found for version $VERSION_NUMBER" >&2 + exit 1 fi # 输出到环境变量 @@ -53,7 +53,7 @@ jobs: echo 'CHANGELOG<> $GITHUB_OUTPUT + } >> "$GITHUB_OUTPUT" - name: Build binaries run: | @@ -76,12 +76,14 @@ jobs: -o "$output" \ ./cmd/sshx + cp skills/sshx/SKILL.md dist/SKILL.md + # 压缩二进制文件 if [ "$os" = "windows" ]; then - zip "dist/sshx-${os}-${arch}.zip" "$output" + (cd dist && zip "sshx-${os}-${arch}.zip" "$(basename "$output")" SKILL.md) rm "$output" else - tar czf "dist/sshx-${os}-${arch}.tar.gz" -C dist "$(basename $output)" + tar czf "dist/sshx-${os}-${arch}.tar.gz" -C dist "$(basename "$output")" SKILL.md rm "$output" fi } @@ -93,19 +95,20 @@ jobs: build darwin arm64 build windows amd64 build windows arm64 + rm dist/SKILL.md - name: Generate checksums run: | cd dist - sha256sum * > checksums.txt + sha256sum -- * > checksums.txt cat checksums.txt - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: name: Release ${{ steps.get_version.outputs.VERSION }} body: | - ## SSHX - Secure SSH & SFTP Client with Built-in Password Manager + ## SSHX - Agent-native remote host execution over SSH ### 📋 What's Changed @@ -211,7 +214,7 @@ jobs: cat > tap/Formula/sshx.rb < ⚠️ **Toolchain constraint:** CI's test/lint/security jobs run on **Go 1.24**. -> The `go` directive in `go.mod` must stay at `1.24.0`. When adding a dependency, -> pin it to a version whose own `go` directive is ≤ 1.24 (e.g. `x/term v0.37.0`, -> `x/sys v0.38.0`). Do not let `go get` silently bump the directive to 1.25+. +> ⚠️ **Toolchain constraint:** CI's test/lint/security jobs run on **Go 1.25.10**. +> The `go` directive in `go.mod` must stay at `1.25.10` unless a deliberate +> security or compatibility review changes the baseline. New dependencies must +> support that toolchain; do not let `go get` silently bump the directive. ## 6. Development Workflow (Methods) @@ -198,10 +198,11 @@ Notes: ### CI (`.github/workflows/`) -- `ci.yml`: **Test** (ubuntu + macOS, Go 1.24, `-race -cover`), **Lint** - (golangci-lint), **Security Scan** (`gosec` via golangci-lint and the - standalone scanner), **Analyze** (CodeQL, Go). -- `release.yml`: builds release artifacts (Go 1.25 in the release job only). +- `ci.yml`: **Test** (ubuntu + macOS, Go 1.25.10, `-race -cover`), **Lint** + (golangci-lint), **Security Scan** (`gosec` plus `govulncheck`), **Analyze** + (CodeQL, Go). +- `release.yml`: builds release artifacts with Go 1.25.10 and bundles the matching + Agent skill in every archive. All `ci.yml` checks must be green before merge. @@ -364,7 +365,7 @@ unless the mission in §1–§3 is formally revised. ## 11. Release Process - Semantic Versioning; changes recorded in `CHANGELOG.md` (Keep a Changelog). -- Tagging is scripted (`scripts/tag.sh`, `make tag`); release notes via +- Tagging is scripted (`scripts/tag.sh`, `make tag TAG=vX.Y.Z`); release notes via `scripts/release-note.sh` (`make renote`). - `release.yml` cross-compiles and publishes artifacts on tag push. - Install paths: `go install`, `install.sh` (Linux/macOS), `install.ps1` @@ -378,9 +379,9 @@ When working in this repo: 1. **Stay within the mission.** Re-read §3 before adding features. Default to a smaller change. Never reintroduce MCP, a daemon, a connection pool, tunneling, or a GUI. -2. **Hold the toolchain line.** Keep `go.mod` at `go 1.24.0`. If a dependency +2. **Hold the toolchain line.** Keep `go.mod` at `go 1.25.10`. If a dependency forces a newer directive, pin an older compatible version instead of bumping - the directive (CI runs Go 1.24). + the directive (CI runs Go 1.25.10). 3. **Verify before declaring done.** Run `make check` (and `golangci-lint run`) locally; reproduce the original symptom and confirm it is gone. For PR work, watch CI to green (`gh pr checks --watch`). diff --git a/CHANGELOG.md b/CHANGELOG.md index e0165b8..cca81ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-08-12 + ### Added - Add `sshx inspect` with built-in system/resource/network capabilities and a @@ -24,12 +26,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 SFTP, server-to-server transfer, keyring-backed sudo, and audit recovery. - Run the E2E suite on Linux and macOS CI, including production-binary checks against an ephemeral macOS Keychain. +- Bundle the matching Agent skill in release archives and install it alongside + the binary through the Linux/macOS installer. ### Changed - Separate SSH login and sudo password fields while preserving the documented keyring boundary: stored password keys are used for sudo, not SSH login. - Upgrade the CI cache and Codecov actions to their supported major versions. +- Raise the minimum Go toolchain to 1.25.10 so sshx consumes patched standard + library code and the patched SSH + implementation in `golang.org/x/crypto v0.52.0`. +- Fail the release workflow when a tag has no exact versioned changelog entry. +- Allow the tag script to accept an explicit semantic version and reject tags + without a matching changelog section. ### Security @@ -38,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Treat remote observation snapshots as untrusted input by enforcing schema and size limits, owner-only permissions, authenticated UID binding, clean paths, parent-directory checks, symlink rejection, and host-key/boot-ID identity. +- Reject a symlinked remote observation root before creating any managed cache + directories, preventing writes outside the intended cache tree. ### Fixed @@ -273,7 +285,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI/CD workflow and automated release process - Tag creation script -[Unreleased]: https://github.com/talkincode/sshx/compare/v0.0.12...HEAD +[Unreleased]: https://github.com/talkincode/sshx/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/talkincode/sshx/compare/v0.0.14...v0.1.0 +[0.0.14]: https://github.com/talkincode/sshx/compare/v0.0.13...v0.0.14 +[0.0.13]: https://github.com/talkincode/sshx/compare/v0.0.12...v0.0.13 [0.0.12]: https://github.com/talkincode/sshx/compare/v0.0.11...v0.0.12 [0.0.7]: https://github.com/talkincode/sshx/compare/v0.0.6...v0.0.7 [0.0.6]: https://github.com/talkincode/sshx/compare/v0.0.5...v0.0.6 diff --git a/Makefile b/Makefile index eae6198..668bd2f 100644 --- a/Makefile +++ b/Makefile @@ -174,7 +174,7 @@ ci: deps check test-coverage ## CI/CD workflow (deps, check, coverage) tag: @echo "🏷️ Starting tag creation process..." - @./scripts/tag.sh + @./scripts/tag.sh "$(TAG)" renote: @echo "🏷️ 开始更新release note..." diff --git a/README.md b/README.md index 9b36a27..eab3bb0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,9 @@ This pulls prebuilt binaries from the [talkincode/homebrew-tap](https://github.c curl -fsSL https://raw.githubusercontent.com/talkincode/sshx/main/install.sh | bash ``` +The installer verifies the release checksum and installs both the binary and +the matching Agent skill at `~/.agents/skills/sshx/SKILL.md`. + Or download and run: ```bash diff --git a/README_CN.md b/README_CN.md index b4729c5..072584b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -117,6 +117,9 @@ brew install talkincode/tap/sshx curl -fsSL https://raw.githubusercontent.com/talkincode/sshx/main/install.sh | bash ``` +安装脚本会校验 Release 校验和,并同时安装二进制和对应版本的 Agent skill +到 `~/.agents/skills/sshx/SKILL.md`。 + 或下载后运行: ```bash diff --git a/RELEASE.md b/RELEASE.md index e80c871..dffc056 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -37,11 +37,8 @@ git push origin main ### 3. Create and Push Tag ```bash -# Create tag -git tag -a v1.0.1 -m "Release v1.0.1" - -# Push tag to remote repository -git push origin v1.0.1 +# Validate CHANGELOG, create the annotated tag, and push it +make tag TAG=v1.0.1 ``` ### 4. Automated Build @@ -55,8 +52,10 @@ After pushing the tag, GitHub Actions will automatically: - macOS x86_64 (Intel) - macOS ARM64 (Apple Silicon) - Windows x86_64 + - Windows ARM64 -2. ✅ Create compressed archives for each binary: +2. ✅ Create compressed archives containing each binary and the matching + `skills/sshx/SKILL.md`: - Linux/macOS: `.tar.gz` format - Windows: `.zip` format @@ -80,7 +79,8 @@ https://github.com/talkincode/sshx/releases Check: - ✅ Release has been created -- ✅ All 5 platform binaries have been uploaded +- ✅ All 6 platform archives have been uploaded +- ✅ Every archive contains `SKILL.md` - ✅ checksums.txt file exists - ✅ Release notes are complete @@ -221,7 +221,7 @@ Tasks: 1. Run tests on multiple operating systems 2. Generate code coverage reports 3. Run code checks (golangci-lint) -4. Run security scans (gosec) +4. Run security scans (`gosec` and `govulncheck`) ## Reference Resources diff --git a/go.mod b/go.mod index d415c96..dd5b08c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/talkincode/sshx -go 1.24.0 +go 1.25.10 require ( github.com/joho/godotenv v1.5.1 @@ -8,8 +8,8 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/stretchr/testify v1.11.1 github.com/zalando/go-keyring v0.2.6 - golang.org/x/crypto v0.44.0 - golang.org/x/term v0.37.0 + golang.org/x/crypto v0.52.0 + golang.org/x/term v0.43.0 ) require ( @@ -19,7 +19,7 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 59ad43b..ed46dcd 100644 --- a/go.sum +++ b/go.sum @@ -26,14 +26,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/install.sh b/install.sh index 0f3207e..2e9eb64 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,7 @@ REPO="talkincode/sshx" VERSION="${1:-latest}" # Use argument or default to latest BINARY_NAME="sshx" INSTALL_DIR="/usr/local/bin" +SKILL_INSTALL_DIR="${SSHX_SKILLS_DIR:-${HOME}/.agents/skills/sshx}" # Functions print_info() { @@ -52,13 +53,15 @@ detect_platform() { # Detect Architecture # On macOS, use sysctl to get the real hardware architecture (not affected by Rosetta 2) if [ "$os" = "darwin" ]; then - local hw_arch=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo "") + local hw_arch + hw_arch=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo "") if echo "$hw_arch" | grep -q "Apple"; then # Apple Silicon arch="arm64" else # Intel Mac or fallback to uname - local machine=$(uname -m) + local machine + machine=$(uname -m) case "$machine" in x86_64|amd64) arch="amd64" ;; arm64|aarch64) arch="arm64" ;; @@ -82,7 +85,8 @@ detect_platform() { # Get latest version from GitHub get_latest_version() { - local latest_version=$(curl -sL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + local latest_version + latest_version=$(curl -sL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') if [ -z "$latest_version" ]; then print_error "Failed to fetch latest version" >&2 @@ -140,7 +144,8 @@ verify_checksum() { # Download and install install_sshx() { - local platform=$(detect_platform) + local platform + platform=$(detect_platform) local version="$VERSION" if [ "$version" = "latest" ]; then @@ -158,7 +163,8 @@ install_sshx() { print_info "Downloading from: $download_url" # Create temporary directory - local tmp_dir=$(mktemp -d) + local tmp_dir + tmp_dir=$(mktemp -d) cd "$tmp_dir" # Download @@ -216,6 +222,15 @@ install_sshx() { else sudo cp "$binary_file" "${INSTALL_DIR}/${BINARY_NAME}" && sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}" fi + + if [ -f "SKILL.md" ]; then + mkdir -p "$SKILL_INSTALL_DIR" + cp "SKILL.md" "${SKILL_INSTALL_DIR}/SKILL.md" + chmod 0644 "${SKILL_INSTALL_DIR}/SKILL.md" + print_success "Installed agent skill to ${SKILL_INSTALL_DIR}/SKILL.md" + else + print_warning "This release archive does not include the optional agent skill" + fi # Cleanup cd - > /dev/null @@ -226,10 +241,12 @@ install_sshx() { # Verify installation verify_installation() { - if command -v $BINARY_NAME &> /dev/null; then - local installed_version=$($BINARY_NAME --version 2>&1 || echo "unknown") + if command -v "$BINARY_NAME" &> /dev/null; then + local installed_version + installed_version=$($BINARY_NAME --version 2>&1 || echo "unknown") print_success "${BINARY_NAME} installed successfully" - print_info "Location: $(which $BINARY_NAME)" + print_info "Location: $(command -v "$BINARY_NAME")" + print_info "Version: ${installed_version}" echo "" echo "Run '$BINARY_NAME --help' to get started" else @@ -241,8 +258,8 @@ verify_installation() { # Check for existing installation check_existing() { - if command -v $BINARY_NAME &> /dev/null; then - print_warning "${BINARY_NAME} is already installed at: $(which $BINARY_NAME)" + if command -v "$BINARY_NAME" &> /dev/null; then + print_warning "${BINARY_NAME} is already installed at: $(command -v "$BINARY_NAME")" read -p "Do you want to overwrite it? [y/N] " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then @@ -257,7 +274,7 @@ main() { echo "" echo "╔════════════════════════════════════════╗" echo "║ sshx Automatic Installer ║" - echo "║ SSH & SFTP Tool with Password Mgr ║" + echo "║ Agent-native execution over SSH ║" echo "╚════════════════════════════════════════╝" echo "" diff --git a/internal/app/usage.go b/internal/app/usage.go index 814a9cd..ee50605 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -8,7 +8,7 @@ var Version = "dev" // PrintUsage prints the usage information for the sshx command. func PrintUsage() { - fmt.Printf("\nSSH & SFTP Remote Tool with Password Manager (Cross-Platform)\nVersion: %s\n", Version) + fmt.Printf("\nSSHX — Agent-native remote host execution over SSH\nVersion: %s\n", Version) fmt.Println(` Usage: sshx -h= [options] # SSH mode diff --git a/internal/app/usage_test.go b/internal/app/usage_test.go index e7bf9fd..e1e8b00 100644 --- a/internal/app/usage_test.go +++ b/internal/app/usage_test.go @@ -35,7 +35,7 @@ func TestPrintUsage(t *testing.T) { // Verify output contains key sections expectedSections := []string{ - "SSH & SFTP Remote Tool", + "SSHX — Agent-native remote host execution over SSH", "Usage:", "SSH Options:", "Sudo Auto-fill:", diff --git a/internal/sshclient/remote_state.go b/internal/sshclient/remote_state.go index e2dcd1f..9ce220f 100644 --- a/internal/sshclient/remote_state.go +++ b/internal/sshclient/remote_state.go @@ -76,11 +76,10 @@ func (c *SSHClient) ReadRemoteFile(remotePath string, limit int64, expectedUID s } func validateRemoteStateParents(client *sftp.Client, dir, expectedUID string) error { - managedIndex := strings.Index(dir, "/.sshx/observations") - if managedIndex < 0 { - return fmt.Errorf("remote state path is outside .sshx/observations") + managedRoot, err := remoteManagedRoot(dir) + if err != nil { + return err } - managedRoot := dir[:managedIndex] + "/.sshx" current := dir for { info, err := client.Lstat(current) @@ -173,19 +172,33 @@ func secureRemoteDirectory(client *sftp.Client, dir string) error { if err := validateAbsoluteRemotePath(dir); err != nil { return err } - managedIndex := strings.Index(dir, "/.sshx/observations") - if managedIndex < 0 { - return fmt.Errorf("remote state path is outside .sshx/observations") - } - managedRoot := dir[:managedIndex] + "/.sshx" - if err := client.MkdirAll(dir); err != nil { - return fmt.Errorf("create remote state directory: %w", err) + managedRoot, err := remoteManagedRoot(dir) + if err != nil { + return err } - current := dir - for { - info, err := client.Lstat(current) - if err != nil { - return fmt.Errorf("inspect remote directory %s: %w", current, err) + relative := strings.TrimPrefix(dir, managedRoot) + current := managedRoot + directories := []string{managedRoot} + for _, component := range strings.Split(strings.TrimPrefix(relative, "/"), "/") { + if component == "" { + continue + } + current = path.Join(current, component) + directories = append(directories, current) + } + for _, current := range directories { + info, statErr := client.Lstat(current) + if statErr != nil { + if !os.IsNotExist(statErr) { + return fmt.Errorf("inspect remote directory %s: %w", current, statErr) + } + if mkdirErr := client.Mkdir(current); mkdirErr != nil { + return fmt.Errorf("create remote state directory %s: %w", current, mkdirErr) + } + info, statErr = client.Lstat(current) + if statErr != nil { + return fmt.Errorf("inspect created remote directory %s: %w", current, statErr) + } } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("remote state path contains a non-directory or symlink: %s", current) @@ -193,14 +206,30 @@ func secureRemoteDirectory(client *sftp.Client, dir string) error { if err := client.Chmod(current, 0o700); err != nil { return fmt.Errorf("secure remote directory %s: %w", current, err) } - if current == managedRoot { - break + verified, verifyErr := client.Lstat(current) + if verifyErr != nil { + return fmt.Errorf("reinspect remote directory %s: %w", current, verifyErr) + } + if !verified.IsDir() || verified.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("remote state path changed during validation: %s", current) } - current = path.Dir(current) } return nil } +func remoteManagedRoot(dir string) (string, error) { + const marker = "/.sshx/observations" + managedIndex := strings.LastIndex(dir, marker) + if managedIndex < 0 { + return "", fmt.Errorf("remote state path is outside .sshx/observations") + } + afterMarker := dir[managedIndex+len(marker):] + if afterMarker != "" && !strings.HasPrefix(afterMarker, "/") { + return "", fmt.Errorf("remote state path is outside .sshx/observations") + } + return dir[:managedIndex] + "/.sshx", nil +} + func validateAbsoluteRemotePath(remotePath string) error { if !path.IsAbs(remotePath) || path.Clean(remotePath) != remotePath { return fmt.Errorf("remote state path must be a clean absolute path") diff --git a/scripts/tag.sh b/scripts/tag.sh index b3bbb1a..f8137a1 100755 --- a/scripts/tag.sh +++ b/scripts/tag.sh @@ -13,16 +13,38 @@ echo -e "${BLUE}🏷️ Starting to fetch latest tag...${NC}" git fetch --tags # If no tags exist, return v0.0.0 as fallback -latest_tag=$(git describe --tags `git rev-list --tags --max-count=1` 2>/dev/null || echo "v0.0.0") +latest_ref=$(git rev-list --tags --max-count=1) +latest_tag=$(git describe --tags "$latest_ref" 2>/dev/null || echo "v0.0.0") echo -e "${YELLOW}📋 Latest tag: ${latest_tag}${NC}" -# Parse version number -version=${latest_tag#v} -IFS='.' read -r -a parts <<<"$version" -last_idx=$((${#parts[@]} - 1)) -parts[$last_idx]=$((${parts[$last_idx]} + 1)) -new_version=$(IFS='.'; echo "${parts[*]}") -new_tag="v$new_version" +# Use an explicit semantic version when supplied; otherwise preserve the +# historical patch-increment behavior. +requested_tag="${1:-}" +if [ -n "$requested_tag" ]; then + if [[ ! "$requested_tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}❌ Invalid semantic version: ${requested_tag}${NC}" + exit 1 + fi + new_tag="v${requested_tag#v}" +else + version=${latest_tag#v} + IFS='.' read -r -a parts <<<"$version" + last_idx=$((${#parts[@]} - 1)) + parts[last_idx]=$((parts[last_idx] + 1)) + new_version=$(IFS='.'; echo "${parts[*]}") + new_tag="v$new_version" +fi + +if git rev-parse "$new_tag" >/dev/null 2>&1; then + echo -e "${RED}❌ Tag already exists: ${new_tag}${NC}" + exit 1 +fi + +version_number=${new_tag#v} +if ! grep -Fq "## [${version_number}]" CHANGELOG.md; then + echo -e "${RED}❌ CHANGELOG.md has no entry for ${version_number}${NC}" + exit 1 +fi echo -e "${GREEN}🎯 New tag: ${new_tag}${NC}" @@ -41,7 +63,7 @@ else fi # Generate commit log, format: - [commit_hash] commit_message -commit_log=$(git log $commit_range --pretty=format:"- [%h] %s" --reverse) +commit_log=$(git log "$commit_range" --pretty=format:"- [%h] %s" --reverse) if [ -z "$commit_log" ]; then echo -e "${YELLOW}⚠️ No new commits found${NC}" @@ -61,16 +83,16 @@ fi # Confirm tag creation echo -e -n "${YELLOW}Confirm creating tag ${new_tag}? (y/n): ${NC}" -read confirm +read -r confirm if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then echo -e "${BLUE}🚀 Creating annotated tag ${new_tag}...${NC}" # Use -a parameter to create annotated tag, -m parameter to add message - git tag -a $new_tag -m "$tag_message" + git tag -a "$new_tag" -m "$tag_message" echo -e "${BLUE}📤 Pushing tag to remote repository...${NC}" - git push origin $new_tag + git push origin "$new_tag" echo -e "${GREEN}✅ Tag ${new_tag} created and pushed successfully!${NC}" echo -e "${GREEN}📄 Tag description includes $(echo "$commit_log" | wc -l | tr -d ' ') commits${NC}" diff --git a/tests/e2e/inspect_plugin_e2e_test.go b/tests/e2e/inspect_plugin_e2e_test.go index f253490..b8b9a50 100644 --- a/tests/e2e/inspect_plugin_e2e_test.go +++ b/tests/e2e/inspect_plugin_e2e_test.go @@ -467,3 +467,23 @@ func TestCLIConcurrentAndFailedCacheWritesPreserveValidObservation(t *testing.T) _, err = pluginpkg.DecodeObservation(afterFailure) require.NoError(t, err) } + +func TestCLICacheWriteRejectsSymlinkedManagedRootBeforeCreatingChildren(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + require.Equal(t, 0, runSSHX(t, home, []string{"plugin", "create", "symlink.write", "--json"}, nil).exitCode) + require.Equal(t, 0, runSSHX(t, home, []string{"plugin", "trust", "symlink.write", "--json"}, nil).exitCode) + + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(server.root, ".sshx"))) + result := runSSHX(t, home, []string{ + "inspect", "-h=" + server.host, "-p=" + server.port, "-u=operator", "--no-key", + "--accept-unknown-host", "--json", "--cache=remote-prefer", "symlink.write", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + + assert.Equal(t, 255, result.exitCode) + assert.Contains(t, result.stdout, `"error_kind":"cache"`) + entries, err := os.ReadDir(outside) + require.NoError(t, err) + assert.Empty(t, entries, "a rejected managed-root symlink must not create directories outside the cache root") +}