Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,88 @@ 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

- 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 }}
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 }}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.idea
.idea
maskcmd
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<nil>"
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:
Expand Down Expand Up @@ -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
21 changes: 21 additions & 0 deletions argo-workflow-example/example.yaml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions argo-workflow-example/secret.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: bitbucket-repo1
type: Opaque
data:
token: bitbucket-repo1-token # bitbucket-repo1-token
39 changes: 18 additions & 21 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bufio"
"errors"
"fmt"
"github.com/spf13/cobra"
"io"
"os"
"os/exec"
Expand All @@ -13,6 +12,8 @@ import (
"sort"
"strings"
"sync"

"github.com/spf13/cobra"
)

const maxSecretLength = 4096
Expand All @@ -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...)
}
Expand Down Expand Up @@ -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
})

Expand Down
43 changes: 42 additions & 1 deletion command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down
Binary file removed maskcmd
Binary file not shown.
Loading