diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e2a37c0..1480976 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,27 +3,27 @@ name: Release on: push: tags: - - 'v*' # Trigger only when a tag starting with 'v' is pushed (e.g., v1.0.0) + - "v*" # Trigger only when a tag starting with 'v' is pushed (e.g., v1.0.0) permissions: contents: write +env: + ALPINE_GIT_VERSION: v2.47.2 + jobs: build: - name: Build Go Binaries + name: Publish binaries runs-on: ubuntu-20.04 steps: - - name: Get release version from tag - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' # Change to the required Go version + go-version: "1.21" # Change to the required Go version - name: Install dependencies run: go mod tidy @@ -31,22 +31,60 @@ jobs: - name: Build binaries for multiple platforms run: | mkdir -p dist - GOOS=linux GOARCH=amd64 go build -o dist/app-linux-amd64 . - GOOS=linux GOARCH=arm64 go build -o dist/app-linux-arm64 . - GOOS=darwin GOARCH=arm64 go build -o dist/app-macos-arm64 . + GOOS=linux GOARCH=amd64 go build -o dist/maskcmd-linux-amd64 . + GOOS=linux GOARCH=arm64 go build -o dist/maskcmd-linux-arm64 . + GOOS=darwin GOARCH=arm64 go build -o dist/maskcmd-macos-arm64 . - name: Create GitHub Release id: create_release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ env.RELEASE_VERSION }} - name: Release ${{ env.RELEASE_VERSION }} - body: "Automated release for version ${{ env.RELEASE_VERSION }}" + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + body: "Automated release for version ${{ github.ref_name }}" draft: false prerelease: false files: | - dist/app-linux-amd64 - dist/app-linux-arm64 - dist/app-macos-arm64 + dist/maskcmd-linux-amd64 + dist/maskcmd-linux-arm64 + dist/maskcmd-macos-arm64 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + docker: + name: Publish docker image + runs-on: ubuntu-20.04 + needs: build + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PAT }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: caseycs/maskcmd + tags: | + type=semver,pattern=${{ env.ALPINE_GIT_VERSION }}-{{major}}{{minor}}{{patch}} + + - name: Build and Push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: | + linux/amd64 + linux/arm64 + build-args: | + ALPINE_GIT_VERSION=${{ env.ALPINE_GIT_VERSION }} + MASKCMD_VERSION=${{ github.ref_name }} + push: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/.gitignore b/.gitignore index 723ef36..c9747cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +maskcmd \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94b8e0a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +ARG ALPINE_GIT_VERSION=latest +FROM alpine/git:${ALPINE_GIT_VERSION} + +ARG TARGETOS +ARG TARGETARCH + +ARG MASKCMD_VERSION=v0.0.8 + +RUN wget -O /usr/local/bin/maskcmd https://github.com/caseycs/maskcmd/releases/download/$MASKCMD_VERSION/maskcmd-$TARGETOS-$TARGETARCH \ + && chmod +x /usr/local/bin/maskcmd diff --git a/README.md b/README.md index cd88162..ff65086 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,31 @@ Useful for bash scripts within K8S native pipelines, like Argo Workflows. ## Usage examples +### Argo Workflow + +```bash +kubectl apply -f argo-workflow-example/secret.yaml +argo submit argo-workflow-example/example.yaml -w --log +Name: maskcmd-example-mm2nj +Namespace: default +ServiceAccount: unset (will run with the default ServiceAccount) +Status: Pending +Created: Sun Mar 16 22:42:25 +0100 (now) +Progress: +maskcmd-example-mm2nj: + git clone https://x-token-auth:*****@bitbucket.org/project1/repo1.git +maskcmd-example-mm2nj: Cloning into 'repo1'... +maskcmd-example-mm2nj: remote: You may not have access to this repository or it no longer exists in this workspace. If you think this repository exists and you have access, make sure you are authenticated. +maskcmd-example-mm2nj: fatal: repository 'https://bitbucket.org/project1/repo1.git/' not found +maskcmd-example-mm2nj: Error: child command returned exit code: 128 +maskcmd-example-mm2nj: time="2025-03-16T21:42:28.457Z" level=info msg="sub-process exited" argo=true error="" +maskcmd-example-mm2nj: Error: exit status 128 +maskcmd-example-mm2nj Failed at 2025-03-16 22:42:35 +0100 CET +``` + +Notice that there no `bitbucket-repo1-token` (secret value) in the output, but just asterisks (`*****@`). + +[Example K8S manigests](/argo-workflow-example). + ### Shell scripts Mask files content in certain dir: @@ -46,3 +71,7 @@ Error: child command returned exit code: 5 echo $? 5 ``` + +## Docker image + +[Dockerfile](/Dockerfile) is based on recent [alpine/git](https://hub.docker.com/r/alpine/git): https://hub.docker.com/r/caseycs/maskcmd/tags diff --git a/argo-workflow-example/example.yaml b/argo-workflow-example/example.yaml new file mode 100644 index 0000000..50b76cf --- /dev/null +++ b/argo-workflow-example/example.yaml @@ -0,0 +1,21 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Workflow +metadata: + generateName: maskcmd-example- +spec: + entrypoint: demo + templates: + - name: demo + container: + image: caseycs/maskcmd:v2.47.2-008 + command: [maskcmd, --secrets-dir, /secret/, --, sh, -exc] + args: + - | + git clone https://x-token-auth:$(cat /secret/bitbucket-repo1/token)@bitbucket.org/project1/repo1.git + volumeMounts: + - name: secret-bitbucket-repo1 + mountPath: "/secret/bitbucket-repo1" + volumes: + - name: secret-bitbucket-repo1 + secret: + secretName: bitbucket-repo1 \ No newline at end of file diff --git a/argo-workflow-example/secret.yaml b/argo-workflow-example/secret.yaml new file mode 100644 index 0000000..65bad7b --- /dev/null +++ b/argo-workflow-example/secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: bitbucket-repo1 +type: Opaque +data: + token: bitbucket-repo1-token # bitbucket-repo1-token diff --git a/command.go b/command.go index 6f516d5..848e9d2 100644 --- a/command.go +++ b/command.go @@ -4,7 +4,6 @@ import ( "bufio" "errors" "fmt" - "github.com/spf13/cobra" "io" "os" "os/exec" @@ -13,6 +12,8 @@ import ( "sort" "strings" "sync" + + "github.com/spf13/cobra" ) const maxSecretLength = 4096 @@ -36,7 +37,7 @@ func cmdMask(cmd *cobra.Command, args []string) error { if secretsDir, _ := cmd.Flags().GetString("secrets-dir"); secretsDir != "" { secretsFromFiles, err := readSecretsFromDir(secretsDir) if err != nil { - return fmt.Errorf("error reading secrets from directory") + return fmt.Errorf("error reading secrets from directory: %v", err) } masks = append(masks, secretsFromFiles...) } @@ -185,29 +186,25 @@ func readSecretsFromDir(dirPath string) ([]string, error) { return err } - if !info.IsDir() { // Read only files - if info.Size() > maxSecretLength { - return fmt.Errorf("secret file %s is too large (above %dkb)", path, maxSecretLength/1024) - } + // Resolve symlinks + resolvedInfo, err := os.Lstat(path) + if err != nil { + return err // skip broken symlinks or inaccessible files + } - file, err := os.Open(path) - if err != nil { - return err - } - defer file.Close() + if !resolvedInfo.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil + } - scanner := bufio.NewScanner(file) - for scanner.Scan() { - secret := strings.TrimSpace(scanner.Text()) - if secret != "" { - secrets = append(secrets, secret) - } - } + if info.Size() > maxSecretLength { + return fmt.Errorf("secret file %s is too large (above %dkb)", path, maxSecretLength/1024) + } - if err := scanner.Err(); err != nil { - return err - } + secret, err := os.ReadFile(path) + if err != nil { + return err } + secrets = append(secrets, strings.TrimSpace(string(secret))) return nil }) diff --git a/command_test.go b/command_test.go index b1f85cc..a5fb54f 100644 --- a/command_test.go +++ b/command_test.go @@ -2,11 +2,12 @@ package main import ( "bytes" - "github.com/spf13/cobra" "os" "path/filepath" "strings" "testing" + + "github.com/spf13/cobra" ) func TestMaskLine(t *testing.T) { @@ -149,6 +150,46 @@ func TestCmdMask_MaskSecretsFromDirectory(t *testing.T) { } } +func TestCmdMask_MaskSecretsFromDirectory_Symlinks(t *testing.T) { + // Create temporary directories and symlinks similar to mounted K8S pod secret + testTempDir, err := os.MkdirTemp("", "secrets_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(testTempDir) // Cleanup after test + + if err := os.Mkdir(testTempDir+"/..random-name", 0755); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(testTempDir + "/..random-name") // Cleanup after test + + // Create secret file + createTempSecretFile(testTempDir+"/..random-name", "token", "mypassword") + if err = os.Symlink(testTempDir+"/..random-name/token", testTempDir+"/token"); err != nil { + t.Fatal(err) + } + + // Symlink like mounted K8S secret + if err = os.Symlink(testTempDir+"/..random-name", testTempDir+"/..data"); err != nil { + t.Fatal(err) + } + + stdout, stderr, err := executeCommand(buildCmdMask(), "--secrets-dir", testTempDir, "--", "echo", "Password is mypassword") + if err != nil { + t.Fatalf("Error executing command: %v", err) + } + + expectedStdOut := "Password is *****" + if strings.TrimSpace(stdout) != expectedStdOut { + t.Errorf("Expected stdout %q, got %q", expectedStdOut, stdout) + } + + expectedStdErr := "" + if strings.TrimSpace(stderr) != expectedStdErr { + t.Errorf("Expected stderr %q, got %q", expectedStdErr, stderr) + } +} + func TestCmdMask_CustomExitCode(t *testing.T) { os.Setenv("SECRET", "mysecret") defer os.Unsetenv("SECRET") diff --git a/maskcmd b/maskcmd deleted file mode 100755 index 71612be..0000000 Binary files a/maskcmd and /dev/null differ