├── .dockerignore ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml ├── stale.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── Makefile.release ├── Makefile.tools ├── README.md ├── cmd ├── delete.go ├── env.go ├── env_test.go ├── exec.go ├── exec_default.go ├── exec_unix.go ├── export.go ├── export_test.go ├── find.go ├── find_test.go ├── history.go ├── import.go ├── list-services.go ├── list.go ├── read.go ├── root.go ├── root_test.go ├── tag-delete.go ├── tag-read.go ├── tag-write.go ├── tag.go ├── version.go └── write.go ├── environ ├── environ.go └── environ_test.go ├── go.mod ├── go.sum ├── main.go ├── nfpm.yaml.tmpl ├── store ├── awsapi.go ├── awsapi_mock.go ├── backendbenchmarks_test.go ├── nullstore.go ├── s3store.go ├── s3storeKMS.go ├── secretsmanagerstore.go ├── secretsmanagerstore_test.go ├── shared.go ├── shared_test.go ├── ssmstore.go ├── ssmstore_test.go ├── store.go └── store_test.go └── utils ├── utils.go └── utils_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /go.sum linguist-generated=true 2 | /store/awsapi_mock.go linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @segmentio/cloud-native-foundations 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | aws-sdk-go: 14 | patterns: 15 | - "github.com/aws/aws-sdk-go-v2" 16 | - "github.com/aws/aws-sdk-go-v2/*" 17 | - package-ecosystem: "docker" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | exemptMilestones: true 10 | exemptAssignees: true 11 | exemptProjects: true 12 | # Label to use when marking an issue as stale 13 | staleLabel: stale 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked `stale` because it has not had 17 | any activity in the last 60 days. If no further activity occurs within 7 18 | days, it will be closed. *Closed does not mean "never"*, just that it has 19 | no momentum to get accomplished any time soon. 20 | 21 | See [CONTRIBUTING.md](CONTRIBUTING.md) for more info. 22 | # Comment to post when closing a stale issue. Set to `false` to disable 23 | closeComment: > 24 | Closing due to staleness. *Closed does not mean "never"*, just that it has 25 | no momentum to get accomplished any time soon. 26 | 27 | See [CONTRIBUTING.md](CONTRIBUTING.md) for more info. 28 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Test & Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | pull_request: 8 | 9 | jobs: 10 | install-go-modules: 11 | strategy: 12 | matrix: 13 | go: ["1.24.x", "1.23.x", "1.22.x"] 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Setup Go ${{ matrix.go }} 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: ${{ matrix.go }} 24 | 25 | - name: Test install Go modules for v${{ matrix.go }} 26 | run: go install -v . && chamber version 27 | 28 | test: 29 | strategy: 30 | matrix: 31 | go: ["1.24.x", "1.23.x", "1.22.x"] 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Setup Go 38 | uses: actions/setup-go@v4 39 | with: 40 | go-version: ${{ matrix.go }} 41 | 42 | - name: Test 43 | run: make test 44 | 45 | - name: Check modules are tidy and checked in 46 | run: | 47 | export GO111MODULE=on 48 | go mod tidy 49 | if [ "$(git status --porcelain)" != "" ]; then 50 | echo "git tree is dirty after tidying modules" 51 | echo "ensure go.mod and go.sum are tidy" 52 | git status 53 | exit 1 54 | fi 55 | 56 | coverage: 57 | runs-on: ubuntu-latest 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | 62 | - name: Setup Go 63 | uses: actions/setup-go@v4 64 | with: 65 | go-version: "1.24.x" 66 | 67 | - name: Run coverage 68 | run: make coverage 69 | 70 | - name: Upload coverage reports to Codecov 71 | uses: codecov/codecov-action@v4.0.1 72 | with: 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | slug: segmentio/chamber 75 | 76 | dist: 77 | strategy: 78 | matrix: 79 | go: ["1.24.x", "1.23.x", "1.22.x"] 80 | runs-on: ubuntu-latest 81 | needs: test 82 | 83 | steps: 84 | - uses: actions/checkout@v4 85 | 86 | - name: Setup Go 87 | uses: actions/setup-go@v4 88 | with: 89 | go-version: ${{ matrix.go }} 90 | 91 | - name: Install nfpm, rpmbuild 92 | run: sudo make -f Makefile.tools nfpm-debian rpmbuild-debian 93 | 94 | - name: Make distributables 95 | run: make -f Makefile.release dist 96 | - uses: actions/upload-artifact@v4 97 | with: 98 | name: dist-${{ matrix.go }} 99 | path: "dist/*" 100 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | - "v[0-9]+.[0-9]+.[0-9]-[a-zA-Z0-9]+" 7 | 8 | jobs: 9 | dist: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Go 16 | uses: actions/setup-go@v4 17 | with: 18 | go-version: 1.24.x 19 | 20 | - name: Install nfpm, rpmbuild 21 | run: sudo make -f Makefile.tools nfpm-debian rpmbuild-debian 22 | 23 | - name: Make distributables 24 | run: make -f Makefile.release dist 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: dist 28 | path: "dist/*" 29 | 30 | publish-github-release: 31 | runs-on: ubuntu-latest 32 | permissions: 33 | contents: write 34 | needs: dist 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - uses: actions/download-artifact@v4 40 | with: 41 | name: dist 42 | path: "dist" 43 | 44 | - name: Release 45 | uses: softprops/action-gh-release@v1 46 | with: 47 | token: ${{ secrets.GITHUB_TOKEN }} 48 | generate_release_notes: true 49 | fail_on_unmatched_files: true 50 | files: | 51 | dist/* 52 | 53 | publish-dockerhub: 54 | runs-on: ubuntu-latest 55 | needs: dist 56 | 57 | steps: 58 | - uses: actions/checkout@v4 59 | 60 | - uses: actions/download-artifact@v4 61 | with: 62 | name: dist 63 | path: "dist/*" 64 | 65 | - name: Setup Go 66 | uses: actions/setup-go@v4 67 | with: 68 | go-version: 1.22.x 69 | 70 | - name: Set up QEMU 71 | uses: docker/setup-qemu-action@v2 72 | 73 | - name: Set up Docker Buildx 74 | uses: docker/setup-buildx-action@v2 75 | 76 | - name: Login to DockerHub 77 | uses: docker/login-action@v2 78 | with: 79 | username: ${{ secrets.DOCKERHUB_USER }} 80 | password: ${{ secrets.DOCKERHUB_TOKEN }} 81 | 82 | - name: Release 83 | run: make -f Makefile.release publish-dockerhub 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.sw[a-z] 3 | dist/ 4 | /chamber 5 | /coverage.out 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See https://github.com/segmentio/chamber/releases 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | We don't have an official Code of Conduct in place yet. In the meantime, don't be a jerk, and don't discriminate human beings based on irrelevant characteristics. Overuse the :heart: ❤️ emoji. 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thanks for thinking about contributing to Chamber! 2 | 3 | Chamber is an open-source project run with ❤️ by Twilio Segment. We've made it open source in the hope that other folks will find it useful. That said, making open source software takes a lot of work, so we try to keep Chamber focused on its goals. That means first and foremost supporting the use cases we have at Segment, but any other reasonable additions will be accepted with gratitude. 4 | 5 | The purpose of these guidelines is all about setting expectations. 6 | 7 | # Feature requests (`enhancement` label) 8 | 9 | New features should be requested via Issue first, to decide whether it falls within Chamber's scope. _Don't_ start with a feature PR without discussion. 10 | 11 | Even if it is decided that a feature fits Chamber's goals, that doesn't imply that someone is working on it. The only people who are obliged to work on a feature are the people who intend to use it. An `enhancement` issue without an assignee or a milestone means that nobody intends to work on it. If you're interested in working on it, just say so and we can assign it to you. 12 | 13 | An `enhancement` issue with a milestone means we intend to write it, but haven't decided who will do it yet. 14 | 15 | `enhancement` issues are subject to our [Staleness Policy](#Staleness). An `enhancement` that's gone stale means that no one's intending to work on it, which implies the feature isn't really that important. If this isn't the case, commenting during the staleness grace period will freshen it; this should almost always be a commitment to implementing it. 16 | 17 | # Timeliness 18 | 19 | As a user, there's nothing worse than crafting a beautiful PR with extensive tests only to be met with tumbleweeds from a long-abandoned project. We want to assure you that Chamber is maintained and actively worked on, but give you some guidelines on how long you might expect to wait before things get done. 20 | 21 | Issues should be triaged within 1 week, where triaging generally means figuring out which type of issue it is and adding labels. 22 | 23 | Pull requests (that have had design approval in an issue) should expect responses within 3 days. 24 | 25 | If you're finding we aren't abiding by these timelines, feel free to @-mention someone in [CODEOWNERS](.github/CODEOWNERS) to get our attention. If you're the shy type, don't worry that you're bothering us; you're helping us stick to the commitments we've made :) 26 | 27 | # Staleness 28 | 29 | All issues and PRs are subject to staleness after some period of inactivity. An issue/PR going stale indicates there isn't enough interest in getting it resolved. After some grace period, a stale issue/PR will be closed. 30 | 31 | An issue/PR being closed doesn't mean that it will never be addressed, just that there currently isn't any intention to do so. 32 | 33 | During the grace period, any activity will reset the staleness counter. Generally speaking, this should be a commitment to making progress. 34 | 35 | The current staleness policy is defined in [.github/stale.yml](.github/stale.yml). 36 | 37 | Stale issues get the `stale` label. 38 | 39 | # Labels 40 | 41 | - `bug`: behaviour in Chamber that is obviously wrong (but not necessarily obviously solvable). 42 | - `enhancement`: a new feature. Without an assignee, it's looking for someone to take the reins and get it made. 43 | - `help wanted`: no pressing desire to get this addressed. An easy contribution for someone looking to get started contributing. 44 | - `repro hard` (issue): difficult to repro without specific setup. often of third party software. We'll make an effort to help narrow in on the problem, but probably can't guarantee we'll be able to make a definitive judgment on whether it's a real bug. 45 | - `question`: we'll make an effort to answer your question, but won't guarantee we can solve it. 46 | 47 | # Commit messages 48 | 49 | We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) to help generate changelogs and do semver releases. We usually do Squash and Merge to PRs, so PR authors are recommended to use the Conventional Commits format in their PR title. 50 | 51 | # Licensing 52 | 53 | We do not require contributors outside of Segment to execute a contributor license agreement. Instead, we accept contributions under Chamber's [license](LICENSE). In legal terms: _All third party contributors acknowledge that any contributions they provide will be made under the same open source license that Chamber is provided under._ 54 | 55 | # Anti-contribution 56 | 57 | - Obviously, anything that violates our [Code of Conduct](CODE_OF_CONDUCT.md) 58 | - Noisy comments: "me too!", or "here's my setup" when the bug's already been located. Use a :thumbsup: emoji on the issue/PR instead, since we can count these. 59 | - Non-constructive complaining. 60 | - Feature PRs without a discussion issue: it's important we agree the feature is in-scope before anyone wastes time writing code or reviewing 61 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.2-alpine AS build 2 | 3 | WORKDIR /go/src/github.com/segmentio/chamber 4 | COPY . . 5 | 6 | ARG TARGETARCH 7 | ARG VERSION 8 | RUN test -n "${VERSION}" 9 | 10 | RUN apk add -U make ca-certificates 11 | RUN make linux VERSION=${VERSION} TARGETARCH=${TARGETARCH} 12 | 13 | FROM scratch AS run 14 | 15 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 16 | COPY --from=build /go/src/github.com/segmentio/chamber/chamber /chamber 17 | 18 | ENTRYPOINT ["/chamber"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2017 Segment 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Goals: 2 | # - user can build binaries on their system without having to install special tools 3 | # - user can fork the canonical repo and expect to be able to run Github Actions checks 4 | # 5 | # This makefile is meant for humans 6 | 7 | ifndef VERSION 8 | VERSION := $(shell git describe --tags --always --dirty="-dev") 9 | endif 10 | 11 | ifndef TARGETARCH 12 | TARGETARCH := $(shell arch) 13 | endif 14 | 15 | VERSION_NO_V := $(shell echo "$(VERSION)" | sed 's/^v//') 16 | VERSION_MAJOR_MINOR_PATCH := $(shell echo "$(VERSION)" | sed 's/^v\([0-9]*.[0-9]*.[0-9]*\).*/\1/') 17 | VERSION_MAJOR_MINOR := $(shell echo "$(VERSION)" | sed 's/^v\([0-9]*.[0-9]*\).*/\1/') 18 | VERSION_MAJOR := $(shell echo "$(VERSION)" | sed 's/^v\([0-9]*\).*/\1/') 19 | ANALYTICS_WRITE_KEY ?= 20 | LDFLAGS := -ldflags='-X "main.Version=$(VERSION)" -X "main.AnalyticsWriteKey=$(ANALYTICS_WRITE_KEY)"' 21 | MOQ := $(shell command -v moq 2> /dev/null) 22 | SRC := $(shell find . -name '*.go') 23 | GOLANGCI_LINT := $(shell command -v golangci-lint 2> /dev/null) 24 | 25 | # don't rely on target ordering to define a default make goal 26 | .DEFAULT_GOAL := test 27 | 28 | test: store/awsapi_mock.go 29 | go test -v ./... 30 | 31 | coverage: 32 | go test -coverpkg ./... -coverprofile coverage.out ./... 33 | 34 | vet: 35 | go vet ./... 36 | 37 | lint: vet 38 | ifdef GOLANGCI_LINT 39 | @golangci-lint run --max-same-issues 0 --max-issues-per-linter 0 40 | else 41 | @echo "Please install golangci-lint: brew install golangci-lint" 42 | @false 43 | endif 44 | 45 | store/awsapi_mock.go: store/awsapi.go 46 | ifdef MOQ 47 | rm -f $@ 48 | go generate ./... 49 | else 50 | @echo "Unable to generate mocks" 51 | @echo "Please install moq: go install github.com/matryer/moq@latest" 52 | endif 53 | 54 | all: dist/chamber-$(VERSION)-darwin-amd64 dist/chamber-$(VERSION)-linux-amd64 dist/chamber-$(VERSION)-windows-amd64.exe 55 | 56 | clean: 57 | rm -rf ./dist 58 | 59 | dist/: 60 | mkdir -p dist 61 | 62 | fmt: 63 | go fmt ./... 64 | 65 | build: chamber 66 | 67 | chamber: fmt $(SRC) 68 | CGO_ENABLED=0 go build -trimpath $(LDFLAGS) -o $@ 69 | 70 | dist/chamber-$(VERSION)-darwin-amd64: | dist/ 71 | GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath $(LDFLAGS) -o $@ 72 | 73 | dist/chamber-$(VERSION)-darwin-arm64: | dist/ 74 | GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath $(LDFLAGS) -o $@ 75 | 76 | linux: dist/chamber-$(VERSION)-linux-$(TARGETARCH) 77 | cp $^ chamber 78 | 79 | dist/chamber-$(VERSION)-linux-amd64: | dist/ 80 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath $(LDFLAGS) -o $@ 81 | 82 | dist/chamber-$(VERSION)-linux-arm64 dist/chamber-$(VERSION)-linux-aarch64: | dist/ 83 | GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath $(LDFLAGS) -o $@ 84 | 85 | dist/chamber-$(VERSION)-windows-amd64.exe: | dist/ 86 | GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath $(LDFLAGS) -o $@ 87 | 88 | .PHONY: vet test coverage lint clean all fmt build linux 89 | -------------------------------------------------------------------------------- /Makefile.release: -------------------------------------------------------------------------------- 1 | # Goals: 2 | # - Linux releases can be published to Github automatically by Github Actions 3 | # 4 | # This Makefile is meant for machines 5 | 6 | include Makefile 7 | 8 | # set --pre-release if not tagged or tree is dirty or there's a `-` in the tag 9 | ifneq (,$(findstring -,$(VERSION))) 10 | DOCKERHUB_TAG_PREFIX := "prerelease-" 11 | endif 12 | 13 | publish-dockerhub: 14 | docker buildx build \ 15 | -t segment/chamber:$(DOCKERHUB_TAG_PREFIX)$(VERSION_MAJOR_MINOR_PATCH) \ 16 | -t segment/chamber:$(DOCKERHUB_TAG_PREFIX)$(VERSION_MAJOR_MINOR) \ 17 | -t segment/chamber:$(DOCKERHUB_TAG_PREFIX)$(VERSION_MAJOR) \ 18 | -t segment/chamber:$(DOCKERHUB_TAG_PREFIX)$(VERSION_NO_V) \ 19 | --build-arg VERSION=$(VERSION) \ 20 | --platform linux/amd64,linux/arm64 \ 21 | --push \ 22 | . 23 | 24 | dist: dist/chamber-$(VERSION)-darwin-amd64 dist/chamber-$(VERSION)-darwin-arm64 dist/chamber-$(VERSION)-linux-amd64 dist/chamber-$(VERSION)-linux-arm64 dist/chamber-$(VERSION)-windows-amd64.exe dist/chamber_$(VERSION)_amd64.deb dist/chamber_$(VERSION)_amd64.rpm dist/chamber-$(VERSION).sha256sums 25 | 26 | dist/chamber-$(VERSION).sha256sums: dist/chamber-$(VERSION)-darwin-amd64 dist/chamber-$(VERSION)-darwin-arm64 dist/chamber-$(VERSION)-linux-amd64 dist/chamber-$(VERSION)-linux-arm64 dist/chamber-$(VERSION)-windows-amd64.exe dist/chamber_$(VERSION)_amd64.deb dist/chamber_$(VERSION)_amd64.rpm 27 | sha256sum $^ | sed 's|dist/||g' > $@ 28 | 29 | dist/nfpm-$(VERSION).yaml: | dist/ 30 | sed -e "s/\$${VERSION}/$(VERSION)/g" -e "s|\$${DIST_BIN}|dist/chamber-$(VERSION)-linux-amd64|g" < nfpm.yaml.tmpl > $@ 31 | 32 | dist/chamber_$(VERSION)_amd64.deb: dist/nfpm-$(VERSION).yaml dist/chamber-$(VERSION)-linux-amd64 33 | nfpm -f $< pkg --target $@ 34 | 35 | dist/chamber_$(VERSION)_amd64.rpm: dist/nfpm-$(VERSION).yaml dist/chamber-$(VERSION)-linux-amd64 36 | nfpm -f $< pkg --target $@ 37 | 38 | .PHONY: \ 39 | publish-dockerhub 40 | -------------------------------------------------------------------------------- /Makefile.tools: -------------------------------------------------------------------------------- 1 | # Tools installation recipes 2 | # 3 | # These are fragile, non-portable, and often require root 4 | # 5 | NFPM_VERSION := 0.9.3 6 | #from https://github.com/goreleaser/nfpm/releases/download/v0.9.3/nfpm_0.9.3_checksums.txt 7 | NFPM_SHA256 := f875ac060a30ec5c164e5444a7278322b276707493fa0ced6bfdd56640f0a6ea 8 | 9 | nfpm-debian: 10 | cd /tmp && \ 11 | curl -Ls https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz > nfpm.tar.gz && \ 12 | echo "${NFPM_SHA256} nfpm.tar.gz" | \ 13 | sha256sum -c && \ 14 | tar xzvf nfpm.tar.gz && \ 15 | mv nfpm /usr/local/bin 16 | 17 | rpmbuild-debian: 18 | apt update -q && apt install rpm -yq 19 | 20 | rpmbuild-darwin: 21 | brew install rpm 22 | 23 | sha256sum-darwin: 24 | brew install coreutils && ln -s $$(which gsha256sum) /usr/local/bin/sha256sum` 25 | 26 | .PHONY: nfpm-debian \ 27 | rpmbuild-debian \ 28 | rpmbuild-darwin \ 29 | sha256sum-darwin \ 30 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chamber 2 | 3 | Chamber is a tool for managing secrets. Currently it does so by storing 4 | secrets in SSM Parameter Store, an AWS service for storing secrets. 5 | 6 | For detailed info about using chamber, please read 7 | [The Right Way To Manage Secrets](https://aws.amazon.com/blogs/mt/the-right-way-to-store-secrets-using-parameter-store/) 8 | 9 | ## v3.0 Breaking Changes 10 | 11 | * **Use of the SSM Parameter Store's path-based API is now required.** Support 12 | added in v2.0 to avoid it has been removed. The `CHAMBER_NO_PATHS` environment 13 | variable no longer has any effect. You must migrate to the new storage format 14 | using the instructions below, using a 2.x version of chamber. 15 | * **The `--min-throttle-delay` option no longer has any effect.** Support for 16 | specifying a minimum throttle delay has been removed from the underlying AWS 17 | SDK with no direct replacement. Instead, set the new `--retry-mode` option to 18 | "adaptive" to use an experimental model that accounts for throttling errors. 19 | * **Context arguments are required for `Store` methods.** This is a consequence 20 | of migrating to a new AWS SDK. This change has no effect for CLI users, but 21 | those using chamber as a library must update their code to pass contexts. 22 | * **The deprecated `NewS3Store` constructor has been removed.** Use 23 | `NewS3StoreWithBucket` instead. 24 | 25 | ## v2.0 Breaking Changes 26 | 27 | Starting with version 2.0, chamber uses parameter store's path based API by default. 28 | Chamber pre-2.0 supported this API using the `CHAMBER_USE_PATHS` environment variable. 29 | The paths based API has performance benefits and is the recommended best practice 30 | by AWS. 31 | 32 | As a side effect of this change, if you didn't use path based secrets before 2.0, 33 | you will need to set `CHAMBER_NO_PATHS` to enable the old behavior. This option 34 | is deprecated, and We recommend only using this setting for supporting existing 35 | applications. 36 | 37 | To migrate to the new format, you can take advantage of the `export` and `import` 38 | commands. For example, if you wanted to convert secrets for service `foo` to the 39 | new format using chamber 2.0, you can do: 40 | 41 | ```bash 42 | CHAMBER_NO_PATHS=1 chamber export foo | chamber import foo - 43 | ``` 44 | 45 | ### v2.13.0 Breaking Changes 46 | 47 | Support for very old versions of Go has been dropped, and chamber will only test 48 | against versions of Go covered by the Go Release Policy, e.g. the two most recent 49 | major versions. This will ensure that we can reliably update dependencies as needed. 50 | Additionally, chamber binaries will be built with the latest stable version of Go 51 | at the time of release. 52 | 53 | ## Installing 54 | 55 | If you have a functional go environment, you can install with: 56 | 57 | ```bash 58 | go install github.com/segmentio/chamber/v3@latest 59 | ``` 60 | 61 | ### Caveat About `chamber version` and `go install` 62 | 63 | Note that installing with `go install` will not produce an executable containing 64 | any versioning information. This information is passed at compilation time when 65 | the `Makefile` is used for compilation. Without this information, `chamber version` 66 | outputs the following: 67 | 68 | ```text 69 | $ chamber version 70 | chamber dev 71 | ``` 72 | 73 | [See the wiki for more installation options like Docker images, Linux packages, and precompiled binaries.](https://github.com/segmentio/chamber/wiki/Installation) 74 | 75 | ## Authenticating 76 | 77 | Using `chamber` requires you to be running in an environment with an 78 | authenticated AWS user which has the appropriate permission to read/write 79 | values to SSM Parameter Store. 80 | 81 | This is going to vary based on your organization but chamber needs AWS credentials 82 | to run. 83 | 84 | One of the easiest ways to do so is by using [aws-vault](https://github.com/99designs/aws-vault). 85 | To adjust these instructions for your needs, examine the env output of 86 | [Aws-Vault: How It Works](https://github.com/99designs/aws-vault#how-it-works) 87 | and use your organization's secrets tool accordingly with chamber. 88 | 89 | ### An `aws-vault` Usage Example With Chamber 90 | 91 | ```bash 92 | aws-vault exec prod -- chamber 93 | ``` 94 | 95 | For this reason, it is recommended that you create an alias in your shell of 96 | choice to save yourself some typing, for example (from my `.zshrc`): 97 | 98 | ```bash 99 | alias chamberprod='aws-vault exec production -- chamber' 100 | ``` 101 | 102 | ## Setting Up KMS 103 | 104 | Chamber expects to find a KMS key with alias `parameter_store_key` in the 105 | account that you are writing/reading secrets. You can follow the [AWS KMS 106 | documentation](http://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html) 107 | to create your key, and [follow this guide to set up your 108 | alias](http://docs.aws.amazon.com/kms/latest/developerguide/programming-aliases.html). 109 | 110 | If you are a [Terraform](https://www.terraform.io/) user, you can create your 111 | key with the following: 112 | 113 | ```HCL 114 | resource "aws_kms_key" "parameter_store" { 115 | description = "Parameter store kms master key" 116 | deletion_window_in_days = 10 117 | enable_key_rotation = true 118 | } 119 | 120 | resource "aws_kms_alias" "parameter_store_alias" { 121 | name = "alias/parameter_store_key" 122 | target_key_id = "${aws_kms_key.parameter_store.id}" 123 | } 124 | ``` 125 | 126 | If you'd like to use an alternate KMS key to encrypt your secrets, you can set 127 | the environment variable `CHAMBER_KMS_KEY_ALIAS`. As an example, the following 128 | will use your account's default SSM alias: 129 | `CHAMBER_KMS_KEY_ALIAS=aws/ssm` 130 | 131 | ## Usage 132 | 133 | ### Writing Secrets 134 | 135 | ```bash 136 | $ chamber write 137 | ``` 138 | 139 | This operation will write a secret into the secret store. If a secret with that 140 | key already exists, it will increment the version and store a new value. 141 | 142 | If `-` is provided as the value argument, the value will be read from standard 143 | input. 144 | 145 | Secret keys are normalized automatically. The `-` will be `_` and the letters will 146 | be converted to upper case (for example a secret with key `secret_key` and 147 | `secret-key` will become `SECRET_KEY`). 148 | 149 | #### Reserved Service Names 150 | 151 | Starting with version 3.0, the service name "_chamber" is reserved for chamber's 152 | internal use. You will be warned when using the service for any chamber operation. 153 | 154 | #### Tagging on Write 155 | 156 | ```bash 157 | $ chamber write --tags key1=value1,key2=value2 158 | ``` 159 | 160 | This operation will write a secret into the secret store with the specified tags. 161 | Tagging on write is only available for new secrets. 162 | 163 | ### Tagging Secrets 164 | 165 | ```bash 166 | $ chamber tag write tag1=value1 tag2=value2 167 | Key Value 168 | tag1 value1 169 | tag2 value2 170 | $ chamber tag read 171 | Key Value 172 | tag1 value1 173 | tag2 value2 174 | $ chamber tag delete tag1 175 | $ chamber tag read 176 | Key Value 177 | tag2 value2 178 | ``` 179 | 180 | Writing tags normally leaves other tags intact. If you want to replace all tags 181 | with the new ones, use `--delete-other-tags` flag. _Note: The option may change 182 | before the next major release._ 183 | 184 | ```bash 185 | $ chamber tag write --delete-other-tags tag1=value1 186 | Key Value 187 | tag1 value1 188 | ``` 189 | 190 | ### Listing Secrets 191 | 192 | ```bash 193 | $ chamber list service 194 | Key Version LastModified User 195 | apikey 2 06-09 17:30:56 daniel-fuentes 196 | other 1 06-09 17:30:34 daniel-fuentes 197 | ``` 198 | 199 | Listing secrets should show the key names for a given service, along with other 200 | useful metadata including when the secret was last modified, who modified it, 201 | and what the current version is. 202 | 203 | ```bash 204 | $ chamber list -e service 205 | Key Version LastModified User Value 206 | apikey 2 06-09 17:30:56 daniel-fuentes apikeyvalue 207 | other 1 06-09 17:30:34 daniel-fuentes othervalue 208 | ``` 209 | 210 | Listing secrets with expand parameter should show the key names and values for a 211 | given service, along with other useful metadata including when the secret was 212 | last modified, who modified it, and what the current version is. 213 | 214 | ### Historic view 215 | 216 | ```bash 217 | $ chamber history service key 218 | Event Version Date User 219 | Created 1 06-09 17:30:19 daniel-fuentes 220 | Updated 2 06-09 17:30:56 daniel-fuentes 221 | ``` 222 | 223 | The `history` command gives a historical view of a given secret. This view is 224 | useful for auditing changes, and can point you toward the user who made the 225 | change so it's easier to find out why changes were made. 226 | 227 | ### Exec 228 | 229 | ```bash 230 | $ chamber exec -- 231 | ``` 232 | 233 | `exec` populates the environment with the secrets from the specified services 234 | and executes the given command. Secret keys are converted to upper case (for 235 | example a secret with key `secret_key` will become `SECRET_KEY`). 236 | 237 | Secrets from services are loaded in the order specified in the command. For 238 | example, if you do `chamber exec app apptwo -- ...` and both apps have a secret 239 | named `api_key`, the `api_key` from `apptwo` will be the one set in your 240 | environment. 241 | 242 | ### Reading 243 | 244 | ```bash 245 | $ chamber read service key 246 | Key Value Version LastModified User 247 | key secret 1 06-09 17:30:56 daniel-fuentes 248 | ``` 249 | 250 | `read` provides the ability to print out the value of a single secret, as well 251 | as the secret's additional metadata. It does not provide the ability to print 252 | out multiple secrets in order to discourage accessing extra secret material 253 | that is unneeded. Parameter store automatically versions secrets and passing 254 | the `--version/-v` flag to read can print older versions of the secret. Default 255 | version (-1) is the latest secret. 256 | 257 | ### Exporting 258 | 259 | ```bash 260 | $ chamber export [--format ] [--output-file ] 261 | {"key":"secret"} 262 | ``` 263 | 264 | `export` provides ability to export secrets in various file formats. The following 265 | file formats are supported: 266 | 267 | - json (default) 268 | - yaml 269 | - java-properties 270 | - csv 271 | - tsv 272 | - dotenv 273 | - tfvars 274 | 275 | File is written to standard output by default but you may specify an output file. 276 | 277 | ### Caveat About Environment Variables 278 | 279 | `chamber` can emit environment variables in both dotenv format and exported shell 280 | environment variables. As `chamber` allows creating key names that are themselves 281 | not valid shell variable names, secrets emitted in this format will have their 282 | keys modified to confirm to POSIX shell environment variable naming rules: 283 | 284 | - variable names **must** begin with a letter or an underscore 285 | - variable names **must not** begin with a number 286 | - variable names **must** only contain letters, numbers, or underscores 287 | 288 | #### Notes About Dotenv Format 289 | 290 | As there is no formal dotenv spec, `chamber` attempts to 291 | adhere to compliance with [joho/godotenv](https://github.com/joho/godotenv) (which 292 | is itself a port of the Ruby library 293 | [bkeepers/dotenv](https://github.com/bkeepers/dotenv)). The output should be generally 294 | cross-compatible with alternative parsers, but without a formal spec compatibility 295 | is not guaranteed. 296 | 297 | Of note: 298 | 299 | - all key names will be sanitized according the the POSIX shell rules above, and 300 | cast to uppercase 301 | - all values will be rendered using special characters instead of string literals, 302 | e.g. newlines replaced with the character `\n`, tabstops replaced with the character 303 | `\t`, etc. 304 | - no whitespace trimming will be performed on any values 305 | 306 | #### Notes About Exported Environment Variables 307 | 308 | Alternatively, `chamber` may be used to set local environment variables directly 309 | with the `chamber env` command. For example, 310 | 311 | ```shell 312 | source <(chamber env service)` 313 | printf "%s" "$SERVICE_VAR" 314 | ``` 315 | 316 | Note that all secrets printed this way will be prefixed with `export`, so if sourced 317 | inline as in the above example, then any and all secrets will then be available 318 | to any process run after sourcing. 319 | 320 | the `env` subcommand supports output formatting in two specific ways: 321 | 322 | ```text 323 | chamber env -h 324 | Print the secrets from the parameter store in a format to export as environment variables 325 | 326 | Usage: 327 | chamber env [flags] 328 | 329 | Flags: 330 | -p, --preserve-case preserve variable name case 331 | -e, --escape-strings escape special characters in values 332 | ``` 333 | 334 | As `chamber` allows creation of keys with mixed case, `--preserve-case` will ensure 335 | that the original key case is preserved. Note that this will **not** prevent the 336 | key name from being sanitized according to the above POSIX shell rules. 337 | By default, values will be rendered using string literals, e.g. newlines will 338 | be printed as literal newlines, tabstops as literal tabstops. Output may be 339 | emitted using escaped special characters instead (identical to 340 | `chamber export -o dotenv)`) by using the flag `--escape-strings`. 341 | 342 | ### Importing 343 | 344 | ```bash 345 | $ chamber import [--normalize-keys] 346 | ``` 347 | 348 | `import` provides the ability to import secrets from a json or yaml file (like 349 | the kind you get from `chamber export`). 350 | 351 | 352 | > __Note__ 353 | > By default, `import` will **not** normalize key inputs, meaning that keys will 354 | > be written to the secrets backend in the format they exist in the source file. 355 | > In order to normalize keys on import, provide the `--normalize-keys` flag 356 | 357 | When normalizing keys, before write, the key will be be first converted to lowercase 358 | to match how `chamber write` handles keys. 359 | 360 | Example: `DB_HOST` will be converted to `db_host`. 361 | 362 | You can set `filepath` to `-` to instead read input from stdin. 363 | 364 | ### Deleting 365 | 366 | ```bash 367 | $ chamber delete [--exact-key] service key 368 | ``` 369 | 370 | `delete` provides the ability to remove a secret from chamber permanently, 371 | including the secret's additional metadata. There is no way to recover a 372 | secret once it has been deleted so care should be taken with this command. 373 | 374 | 375 | > __Note__ 376 | > By default, `delete` will normalize any provided keys. To change that behavior, 377 | > provide the `--exact-key` flag to attempt to delete the raw provided key. 378 | 379 | Example: Given the following setup, 380 | 381 | ```bash 382 | $ chamber list service 383 | Key Version LastModified User 384 | apikey 2 06-09 17:30:56 daniel-fuentes 385 | APIKEY 1 06-09 17:30:34 daniel-fuentes 386 | ``` 387 | 388 | Calling 389 | 390 | ```bash 391 | $ chamber delete --exact-key service APIKEY 392 | ``` 393 | 394 | will delete only `APIKEY` from the service and leave only 395 | 396 | ```bash 397 | $ chamber list service 398 | Key Version LastModified User 399 | apikey 2 06-09 17:30:56 daniel-fuentes 400 | ``` 401 | 402 | ### Finding 403 | 404 | ```bash 405 | $ chamber find key 406 | ``` 407 | 408 | `find` provides the ability to locate which services use the same key names. 409 | 410 | ```bash 411 | $ chamber find value --by-value 412 | ``` 413 | 414 | Passing `--by-value` or `-v` will search the values of all secrets and return 415 | the services and keys which match. 416 | 417 | ### Listing Services 418 | 419 | ```bash 420 | $ chamber list-services [] 421 | ``` 422 | 423 | `list-services` lists the available services. You can provide a prefix to limit 424 | the results. 425 | 426 | ### AWS Region 427 | 428 | Chamber uses [AWS SDK for Go](https://github.com/aws/aws-sdk-go). To use a 429 | region other than what is specified in `$HOME/.aws/config`, set the environment 430 | variable "AWS_REGION". 431 | 432 | ```bash 433 | $ AWS_REGION=us-west-2 chamber list service 434 | Key Version LastModified User 435 | apikey 3 07-10 09:30:41 daniel-fuentes 436 | other 1 07-10 09:30:35 daniel-fuentes 437 | ``` 438 | 439 | Chamber does not currently read the value of "AWS_DEFAULT_REGION". See 440 | [https://github.com/aws/aws-sdk-go#configuring-aws-region](https://github.com/aws/aws-sdk-go#configuring-aws-region) 441 | for more details. 442 | 443 | If you'd like to use a different region for chamber without changing `AWS_REGION`, 444 | you can use `CHAMBER_AWS_REGION` to override just for chamber. 445 | 446 | ### Custom SSM Endpoint 447 | 448 | If you'd like to use a custom SSM endpoint for chamber, you can use `CHAMBER_AWS_SSM_ENDPOINT` 449 | to override the default URL. 450 | 451 | ## AWS Secrets Manager 452 | Chamber supports AWS Secrets Manager as an optional backend. For example: 453 | 454 | ``` 455 | chamber -b secretsmanager write myservice foo fah 456 | chamber -b secretsmanager write myservice foo2 fah2 457 | ``` 458 | 459 | ### Custom Secrets Manager Endpoint 460 | 461 | If you'd like to use a custom Secrets Manager endpoint for chamber, you can use 462 | `CHAMBER_AWS_SECRETS_MANAGER_ENDPOINT` to override the default URL. 463 | 464 | > [!WARNING] 465 | > Prior to v3.0.0, the endpoint could also be overridden with `CHAMBER_AWS_SSM_ENDPOINT`. This 466 | > has been deprecated and will stop working in a future chamber release. Please use 467 | > `CHAMBER_AWS_SECRETS_MANAGER_ENDPOINT` instead. 468 | 469 | ## S3 Backend (Experimental) 470 | 471 | By default, chamber store secrets in AWS Parameter Store. We now also provide an 472 | experimental S3 backend for storing secrets in S3 instead. 473 | 474 | To configure chamber to use the S3 backend, use `chamber -b s3 --backend-s3-bucket=mybucket`. 475 | Preferably, this bucket should reject uploads that do not set the server side 476 | encryption header ([see this doc for details how](https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/)) 477 | 478 | This feature is experimental, and not currently meant for production work. 479 | 480 | ### S3 Backend using KMS Key Encryption (Experimental) 481 | 482 | This backend is similar to the S3 Backend but uses KMS Key Encryption to encrypt 483 | your documents at rest, similar to the SSM Backend which encrypts your secrets 484 | at rest. You can read how S3 Encrypts documents with KMS [here](https://docs.aws.amazon.com/kms/latest/developerguide/services-s3.html). 485 | 486 | The highlights of SSE-KMS are: 487 | 488 | - You can choose to create and manage encryption keys yourself, or you can choose 489 | to use your default service key uniquely generated on a customer by service by 490 | region level. 491 | - The ETag in the response is not the MD5 of the object data. 492 | - The data keys used to encrypt your data are also encrypted and stored alongside 493 | the data they protect. 494 | - Auditable master keys can be created, rotated, and disabled from the AWS KMS console. 495 | - The security controls in AWS KMS can help you meet encryption-related compliance 496 | requirements. 497 | 498 | Source: [Protecting data using server-side encryption with AWS Key Management Service keys (SSE-KMS)](https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingKMSEncryption.html) 499 | 500 | To configure chamber to use the S3 KMS backend, use 501 | `chamber -b s3-kms --backend-s3-bucket=mybucket --kms-key-alias=alias/keyname`. 502 | You must also supply an environment variable of the KMS Key Alias to use 503 | CHAMBER_KMS_KEY_ALIAS, by default "alias/parameter_store_key" 504 | will be used. 505 | 506 | Preferably, this bucket should reject uploads that do not set the server side 507 | encryption header ([see this doc for details how](https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/)) 508 | 509 | When changing secrets between KMS Keys, you must first delete the Chamber secret 510 | with the existing KMS Key, then write it again with new KMS Key. 511 | 512 | If services contain multiple KMS Keys, `chamber list` and `chamber exec` will only 513 | show Chamber secrets encrypted with KMS Keys you have access to. 514 | 515 | This feature is experimental, and not currently meant for production work. 516 | 517 | ## Null Backend (Experimental) 518 | 519 | If it's preferred to not use any backend at all, use `chamber -b null`. Doing so 520 | will forward existing ENV variables as if Chamber is not in between. 521 | 522 | This feature is experimental, and not currently meant for production work. 523 | 524 | ## Analytics 525 | 526 | `chamber` includes some usage analytics code which Segment uses internally for 527 | tracking usage of internal tools. This analytics code is turned off by default, 528 | and can only be enabled via a linker flag at build time, which we do not set for 529 | public github releases. 530 | 531 | ## Releasing 532 | 533 | To cut a new release, just push a tag named `v` where `` is a 534 | valid semver version. This tag will be used by Github Actions to automatically publish 535 | a github release. 536 | 537 | --- 538 | 539 |
540 | THE CHAMBER OF SECRETS HAS BEEN OPENED 541 |
542 | -------------------------------------------------------------------------------- /cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | analytics "github.com/segmentio/analytics-go/v3" 7 | "github.com/segmentio/chamber/v3/store" 8 | "github.com/segmentio/chamber/v3/utils" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // deleteCmd represents the delete command 13 | var deleteCmd = &cobra.Command{ 14 | Use: "delete ", 15 | Short: "Delete a secret, including all versions", 16 | Args: cobra.ExactArgs(2), 17 | RunE: delete, 18 | } 19 | 20 | var exactKey bool 21 | 22 | func init() { 23 | deleteCmd.Flags().BoolVar(&exactKey, "exact-key", false, "Prevent normalization of the provided key in order to delete any keys that match the exact provided casing.") 24 | RootCmd.AddCommand(deleteCmd) 25 | } 26 | 27 | func delete(cmd *cobra.Command, args []string) error { 28 | service := utils.NormalizeService(args[0]) 29 | if err := validateService(service); err != nil { 30 | return fmt.Errorf("Failed to validate service: %w", err) 31 | } 32 | 33 | key := args[1] 34 | if !exactKey { 35 | key = utils.NormalizeKey(key) 36 | } 37 | 38 | if err := validateKey(key); err != nil { 39 | return fmt.Errorf("Failed to validate key: %w", err) 40 | } 41 | 42 | if analyticsEnabled && analyticsClient != nil { 43 | _ = analyticsClient.Enqueue(analytics.Track{ 44 | UserId: username, 45 | Event: "Ran Command", 46 | Properties: analytics.NewProperties(). 47 | Set("command", "delete"). 48 | Set("chamber-version", chamberVersion). 49 | Set("service", service). 50 | Set("key", key). 51 | Set("backend", backend), 52 | }) 53 | } 54 | secretStore, err := getSecretStore(cmd.Context()) 55 | if err != nil { 56 | return fmt.Errorf("Failed to get secret store: %w", err) 57 | } 58 | secretId := store.SecretId{ 59 | Service: service, 60 | Key: key, 61 | } 62 | 63 | return secretStore.Delete(cmd.Context(), secretId) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/env.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/alessio/shellescape" 10 | analytics "github.com/segmentio/analytics-go/v3" 11 | "github.com/segmentio/chamber/v3/utils" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // originally ported from github.com/joho/godotenv 17 | // exclamation point removed; ruby and node dotenv libraries do not escape it 18 | const doubleQuoteSpecialChars = "\\\n\r\"$`" 19 | 20 | var ( 21 | // envCmd represents the env command 22 | envCmd = &cobra.Command{ 23 | Use: "env ", 24 | Short: "Print the secrets from the parameter store in a format to export as environment variables", 25 | Args: cobra.ExactArgs(1), 26 | RunE: env, 27 | } 28 | preserveCase bool 29 | escapeSpecials bool 30 | ) 31 | 32 | func init() { 33 | envCmd.Flags().SortFlags = false 34 | envCmd.Flags().BoolVarP(&preserveCase, "preserve-case", "p", false, "preserve variable name case") 35 | envCmd.Flags().BoolVarP(&escapeSpecials, "escape-strings", "e", false, "escape special characters in values") 36 | RootCmd.AddCommand(envCmd) 37 | } 38 | 39 | // Print all secrets to standard out as valid shell key-value 40 | // pairs or return an error if secrets cannot be safely 41 | // represented as shell words. 42 | func env(cmd *cobra.Command, args []string) error { 43 | envVars, err := exportEnv(cmd, args) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | for i := range envVars { 49 | fmt.Println(envVars[i]) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // Handle the actual work of retrieving and validating secrets. 56 | // Returns a []string, with each string being a `key=value` pair, 57 | // and returns any errors encountered along the way. 58 | // Keys will be converted into valid shell variable names, 59 | // and converted to uppercase unless --preserve is passed. 60 | // Key ordering is non-deterministic and unstable, as returned 61 | // value from a given secret store is non-deterministic and unstable. 62 | func exportEnv(cmd *cobra.Command, args []string) ([]string, error) { 63 | service := utils.NormalizeService(args[0]) 64 | if err := validateService(service); err != nil { 65 | return nil, fmt.Errorf("Failed to validate service: %w", err) 66 | } 67 | 68 | secretStore, err := getSecretStore(cmd.Context()) 69 | if err != nil { 70 | return nil, fmt.Errorf("Failed to get secret store: %w", err) 71 | } 72 | 73 | rawSecrets, err := secretStore.ListRaw(cmd.Context(), service) 74 | if err != nil { 75 | return nil, fmt.Errorf("Failed to list store contents: %w", err) 76 | } 77 | 78 | if analyticsEnabled && analyticsClient != nil { 79 | _ = analyticsClient.Enqueue(analytics.Track{ 80 | UserId: username, 81 | Event: "Ran Command", 82 | Properties: analytics.NewProperties(). 83 | Set("command", "env"). 84 | Set("chamber-version", chamberVersion). 85 | Set("service", service). 86 | Set("backend", backend), 87 | }) 88 | } 89 | 90 | params := make(map[string]string) 91 | for _, rawSecret := range rawSecrets { 92 | params[key(rawSecret.Key)] = rawSecret.Value 93 | } 94 | 95 | out, err := buildEnvOutput(params) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | // ensure output prints variable declarations as exported 101 | for i := range out { 102 | // Sprintf because each declaration already ends in a newline 103 | out[i] = fmt.Sprintf("export %s", out[i]) 104 | } 105 | 106 | return out, nil 107 | } 108 | 109 | // output will be returned lexically sorted by key name 110 | func buildEnvOutput(params map[string]string) ([]string, error) { 111 | out := []string{} 112 | for _, key := range sortedKeys(params) { 113 | name := sanitizeKey(key) 114 | if !preserveCase { 115 | name = strings.ToUpper(name) 116 | } 117 | 118 | if err := validateShellName(name); err != nil { 119 | return nil, err 120 | } 121 | 122 | // the default format prints all escape sequences as 123 | // string literals, and wraps values in single quotes 124 | // if they're unsafe or multi-line strings. 125 | s := fmt.Sprintf(`%s=%s`, name, shellescape.Quote(params[key])) 126 | if escapeSpecials { 127 | // this format collapses special characters like newlines 128 | // or carriage returns. requires escape sequences to be interpolated 129 | // by whatever parses our key="value" pairs. 130 | s = fmt.Sprintf(`%s="%s"`, name, doubleQuoteEscape(params[key])) 131 | } 132 | 133 | // don't rely on printf to handle properly quoting or 134 | // escaping shell output -- just white-knuckle it ourselves. 135 | 136 | out = append(out, s) 137 | } 138 | 139 | return out, nil 140 | } 141 | 142 | // The name of a variable can contain only letters (a-z, case insensitive), 143 | // numbers (0-9) or the underscore character (_). It may only begin with 144 | // a letter or an underscore. 145 | func validateShellName(s string) error { 146 | shellChars := regexp.MustCompile(`^[A-Za-z0-9_]+$`).MatchString 147 | validShellName := regexp.MustCompile(`^[A-Za-z_]{1}`).MatchString 148 | 149 | if !shellChars(s) { 150 | return fmt.Errorf("cmd: %q contains invalid characters for a shell variable name", s) 151 | } 152 | 153 | if !validShellName(s) { 154 | return fmt.Errorf("cmd: shell variable name %q must start with a letter or underscore", s) 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // note that all character width will be preserved; a single space 161 | // (or period, tab, or newline) will be replaced with a single underscore. 162 | // no squeezing/collapsing of replaced characters is performed at all. 163 | func sanitizeKey(s string) string { 164 | // I promise, we don't actually care about allocations here. 165 | // allocate *away*. 166 | s = strings.TrimSpace(s) 167 | s = strings.ReplaceAll(s, "-", "_") 168 | s = strings.ReplaceAll(s, ".", "_") 169 | // whitespace gets a visit from The Big Hammer that is regex. 170 | s = regexp.MustCompile(`[[:space:]]`).ReplaceAllString(s, "_") 171 | 172 | return s 173 | } 174 | 175 | // originally ported from github.com/joho/godotenv 176 | func doubleQuoteEscape(line string) string { 177 | for _, c := range doubleQuoteSpecialChars { 178 | toReplace := "\\" + string(c) 179 | if c == '\n' { 180 | toReplace = `\n` 181 | } 182 | if c == '\r' { 183 | toReplace = `\r` 184 | } 185 | line = strings.Replace(line, string(c), toReplace, -1) 186 | } 187 | return line 188 | } 189 | 190 | // return the keys from params, sorted by keyname. 191 | // note that sort.Strings() is not case insensitive. 192 | // e.g. []string{"A", "b", "cat", "Dog", "dog"} will sort as: 193 | // []string{"A", "Dog", "b", "cat", "dog"}. That doesn't 194 | // really matter here but it may lead to surprises. 195 | func sortedKeys(params map[string]string) []string { 196 | keys := []string{} 197 | 198 | for key := range params { 199 | keys = append(keys, key) 200 | } 201 | sort.Strings(keys) 202 | return keys 203 | } 204 | -------------------------------------------------------------------------------- /cmd/env_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_validateShellName(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | str string 11 | shouldFail bool 12 | }{ 13 | {name: "strings with spaces should fail", str: "invalid strings", shouldFail: true}, 14 | {name: "strings with only underscores should pass", str: "valid_string", shouldFail: false}, 15 | {name: "strings with dashes should fail", str: "validish-string", shouldFail: true}, 16 | {name: "strings that start with numbers should fail", str: "1invalidstring", shouldFail: true}, 17 | {name: "strings that start with underscores should pass", str: "_1validstring", shouldFail: false}, 18 | {name: "strings that contain periods should fail", str: "invalid.string", shouldFail: true}, 19 | } 20 | 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | if err := validateShellName(tt.str); (err != nil) != tt.shouldFail { 24 | t.Errorf("validateShellName error: %v, expect wantErr %v", err, tt.shouldFail) 25 | } 26 | }) 27 | } 28 | } 29 | 30 | func Test_sanitizeKey(t *testing.T) { 31 | tests := []struct { 32 | given string 33 | expected string 34 | }{ 35 | {given: "invalid strings", expected: "invalid_strings"}, 36 | {given: "extremely invalid strings", expected: "extremely__invalid__strings"}, 37 | {given: "\nunbelievably\tinvalid\tstrings\n", expected: "unbelievably_invalid_strings"}, 38 | {given: "valid_string", expected: "valid_string"}, 39 | {given: "validish-string", expected: "validish_string"}, 40 | {given: "valid.string", expected: "valid_string"}, 41 | // the following two strings should not be corrected, simply returned as-is. 42 | {given: "1invalidstring", expected: "1invalidstring"}, 43 | {given: "_1validstring", expected: "_1validstring"}, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run("test sanitizing key names", func(t *testing.T) { 48 | if got := sanitizeKey(tt.given); got != tt.expected { 49 | t.Errorf("shellName error: want %q, got %q", tt.expected, got) 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func Test_doubleQuoteEscape(t *testing.T) { 56 | tests := []struct { 57 | given string 58 | expected string 59 | }{ 60 | {given: "ordinary string", expected: "ordinary string"}, 61 | {given: `string\with\backslashes`, expected: `string\\with\\backslashes`}, 62 | {given: "string\nwith\nnewlines", expected: `string\nwith\nnewlines`}, 63 | {given: "string\rwith\rcarriage returns", expected: `string\rwith\rcarriage returns`}, 64 | {given: `string"with"quotation marks`, expected: `string\"with\"quotation marks`}, 65 | {given: `string!with!excl`, expected: `string!with!excl`}, // do not escape ! 66 | {given: `string$with$dollar signs`, expected: `string\$with\$dollar signs`}, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run("test sanitizing key names", func(t *testing.T) { 71 | if got := doubleQuoteEscape(tt.given); got != tt.expected { 72 | t.Errorf("shellName error: want %q, got %q", tt.expected, got) 73 | } 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "strings" 9 | 10 | analytics "github.com/segmentio/analytics-go/v3" 11 | "github.com/segmentio/chamber/v3/environ" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // When true, only use variables retrieved from the backend, do not inherit existing environment variables 16 | var pristine bool 17 | 18 | // When true, enable strict mode, which checks that all secrets replace env vars with a special sentinel value 19 | var strict bool 20 | 21 | // Value to expect in strict mode 22 | var strictValue string 23 | 24 | // Default value to expect in strict mode 25 | const strictValueDefault = "chamberme" 26 | 27 | // execCmd represents the exec command 28 | var execCmd = &cobra.Command{ 29 | Use: "exec -- []", 30 | Short: "Executes a command with secrets loaded into the environment", 31 | Args: func(cmd *cobra.Command, args []string) error { 32 | dashIx := cmd.ArgsLenAtDash() 33 | if dashIx == -1 { 34 | return errors.New("please separate services and command with '--'. See usage") 35 | } 36 | if err := cobra.MinimumNArgs(1)(cmd, args[:dashIx]); err != nil { 37 | return fmt.Errorf("at least one service must be specified: %w", err) 38 | } 39 | if err := cobra.MinimumNArgs(1)(cmd, args[dashIx:]); err != nil { 40 | return fmt.Errorf("must specify command to run. See usage: %w", err) 41 | } 42 | return nil 43 | }, 44 | RunE: execRun, 45 | Example: ` 46 | Given a secret store like this: 47 | 48 | $ echo '{"db_username": "root", "db_password": "hunter22"}' | chamber import - 49 | 50 | --strict will fail with unfilled env vars 51 | 52 | $ HOME=/tmp DB_USERNAME=chamberme DB_PASSWORD=chamberme EXTRA=chamberme chamber exec --strict service exec -- env 53 | chamber: extra unfilled env var EXTRA 54 | exit 1 55 | 56 | --pristine takes effect after checking for --strict values 57 | 58 | $ HOME=/tmp DB_USERNAME=chamberme DB_PASSWORD=chamberme chamber exec --strict --pristine service exec -- env 59 | DB_USERNAME=root 60 | DB_PASSWORD=hunter22 61 | `, 62 | } 63 | 64 | func init() { 65 | execCmd.Flags().BoolVar(&pristine, "pristine", false, "only use variables retrieved from the backend; do not inherit existing environment variables") 66 | execCmd.Flags().BoolVar(&strict, "strict", false, `enable strict mode: 67 | only inject secrets for which there is a corresponding env var with value 68 | , and fail if there are any env vars with that value missing 69 | from secrets`) 70 | execCmd.Flags().StringVar(&strictValue, "strict-value", strictValueDefault, "value to expect in --strict mode") 71 | RootCmd.AddCommand(execCmd) 72 | } 73 | 74 | func execRun(cmd *cobra.Command, args []string) error { 75 | dashIx := cmd.ArgsLenAtDash() 76 | services, command, commandArgs := args[:dashIx], args[dashIx], args[dashIx+1:] 77 | 78 | if analyticsEnabled && analyticsClient != nil { 79 | _ = analyticsClient.Enqueue(analytics.Track{ 80 | UserId: username, 81 | Event: "Ran Command", 82 | Properties: analytics.NewProperties(). 83 | Set("command", "exec"). 84 | Set("chamber-version", chamberVersion). 85 | Set("services", services). 86 | Set("backend", backend), 87 | }) 88 | } 89 | 90 | for _, service := range services { 91 | if err := validateServiceWithLabel(service); err != nil { 92 | return fmt.Errorf("Failed to validate service: %w", err) 93 | } 94 | } 95 | 96 | secretStore, err := getSecretStore(cmd.Context()) 97 | if err != nil { 98 | return fmt.Errorf("Failed to get secret store: %w", err) 99 | } 100 | 101 | if pristine { 102 | slog.Debug("chamber: pristine mode engaged") 103 | } 104 | 105 | var env environ.Environ 106 | if strict { 107 | slog.Debug("chamber: strict mode engaged") 108 | var err error 109 | env = environ.Environ(os.Environ()) 110 | err = env.LoadStrict(cmd.Context(), secretStore, strictValue, pristine, services...) 111 | if err != nil { 112 | return err 113 | } 114 | } else { 115 | if !pristine { 116 | env = environ.Environ(os.Environ()) 117 | } 118 | for _, service := range services { 119 | collisions := make([]string, 0) 120 | // TODO: these interfaces should look the same as Strict*, so move pristine in there 121 | err := env.Load(cmd.Context(), secretStore, service, &collisions) 122 | if err != nil { 123 | return fmt.Errorf("Failed to list store contents: %w", err) 124 | } 125 | 126 | for _, c := range collisions { 127 | fmt.Fprintf(os.Stderr, "warning: service %s overwriting environment variable %s\n", service, c) 128 | } 129 | } 130 | } 131 | 132 | slog.Debug(fmt.Sprintf("info: With environment %s\n", strings.Join(env, ","))) 133 | 134 | return exec(command, commandArgs, env) 135 | } 136 | -------------------------------------------------------------------------------- /cmd/exec_default.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !darwin 2 | 3 | package cmd 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | osexec "os/exec" 9 | "os/signal" 10 | "syscall" 11 | ) 12 | 13 | // exec executes the given command, passing it args and setting its environment 14 | // to env. 15 | // The exec function is allowed to never return and cause the program to exit. 16 | func exec(command string, args []string, env []string) error { 17 | ecmd := osexec.Command(command, args...) 18 | ecmd.Stdin = os.Stdin 19 | ecmd.Stdout = os.Stdout 20 | ecmd.Stderr = os.Stderr 21 | ecmd.Env = env 22 | 23 | sigChan := make(chan os.Signal, 1) 24 | signal.Notify(sigChan) 25 | 26 | if err := ecmd.Start(); err != nil { 27 | return fmt.Errorf("Failed to start command: %w", err) 28 | } 29 | 30 | go func() { 31 | for { 32 | sig := <-sigChan 33 | ecmd.Process.Signal(sig) 34 | } 35 | }() 36 | 37 | if err := ecmd.Wait(); err != nil { 38 | ecmd.Process.Signal(os.Kill) 39 | return fmt.Errorf("Failed to wait for command termination: %w", err) 40 | } 41 | 42 | waitStatus := ecmd.ProcessState.Sys().(syscall.WaitStatus) 43 | os.Exit(waitStatus.ExitStatus()) 44 | return nil // unreachable but Go doesn't know about it 45 | } 46 | -------------------------------------------------------------------------------- /cmd/exec_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package cmd 4 | 5 | import ( 6 | osexec "os/exec" 7 | 8 | "golang.org/x/sys/unix" 9 | ) 10 | 11 | func exec(command string, args []string, env []string) error { 12 | argv0, err := osexec.LookPath(command) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | argv := make([]string, 0, 1+len(args)) 18 | argv = append(argv, command) 19 | argv = append(argv, args...) 20 | 21 | // Only returns if the execution fails. 22 | return unix.Exec(argv0, argv, env) 23 | } 24 | -------------------------------------------------------------------------------- /cmd/export.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "encoding/csv" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | 12 | "github.com/magiconair/properties" 13 | 14 | analytics "github.com/segmentio/analytics-go/v3" 15 | "github.com/segmentio/chamber/v3/utils" 16 | "github.com/spf13/cobra" 17 | "gopkg.in/yaml.v3" 18 | ) 19 | 20 | // exportCmd represents the export command 21 | var ( 22 | exportFormat string 23 | exportOutput string 24 | 25 | exportCmd = &cobra.Command{ 26 | Use: "export ", 27 | Short: "Exports parameters in the specified format", 28 | Args: cobra.MinimumNArgs(1), 29 | RunE: runExport, 30 | } 31 | ) 32 | 33 | func init() { 34 | exportCmd.Flags().SortFlags = false 35 | exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "json", "Output format (json, yaml, java-properties, csv, tsv, dotenv, tfvars)") 36 | exportCmd.Flags().StringVarP(&exportOutput, "output-file", "o", "", "Output file (default is standard output)") 37 | 38 | RootCmd.AddCommand(exportCmd) 39 | } 40 | 41 | func runExport(cmd *cobra.Command, args []string) error { 42 | var err error 43 | 44 | if analyticsEnabled && analyticsClient != nil { 45 | _ = analyticsClient.Enqueue(analytics.Track{ 46 | UserId: username, 47 | Event: "Ran Command", 48 | Properties: analytics.NewProperties(). 49 | Set("command", "export"). 50 | Set("chamber-version", chamberVersion). 51 | Set("services", args). 52 | Set("backend", backend), 53 | }) 54 | } 55 | 56 | secretStore, err := getSecretStore(cmd.Context()) 57 | if err != nil { 58 | return err 59 | } 60 | params := make(map[string]string) 61 | for _, service := range args { 62 | service = utils.NormalizeService(service) 63 | if err := validateService(service); err != nil { 64 | return fmt.Errorf("Failed to validate service %s: %w", service, err) 65 | } 66 | 67 | rawSecrets, err := secretStore.ListRaw(cmd.Context(), service) 68 | if err != nil { 69 | return fmt.Errorf("Failed to list store contents for service %s: %w", service, err) 70 | } 71 | for _, rawSecret := range rawSecrets { 72 | k := key(rawSecret.Key) 73 | if _, ok := params[k]; ok { 74 | fmt.Fprintf(os.Stderr, "warning: parameter %s specified more than once (overridden by service %s)\n", k, service) 75 | } 76 | params[k] = rawSecret.Value 77 | } 78 | } 79 | 80 | file := os.Stdout 81 | if exportOutput != "" { 82 | if file, err = os.OpenFile(exportOutput, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); err != nil { 83 | return fmt.Errorf("Failed to open output file for writing: %w", err) 84 | } 85 | // TODO: check for errors flushing, syncing, or closing 86 | defer file.Close() 87 | defer file.Sync() 88 | } 89 | w := bufio.NewWriter(file) 90 | defer w.Flush() 91 | 92 | switch strings.ToLower(exportFormat) { 93 | case "json": 94 | err = exportAsJson(params, w) 95 | case "yaml": 96 | err = exportAsYaml(params, w) 97 | case "java-properties", "properties": 98 | err = exportAsJavaProperties(params, w) 99 | case "csv": 100 | err = exportAsCsv(params, w) 101 | case "tsv": 102 | err = exportAsTsv(params, w) 103 | case "dotenv": 104 | err = exportAsEnvFile(params, w) 105 | case "tfvars": 106 | err = exportAsTFvars(params, w) 107 | default: 108 | err = fmt.Errorf("Unsupported export format: %s", exportFormat) 109 | } 110 | 111 | if err != nil { 112 | return fmt.Errorf("Unable to export parameters: %w", err) 113 | } 114 | 115 | return nil 116 | } 117 | 118 | // this is fundamentally broken, in that there is no actual .env file 119 | // spec. some parsers support values spanned over multiple lines 120 | // as long as they're quoted, others only support character literals 121 | // inside of quotes. we should probably offer the option to control 122 | // which spec we adhere to, or use a marshaler that provides a 123 | // spec instead of hoping for the best. 124 | func exportAsEnvFile(params map[string]string, w io.Writer) error { 125 | // use top-level escapeSpecials variable to ensure that 126 | // the dotenv format prints escaped values every time 127 | escapeSpecials = true 128 | out, err := buildEnvOutput(params) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | for i := range out { 134 | _, err := w.Write([]byte(fmt.Sprintln(out[i]))) 135 | if err != nil { 136 | return err 137 | } 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func exportAsTFvars(params map[string]string, w io.Writer) error { 144 | // Terraform Variables is like dotenv, but removes the TF_VAR and keeps lowercase 145 | for _, k := range sortedKeys(params) { 146 | key := sanitizeKey(strings.TrimPrefix(k, "tf_var_")) 147 | 148 | _, err := w.Write([]byte(fmt.Sprintf(`%s = "%s"`+"\n", key, doubleQuoteEscape(params[k])))) 149 | if err != nil { 150 | return fmt.Errorf("failed to write variable with key %s: %v", k, err) 151 | } 152 | } 153 | return nil 154 | } 155 | 156 | func exportAsJson(params map[string]string, w io.Writer) error { 157 | // JSON like: 158 | // {"param1":"value1","param2":"value2"} 159 | // NOTE: json encoder does sorting by key 160 | return json.NewEncoder(w).Encode(params) 161 | } 162 | 163 | func exportAsYaml(params map[string]string, w io.Writer) error { 164 | return yaml.NewEncoder(w).Encode(params) 165 | } 166 | 167 | func exportAsJavaProperties(params map[string]string, w io.Writer) error { 168 | // Java Properties like: 169 | // param1 = value1 170 | // param2 = value2 171 | // ... 172 | 173 | // Load params 174 | p := properties.NewProperties() 175 | p.DisableExpansion = true 176 | for _, k := range sortedKeys(params) { 177 | _, _, err := p.Set(k, params[k]) 178 | if err != nil { 179 | return fmt.Errorf("failed to set property %s: %v", k, err) 180 | } 181 | } 182 | 183 | // Java expects properties in ISO-8859-1 by default 184 | _, err := p.Write(w, properties.ISO_8859_1) 185 | return err 186 | } 187 | 188 | func exportAsCsv(params map[string]string, w io.Writer) error { 189 | // CSV (Comma Separated Values) like: 190 | // param1,value1 191 | // param2,value2 192 | csvWriter := csv.NewWriter(w) 193 | defer csvWriter.Flush() 194 | for _, k := range sortedKeys(params) { 195 | if err := csvWriter.Write([]string{k, params[k]}); err != nil { 196 | return fmt.Errorf("Failed to write param %q to CSV file: %w", k, err) 197 | } 198 | } 199 | return nil 200 | } 201 | 202 | func exportAsTsv(params map[string]string, w io.Writer) error { 203 | // TSV (Tab Separated Values) like: 204 | tsvWriter := csv.NewWriter(w) 205 | tsvWriter.Comma = '\t' 206 | defer tsvWriter.Flush() 207 | for _, k := range sortedKeys(params) { 208 | if err := tsvWriter.Write([]string{k, params[k]}); err != nil { 209 | return fmt.Errorf("Failed to write param %q to TSV file: %w", k, err) 210 | } 211 | } 212 | return nil 213 | } 214 | -------------------------------------------------------------------------------- /cmd/export_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestExportDotenv(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | params map[string]string 15 | output []string 16 | }{ 17 | { 18 | name: "simple string, simple test", 19 | params: map[string]string{"foo": "bar"}, 20 | output: []string{`FOO="bar"`}, 21 | }, 22 | { 23 | name: "literal dollar signs should be properly escaped", 24 | params: map[string]string{"foo": "bar", "baz": `$qux`}, 25 | output: []string{`FOO="bar"`, `BAZ="\$qux"`}, 26 | }, 27 | { 28 | name: "double quotes should be fully escaped", 29 | params: map[string]string{"foo": "bar", "baz": `"qux"`}, 30 | output: []string{`FOO="bar"`, `BAZ="\"qux\""`}, 31 | }, 32 | } 33 | for _, test := range tests { 34 | t.Run(test.name, func(t *testing.T) { 35 | buf := &bytes.Buffer{} 36 | err := exportAsEnvFile(test.params, buf) 37 | 38 | assert.Nil(t, err) 39 | assert.ElementsMatch(t, test.output, strings.Split(strings.TrimSpace(buf.String()), "\n")) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /cmd/find.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | "github.com/segmentio/chamber/v3/store" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // findCmd represents the find command 14 | var findCmd = &cobra.Command{ 15 | Use: "find ", 16 | Short: "Find the given secret across all services", 17 | Args: cobra.ExactArgs(1), 18 | RunE: find, 19 | } 20 | 21 | var ( 22 | blankService string 23 | byValue bool 24 | includeSecrets bool 25 | matches []store.SecretId 26 | ) 27 | 28 | func init() { 29 | findCmd.Flags().BoolVarP(&byValue, "by-value", "v", false, "Find parameters by value") 30 | RootCmd.AddCommand(findCmd) 31 | } 32 | 33 | func find(cmd *cobra.Command, args []string) error { 34 | findSecret := args[0] 35 | 36 | if byValue { 37 | includeSecrets = false 38 | } else { 39 | includeSecrets = true 40 | } 41 | 42 | secretStore, err := getSecretStore(cmd.Context()) 43 | if err != nil { 44 | return fmt.Errorf("Failed to get secret store: %w", err) 45 | } 46 | services, err := secretStore.ListServices(cmd.Context(), blankService, includeSecrets) 47 | if err != nil { 48 | return fmt.Errorf("Failed to list store contents: %w", err) 49 | } 50 | 51 | if byValue { 52 | for _, service := range services { 53 | allSecrets, err := secretStore.List(cmd.Context(), service, true) 54 | if err == nil { 55 | matches = append(matches, findValueMatch(allSecrets, findSecret)...) 56 | } 57 | } 58 | } else { 59 | matches = append(matches, findKeyMatch(services, findSecret)...) 60 | } 61 | 62 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) 63 | fmt.Fprint(w, "Service") 64 | if byValue { 65 | fmt.Fprint(w, "\tKey") 66 | } 67 | fmt.Fprintln(w, "") 68 | 69 | for _, match := range matches { 70 | fmt.Fprintf(w, "%s", match.Service) 71 | if byValue { 72 | fmt.Fprintf(w, "\t%s", match.Key) 73 | } 74 | fmt.Fprintln(w, "") 75 | } 76 | w.Flush() 77 | 78 | return nil 79 | } 80 | 81 | func findKeyMatch(services []string, searchTerm string) []store.SecretId { 82 | keyMatches := []store.SecretId{} 83 | 84 | for _, service := range services { 85 | if searchTerm == key(service) { 86 | 87 | keyMatches = append(keyMatches, store.SecretId{ 88 | Service: path(service), 89 | Key: key(service), 90 | }) 91 | } 92 | } 93 | return keyMatches 94 | } 95 | 96 | func findValueMatch(secrets []store.Secret, searchTerm string) []store.SecretId { 97 | valueMatches := []store.SecretId{} 98 | 99 | for _, secret := range secrets { 100 | if *secret.Value == searchTerm { 101 | valueMatches = append(valueMatches, store.SecretId{ 102 | Service: path(secret.Meta.Key), 103 | Key: key(secret.Meta.Key), 104 | }) 105 | } 106 | } 107 | return valueMatches 108 | } 109 | 110 | func path(s string) string { 111 | sep := "/" 112 | 113 | tokens := strings.Split(s, sep) 114 | secretPath := strings.Join(tokens[1:len(tokens)-1], "/") 115 | return secretPath 116 | } 117 | -------------------------------------------------------------------------------- /cmd/find_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/segmentio/chamber/v3/store" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestFindFunctions(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | params string 16 | output string 17 | }{ 18 | {name: "service1", params: "/service1/key_one", output: "service1"}, 19 | {name: "service2", params: "/service2/subService/key_two", output: "service2/subService"}, 20 | {name: "service3", params: "/service3/subService/subSubService/key_three", output: "service3/subService/subSubService"}, 21 | } 22 | for _, test := range tests { 23 | t.Run(test.name, func(t *testing.T) { 24 | result := path(test.params) 25 | assert.Equal(t, test.output, result) 26 | }) 27 | } 28 | 29 | keyMatchTests := []struct { 30 | name string 31 | services []string 32 | searchTerm string 33 | output []store.SecretId 34 | }{ 35 | { 36 | name: "findNoMatches", 37 | services: []string{ 38 | "/service1/launch_darkly_key", 39 | "/service2/s3_bucket_base", 40 | "/service3/slack_token", 41 | }, 42 | searchTerm: "s3_bucket", 43 | output: []store.SecretId{}, 44 | }, 45 | { 46 | name: "findSomeMatches", 47 | services: []string{ 48 | "/service1/s3_bucket", 49 | "/service2/s3_bucket_base", 50 | "/service3/s3_bucket", 51 | }, 52 | searchTerm: "s3_bucket", 53 | output: []store.SecretId{ 54 | {Service: "service1", Key: "s3_bucket"}, 55 | {Service: "service3", Key: "s3_bucket"}, 56 | }, 57 | }, 58 | { 59 | name: "findEverythingMatches", 60 | services: []string{ 61 | "/service1/s3_bucket", 62 | "/service2/s3_bucket", 63 | "/service3/s3_bucket", 64 | }, 65 | searchTerm: "s3_bucket", 66 | output: []store.SecretId{ 67 | {Service: "service1", Key: "s3_bucket"}, 68 | {Service: "service2", Key: "s3_bucket"}, 69 | {Service: "service3", Key: "s3_bucket"}, 70 | }, 71 | }, 72 | } 73 | 74 | for _, test := range keyMatchTests { 75 | t.Run(test.name, func(t *testing.T) { 76 | result := findKeyMatch(test.services, test.searchTerm) 77 | fmt.Println(result) 78 | assert.Equal(t, test.output, result) 79 | }) 80 | } 81 | 82 | valueDarklyToken := "1@m@Pr3t3ndL@unchD@rkl3yK3y" 83 | valueSlackToken := "1@m@Pr3t3ndSlackToken" 84 | valueGoodS3Bucket := "s3://this_bucket" 85 | valueBadS3Bucket := "s3://not_your_bucket" 86 | 87 | valueMatchTests := []struct { 88 | name string 89 | secrets []store.Secret 90 | searchTerm string 91 | output []store.SecretId 92 | }{ 93 | { 94 | name: "findNoMatches", 95 | secrets: []store.Secret{ 96 | { 97 | Value: &valueDarklyToken, 98 | Meta: store.SecretMetadata{ 99 | Created: time.Now(), 100 | CreatedBy: "no one", 101 | Version: 0, 102 | Key: "/service1/launch_darkly_key", 103 | }, 104 | }, 105 | { 106 | Value: &valueSlackToken, 107 | Meta: store.SecretMetadata{ 108 | Created: time.Now(), 109 | CreatedBy: "no one", 110 | Version: 0, 111 | Key: "/service1/slack_token", 112 | }, 113 | }, 114 | { 115 | Value: &valueBadS3Bucket, 116 | Meta: store.SecretMetadata{ 117 | Created: time.Now(), 118 | CreatedBy: "no one", 119 | Version: 0, 120 | Key: "/service1/s3_bucket", 121 | }, 122 | }, 123 | }, 124 | searchTerm: "s3://this_bucket", 125 | output: []store.SecretId{}, 126 | }, 127 | { 128 | "findSomeMatches", 129 | []store.Secret{ 130 | { 131 | Value: &valueDarklyToken, 132 | Meta: store.SecretMetadata{ 133 | Created: time.Now(), 134 | CreatedBy: "no one", 135 | Version: 0, 136 | Key: "/service1/launch_darkly_key", 137 | }, 138 | }, 139 | { 140 | Value: &valueGoodS3Bucket, 141 | Meta: store.SecretMetadata{ 142 | Created: time.Now(), 143 | CreatedBy: "no one", 144 | Version: 0, 145 | Key: "/service1/s3_bucket_name", 146 | }, 147 | }, 148 | { 149 | Value: &valueGoodS3Bucket, 150 | Meta: store.SecretMetadata{ 151 | Created: time.Now(), 152 | CreatedBy: "no one", 153 | Version: 0, 154 | Key: "/service1/s3_bucket", 155 | }, 156 | }, 157 | }, 158 | "s3://this_bucket", 159 | []store.SecretId{ 160 | { 161 | Service: "service1", 162 | Key: "s3_bucket_name", 163 | }, 164 | { 165 | Service: "service1", 166 | Key: "s3_bucket", 167 | }, 168 | }, 169 | }, 170 | { 171 | "findEverythingMatches", 172 | []store.Secret{ 173 | { 174 | Value: &valueGoodS3Bucket, 175 | Meta: store.SecretMetadata{ 176 | Created: time.Now(), 177 | CreatedBy: "no one", 178 | Version: 0, 179 | Key: "/service1/s3_bucket_base", 180 | }, 181 | }, 182 | { 183 | Value: &valueGoodS3Bucket, 184 | Meta: store.SecretMetadata{ 185 | Created: time.Now(), 186 | CreatedBy: "no one", 187 | Version: 0, 188 | Key: "/service1/s3_bucket_name", 189 | }, 190 | }, 191 | { 192 | Value: &valueGoodS3Bucket, 193 | Meta: store.SecretMetadata{ 194 | Created: time.Now(), 195 | CreatedBy: "no one", 196 | Version: 0, 197 | Key: "/service1/s3_bucket", 198 | }, 199 | }, 200 | }, 201 | "s3://this_bucket", 202 | []store.SecretId{ 203 | { 204 | Service: "service1", 205 | Key: "s3_bucket_base", 206 | }, 207 | { 208 | Service: "service1", 209 | Key: "s3_bucket_name", 210 | }, 211 | { 212 | Service: "service1", 213 | Key: "s3_bucket", 214 | }, 215 | }, 216 | }, 217 | } 218 | 219 | for _, test := range valueMatchTests { 220 | t.Run(test.name, func(t *testing.T) { 221 | result := findValueMatch(test.secrets, test.searchTerm) 222 | assert.Equal(t, test.output, result) 223 | }) 224 | } 225 | 226 | } 227 | -------------------------------------------------------------------------------- /cmd/history.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/tabwriter" 7 | 8 | analytics "github.com/segmentio/analytics-go/v3" 9 | "github.com/segmentio/chamber/v3/store" 10 | "github.com/segmentio/chamber/v3/utils" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // historyCmd represents the history command 15 | var historyCmd = &cobra.Command{ 16 | Use: "history ", 17 | Short: "View the history of a secret", 18 | Args: cobra.ExactArgs(2), 19 | RunE: history, 20 | } 21 | 22 | func init() { 23 | RootCmd.AddCommand(historyCmd) 24 | } 25 | 26 | func history(cmd *cobra.Command, args []string) error { 27 | service := utils.NormalizeService(args[0]) 28 | if err := validateService(service); err != nil { 29 | return fmt.Errorf("Failed to validate service: %w", err) 30 | } 31 | 32 | key := utils.NormalizeKey(args[1]) 33 | if err := validateKey(key); err != nil { 34 | return fmt.Errorf("Failed to validate key: %w", err) 35 | } 36 | 37 | if analyticsEnabled && analyticsClient != nil { 38 | _ = analyticsClient.Enqueue(analytics.Track{ 39 | UserId: username, 40 | Event: "Ran Command", 41 | Properties: analytics.NewProperties(). 42 | Set("command", "history"). 43 | Set("chamber-version", chamberVersion). 44 | Set("service", service). 45 | Set("key", key). 46 | Set("backend", backend), 47 | }) 48 | } 49 | 50 | secretStore, err := getSecretStore(cmd.Context()) 51 | if err != nil { 52 | return fmt.Errorf("Failed to get secret store: %w", err) 53 | } 54 | secretId := store.SecretId{ 55 | Service: service, 56 | Key: key, 57 | } 58 | 59 | events, err := secretStore.History(cmd.Context(), secretId) 60 | if err != nil { 61 | return fmt.Errorf("Failed to get history: %w", err) 62 | } 63 | 64 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) 65 | fmt.Fprintln(w, "Event\tVersion\tDate\tUser") 66 | for _, event := range events { 67 | fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", 68 | event.Type, 69 | event.Version, 70 | event.Time.Local().Format(ShortTimeFormat), 71 | event.User, 72 | ) 73 | } 74 | w.Flush() 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | analytics "github.com/segmentio/analytics-go/v3" 9 | "github.com/segmentio/chamber/v3/store" 10 | "github.com/segmentio/chamber/v3/utils" 11 | "github.com/spf13/cobra" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | var ( 16 | importCmd = &cobra.Command{ 17 | Use: "import ", 18 | Short: "import secrets from json or yaml", 19 | Args: cobra.ExactArgs(2), 20 | RunE: importRun, 21 | } 22 | normalizeKeys bool 23 | ) 24 | 25 | func init() { 26 | importCmd.Flags().BoolVar(&normalizeKeys, "normalize-keys", false, "Normalize keys to match how `chamber write` would handle them. If not specified, keys will be written exactly how they are defined in the import source.") 27 | RootCmd.AddCommand(importCmd) 28 | } 29 | 30 | func importRun(cmd *cobra.Command, args []string) error { 31 | service := utils.NormalizeService(args[0]) 32 | if err := validateService(service); err != nil { 33 | return fmt.Errorf("Failed to validate service: %w", err) 34 | } 35 | 36 | var in io.Reader 37 | var err error 38 | 39 | file := args[1] 40 | if file == "-" { 41 | in = os.Stdin 42 | } else { 43 | in, err = os.Open(file) 44 | if err != nil { 45 | return fmt.Errorf("Failed to open file: %w", err) 46 | } 47 | } 48 | 49 | var toBeImported map[string]string 50 | 51 | decoder := yaml.NewDecoder(in) 52 | if err := decoder.Decode(&toBeImported); err != nil { 53 | return fmt.Errorf("Failed to decode input as json: %w", err) 54 | } 55 | 56 | if analyticsEnabled && analyticsClient != nil { 57 | _ = analyticsClient.Enqueue(analytics.Track{ 58 | UserId: username, 59 | Event: "Ran Command", 60 | Properties: analytics.NewProperties(). 61 | Set("command", "import"). 62 | Set("chamber-version", chamberVersion). 63 | Set("service", service). 64 | Set("backend", backend), 65 | }) 66 | } 67 | 68 | secretStore, err := getSecretStore(cmd.Context()) 69 | if err != nil { 70 | return fmt.Errorf("Failed to get secret store: %w", err) 71 | } 72 | 73 | for key, value := range toBeImported { 74 | if normalizeKeys { 75 | key = utils.NormalizeKey(key) 76 | } 77 | secretId := store.SecretId{ 78 | Service: service, 79 | Key: key, 80 | } 81 | if err := secretStore.Write(cmd.Context(), secretId, value); err != nil { 82 | return fmt.Errorf("Failed to write secret: %w", err) 83 | } 84 | } 85 | 86 | fmt.Fprintf(os.Stdout, "Successfully imported %d secrets\n", len(toBeImported)) 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /cmd/list-services.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "text/tabwriter" 8 | 9 | "github.com/segmentio/chamber/v3/utils" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // listServicesCmd represents the list command 14 | var listServicesCmd = &cobra.Command{ 15 | Use: "list-services ", 16 | Short: "List services", 17 | RunE: listServices, 18 | } 19 | 20 | var ( 21 | includeSecretName bool 22 | ) 23 | 24 | func init() { 25 | listServicesCmd.Flags().BoolVarP(&includeSecretName, "secrets", "s", false, "Include secret names in the list") 26 | RootCmd.AddCommand(listServicesCmd) 27 | } 28 | 29 | func listServices(cmd *cobra.Command, args []string) error { 30 | var service string 31 | if len(args) == 0 { 32 | service = "" 33 | } else { 34 | service = utils.NormalizeService(args[0]) 35 | 36 | } 37 | secretStore, err := getSecretStore(cmd.Context()) 38 | if err != nil { 39 | return fmt.Errorf("Failed to get secret store: %w", err) 40 | } 41 | secrets, err := secretStore.ListServices(cmd.Context(), service, includeSecretName) 42 | if err != nil { 43 | return fmt.Errorf("Failed to list store contents: %w", err) 44 | } 45 | 46 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) 47 | fmt.Fprint(w, "Service") 48 | fmt.Fprintln(w, "") 49 | 50 | sort.Strings(secrets) 51 | 52 | for _, secret := range secrets { 53 | fmt.Fprintf(w, "%s", 54 | secret) 55 | fmt.Fprintln(w, "") 56 | } 57 | w.Flush() 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | "text/tabwriter" 9 | 10 | analytics "github.com/segmentio/analytics-go/v3" 11 | "github.com/segmentio/chamber/v3/store" 12 | "github.com/segmentio/chamber/v3/utils" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // listCmd represents the list command 17 | var listCmd = &cobra.Command{ 18 | Use: "list ", 19 | Short: "List the secrets set for a service", 20 | Args: cobra.ExactArgs(1), 21 | RunE: list, 22 | } 23 | 24 | var ( 25 | withValues bool 26 | sortByTime bool 27 | sortByUser bool 28 | sortByVersion bool 29 | ) 30 | 31 | func init() { 32 | listCmd.Flags().BoolVarP(&withValues, "expand", "e", false, "Expand parameter list with values") 33 | listCmd.Flags().BoolVarP(&sortByTime, "time", "t", false, "Sort by modified time") 34 | listCmd.Flags().BoolVarP(&sortByUser, "user", "u", false, "Sort by user") 35 | listCmd.Flags().BoolVarP(&sortByVersion, "version", "v", false, "Sort by version") 36 | RootCmd.AddCommand(listCmd) 37 | } 38 | 39 | func list(cmd *cobra.Command, args []string) error { 40 | service := utils.NormalizeService(args[0]) 41 | if err := validateServiceWithLabel(service); err != nil { 42 | return fmt.Errorf("Failed to validate service: %w", err) 43 | } 44 | 45 | if analyticsEnabled && analyticsClient != nil { 46 | _ = analyticsClient.Enqueue(analytics.Track{ 47 | UserId: username, 48 | Event: "Ran Command", 49 | Properties: analytics.NewProperties(). 50 | Set("command", "list"). 51 | Set("chamber-version", chamberVersion). 52 | Set("service", service). 53 | Set("backend", backend), 54 | }) 55 | } 56 | 57 | secretStore, err := getSecretStore(cmd.Context()) 58 | if err != nil { 59 | return fmt.Errorf("Failed to get secret store: %w", err) 60 | } 61 | secrets, err := secretStore.List(cmd.Context(), service, withValues) 62 | if err != nil { 63 | return fmt.Errorf("Failed to list store contents: %w", err) 64 | } 65 | 66 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) 67 | 68 | fmt.Fprint(w, "Key\tVersion\tLastModified\tUser") 69 | if withValues { 70 | fmt.Fprint(w, "\tValue") 71 | } 72 | fmt.Fprintln(w, "") 73 | 74 | sort.Sort(ByName(secrets)) 75 | if sortByTime { 76 | sort.Sort(ByTime(secrets)) 77 | } 78 | if sortByUser { 79 | sort.Sort(ByUser(secrets)) 80 | } 81 | if sortByVersion { 82 | sort.Sort(ByVersion(secrets)) 83 | } 84 | 85 | for _, secret := range secrets { 86 | fmt.Fprintf(w, "%s\t%d\t%s\t%s", 87 | key(secret.Meta.Key), 88 | secret.Meta.Version, 89 | secret.Meta.Created.Local().Format(ShortTimeFormat), 90 | secret.Meta.CreatedBy) 91 | if withValues { 92 | fmt.Fprintf(w, "\t%s", *secret.Value) 93 | } 94 | fmt.Fprintln(w, "") 95 | } 96 | 97 | w.Flush() 98 | return nil 99 | } 100 | 101 | func key(s string) string { 102 | sep := "/" 103 | 104 | tokens := strings.Split(s, sep) 105 | secretKey := tokens[len(tokens)-1] 106 | return secretKey 107 | } 108 | 109 | type ByName []store.Secret 110 | 111 | func (a ByName) Len() int { return len(a) } 112 | func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 113 | func (a ByName) Less(i, j int) bool { return a[i].Meta.Key < a[j].Meta.Key } 114 | 115 | type ByTime []store.Secret 116 | 117 | func (a ByTime) Len() int { return len(a) } 118 | func (a ByTime) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 119 | func (a ByTime) Less(i, j int) bool { return a[i].Meta.Created.Before(a[j].Meta.Created) } 120 | 121 | type ByUser []store.Secret 122 | 123 | func (a ByUser) Len() int { return len(a) } 124 | func (a ByUser) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 125 | func (a ByUser) Less(i, j int) bool { return a[i].Meta.CreatedBy < a[j].Meta.CreatedBy } 126 | 127 | type ByVersion []store.Secret 128 | 129 | func (a ByVersion) Len() int { return len(a) } 130 | func (a ByVersion) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 131 | func (a ByVersion) Less(i, j int) bool { return a[i].Meta.Version < a[j].Meta.Version } 132 | -------------------------------------------------------------------------------- /cmd/read.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/tabwriter" 7 | 8 | analytics "github.com/segmentio/analytics-go/v3" 9 | "github.com/segmentio/chamber/v3/store" 10 | "github.com/segmentio/chamber/v3/utils" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ( 15 | version int 16 | quiet bool 17 | 18 | // readCmd represents the read command 19 | readCmd = &cobra.Command{ 20 | Use: "read ", 21 | Short: "Read a specific secret from the parameter store", 22 | Args: cobra.ExactArgs(2), 23 | RunE: read, 24 | } 25 | ) 26 | 27 | func init() { 28 | readCmd.Flags().IntVarP(&version, "version", "v", -1, "The version number of the secret. Defaults to latest.") 29 | readCmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Only print the secret") 30 | RootCmd.AddCommand(readCmd) 31 | } 32 | 33 | func read(cmd *cobra.Command, args []string) error { 34 | service := utils.NormalizeService(args[0]) 35 | if err := validateService(service); err != nil { 36 | return fmt.Errorf("Failed to validate service: %w", err) 37 | } 38 | 39 | key := utils.NormalizeKey(args[1]) 40 | if err := validateKey(key); err != nil { 41 | return fmt.Errorf("Failed to validate key: %w", err) 42 | } 43 | 44 | if analyticsEnabled && analyticsClient != nil { 45 | _ = analyticsClient.Enqueue(analytics.Track{ 46 | UserId: username, 47 | Event: "Ran Command", 48 | Properties: analytics.NewProperties(). 49 | Set("command", "read"). 50 | Set("chamber-version", chamberVersion). 51 | Set("service", service). 52 | Set("key", key). 53 | Set("backend", backend), 54 | }) 55 | } 56 | 57 | secretStore, err := getSecretStore(cmd.Context()) 58 | if err != nil { 59 | return fmt.Errorf("Failed to get secret store: %w", err) 60 | } 61 | 62 | secretId := store.SecretId{ 63 | Service: service, 64 | Key: key, 65 | } 66 | 67 | secret, err := secretStore.Read(cmd.Context(), secretId, version) 68 | if err != nil { 69 | return fmt.Errorf("Failed to read: %w", err) 70 | } 71 | 72 | if quiet { 73 | fmt.Fprintf(os.Stdout, "%s\n", *secret.Value) 74 | return nil 75 | } 76 | 77 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) 78 | fmt.Fprintln(w, "Key\tValue\tVersion\tLastModified\tUser") 79 | fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", 80 | key, 81 | *secret.Value, 82 | secret.Meta.Version, 83 | secret.Meta.Created.Local().Format(ShortTimeFormat), 84 | secret.Meta.CreatedBy) 85 | w.Flush() 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/aws/aws-sdk-go-v2/aws" 15 | analytics "github.com/segmentio/analytics-go/v3" 16 | "github.com/segmentio/chamber/v3/store" 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | // Regex's used to validate service and key names 21 | var ( 22 | validKeyFormat = regexp.MustCompile(`^[\w\-\.]+$`) 23 | validServicePathFormat = regexp.MustCompile(`^[\w\-\.]+(\/[\w\-\.]+)*$`) 24 | validServicePathFormatWithLabel = regexp.MustCompile(`^[\w\-\.]+((\/[\w\-\.]+)+(\:[\w\-\.]+)*)?$`) 25 | validTagKeyFormat = regexp.MustCompile(`^[A-Za-z0-9 +\-=\._:/@]{1,128}$`) 26 | validTagValueFormat = regexp.MustCompile(`^[A-Za-z0-9 +\-=\._:/@]{1,256}$`) 27 | 28 | verbose bool 29 | numRetries int 30 | // Deprecated: Use retryMode instead. 31 | minThrottleDelay time.Duration 32 | retryMode string 33 | chamberVersion string 34 | // one of *Backend consts 35 | backend string 36 | backendFlag string 37 | backendS3BucketFlag string 38 | kmsKeyAliasFlag string 39 | 40 | analyticsEnabled bool 41 | analyticsWriteKey string 42 | analyticsClient analytics.Client 43 | username string 44 | ) 45 | 46 | const ( 47 | // ShortTimeFormat is a short format for printing timestamps 48 | ShortTimeFormat = "2006-01-02 15:04:05" 49 | 50 | // DefaultNumRetries is the default for the number of retries we'll use for our SSM client 51 | DefaultNumRetries = 10 52 | ) 53 | 54 | const ( 55 | NullBackend = "NULL" 56 | SSMBackend = "SSM" 57 | SecretsManagerBackend = "SECRETSMANAGER" 58 | S3Backend = "S3" 59 | S3KMSBackend = "S3-KMS" 60 | 61 | BackendEnvVar = "CHAMBER_SECRET_BACKEND" 62 | BucketEnvVar = "CHAMBER_S3_BUCKET" 63 | KMSKeyEnvVar = "CHAMBER_KMS_KEY_ALIAS" 64 | NumRetriesEnvVar = "CHAMBER_RETRIES" 65 | 66 | DefaultKMSKey = "alias/parameter_store_key" 67 | ) 68 | 69 | var Backends = []string{SSMBackend, SecretsManagerBackend, S3Backend, NullBackend, S3KMSBackend} 70 | 71 | // RootCmd represents the base command when called without any subcommands 72 | var RootCmd = &cobra.Command{ 73 | Use: "chamber", 74 | Short: "CLI for storing secrets", 75 | SilenceUsage: true, 76 | PersistentPreRun: prerun, 77 | PersistentPostRun: postrun, 78 | } 79 | 80 | func init() { 81 | RootCmd.PersistentFlags().IntVarP(&numRetries, "retries", "r", DefaultNumRetries, "For SSM or Secrets Manager, the number of retries we'll make before giving up; AKA $CHAMBER_RETRIES") 82 | RootCmd.PersistentFlags().DurationVarP(&minThrottleDelay, "min-throttle-delay", "", 0, "DEPRECATED and no longer has any effect. Use retry-mode instead") 83 | _ = RootCmd.PersistentFlags().MarkDeprecated("min-throttle-delay", "use --retry-mode instead") 84 | RootCmd.PersistentFlags().StringVarP(&retryMode, "retry-mode", "", store.DefaultRetryMode.String(), 85 | `For SSM, the model used to retry requests 86 | `+aws.RetryModeStandard.String()+` 87 | `+aws.RetryModeAdaptive.String(), 88 | ) 89 | RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "", false, "Print more information to STDERR") 90 | RootCmd.PersistentFlags().StringVarP(&backendFlag, "backend", "b", "ssm", 91 | `Backend to use; AKA $CHAMBER_SECRET_BACKEND 92 | null: no-op 93 | ssm: SSM Parameter Store 94 | secretsmanager: Secrets Manager 95 | s3: S3; requires --backend-s3-bucket 96 | s3-kms: S3 using AWS-KMS encryption; requires --backend-s3-bucket and --kms-key-alias set (if you want to write or delete keys).`, 97 | ) 98 | RootCmd.PersistentFlags().StringVarP(&backendS3BucketFlag, "backend-s3-bucket", "", "", "bucket for S3 backend; AKA $CHAMBER_S3_BUCKET") 99 | RootCmd.PersistentFlags().StringVarP(&kmsKeyAliasFlag, "kms-key-alias", "", DefaultKMSKey, "KMS Key Alias for writing and deleting secrets; AKA $CHAMBER_KMS_KEY_ALIAS. This option is currently only supported for the S3-KMS backend.") 100 | } 101 | 102 | // Execute adds all child commands to the root command sets flags appropriately. 103 | // This is called by main.main(). It only needs to happen once to the rootCmd. 104 | func Execute(vers string, writeKey string) { 105 | chamberVersion = vers 106 | 107 | analyticsWriteKey = writeKey 108 | analyticsEnabled = analyticsWriteKey != "" 109 | 110 | if cmd, err := RootCmd.ExecuteC(); err != nil { 111 | if strings.Contains(err.Error(), "arg(s)") || strings.Contains(err.Error(), "usage") { 112 | _ = cmd.Usage() 113 | } 114 | os.Exit(1) 115 | } 116 | } 117 | 118 | func validateService(service string) error { 119 | if !validServicePathFormat.MatchString(service) { 120 | return fmt.Errorf("Failed to validate service name '%s'. Only alphanumeric, dashes, forward slashes, full stops and underscores are allowed for service names. Service names must not start or end with a forward slash", service) 121 | } 122 | if store.ReservedService(service) { 123 | fmt.Fprintf(os.Stderr, "Service name %s is reserved for chamber's own use and will be prohibited in a future version. Please switch to a different service name.\n", service) 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func validateServiceWithLabel(service string) error { 130 | if !validServicePathFormatWithLabel.MatchString(service) { 131 | return fmt.Errorf("Failed to validate service name '%s'. Only alphanumeric, dashes, forward slashes, full stops and underscores are allowed for service names, and colon followed by a label name. Service names must not start or end with a forward slash or colon", service) 132 | } 133 | if store.ReservedService(service) { 134 | fmt.Fprintf(os.Stderr, "Service name %s is reserved for chamber's own use and will be prohibited in a future version. Please switch to a different service name.\n", service) 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func validateKey(key string) error { 141 | if !validKeyFormat.MatchString(key) { 142 | return fmt.Errorf("Failed to validate key name '%s'. Only alphanumeric, dashes, full stops and underscores are allowed for key names", key) 143 | } 144 | return nil 145 | } 146 | 147 | func validateTag(key string, value string) error { 148 | if !validTagKeyFormat.MatchString(key) { 149 | return fmt.Errorf("Failed to validate tag key '%s'. Only 128 alphanumeric, space, and characters +-=._:/@ are allowed for tag keys", key) 150 | } 151 | if !validTagValueFormat.MatchString(value) { 152 | return fmt.Errorf("Failed to validate tag value '%s'. Only 256 alphanumeric, space, and characters +-=._:/@ are allowed for tag values", value) 153 | } 154 | return nil 155 | } 156 | 157 | func getSecretStore(ctx context.Context) (store.Store, error) { 158 | rootPflags := RootCmd.PersistentFlags() 159 | if backendEnvVarValue := os.Getenv(BackendEnvVar); !rootPflags.Changed("backend") && backendEnvVarValue != "" { 160 | backend = backendEnvVarValue 161 | } else { 162 | backend = backendFlag 163 | } 164 | backend = strings.ToUpper(backend) 165 | 166 | if numRetriesEnvVarValue := os.Getenv(NumRetriesEnvVar); !rootPflags.Changed("retries") && numRetriesEnvVarValue != "" { 167 | var err error 168 | numRetries, err = strconv.Atoi(numRetriesEnvVarValue) 169 | if err != nil { 170 | return nil, errors.New("Cannot parse $CHAMBER_RETRIES to an integer.") 171 | } 172 | } 173 | 174 | var s store.Store 175 | var err error 176 | 177 | switch backend { 178 | case NullBackend: 179 | s = store.NewNullStore() 180 | case S3Backend: 181 | if kmsKeyAliasFlag != DefaultKMSKey { 182 | return nil, errors.New("Unable to use --kms-key-alias with this backend.") 183 | } 184 | 185 | var bucket string 186 | if bucketEnvVarValue := os.Getenv(BucketEnvVar); !rootPflags.Changed("backend-s3-bucket") && bucketEnvVarValue != "" { 187 | bucket = bucketEnvVarValue 188 | } else { 189 | bucket = backendS3BucketFlag 190 | } 191 | if bucket == "" { 192 | return nil, errors.New("Must set bucket for s3 backend") 193 | } 194 | s, err = store.NewS3StoreWithBucket(ctx, numRetries, bucket) 195 | case S3KMSBackend: 196 | var bucket string 197 | if bucketEnvVarValue := os.Getenv(BucketEnvVar); !rootPflags.Changed("backend-s3-bucket") && bucketEnvVarValue != "" { 198 | bucket = bucketEnvVarValue 199 | } else { 200 | bucket = backendS3BucketFlag 201 | } 202 | if bucket == "" { 203 | return nil, errors.New("Must set bucket for s3 backend") 204 | } 205 | 206 | var kmsKeyAlias string 207 | if kmsKeyAliasValue := os.Getenv(KMSKeyEnvVar); !rootPflags.Changed("kms-key-alias") && kmsKeyAliasValue != "" { 208 | kmsKeyAlias = kmsKeyAliasValue 209 | } else { 210 | kmsKeyAlias = kmsKeyAliasFlag 211 | } 212 | 213 | if !strings.HasPrefix(kmsKeyAlias, "alias/") { 214 | kmsKeyAlias = fmt.Sprintf("alias/%s", kmsKeyAlias) 215 | } 216 | 217 | if kmsKeyAlias == "" { 218 | return nil, errors.New("Must set kmsKeyAlias for S3 KMS backend") 219 | } 220 | 221 | s, err = store.NewS3KMSStore(ctx, numRetries, bucket, kmsKeyAlias) 222 | case SecretsManagerBackend: 223 | s, err = store.NewSecretsManagerStore(ctx, numRetries) 224 | case SSMBackend: 225 | if kmsKeyAliasFlag != DefaultKMSKey { 226 | return nil, errors.New("Unable to use --kms-key-alias with this backend. Use CHAMBER_KMS_KEY_ALIAS instead.") 227 | } 228 | 229 | var parsedRetryMode aws.RetryMode 230 | parsedRetryMode, err = aws.ParseRetryMode(retryMode) 231 | if err != nil { 232 | return nil, fmt.Errorf("Invalid retry mode %s", retryMode) 233 | } 234 | s, err = store.NewSSMStoreWithRetryMode(ctx, numRetries, parsedRetryMode) 235 | default: 236 | return nil, fmt.Errorf("invalid backend `%s`", backend) 237 | } 238 | return s, err 239 | } 240 | 241 | func prerun(cmd *cobra.Command, args []string) { 242 | if analyticsEnabled { 243 | // set up analytics client 244 | analyticsClient, _ = analytics.NewWithConfig(analyticsWriteKey, analytics.Config{ 245 | BatchSize: 1, 246 | }) 247 | 248 | username = os.Getenv("USER") 249 | _ = analyticsClient.Enqueue(analytics.Identify{ 250 | UserId: username, 251 | Traits: analytics.NewTraits(). 252 | Set("chamber-version", chamberVersion), 253 | }) 254 | } 255 | 256 | if verbose { 257 | levelVar := &slog.LevelVar{} 258 | levelVar.Set(slog.LevelDebug) 259 | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: levelVar}) 260 | slog.SetDefault(slog.New(handler)) 261 | } 262 | } 263 | 264 | func postrun(cmd *cobra.Command, args []string) { 265 | if analyticsEnabled && analyticsClient != nil { 266 | analyticsClient.Close() 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestValidateKey(t *testing.T) { 10 | validKeyFormat := []string{ 11 | "foo", 12 | "foo.bar", 13 | "foo.", 14 | ".foo", 15 | "foo-bar", 16 | } 17 | 18 | for _, k := range validKeyFormat { 19 | t.Run("Key validation should return Nil", func(t *testing.T) { 20 | result := validateKey(k) 21 | assert.Nil(t, result) 22 | }) 23 | } 24 | } 25 | 26 | func TestValidateKey_Invalid(t *testing.T) { 27 | invalidKeyFormat := []string{ 28 | "/foo", 29 | "foo//bar", 30 | "foo/bar", 31 | } 32 | 33 | for _, k := range invalidKeyFormat { 34 | t.Run("Key validation should return Error", func(t *testing.T) { 35 | result := validateKey(k) 36 | assert.Error(t, result) 37 | }) 38 | } 39 | } 40 | 41 | func TestValidateService_Path(t *testing.T) { 42 | validServicePathFormat := []string{ 43 | "foo", 44 | "foo.", 45 | ".foo", 46 | "foo.bar", 47 | "foo-bar", 48 | "foo/bar", 49 | "foo.bar/foo", 50 | "foo-bar/foo", 51 | "foo-bar/foo-bar", 52 | "foo/bar/foo", 53 | "foo/bar/foo-bar", 54 | "_chamber", // currently valid, but will be prohibited in a future version 55 | } 56 | 57 | for _, k := range validServicePathFormat { 58 | t.Run("Service with PATH validation should return Nil", func(t *testing.T) { 59 | result := validateService(k) 60 | assert.Nil(t, result) 61 | }) 62 | } 63 | } 64 | 65 | func TestValidateService_Path_Invalid(t *testing.T) { 66 | invalidServicePathFormat := []string{ 67 | "foo/", 68 | "/foo", 69 | "foo//bar", 70 | } 71 | 72 | for _, k := range invalidServicePathFormat { 73 | t.Run("Service with PATH validation should return Error", func(t *testing.T) { 74 | result := validateService(k) 75 | assert.Error(t, result) 76 | }) 77 | } 78 | } 79 | 80 | func TestValidateService_PathLabel(t *testing.T) { 81 | validServicePathFormatWithLabel := []string{ 82 | "foo", 83 | "foo/bar:-current-", 84 | "foo.bar/foo:current", 85 | "foo-bar/foo:current", 86 | "foo-bar/foo-bar:current", 87 | "foo/bar/foo:current", 88 | "foo/bar/foo-bar:current", 89 | "foo/bar/foo-bar", 90 | "_chamber", // currently valid, but will be prohibited in a future version 91 | } 92 | 93 | for _, k := range validServicePathFormatWithLabel { 94 | t.Run("Service with PATH validation and label should return Nil", func(t *testing.T) { 95 | result := validateServiceWithLabel(k) 96 | assert.Nil(t, result) 97 | }) 98 | } 99 | } 100 | 101 | func TestValidateService_PathLabel_Invalid(t *testing.T) { 102 | invalidServicePathFormatWithLabel := []string{ 103 | "foo:current$", 104 | "foo.:", 105 | ":foo/bar:current", 106 | "foo.bar:cur|rent", 107 | } 108 | 109 | for _, k := range invalidServicePathFormatWithLabel { 110 | t.Run("Service with PATH validation and label should return Error", func(t *testing.T) { 111 | result := validateServiceWithLabel(k) 112 | assert.Error(t, result) 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /cmd/tag-delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | analytics "github.com/segmentio/analytics-go/v3" 7 | "github.com/segmentio/chamber/v3/store" 8 | "github.com/segmentio/chamber/v3/utils" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | // tagWriteCmd represents the tag read command 14 | tagDeleteCmd = &cobra.Command{ 15 | Use: "delete ...", 16 | Short: "Delete tags for a specific secret", 17 | Args: cobra.MinimumNArgs(3), 18 | RunE: tagDelete, 19 | } 20 | ) 21 | 22 | func init() { 23 | tagCmd.AddCommand(tagDeleteCmd) 24 | } 25 | 26 | func tagDelete(cmd *cobra.Command, args []string) error { 27 | service := utils.NormalizeService(args[0]) 28 | if err := validateService(service); err != nil { 29 | return fmt.Errorf("Failed to validate service: %w", err) 30 | } 31 | 32 | key := utils.NormalizeKey(args[1]) 33 | if err := validateKey(key); err != nil { 34 | return fmt.Errorf("Failed to validate key: %w", err) 35 | } 36 | 37 | tagKeys := make([]string, len(args)-2) 38 | for i, tagArg := range args[2:] { 39 | if err := validateTag(tagArg, "dummy"); err != nil { 40 | return fmt.Errorf("Failed to validate tag key %s: %w", tagArg, err) 41 | } 42 | tagKeys[i] = tagArg 43 | } 44 | 45 | if analyticsEnabled && analyticsClient != nil { 46 | _ = analyticsClient.Enqueue(analytics.Track{ 47 | UserId: username, 48 | Event: "Ran Command", 49 | Properties: analytics.NewProperties(). 50 | Set("command", "tag delete"). 51 | Set("chamber-version", chamberVersion). 52 | Set("service", service). 53 | Set("key", key). 54 | Set("backend", backend), 55 | }) 56 | } 57 | 58 | secretStore, err := getSecretStore(cmd.Context()) 59 | if err != nil { 60 | return fmt.Errorf("Failed to get secret store: %w", err) 61 | } 62 | 63 | secretId := store.SecretId{ 64 | Service: service, 65 | Key: key, 66 | } 67 | 68 | err = secretStore.DeleteTags(cmd.Context(), secretId, tagKeys) 69 | if err != nil { 70 | return fmt.Errorf("Failed to delete tags: %w", err) 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /cmd/tag-read.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/tabwriter" 7 | 8 | analytics "github.com/segmentio/analytics-go/v3" 9 | "github.com/segmentio/chamber/v3/store" 10 | "github.com/segmentio/chamber/v3/utils" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ( 15 | // tagReadCmd represents the tag read command 16 | tagReadCmd = &cobra.Command{ 17 | Use: "read ", 18 | Short: "Read tags for a specific secret", 19 | Args: cobra.ExactArgs(2), 20 | RunE: tagRead, 21 | } 22 | ) 23 | 24 | func init() { 25 | tagCmd.AddCommand(tagReadCmd) 26 | } 27 | 28 | func tagRead(cmd *cobra.Command, args []string) error { 29 | service := utils.NormalizeService(args[0]) 30 | if err := validateService(service); err != nil { 31 | return fmt.Errorf("Failed to validate service: %w", err) 32 | } 33 | 34 | key := utils.NormalizeKey(args[1]) 35 | if err := validateKey(key); err != nil { 36 | return fmt.Errorf("Failed to validate key: %w", err) 37 | } 38 | 39 | if analyticsEnabled && analyticsClient != nil { 40 | _ = analyticsClient.Enqueue(analytics.Track{ 41 | UserId: username, 42 | Event: "Ran Command", 43 | Properties: analytics.NewProperties(). 44 | Set("command", "tag read"). 45 | Set("chamber-version", chamberVersion). 46 | Set("service", service). 47 | Set("key", key). 48 | Set("backend", backend), 49 | }) 50 | } 51 | 52 | secretStore, err := getSecretStore(cmd.Context()) 53 | if err != nil { 54 | return fmt.Errorf("Failed to get secret store: %w", err) 55 | } 56 | 57 | secretId := store.SecretId{ 58 | Service: service, 59 | Key: key, 60 | } 61 | 62 | tags, err := secretStore.ReadTags(cmd.Context(), secretId) 63 | if err != nil { 64 | return fmt.Errorf("Failed to read tags: %w", err) 65 | } 66 | 67 | if quiet { 68 | fmt.Fprintf(os.Stdout, "%s\n", tags) 69 | return nil 70 | } 71 | 72 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) 73 | fmt.Fprintln(w, "Key\tValue") 74 | for k, v := range tags { 75 | fmt.Fprintf(w, "%s\t%s\n", k, v) 76 | } 77 | w.Flush() 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /cmd/tag-write.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | analytics "github.com/segmentio/analytics-go/v3" 10 | "github.com/segmentio/chamber/v3/store" 11 | "github.com/segmentio/chamber/v3/utils" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | deleteOtherTags bool 17 | 18 | // tagWriteCmd represents the tag read command 19 | tagWriteCmd = &cobra.Command{ 20 | Use: "write ...", 21 | Short: "Write tags for a specific secret", 22 | Args: cobra.MinimumNArgs(3), 23 | RunE: tagWrite, 24 | } 25 | ) 26 | 27 | func init() { 28 | tagWriteCmd.Flags().BoolVar(&deleteOtherTags, "delete-other-tags", false, "Delete tags not specified in the command") 29 | tagCmd.AddCommand(tagWriteCmd) 30 | } 31 | 32 | func tagWrite(cmd *cobra.Command, args []string) error { 33 | service := utils.NormalizeService(args[0]) 34 | if err := validateService(service); err != nil { 35 | return fmt.Errorf("Failed to validate service: %w", err) 36 | } 37 | 38 | key := utils.NormalizeKey(args[1]) 39 | if err := validateKey(key); err != nil { 40 | return fmt.Errorf("Failed to validate key: %w", err) 41 | } 42 | 43 | tags := make(map[string]string, len(args)-2) 44 | for _, tagArg := range args[2:] { 45 | tagKey, tagValue, found := strings.Cut(tagArg, "=") 46 | if !found { 47 | return fmt.Errorf("Failed to parse tag %s: tag must be in the form key=value", tagArg) 48 | } 49 | if err := validateTag(tagKey, tagValue); err != nil { 50 | return fmt.Errorf("Failed to validate tag with key %s: %w", tagKey, err) 51 | } 52 | tags[tagKey] = tagValue 53 | } 54 | 55 | if analyticsEnabled && analyticsClient != nil { 56 | _ = analyticsClient.Enqueue(analytics.Track{ 57 | UserId: username, 58 | Event: "Ran Command", 59 | Properties: analytics.NewProperties(). 60 | Set("command", "tag write"). 61 | Set("chamber-version", chamberVersion). 62 | Set("service", service). 63 | Set("key", key). 64 | Set("backend", backend), 65 | }) 66 | } 67 | 68 | secretStore, err := getSecretStore(cmd.Context()) 69 | if err != nil { 70 | return fmt.Errorf("Failed to get secret store: %w", err) 71 | } 72 | 73 | secretId := store.SecretId{ 74 | Service: service, 75 | Key: key, 76 | } 77 | 78 | err = secretStore.WriteTags(cmd.Context(), secretId, tags, deleteOtherTags) 79 | if err != nil { 80 | return fmt.Errorf("Failed to write tags: %w", err) 81 | } 82 | 83 | if quiet { 84 | fmt.Fprintf(os.Stdout, "%s\n", tags) 85 | return nil 86 | } 87 | 88 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) 89 | fmt.Fprintln(w, "Key\tValue") 90 | for k, v := range tags { 91 | fmt.Fprintf(w, "%s\t%s\n", k, v) 92 | } 93 | w.Flush() 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /cmd/tag.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | // tagCmd represents the tag command 9 | tagCmd = &cobra.Command{ 10 | Use: "tag ...", 11 | Short: "work with tags on secrets", 12 | } 13 | ) 14 | 15 | func init() { 16 | RootCmd.AddCommand(tagCmd) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | analytics "github.com/segmentio/analytics-go/v3" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // versionCmd represents the version command 12 | var versionCmd = &cobra.Command{ 13 | Use: "version", 14 | Short: "print version", 15 | RunE: versionRun, 16 | } 17 | 18 | func init() { 19 | RootCmd.AddCommand(versionCmd) 20 | } 21 | 22 | func versionRun(cmd *cobra.Command, args []string) error { 23 | fmt.Fprintf(os.Stdout, "chamber %s\n", chamberVersion) 24 | if analyticsEnabled && analyticsClient != nil { 25 | _ = analyticsClient.Enqueue(analytics.Track{ 26 | UserId: username, 27 | Event: "Ran Command", 28 | Properties: analytics.NewProperties(). 29 | Set("command", "version"). 30 | Set("chamber-version", chamberVersion). 31 | Set("backend", backend), 32 | }) 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /cmd/write.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | analytics "github.com/segmentio/analytics-go/v3" 11 | "github.com/segmentio/chamber/v3/store" 12 | "github.com/segmentio/chamber/v3/utils" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | singleline bool 18 | skipUnchanged bool 19 | tags map[string]string 20 | 21 | // writeCmd represents the write command 22 | writeCmd = &cobra.Command{ 23 | Use: "write [--] ", 24 | Short: "write a secret", 25 | Args: cobra.ExactArgs(3), 26 | RunE: write, 27 | } 28 | ) 29 | 30 | func init() { 31 | writeCmd.Flags().BoolVarP(&singleline, "singleline", "s", false, "Insert single line parameter (end with \\n)") 32 | writeCmd.Flags().BoolVarP(&skipUnchanged, "skip-unchanged", "", false, "Skip writing secret if value is unchanged") 33 | writeCmd.Flags().StringToStringVarP(&tags, "tags", "t", map[string]string{}, "Add tags to the secret; new secrets only") 34 | RootCmd.AddCommand(writeCmd) 35 | } 36 | 37 | func write(cmd *cobra.Command, args []string) error { 38 | service := utils.NormalizeService(args[0]) 39 | if err := validateService(service); err != nil { 40 | return fmt.Errorf("Failed to validate service: %w", err) 41 | } 42 | 43 | key := utils.NormalizeKey(args[1]) 44 | if err := validateKey(key); err != nil { 45 | return fmt.Errorf("Failed to validate key: %w", err) 46 | } 47 | 48 | if analyticsEnabled && analyticsClient != nil { 49 | _ = analyticsClient.Enqueue(analytics.Track{ 50 | UserId: username, 51 | Event: "Ran Command", 52 | Properties: analytics.NewProperties(). 53 | Set("command", "write"). 54 | Set("chamber-version", chamberVersion). 55 | Set("service", service). 56 | Set("backend", backend). 57 | Set("key", key), 58 | }) 59 | } 60 | 61 | value := args[2] 62 | if value == "-" { 63 | // Read value from standard input 64 | if singleline { 65 | buf := bufio.NewReader(os.Stdin) 66 | v, err := buf.ReadString('\n') 67 | if err != nil { 68 | return err 69 | } 70 | value = strings.TrimSuffix(v, "\n") 71 | } else { 72 | v, err := io.ReadAll(os.Stdin) 73 | if err != nil { 74 | return err 75 | } 76 | value = string(v) 77 | } 78 | } 79 | 80 | secretStore, err := getSecretStore(cmd.Context()) 81 | if err != nil { 82 | return fmt.Errorf("Failed to get secret store: %w", err) 83 | } 84 | 85 | secretId := store.SecretId{ 86 | Service: service, 87 | Key: key, 88 | } 89 | 90 | if skipUnchanged { 91 | currentSecret, err := secretStore.Read(cmd.Context(), secretId, -1) 92 | if err == nil && value == *currentSecret.Value { 93 | return nil 94 | } 95 | } 96 | 97 | if len(tags) > 0 { 98 | return secretStore.WriteWithTags(cmd.Context(), secretId, value, tags) 99 | } else { 100 | return secretStore.Write(cmd.Context(), secretId, value) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /environ/environ.go: -------------------------------------------------------------------------------- 1 | package environ 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/segmentio/chamber/v3/store" 9 | "github.com/segmentio/chamber/v3/utils" 10 | ) 11 | 12 | // environ is a slice of strings representing the environment, in the form "key=value". 13 | type Environ []string 14 | 15 | // Unset an environment variable by key 16 | func (e *Environ) Unset(key string) { 17 | for i := range *e { 18 | if strings.HasPrefix((*e)[i], key+"=") { 19 | (*e)[i] = (*e)[len(*e)-1] 20 | *e = (*e)[:len(*e)-1] 21 | break 22 | } 23 | } 24 | } 25 | 26 | // IsSet returns whether or not a key is currently set in the environ 27 | func (e *Environ) IsSet(key string) bool { 28 | for i := range *e { 29 | if strings.HasPrefix((*e)[i], key+"=") { 30 | return true 31 | } 32 | } 33 | return false 34 | } 35 | 36 | // Set adds an environment variable, replacing any existing ones of the same key 37 | func (e *Environ) Set(key, val string) { 38 | e.Unset(key) 39 | *e = append(*e, key+"="+val) 40 | } 41 | 42 | // Map squashes the list-like environ, taking the latter value when there are 43 | // collisions, like a shell would. Invalid items (e.g., missing `=`) are dropped 44 | func (e *Environ) Map() map[string]string { 45 | ret := map[string]string{} 46 | for _, kv := range []string(*e) { 47 | s := strings.SplitN(kv, "=", 2) 48 | if len(s) != 2 { 49 | // drop invalid kv pairs 50 | // I guess this could happen in theory 51 | continue 52 | } 53 | ret[s[0]] = s[1] 54 | } 55 | return ret 56 | } 57 | 58 | // fromMap returns an Environ based on m. Order is arbitrary. 59 | func fromMap(m map[string]string) Environ { 60 | e := make([]string, 0, len(m)) 61 | 62 | for k, v := range m { 63 | e = append(e, k+"="+v) 64 | } 65 | return Environ(e) 66 | } 67 | 68 | // like cmd/list.key, but without the env var lookup 69 | func key(s string) string { 70 | sep := "/" 71 | tokens := strings.Split(s, sep) 72 | secretKey := tokens[len(tokens)-1] 73 | return secretKey 74 | } 75 | 76 | // transforms a secret key to an env var name, i.e. upppercase, substitute `-` -> `_` 77 | func secretKeyToEnvVarName(k string) string { 78 | return normalizeEnvVarName(key(k)) 79 | } 80 | 81 | func normalizeEnvVarName(k string) string { 82 | return strings.Replace(strings.ToUpper(k), "-", "_", -1) 83 | } 84 | 85 | // load loads environment variables into e from s given a service 86 | // collisions will be populated with any keys that get overwritten 87 | func (e *Environ) load(ctx context.Context, s store.Store, service string, collisions *[]string) error { 88 | rawSecrets, err := s.ListRaw(ctx, utils.NormalizeService(service)) 89 | if err != nil { 90 | return err 91 | } 92 | for _, rawSecret := range rawSecrets { 93 | envVarKey := secretKeyToEnvVarName(rawSecret.Key) 94 | 95 | if e.IsSet(envVarKey) { 96 | *collisions = append(*collisions, envVarKey) 97 | } 98 | e.Set(envVarKey, rawSecret.Value) 99 | } 100 | return nil 101 | } 102 | 103 | // Load loads environment variables into e from s given a service 104 | // collisions will be populated with any keys that get overwritten 105 | func (e *Environ) Load(ctx context.Context, s store.Store, service string, collisions *[]string) error { 106 | return e.load(ctx, s, service, collisions) 107 | } 108 | 109 | // LoadStrict loads all services from s in strict mode: env vars in e with value equal to valueExpected 110 | // are the only ones substituted. If there are any env vars in s that are also in e, but don't have their value 111 | // set to valueExpected, this is an error. 112 | func (e *Environ) LoadStrict(ctx context.Context, s store.Store, valueExpected string, pristine bool, services ...string) error { 113 | return e.loadStrict(ctx, s, valueExpected, pristine, services...) 114 | } 115 | 116 | func (e *Environ) loadStrict(ctx context.Context, s store.Store, valueExpected string, pristine bool, services ...string) error { 117 | for _, service := range services { 118 | rawSecrets, err := s.ListRaw(ctx, utils.NormalizeService(service)) 119 | if err != nil { 120 | return err 121 | } 122 | err = e.loadStrictOne(rawSecrets, valueExpected, pristine) 123 | if err != nil { 124 | return err 125 | } 126 | } 127 | return nil 128 | } 129 | 130 | func (e *Environ) loadStrictOne(rawSecrets []store.RawSecret, valueExpected string, pristine bool) error { 131 | parentMap := e.Map() 132 | parentExpects := map[string]struct{}{} 133 | for k, v := range parentMap { 134 | if v == valueExpected { 135 | if k != normalizeEnvVarName(k) { 136 | return ErrExpectedKeyUnnormalized{Key: k, ValueExpected: valueExpected} 137 | } 138 | // TODO: what if this key isn't chamber-compatible but could collide? MY_cool_var vs my-cool-var 139 | parentExpects[k] = struct{}{} 140 | } 141 | } 142 | 143 | envVarKeysAdded := map[string]struct{}{} 144 | for _, rawSecret := range rawSecrets { 145 | envVarKey := secretKeyToEnvVarName(rawSecret.Key) 146 | 147 | parentVal, parentOk := parentMap[envVarKey] 148 | // skip injecting secrets that are not present in the parent 149 | if !parentOk { 150 | continue 151 | } 152 | delete(parentExpects, envVarKey) 153 | if parentVal != valueExpected { 154 | return ErrStoreUnexpectedValue{Key: envVarKey, ValueExpected: valueExpected, ValueActual: parentVal} 155 | } 156 | envVarKeysAdded[envVarKey] = struct{}{} 157 | e.Set(envVarKey, rawSecret.Value) 158 | } 159 | for k := range parentExpects { 160 | return ErrStoreMissingKey{Key: k, ValueExpected: valueExpected} 161 | } 162 | 163 | if pristine { 164 | // unset all envvars that were in the parent env but not in store 165 | for k := range parentMap { 166 | if _, ok := envVarKeysAdded[k]; !ok { 167 | e.Unset(k) 168 | } 169 | } 170 | } 171 | 172 | return nil 173 | } 174 | 175 | type ErrStoreUnexpectedValue struct { 176 | // store-style key 177 | Key string 178 | ValueExpected string 179 | ValueActual string 180 | } 181 | 182 | func (e ErrStoreUnexpectedValue) Error() string { 183 | return fmt.Sprintf("parent env has %s, but was expecting value `%s`, not `%s`", e.Key, e.ValueExpected, e.ValueActual) 184 | } 185 | 186 | type ErrStoreMissingKey struct { 187 | // env-style key 188 | Key string 189 | ValueExpected string 190 | } 191 | 192 | func (e ErrStoreMissingKey) Error() string { 193 | return fmt.Sprintf("parent env was expecting %s=%s, but was not in store", e.Key, e.ValueExpected) 194 | } 195 | 196 | type ErrExpectedKeyUnnormalized struct { 197 | Key string 198 | ValueExpected string 199 | } 200 | 201 | func (e ErrExpectedKeyUnnormalized) Error() string { 202 | return fmt.Sprintf("parent env has key `%s` with expected value `%s`, but key is not normalized like `%s`, so would never get substituted", 203 | e.Key, e.ValueExpected, normalizeEnvVarName(e.Key)) 204 | } 205 | -------------------------------------------------------------------------------- /environ/environ_test.go: -------------------------------------------------------------------------------- 1 | package environ 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/segmentio/chamber/v3/store" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestEnvironStrict(t *testing.T) { 12 | cases := []struct { 13 | name string 14 | e Environ 15 | // default: "chamberme" 16 | strictVal string 17 | pristine bool 18 | secrets map[string]string 19 | expectedEnvMap map[string]string 20 | expectedErr error 21 | }{ 22 | { 23 | name: "parent ⊃ secrets (!pristine)", 24 | e: fromMap(map[string]string{ 25 | "HOME": "/tmp", 26 | "DB_USERNAME": "chamberme", 27 | "DB_PASSWORD": "chamberme", 28 | }), 29 | secrets: map[string]string{ 30 | "db_username": "root", 31 | "db_password": "hunter22", 32 | }, 33 | expectedEnvMap: map[string]string{ 34 | "HOME": "/tmp", 35 | "DB_USERNAME": "root", 36 | "DB_PASSWORD": "hunter22", 37 | }, 38 | }, 39 | 40 | { 41 | name: "parent ⊃ secrets with unfilled (!pristine)", 42 | e: fromMap(map[string]string{ 43 | "HOME": "/tmp", 44 | "DB_USERNAME": "chamberme", 45 | "DB_PASSWORD": "chamberme", 46 | "EXTRA": "chamberme", 47 | }), 48 | secrets: map[string]string{ 49 | "db_username": "root", 50 | "db_password": "hunter22", 51 | }, 52 | expectedErr: ErrStoreMissingKey{Key: "EXTRA", ValueExpected: "chamberme"}, 53 | }, 54 | 55 | { 56 | name: "parent ⊃ secrets (pristine)", 57 | e: fromMap(map[string]string{ 58 | "HOME": "/tmp", 59 | "DB_USERNAME": "chamberme", 60 | "DB_PASSWORD": "chamberme", 61 | }), 62 | pristine: true, 63 | secrets: map[string]string{ 64 | "db_username": "root", 65 | "db_password": "hunter22", 66 | }, 67 | expectedEnvMap: map[string]string{ 68 | "DB_USERNAME": "root", 69 | "DB_PASSWORD": "hunter22", 70 | }, 71 | }, 72 | 73 | { 74 | name: "parent with unnormalized key name", 75 | e: fromMap(map[string]string{ 76 | "HOME": "/tmp", 77 | "DB_username": "chamberme", 78 | "DB_PASSWORD": "chamberme", 79 | }), 80 | pristine: true, 81 | secrets: map[string]string{ 82 | "db_username": "root", 83 | "db_password": "hunter22", 84 | }, 85 | expectedErr: ErrExpectedKeyUnnormalized{Key: "DB_username", ValueExpected: "chamberme"}, 86 | }, 87 | } 88 | 89 | for _, tc := range cases { 90 | t.Run(tc.name, func(t *testing.T) { 91 | rawSecrets := make([]store.RawSecret, 0, len(tc.secrets)) 92 | for k, v := range tc.secrets { 93 | rawSecrets = append(rawSecrets, store.RawSecret{ 94 | Key: k, 95 | Value: v, 96 | }) 97 | } 98 | strictVal := tc.strictVal 99 | if strictVal == "" { 100 | strictVal = "chamberme" 101 | } 102 | err := tc.e.loadStrictOne(rawSecrets, strictVal, tc.pristine) 103 | if err != nil { 104 | assert.EqualValues(t, tc.expectedErr, err) 105 | } else { 106 | assert.EqualValues(t, tc.expectedEnvMap, tc.e.Map()) 107 | } 108 | }) 109 | } 110 | } 111 | 112 | func TestMap(t *testing.T) { 113 | cases := []struct { 114 | name string 115 | in Environ 116 | out map[string]string 117 | }{ 118 | { 119 | "basic", 120 | Environ([]string{ 121 | "k=v", 122 | }), 123 | map[string]string{ 124 | "k": "v", 125 | }, 126 | }, 127 | { 128 | "dropping malformed", 129 | Environ([]string{ 130 | "k=v", 131 | // should work 132 | "k2=", 133 | }), 134 | map[string]string{ 135 | "k": "v", 136 | "k2": "", 137 | }, 138 | }, 139 | { 140 | "squash", 141 | Environ([]string{ 142 | "k=v1", 143 | "k=v2", 144 | }), 145 | map[string]string{ 146 | "k": "v2", 147 | }, 148 | }, 149 | } 150 | 151 | for _, tc := range cases { 152 | t.Run(tc.name, func(t *testing.T) { 153 | m := tc.in.Map() 154 | assert.EqualValues(t, m, tc.out) 155 | }) 156 | } 157 | } 158 | 159 | func TestFromMap(t *testing.T) { 160 | cases := []struct { 161 | name string 162 | in map[string]string 163 | out Environ 164 | }{ 165 | { 166 | "basic", 167 | map[string]string{ 168 | "k1": "v1", 169 | "k2": "v2", 170 | }, 171 | Environ([]string{ 172 | "k1=v1", 173 | "k2=v2", 174 | }), 175 | }, 176 | } 177 | 178 | for _, tc := range cases { 179 | t.Run(tc.name, func(t *testing.T) { 180 | e := fromMap(tc.in) 181 | // maps order is non-deterministic 182 | sort.Strings(e) 183 | assert.EqualValues(t, e, tc.out) 184 | }) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/segmentio/chamber/v3 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/alessio/shellescape v1.4.2 7 | github.com/aws/aws-sdk-go-v2 v1.32.8 8 | github.com/aws/aws-sdk-go-v2/config v1.28.10 9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 10 | github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2 11 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.10 12 | github.com/aws/aws-sdk-go-v2/service/ssm v1.56.4 13 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 14 | github.com/aws/smithy-go v1.22.1 15 | github.com/magiconair/properties v1.8.9 16 | github.com/segmentio/analytics-go/v3 v3.3.0 17 | github.com/spf13/cobra v1.8.1 18 | github.com/stretchr/testify v1.10.0 19 | golang.org/x/sys v0.29.0 20 | gopkg.in/yaml.v3 v3.0.1 21 | ) 22 | 23 | require ( 24 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect 25 | github.com/aws/aws-sdk-go-v2/credentials v1.17.51 // indirect 26 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 // indirect 27 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 // indirect 28 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect 29 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.27 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.8 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.8 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 // indirect 36 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect 37 | github.com/davecgh/go-spew v1.1.1 // indirect 38 | github.com/google/uuid v1.3.1 // indirect 39 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 40 | github.com/jmespath/go-jmespath v0.4.0 // indirect 41 | github.com/kr/pretty v0.3.1 // indirect 42 | github.com/pmezard/go-difflib v1.0.0 // indirect 43 | github.com/segmentio/backo-go v1.0.1 // indirect 44 | github.com/spf13/pflag v1.0.5 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= 2 | github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 3 | github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= 4 | github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= 5 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= 6 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc= 7 | github.com/aws/aws-sdk-go-v2/config v1.28.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg= 8 | github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg= 9 | github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk= 10 | github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ= 11 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI= 12 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA= 13 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs= 14 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw= 15 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8= 16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8= 17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= 18 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= 19 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.27 h1:AmB5QxnD+fBFrg9LcqzkgF/CaYvMyU/BTlejG4t1S7Q= 20 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.27/go.mod h1:Sai7P3xTiyv9ZUYO3IFxMnmiIP759/67iQbU4kdmkyU= 21 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= 22 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= 23 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.8 h1:iwYS40JnrBeA9e9aI5S6KKN4EB2zR4iUVYN0nwVivz4= 24 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.8/go.mod h1:Fm9Mi+ApqmFiknZtGpohVcBGvpTu542VC4XO9YudRi0= 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA= 26 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE= 27 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.8 h1:/Mn7gTedG86nbpjT4QEKsN1D/fThiYe1qvq7WsBGNHg= 28 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.8/go.mod h1:Ae3va9LPmvjj231ukHB6UeT8nS7wTPfC3tMZSZMwNYg= 29 | github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2 h1:a7aQ3RW+ug4IbhoQp29NZdc7vqrzKZZfWZSaQAXOZvQ= 30 | github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2/go.mod h1:xMekrnhmJ5aqmyxtmALs7mlvXw5xRh+eYjOjvrIIFJ4= 31 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.10 h1:SDZdvqySr0vBfd2hqIIymCJXRsArXyFI9Yz0cgYEU5g= 32 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.10/go.mod h1:2Hp1QzEIaEw6v25llGTlGM+Xx7FRiCIS90Tb+iqVEfo= 33 | github.com/aws/aws-sdk-go-v2/service/ssm v1.56.4 h1:oXh/PjaKtStu7RkaUtuKX6+h/OxXriMa9WyQQhylKG0= 34 | github.com/aws/aws-sdk-go-v2/service/ssm v1.56.4/go.mod h1:IiHGbiFg4wVdEKrvFi/zxVZbjfEpgSe21N9RwyQFXCU= 35 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8= 36 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY= 37 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw= 38 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY= 39 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dvu921NpGk7y43tAM= 40 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc= 41 | github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= 42 | github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 43 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= 44 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 45 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 46 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 47 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 48 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 49 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 50 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 51 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 52 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 53 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 54 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 55 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 56 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 57 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 58 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 59 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 60 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 61 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 62 | github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= 63 | github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 64 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 65 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 66 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 67 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 68 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 69 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 70 | github.com/segmentio/analytics-go/v3 v3.3.0 h1:8VOMaVGBW03pdBrj1CMFfY9o/rnjJC+1wyQHlVxjw5o= 71 | github.com/segmentio/analytics-go/v3 v3.3.0/go.mod h1:p8owAF8X+5o27jmvUognuXxdtqvSGtD0ZrfY2kcS9bE= 72 | github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4= 73 | github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc= 74 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 75 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 76 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 77 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 78 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 79 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 80 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 81 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 82 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 83 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 86 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 87 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 88 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/segmentio/chamber/v3/cmd" 5 | ) 6 | 7 | var ( 8 | // This is updated by linker flags during build 9 | Version = "dev" 10 | AnalyticsWriteKey = "" 11 | ) 12 | 13 | func main() { 14 | cmd.Execute(Version, AnalyticsWriteKey) 15 | } 16 | -------------------------------------------------------------------------------- /nfpm.yaml.tmpl: -------------------------------------------------------------------------------- 1 | name: "chamber" 2 | arch: "amd64" 3 | platform: "linux" 4 | version: "${VERSION}" 5 | section: "default" 6 | priority: "extra" 7 | provides: 8 | - chamber 9 | vendor: 'Segment, Inc.' 10 | maintainer: tooling-team@segment.com 11 | homepage: "https://github.com/segmentio/chamber" 12 | license: "MIT" 13 | # IMHO packages should install to /usr/bin 14 | bindir: /usr/bin 15 | files: 16 | "${DIST_BIN}": "/usr/bin/chamber" 17 | description: > 18 | Chamber is a tool for managing secrets. Currently it does so by storing 19 | secrets in SSM Parameter Store, an AWS service for storing secrets. 20 | -------------------------------------------------------------------------------- /store/awsapi.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go-v2/service/s3" 7 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager" 8 | "github.com/aws/aws-sdk-go-v2/service/ssm" 9 | "github.com/aws/aws-sdk-go-v2/service/sts" 10 | ) 11 | 12 | // The interfaces defined here collect together all of the SDK functions used 13 | // throughout chamber. Code that works with AWS does so through these interfaces. 14 | // The "real" AWS SDK client objects implement these interfaces, since they 15 | // contain all of the methods (and more). Mock versions of these interfaces are 16 | // generated using the moq utility for substitution in unit tests. For more, see 17 | // https://aws.github.io/aws-sdk-go-v2/docs/unit-testing/ . 18 | 19 | //go:generate moq -out awsapi_mock.go . apiS3 apiSSM apiSTS apiSecretsManager 20 | 21 | type apiS3 interface { 22 | DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) 23 | GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) 24 | ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) 25 | PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) 26 | } 27 | 28 | type apiSSM interface { 29 | AddTagsToResource(ctx context.Context, params *ssm.AddTagsToResourceInput, optFns ...func(*ssm.Options)) (*ssm.AddTagsToResourceOutput, error) 30 | DeleteParameter(ctx context.Context, params *ssm.DeleteParameterInput, optFns ...func(*ssm.Options)) (*ssm.DeleteParameterOutput, error) 31 | DescribeParameters(ctx context.Context, params *ssm.DescribeParametersInput, optFns ...func(*ssm.Options)) (*ssm.DescribeParametersOutput, error) 32 | GetParameterHistory(ctx context.Context, params *ssm.GetParameterHistoryInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterHistoryOutput, error) 33 | GetParameters(ctx context.Context, params *ssm.GetParametersInput, optFns ...func(*ssm.Options)) (*ssm.GetParametersOutput, error) 34 | GetParametersByPath(ctx context.Context, params *ssm.GetParametersByPathInput, optFns ...func(*ssm.Options)) (*ssm.GetParametersByPathOutput, error) 35 | ListTagsForResource(ctx context.Context, params *ssm.ListTagsForResourceInput, optFns ...func(*ssm.Options)) (*ssm.ListTagsForResourceOutput, error) 36 | PutParameter(ctx context.Context, params *ssm.PutParameterInput, optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) 37 | RemoveTagsFromResource(ctx context.Context, params *ssm.RemoveTagsFromResourceInput, optFns ...func(*ssm.Options)) (*ssm.RemoveTagsFromResourceOutput, error) 38 | } 39 | 40 | type apiSTS interface { 41 | GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) 42 | } 43 | 44 | type apiSecretsManager interface { 45 | CreateSecret(ctx context.Context, params *secretsmanager.CreateSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) 46 | DescribeSecret(ctx context.Context, params *secretsmanager.DescribeSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.DescribeSecretOutput, error) 47 | GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) 48 | ListSecretVersionIds(ctx context.Context, params *secretsmanager.ListSecretVersionIdsInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretVersionIdsOutput, error) 49 | PutSecretValue(ctx context.Context, params *secretsmanager.PutSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error) 50 | } 51 | -------------------------------------------------------------------------------- /store/backendbenchmarks_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "math/rand" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | // This file contains some tests which can be used to benchmark 16 | // performance against real AWS API's. Since this requires provisioned 17 | // infra and authed AWS user/role, these tests are disabled during automated testing. 18 | 19 | // To enable set the testing flag backendbenchmark (go test -benchmark) 20 | 21 | const ( 22 | KeysPerService = 15 23 | ) 24 | 25 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 26 | 27 | var benchmarkEnabled bool 28 | 29 | func init() { 30 | flag.BoolVar(&benchmarkEnabled, "benchmark", false, "run backend benchmarks") 31 | } 32 | 33 | func RandStringRunes(n int) string { 34 | b := make([]rune, n) 35 | for i := range b { 36 | b[i] = letterRunes[rand.Intn(len(letterRunes))] 37 | } 38 | return string(b) 39 | } 40 | 41 | func benchmarkStore(t *testing.T, store Store, services []string) { 42 | ctx := context.Background() 43 | setupStore(t, ctx, store, services) 44 | defer cleanupStore(t, ctx, store, services) 45 | 46 | concurrentExecs := []int{1, 10, 500, 1000} 47 | 48 | for _, concurrency := range concurrentExecs { 49 | var wg sync.WaitGroup 50 | start := time.Now() 51 | 52 | for i := 0; i < concurrency; i++ { 53 | wg.Add(1) 54 | go func() { 55 | // TODO: collect errors in a channel 56 | _ = emulateExec(t, ctx, &wg, store, services) 57 | }() 58 | } 59 | wg.Wait() 60 | elapsed := time.Since(start) 61 | t.Logf("Concurrently started %d services in %s", concurrency, elapsed) 62 | } 63 | } 64 | 65 | func emulateExec(t *testing.T, ctx context.Context, wg *sync.WaitGroup, s Store, services []string) error { 66 | defer wg.Done() 67 | // Exec calls ListRaw once per service specified 68 | for _, service := range services { 69 | _, err := s.ListRaw(ctx, service) 70 | if err != nil { 71 | t.Logf("Failed to execute ListRaw: %s", err) 72 | return err 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | func TestS3StoreConcurrency(t *testing.T) { 79 | if !benchmarkEnabled { 80 | t.SkipNow() 81 | } 82 | s, _ := NewS3StoreWithBucket(context.Background(), 10, "chamber-test") 83 | benchmarkStore(t, s, []string{"foo"}) 84 | } 85 | 86 | func TestSecretsManagerStoreConcurrency(t *testing.T) { 87 | if !benchmarkEnabled { 88 | t.SkipNow() 89 | } 90 | s, _ := NewSecretsManagerStore(context.Background(), 10) 91 | benchmarkStore(t, s, []string{"foo"}) 92 | } 93 | 94 | func TestSSMConcurrency(t *testing.T) { 95 | if !benchmarkEnabled { 96 | t.SkipNow() 97 | } 98 | 99 | s, _ := NewSSMStore(context.Background(), 10) 100 | benchmarkStore(t, s, []string{"foo"}) 101 | } 102 | 103 | func setupStore(t *testing.T, ctx context.Context, store Store, services []string) { 104 | // populate the store for services listed 105 | for _, service := range services { 106 | for i := 0; i < KeysPerService; i++ { 107 | key := fmt.Sprintf("var%d", i) 108 | id := SecretId{ 109 | Service: service, 110 | Key: key, 111 | } 112 | 113 | require.NoError(t, store.Write(ctx, id, RandStringRunes(100))) 114 | } 115 | } 116 | } 117 | 118 | func cleanupStore(t *testing.T, ctx context.Context, store Store, services []string) { 119 | for _, service := range services { 120 | for i := 0; i < KeysPerService; i++ { 121 | key := fmt.Sprintf("var%d", i) 122 | id := SecretId{ 123 | Service: service, 124 | Key: key, 125 | } 126 | 127 | require.NoError(t, store.Delete(ctx, id)) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /store/nullstore.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | var _ Store = &NullStore{} 9 | 10 | type NullStore struct{} 11 | 12 | func NewNullStore() *NullStore { 13 | return &NullStore{} 14 | } 15 | 16 | func (s *NullStore) Config(ctx context.Context) (StoreConfig, error) { 17 | return StoreConfig{ 18 | Version: LatestStoreConfigVersion, 19 | }, nil 20 | } 21 | 22 | func (s *NullStore) SetConfig(ctx context.Context, config StoreConfig) error { 23 | return errors.New("SetConfig is not implemented for Null Store") 24 | } 25 | 26 | func (s *NullStore) Write(ctx context.Context, id SecretId, value string) error { 27 | return errors.New("Write is not implemented for Null Store") 28 | } 29 | 30 | func (s *NullStore) WriteWithTags(ctx context.Context, id SecretId, value string, tags map[string]string) error { 31 | return errors.New("WriteWithTags is not implemented for Null Store") 32 | } 33 | 34 | func (s *NullStore) Read(ctx context.Context, id SecretId, version int) (Secret, error) { 35 | return Secret{}, errors.New("Not implemented for Null Store") 36 | } 37 | 38 | func (s *NullStore) WriteTags(ctx context.Context, id SecretId, tags map[string]string, deleteOtherTags bool) error { 39 | return errors.New("Not implemented for Null Store") 40 | } 41 | 42 | func (s *NullStore) ReadTags(ctx context.Context, id SecretId) (map[string]string, error) { 43 | return nil, errors.New("Not implemented for Null Store") 44 | } 45 | 46 | func (s *NullStore) ListServices(ctx context.Context, service string, includeSecretNames bool) ([]string, error) { 47 | return nil, nil 48 | } 49 | 50 | func (s *NullStore) List(ctx context.Context, service string, includeValues bool) ([]Secret, error) { 51 | return []Secret{}, nil 52 | } 53 | 54 | func (s *NullStore) ListRaw(ctx context.Context, service string) ([]RawSecret, error) { 55 | return []RawSecret{}, nil 56 | } 57 | 58 | func (s *NullStore) History(ctx context.Context, id SecretId) ([]ChangeEvent, error) { 59 | return []ChangeEvent{}, nil 60 | } 61 | 62 | func (s *NullStore) Delete(ctx context.Context, id SecretId) error { 63 | return errors.New("Not implemented for Null Store") 64 | } 65 | 66 | func (s *NullStore) DeleteTags(ctx context.Context, id SecretId, tags []string) error { 67 | return errors.New("Not implemented for Null Store") 68 | } 69 | -------------------------------------------------------------------------------- /store/s3store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "sort" 11 | "time" 12 | 13 | "github.com/aws/aws-sdk-go-v2/aws" 14 | "github.com/aws/aws-sdk-go-v2/service/s3" 15 | "github.com/aws/aws-sdk-go-v2/service/s3/types" 16 | "github.com/aws/aws-sdk-go-v2/service/sts" 17 | ) 18 | 19 | const ( 20 | MaximumVersions = 100 21 | 22 | latestObjectName = "__latest.json" 23 | ) 24 | 25 | // secretObject is the serialized format for storing secrets 26 | // as an s3 object 27 | type secretObject struct { 28 | Service string `json:"service"` 29 | Key string `json:"key"` 30 | Values map[int]secretVersion `json:"values"` 31 | } 32 | 33 | // secretVersion holds all the metadata for a specific version 34 | // of a secret 35 | type secretVersion struct { 36 | Created time.Time `json:"created"` 37 | CreatedBy string `json:"created_by"` 38 | Version int `json:"version"` 39 | Value string `json:"value"` 40 | } 41 | 42 | // latest is used to keep a single object in s3 with all of the 43 | // most recent values for the given service's secrets. Keeping this 44 | // in a single s3 object allows us to use a single s3 GetObject 45 | // for ListRaw (and thus chamber exec). 46 | type latest struct { 47 | Latest map[string]string `json:"latest"` 48 | } 49 | 50 | var _ Store = &S3Store{} 51 | 52 | type S3Store struct { 53 | svc apiS3 54 | stsSvc apiSTS 55 | bucket string 56 | } 57 | 58 | func NewS3StoreWithBucket(ctx context.Context, numRetries int, bucket string) (*S3Store, error) { 59 | config, _, err := getConfig(ctx, numRetries, aws.RetryModeStandard) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | svc := s3.NewFromConfig(config) 65 | 66 | stsSvc := sts.NewFromConfig(config) 67 | 68 | return &S3Store{ 69 | svc: svc, 70 | stsSvc: stsSvc, 71 | bucket: bucket, 72 | }, nil 73 | } 74 | 75 | func (s *S3Store) Config(ctx context.Context) (StoreConfig, error) { 76 | return StoreConfig{ 77 | Version: LatestStoreConfigVersion, 78 | }, nil 79 | } 80 | 81 | func (s *S3Store) SetConfig(ctx context.Context, config StoreConfig) error { 82 | return errors.New("Not implemented for S3 Store") 83 | } 84 | 85 | func (s *S3Store) Write(ctx context.Context, id SecretId, value string) error { 86 | index, err := s.readLatest(ctx, id.Service) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | objPath := getObjectPath(id) 92 | existing, ok, err := s.readObjectById(ctx, id) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | var obj secretObject 98 | if ok { 99 | obj = existing 100 | } else { 101 | obj = secretObject{ 102 | Service: id.Service, 103 | Key: fmt.Sprintf("/%s/%s", id.Service, id.Key), 104 | Values: map[int]secretVersion{}, 105 | } 106 | } 107 | 108 | thisVersion := getLatestVersion(obj.Values) + 1 109 | user, err := s.getCurrentUser(ctx) 110 | if err != nil { 111 | return err 112 | } 113 | obj.Values[thisVersion] = secretVersion{ 114 | Version: thisVersion, 115 | Value: value, 116 | Created: time.Now().UTC(), 117 | CreatedBy: user, 118 | } 119 | 120 | pruneOldVersions(obj.Values) 121 | 122 | contents, err := json.Marshal(obj) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | putObjectInput := &s3.PutObjectInput{ 128 | Bucket: aws.String(s.bucket), 129 | ServerSideEncryption: types.ServerSideEncryptionAes256, 130 | Key: aws.String(objPath), 131 | Body: bytes.NewReader(contents), 132 | } 133 | 134 | _, err = s.svc.PutObject(ctx, putObjectInput) 135 | if err != nil { 136 | // TODO: catch specific awserr 137 | return err 138 | } 139 | 140 | index.Latest[id.Key] = value 141 | return s.writeLatest(ctx, id.Service, index) 142 | } 143 | 144 | func (s *S3Store) WriteWithTags(ctx context.Context, id SecretId, value string, tags map[string]string) error { 145 | return errors.New("Not implemented for S3 Store") 146 | } 147 | 148 | func (s *S3Store) Read(ctx context.Context, id SecretId, version int) (Secret, error) { 149 | obj, ok, err := s.readObjectById(ctx, id) 150 | if err != nil { 151 | return Secret{}, err 152 | } 153 | 154 | if !ok { 155 | return Secret{}, ErrSecretNotFound 156 | } 157 | 158 | if version == -1 { 159 | version = getLatestVersion(obj.Values) 160 | } 161 | val, ok := obj.Values[version] 162 | if !ok { 163 | return Secret{}, ErrSecretNotFound 164 | } 165 | 166 | return Secret{ 167 | Value: aws.String(val.Value), 168 | Meta: SecretMetadata{ 169 | Created: val.Created, 170 | CreatedBy: val.CreatedBy, 171 | Version: val.Version, 172 | Key: obj.Key, 173 | }, 174 | }, nil 175 | } 176 | 177 | func (s *S3Store) WriteTags(ctx context.Context, id SecretId, tags map[string]string, deleteOtherTags bool) error { 178 | return errors.New("Not implemented for S3 Store") 179 | } 180 | 181 | func (s *S3Store) ReadTags(ctx context.Context, id SecretId) (map[string]string, error) { 182 | return nil, errors.New("Not implemented for S3 Store") 183 | } 184 | 185 | func (s *S3Store) ListServices(ctx context.Context, service string, includeSecretName bool) ([]string, error) { 186 | return nil, fmt.Errorf("S3 Backend is experimental and does not implement this command") 187 | } 188 | 189 | func (s *S3Store) List(ctx context.Context, service string, includeValues bool) ([]Secret, error) { 190 | index, err := s.readLatest(ctx, service) 191 | if err != nil { 192 | return []Secret{}, err 193 | } 194 | 195 | secrets := []Secret{} 196 | for key := range index.Latest { 197 | obj, ok, err := s.readObjectById(ctx, SecretId{Service: service, Key: key}) 198 | if err != nil { 199 | return []Secret{}, err 200 | } 201 | if !ok { 202 | return []Secret{}, ErrSecretNotFound 203 | } 204 | version := getLatestVersion(obj.Values) 205 | 206 | val, ok := obj.Values[version] 207 | if !ok { 208 | return []Secret{}, ErrSecretNotFound 209 | } 210 | 211 | s := Secret{ 212 | Meta: SecretMetadata{ 213 | Created: val.Created, 214 | CreatedBy: val.CreatedBy, 215 | Version: val.Version, 216 | Key: obj.Key, 217 | }, 218 | } 219 | 220 | if includeValues { 221 | s.Value = &val.Value 222 | } 223 | secrets = append(secrets, s) 224 | 225 | } 226 | 227 | return secrets, nil 228 | } 229 | 230 | func (s *S3Store) ListRaw(ctx context.Context, service string) ([]RawSecret, error) { 231 | index, err := s.readLatest(ctx, service) 232 | if err != nil { 233 | return []RawSecret{}, err 234 | } 235 | 236 | secrets := []RawSecret{} 237 | for key, value := range index.Latest { 238 | s := RawSecret{ 239 | Key: fmt.Sprintf("/%s/%s", service, key), 240 | Value: value, 241 | } 242 | secrets = append(secrets, s) 243 | 244 | } 245 | 246 | return secrets, nil 247 | } 248 | 249 | func (s *S3Store) History(ctx context.Context, id SecretId) ([]ChangeEvent, error) { 250 | obj, ok, err := s.readObjectById(ctx, id) 251 | if err != nil { 252 | return []ChangeEvent{}, err 253 | } 254 | 255 | if !ok { 256 | return []ChangeEvent{}, ErrSecretNotFound 257 | } 258 | 259 | events := []ChangeEvent{} 260 | 261 | for ix, secretVersion := range obj.Values { 262 | events = append(events, ChangeEvent{ 263 | Type: getChangeType(ix), 264 | Time: secretVersion.Created, 265 | User: secretVersion.CreatedBy, 266 | Version: secretVersion.Version, 267 | }) 268 | } 269 | 270 | // Sort events by version 271 | sort.Slice(events, func(i, j int) bool { 272 | return events[i].Version < events[j].Version 273 | }) 274 | return events, nil 275 | } 276 | 277 | func (s *S3Store) Delete(ctx context.Context, id SecretId) error { 278 | index, err := s.readLatest(ctx, id.Service) 279 | if err != nil { 280 | return err 281 | } 282 | 283 | delete(index.Latest, id.Key) 284 | 285 | if err := s.deleteObjectById(ctx, id); err != nil { 286 | return err 287 | } 288 | 289 | return s.writeLatest(ctx, id.Service, index) 290 | } 291 | 292 | func (s *S3Store) DeleteTags(ctx context.Context, id SecretId, tagKeys []string) error { 293 | return errors.New("Not implemented for S3 Store") 294 | } 295 | 296 | // getCurrentUser uses the STS API to get the current caller identity, 297 | // so that secret value changes can be correctly attributed to the right 298 | // aws user/role 299 | func (s *S3Store) getCurrentUser(ctx context.Context) (string, error) { 300 | resp, err := s.stsSvc.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) 301 | if err != nil { 302 | return "", err 303 | } 304 | 305 | return *resp.Arn, nil 306 | } 307 | 308 | func (s *S3Store) deleteObjectById(ctx context.Context, id SecretId) error { 309 | path := getObjectPath(id) 310 | return s.deleteObject(ctx, path) 311 | } 312 | 313 | func (s *S3Store) deleteObject(ctx context.Context, path string) error { 314 | deleteObjectInput := &s3.DeleteObjectInput{ 315 | Bucket: aws.String(s.bucket), 316 | Key: aws.String(path), 317 | } 318 | 319 | _, err := s.svc.DeleteObject(ctx, deleteObjectInput) 320 | return err 321 | } 322 | 323 | func (s *S3Store) readObject(ctx context.Context, path string) (secretObject, bool, error) { 324 | getObjectInput := &s3.GetObjectInput{ 325 | Bucket: aws.String(s.bucket), 326 | Key: aws.String(path), 327 | } 328 | 329 | resp, err := s.svc.GetObject(ctx, getObjectInput) 330 | if err != nil { 331 | // handle specific AWS errors 332 | var nsb *types.NoSuchBucket 333 | if errors.As(err, &nsb) { 334 | return secretObject{}, false, err 335 | } 336 | var nsk *types.NoSuchKey 337 | if errors.As(err, &nsk) { 338 | return secretObject{}, false, nil 339 | } 340 | // generic errors 341 | return secretObject{}, false, err 342 | } 343 | 344 | raw, err := io.ReadAll(resp.Body) 345 | if err != nil { 346 | return secretObject{}, false, err 347 | } 348 | 349 | var obj secretObject 350 | if err := json.Unmarshal(raw, &obj); err != nil { 351 | return secretObject{}, false, err 352 | } 353 | 354 | return obj, true, nil 355 | 356 | } 357 | 358 | func (s *S3Store) readObjectById(ctx context.Context, id SecretId) (secretObject, bool, error) { 359 | path := getObjectPath(id) 360 | return s.readObject(ctx, path) 361 | } 362 | 363 | func (s *S3Store) puts3raw(ctx context.Context, path string, contents []byte) error { 364 | putObjectInput := &s3.PutObjectInput{ 365 | Bucket: aws.String(s.bucket), 366 | ServerSideEncryption: types.ServerSideEncryptionAes256, 367 | Key: aws.String(path), 368 | Body: bytes.NewReader(contents), 369 | } 370 | 371 | _, err := s.svc.PutObject(ctx, putObjectInput) 372 | return err 373 | } 374 | 375 | func (s *S3Store) readLatest(ctx context.Context, service string) (latest, error) { 376 | path := fmt.Sprintf("%s/%s", service, latestObjectName) 377 | 378 | getObjectInput := &s3.GetObjectInput{ 379 | Bucket: aws.String(s.bucket), 380 | Key: aws.String(path), 381 | } 382 | 383 | resp, err := s.svc.GetObject(ctx, getObjectInput) 384 | if err != nil { 385 | var nsk *types.NoSuchKey 386 | if errors.As(err, &nsk) { 387 | // Index doesn't exist yet, return an empty index 388 | return latest{Latest: map[string]string{}}, nil 389 | } 390 | return latest{}, err 391 | } 392 | 393 | raw, err := io.ReadAll(resp.Body) 394 | if err != nil { 395 | return latest{}, err 396 | } 397 | 398 | var index latest 399 | if err := json.Unmarshal(raw, &index); err != nil { 400 | return latest{}, err 401 | } 402 | 403 | return index, nil 404 | } 405 | 406 | func (s *S3Store) writeLatest(ctx context.Context, service string, index latest) error { 407 | path := fmt.Sprintf("%s/%s", service, latestObjectName) 408 | 409 | raw, err := json.Marshal(index) 410 | if err != nil { 411 | return err 412 | } 413 | 414 | return s.puts3raw(ctx, path, raw) 415 | } 416 | 417 | func getObjectPath(id SecretId) string { 418 | return fmt.Sprintf("%s/%s.json", id.Service, id.Key) 419 | } 420 | 421 | func getLatestVersion(m map[int]secretVersion) int { 422 | max := 0 423 | for k := range m { 424 | if k > max { 425 | max = k 426 | } 427 | } 428 | return max 429 | } 430 | 431 | func pruneOldVersions(m map[int]secretVersion) { 432 | newest := getLatestVersion(m) 433 | 434 | for version := range m { 435 | if version < newest-MaximumVersions { 436 | delete(m, version) 437 | } 438 | } 439 | } 440 | -------------------------------------------------------------------------------- /store/s3storeKMS.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "strings" 11 | "time" 12 | 13 | "github.com/aws/aws-sdk-go-v2/aws" 14 | "github.com/aws/aws-sdk-go-v2/service/s3" 15 | "github.com/aws/aws-sdk-go-v2/service/s3/types" 16 | "github.com/aws/aws-sdk-go-v2/service/sts" 17 | "github.com/aws/smithy-go" 18 | ) 19 | 20 | // latest is used to keep a single object in s3 with all of the 21 | // most recent values for the given service's secrets. Keeping this 22 | // in a single s3 object allows us to use a single s3 GetObject 23 | // for ListRaw (and thus chamber exec). 24 | type LatestIndexFile struct { 25 | Latest map[string]LatestValue `json:"latest"` 26 | } 27 | 28 | type LatestValue struct { 29 | Version int `json:"version"` 30 | Value string `json:"value"` 31 | KMSAlias string `json:"KMSAlias"` 32 | } 33 | 34 | var _ Store = &S3KMSStore{} 35 | 36 | type S3KMSStore struct { 37 | S3Store 38 | svc apiS3 39 | stsSvc apiSTS 40 | bucket string 41 | kmsKeyAlias string 42 | } 43 | 44 | func NewS3KMSStore(ctx context.Context, numRetries int, bucket string, kmsKeyAlias string) (*S3KMSStore, error) { 45 | config, _, err := getConfig(ctx, numRetries, aws.RetryModeStandard) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | svc := s3.NewFromConfig(config) 51 | 52 | stsSvc := sts.NewFromConfig(config) 53 | 54 | if kmsKeyAlias == "" { 55 | kmsKeyAlias = DefaultKeyID 56 | } 57 | 58 | s3store := &S3Store{ 59 | svc: svc, 60 | stsSvc: stsSvc, 61 | bucket: bucket, 62 | } 63 | 64 | return &S3KMSStore{ 65 | S3Store: *s3store, 66 | svc: svc, 67 | stsSvc: stsSvc, 68 | bucket: bucket, 69 | kmsKeyAlias: kmsKeyAlias, 70 | }, nil 71 | } 72 | 73 | func (s *S3KMSStore) Write(ctx context.Context, id SecretId, value string) error { 74 | index, err := s.readLatest(ctx, id.Service) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | if val, ok := index.Latest[id.Key]; val.KMSAlias != s.kmsKeyAlias && ok { 80 | return fmt.Errorf("Unable to overwrite secret %s using new KMS key %s; mismatch with existing key %s", id.Key, s.kmsKeyAlias, val.KMSAlias) 81 | } 82 | 83 | objPath := getObjectPath(id) 84 | existing, ok, err := s.readObjectById(ctx, id) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | var obj secretObject 90 | if ok { 91 | obj = existing 92 | } else { 93 | obj = secretObject{ 94 | Service: id.Service, 95 | Key: fmt.Sprintf("/%s/%s", id.Service, id.Key), 96 | Values: map[int]secretVersion{}, 97 | } 98 | } 99 | 100 | thisVersion := getLatestVersion(obj.Values) + 1 101 | user, err := s.getCurrentUser(ctx) 102 | if err != nil { 103 | return err 104 | } 105 | obj.Values[thisVersion] = secretVersion{ 106 | Version: thisVersion, 107 | Value: value, 108 | Created: time.Now().UTC(), 109 | CreatedBy: user, 110 | } 111 | 112 | pruneOldVersions(obj.Values) 113 | 114 | contents, err := json.Marshal(obj) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | putObjectInput := &s3.PutObjectInput{ 120 | Bucket: aws.String(s.bucket), 121 | ServerSideEncryption: types.ServerSideEncryptionAwsKms, 122 | SSEKMSKeyId: aws.String(s.kmsKeyAlias), 123 | Key: aws.String(objPath), 124 | Body: bytes.NewReader(contents), 125 | } 126 | 127 | _, err = s.svc.PutObject(ctx, putObjectInput) 128 | if err != nil { 129 | // TODO: catch specific awserr 130 | return err 131 | } 132 | 133 | index.Latest[id.Key] = LatestValue{ 134 | Version: thisVersion, 135 | Value: value, 136 | KMSAlias: s.kmsKeyAlias, 137 | } 138 | return s.writeLatest(ctx, id.Service, index) 139 | } 140 | 141 | func (s *S3KMSStore) WriteWithTags(ctx context.Context, id SecretId, value string, tags map[string]string) error { 142 | return errors.New("Not implemented for S3 KMS Store") 143 | } 144 | 145 | func (s *S3KMSStore) ListServices(ctx context.Context, service string, includeSecretName bool) ([]string, error) { 146 | return nil, fmt.Errorf("S3KMS Backend is experimental and does not implement this command") 147 | } 148 | 149 | func (s *S3KMSStore) List(ctx context.Context, service string, includeValues bool) ([]Secret, error) { 150 | index, err := s.readLatest(ctx, service) 151 | if err != nil { 152 | return []Secret{}, err 153 | } 154 | 155 | secrets := []Secret{} 156 | for key := range index.Latest { 157 | obj, ok, err := s.readObjectById(ctx, SecretId{Service: service, Key: key}) 158 | if err != nil { 159 | return []Secret{}, err 160 | } 161 | if !ok { 162 | return []Secret{}, ErrSecretNotFound 163 | } 164 | version := getLatestVersion(obj.Values) 165 | 166 | val, ok := obj.Values[version] 167 | if !ok { 168 | return []Secret{}, ErrSecretNotFound 169 | } 170 | 171 | s := Secret{ 172 | Meta: SecretMetadata{ 173 | Created: val.Created, 174 | CreatedBy: val.CreatedBy, 175 | Version: val.Version, 176 | Key: obj.Key, 177 | }, 178 | } 179 | 180 | if includeValues { 181 | s.Value = &val.Value 182 | } 183 | secrets = append(secrets, s) 184 | 185 | } 186 | 187 | return secrets, nil 188 | } 189 | 190 | // ListRaw returns RawSecrets by extracting them from the index file. It only ever uses the 191 | // index file; it never consults the actual secrets, so if the index file is out of sync, these 192 | // results will reflect that. 193 | func (s *S3KMSStore) ListRaw(ctx context.Context, service string) ([]RawSecret, error) { 194 | index, err := s.readLatest(ctx, service) 195 | if err != nil { 196 | return []RawSecret{}, err 197 | } 198 | 199 | // Read raw secrets directly from the index file (which caches the latest values) 200 | secrets := []RawSecret{} 201 | for key := range index.Latest { 202 | s := RawSecret{ 203 | Key: fmt.Sprintf("/%s/%s", service, key), 204 | Value: index.Latest[key].Value, 205 | } 206 | secrets = append(secrets, s) 207 | } 208 | 209 | return secrets, nil 210 | } 211 | 212 | func (s *S3KMSStore) Delete(ctx context.Context, id SecretId) error { 213 | index, err := s.readLatest(ctx, id.Service) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | if val, ok := index.Latest[id.Key]; val.KMSAlias != s.kmsKeyAlias && ok { 219 | return fmt.Errorf("Unable to overwrite secret %s using new KMS key %s; mismatch with existing key %s", id.Key, s.kmsKeyAlias, val.KMSAlias) 220 | } 221 | 222 | delete(index.Latest, id.Key) 223 | 224 | if err := s.deleteObjectById(ctx, id); err != nil { 225 | return err 226 | } 227 | 228 | return s.writeLatest(ctx, id.Service, index) 229 | } 230 | 231 | func (s *S3KMSStore) puts3raw(ctx context.Context, path string, contents []byte) error { 232 | putObjectInput := &s3.PutObjectInput{ 233 | Bucket: aws.String(s.bucket), 234 | ServerSideEncryption: types.ServerSideEncryptionAwsKms, 235 | SSEKMSKeyId: aws.String(s.kmsKeyAlias), 236 | Key: aws.String(path), 237 | Body: bytes.NewReader(contents), 238 | } 239 | 240 | _, err := s.svc.PutObject(ctx, putObjectInput) 241 | return err 242 | } 243 | 244 | func (s *S3KMSStore) readLatestFile(ctx context.Context, path string) (LatestIndexFile, error) { 245 | getObjectInput := &s3.GetObjectInput{ 246 | Bucket: aws.String(s.bucket), 247 | Key: aws.String(path), 248 | } 249 | 250 | resp, err := s.svc.GetObject(ctx, getObjectInput) 251 | 252 | if err != nil { 253 | var nsk *types.NoSuchKey 254 | if errors.As(err, &nsk) { 255 | // Index doesn't exist yet, return an empty index 256 | return LatestIndexFile{Latest: map[string]LatestValue{}}, nil 257 | } 258 | var apiErr smithy.APIError 259 | if errors.As(err, &apiErr) { 260 | if apiErr.ErrorCode() == "AccessDenied" { 261 | // If we're not able to read the latest index for a KMS Key then proceed like it doesn't exist. 262 | // We do this because in a chamber secret folder there might be other secrets written with a KMS Key that you don't have access to. 263 | return LatestIndexFile{Latest: map[string]LatestValue{}}, nil 264 | } 265 | } 266 | return LatestIndexFile{}, err 267 | } 268 | 269 | raw, err := io.ReadAll(resp.Body) 270 | if err != nil { 271 | return LatestIndexFile{}, err 272 | } 273 | 274 | var index LatestIndexFile 275 | if err := json.Unmarshal(raw, &index); err != nil { 276 | return LatestIndexFile{}, err 277 | } 278 | 279 | return index, nil 280 | } 281 | 282 | func (s *S3KMSStore) readLatest(ctx context.Context, service string) (LatestIndexFile, error) { 283 | // Create an empty latest, this will be used to merge together the various KMS Latest Files 284 | latestResult := LatestIndexFile{Latest: map[string]LatestValue{}} 285 | 286 | // List all the files that are prefixed with kms and use them as latest.json files for that KMS Key. 287 | params := &s3.ListObjectsV2Input{ 288 | Bucket: aws.String(s.bucket), 289 | Prefix: aws.String(fmt.Sprintf("%s/__kms", service)), 290 | } 291 | 292 | var paginationError error 293 | paginator := s3.NewListObjectsV2Paginator(s.svc, params) 294 | for paginator.HasMorePages() { 295 | page, err := paginator.NextPage(ctx) 296 | if err != nil { 297 | return latestResult, err 298 | } 299 | 300 | for index := range page.Contents { 301 | key_name := *page.Contents[index].Key 302 | result, err := s.readLatestFile(ctx, key_name) 303 | 304 | if err != nil { 305 | paginationError = fmt.Errorf("Error reading latest index for KMS Key (%s): %s", key_name, err) 306 | break 307 | } 308 | 309 | // Check if the chamber key already exists in the index.Latest map. 310 | // Prefer the most recent version. 311 | for k, v := range result.Latest { 312 | if val, ok := latestResult.Latest[k]; ok { 313 | if val.Version > v.Version { 314 | latestResult.Latest[k] = val 315 | } else { 316 | latestResult.Latest[k] = v 317 | } 318 | } else { 319 | latestResult.Latest[k] = v 320 | } 321 | } 322 | } 323 | } 324 | 325 | if paginationError != nil { 326 | return latestResult, paginationError 327 | } 328 | 329 | return latestResult, nil 330 | } 331 | 332 | func (s *S3KMSStore) latestFileKeyNameByKMSKey() string { 333 | return fmt.Sprintf("__kms_%s__latest.json", strings.Replace(s.kmsKeyAlias, "/", "_", -1)) 334 | } 335 | 336 | func (s *S3KMSStore) writeLatest(ctx context.Context, service string, index LatestIndexFile) error { 337 | path := fmt.Sprintf("%s/%s", service, s.latestFileKeyNameByKMSKey()) 338 | for k, v := range index.Latest { 339 | if v.KMSAlias != s.kmsKeyAlias { 340 | delete(index.Latest, k) 341 | } 342 | } 343 | 344 | raw, err := json.Marshal(index) 345 | if err != nil { 346 | return err 347 | } 348 | 349 | return s.puts3raw(ctx, path, raw) 350 | } 351 | -------------------------------------------------------------------------------- /store/secretsmanagerstore.go: -------------------------------------------------------------------------------- 1 | // Secrets Manager Store is maintained by Dan MacTough https://github.com/danmactough. Thanks Dan! 2 | package store 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "reflect" 11 | "sort" 12 | "strconv" 13 | "time" 14 | 15 | "github.com/aws/aws-sdk-go-v2/aws" 16 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager" 17 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" 18 | "github.com/aws/aws-sdk-go-v2/service/sts" 19 | ) 20 | 21 | const ( 22 | // CustomSecretsManagerEndpointEnvVar is the name of the environment variable specifying a custom 23 | // base Secrets Manager endpoint. 24 | CustomSecretsManagerEndpointEnvVar = "CHAMBER_AWS_SECRETS_MANAGER_ENDPOINT" 25 | ) 26 | 27 | // We store all Chamber metadata in a stringified JSON format, 28 | // in a field named "_chamber_metadata" 29 | const metadataKey = "_chamber_metadata" 30 | 31 | // secretValueObject is the serialized format for storing secrets 32 | // as a SecretsManager SecretValue 33 | type secretValueObject map[string]string 34 | 35 | // We use a custom unmarshaller to provide better interoperability 36 | // with Secrets Manager secrets that are created/managed outside Chamber 37 | // For example, when creating secrets for an RDS instance, 38 | // the "port" is stored as a number, so we need to convert it to a string. 39 | // So we handle converting numbers and also booleans to strings. 40 | func (o *secretValueObject) UnmarshalJSON(b []byte) error { 41 | var v map[string]interface{} 42 | if err := json.Unmarshal(b, &v); err != nil { 43 | return err 44 | } 45 | 46 | res := secretValueObject{} 47 | 48 | for key, value := range v { 49 | var s string 50 | switch value := reflect.ValueOf(value); value.Kind() { 51 | case reflect.String: 52 | s = value.String() 53 | case reflect.Float64: 54 | s = strconv.FormatFloat(value.Float(), 'f', -1, 64) 55 | case reflect.Bool: 56 | s = strconv.FormatBool(value.Bool()) 57 | default: 58 | s = "" 59 | } 60 | res[key] = s 61 | } 62 | *o = res 63 | return nil 64 | } 65 | 66 | // secretValueObjectMetadata holds all the metadata for all the secrets 67 | // keyed by the name of the secret 68 | type secretValueObjectMetadata map[string]secretMetadata 69 | 70 | // secretMetadata holds all the metadata for a specific version 71 | // of a specific secret 72 | type secretMetadata struct { 73 | Created time.Time `json:"created"` 74 | CreatedBy string `json:"created_by"` 75 | Version int `json:"version"` 76 | } 77 | 78 | // ensure SecretsManagerStore confirms to Store interface 79 | var _ Store = &SecretsManagerStore{} 80 | 81 | // SecretsManagerStore implements the Store interface for storing secrets in SSM Parameter 82 | // Store 83 | type SecretsManagerStore struct { 84 | svc apiSecretsManager 85 | stsSvc apiSTS 86 | config aws.Config 87 | } 88 | 89 | // NewSecretsManagerStore creates a new SecretsManagerStore 90 | func NewSecretsManagerStore(ctx context.Context, numRetries int) (*SecretsManagerStore, error) { 91 | cfg, _, err := getConfig(ctx, numRetries, aws.RetryModeStandard) 92 | if err != nil { 93 | return nil, err 94 | } 95 | customSecretsManagerEndpoint, ok := os.LookupEnv(CustomSecretsManagerEndpointEnvVar) 96 | if ok { 97 | cfg.BaseEndpoint = aws.String(customSecretsManagerEndpoint) 98 | } else { 99 | // Preserving incorrect and deprecated use of the SSM environment variable from v2 100 | customSecretsManagerEndpoint, ok = os.LookupEnv(CustomSSMEndpointEnvVar) 101 | if ok { 102 | cfg.BaseEndpoint = aws.String(customSecretsManagerEndpoint) 103 | } 104 | } 105 | 106 | svc := secretsmanager.NewFromConfig(cfg) 107 | 108 | stsSvc := sts.NewFromConfig(cfg) 109 | 110 | return &SecretsManagerStore{ 111 | svc: svc, 112 | stsSvc: stsSvc, 113 | config: cfg, 114 | }, nil 115 | } 116 | 117 | func (s *SecretsManagerStore) Config(ctx context.Context) (StoreConfig, error) { 118 | return StoreConfig{ 119 | Version: LatestStoreConfigVersion, 120 | }, nil 121 | } 122 | 123 | func (s *SecretsManagerStore) SetConfig(ctx context.Context, config StoreConfig) error { 124 | return errors.New("Not implemented for Secrets Manager Store") 125 | } 126 | 127 | // Write writes a given value to a secret identified by id. If the secret 128 | // already exists, then write a new version. 129 | func (s *SecretsManagerStore) Write(ctx context.Context, id SecretId, value string) error { 130 | version := 1 131 | // first read to get the current version 132 | latest, err := s.readLatest(ctx, id.Service) 133 | mustCreate := false 134 | deleteKeyFromSecret := len(value) == 0 135 | 136 | // Failure to readLatest may be a true error or an expected error. 137 | // We expect that when we write a secret that it may not already exist: 138 | // that's the secretsmanager.ErrCodeResourceNotFoundException. 139 | if err != nil { 140 | // However, if the operation is to deleteKeyFromSecret and there's either 141 | // a true error or the secret does not yet exist, that's true error because 142 | // we cannot delete something that does not exist. 143 | if deleteKeyFromSecret { 144 | return err 145 | } 146 | if err != ErrSecretNotFound { 147 | var rnfe *types.ResourceNotFoundException 148 | if errors.As(err, &rnfe) { 149 | mustCreate = true 150 | } else { 151 | return err 152 | } 153 | } 154 | } 155 | 156 | if deleteKeyFromSecret { 157 | if _, ok := latest[id.Key]; ok { 158 | delete(latest, id.Key) 159 | } else { 160 | return ErrSecretNotFound 161 | } 162 | metadata, err := getHydratedMetadata(&latest) 163 | if err != nil { 164 | return err 165 | } 166 | delete(metadata, id.Key) 167 | 168 | rawMetadata, err := dehydrateMetadata(&metadata) 169 | if err != nil { 170 | return err 171 | } 172 | latest[metadataKey] = rawMetadata 173 | } else { 174 | user, err := s.getCurrentUser(ctx) 175 | if err != nil { 176 | return err 177 | } 178 | 179 | metadata, err := getHydratedMetadata(&latest) 180 | if err != nil { 181 | return err 182 | } 183 | 184 | if keyMetadata, ok := metadata[id.Key]; ok { 185 | version = keyMetadata.Version + 1 186 | } 187 | 188 | metadata[id.Key] = secretMetadata{ 189 | Version: version, 190 | Created: time.Now().UTC(), 191 | CreatedBy: user, 192 | } 193 | 194 | rawMetadata, err := dehydrateMetadata(&metadata) 195 | if err != nil { 196 | return err 197 | } 198 | 199 | latest[id.Key] = value 200 | latest[metadataKey] = rawMetadata 201 | } 202 | 203 | contents, err := json.Marshal(latest) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | if mustCreate { 209 | createSecretValueInput := &secretsmanager.CreateSecretInput{ 210 | Name: aws.String(id.Service), 211 | SecretString: aws.String(string(contents)), 212 | } 213 | _, err = s.svc.CreateSecret(ctx, createSecretValueInput) 214 | if err != nil { 215 | return err 216 | } 217 | } else { 218 | // Check that rotation is not enabled. We refuse to write to secrets with 219 | // rotation enabled. 220 | describeSecretInput := &secretsmanager.DescribeSecretInput{ 221 | SecretId: aws.String(id.Service), 222 | } 223 | details, err := s.svc.DescribeSecret(ctx, describeSecretInput) 224 | if err != nil { 225 | return err 226 | } 227 | if details.RotationEnabled != nil && *details.RotationEnabled { 228 | return fmt.Errorf("Cannot write to a secret with rotation enabled") 229 | } 230 | 231 | putSecretValueInput := &secretsmanager.PutSecretValueInput{ 232 | SecretId: aws.String(id.Service), 233 | SecretString: aws.String(string(contents)), 234 | VersionStages: []string{"AWSCURRENT", "CHAMBER" + fmt.Sprint(version)}, 235 | } 236 | _, err = s.svc.PutSecretValue(ctx, putSecretValueInput) 237 | if err != nil { 238 | return err 239 | } 240 | } 241 | 242 | return nil 243 | } 244 | 245 | func (s *SecretsManagerStore) WriteWithTags(ctx context.Context, id SecretId, value string, tags map[string]string) error { 246 | return errors.New("tags on write not implemented for Secrets Manager Store") 247 | } 248 | 249 | // Read reads a secret at a specific version. 250 | // To grab the latest version, use -1 as the version number. 251 | func (s *SecretsManagerStore) Read(ctx context.Context, id SecretId, version int) (Secret, error) { 252 | if version == -1 { 253 | latest, err := s.readLatest(ctx, id.Service) 254 | if err != nil { 255 | return Secret{}, err 256 | } 257 | 258 | value, ok := latest[id.Key] 259 | if !ok { 260 | return Secret{}, ErrSecretNotFound 261 | } 262 | 263 | keyMetadata, err := getHydratedKeyMetadata(&latest, &id.Key) 264 | if err != nil { 265 | return Secret{}, err 266 | } 267 | 268 | return Secret{ 269 | Value: &value, 270 | Meta: SecretMetadata{ 271 | Created: keyMetadata.Created, 272 | CreatedBy: keyMetadata.CreatedBy, 273 | Version: keyMetadata.Version, 274 | Key: id.Key, 275 | }, 276 | }, nil 277 | 278 | } 279 | return s.readVersion(ctx, id, version) 280 | } 281 | 282 | // Delete removes a secret. Note this removes all versions of the secret. (True?) 283 | func (s *SecretsManagerStore) Delete(ctx context.Context, id SecretId) error { 284 | // delegate to Write 285 | return s.Write(ctx, id, "") 286 | } 287 | 288 | func (s *SecretsManagerStore) DeleteTags(ctx context.Context, id SecretId, tagKeys []string) error { 289 | return errors.New("Not implemented for Secrets Manager Store") 290 | } 291 | 292 | func (s *SecretsManagerStore) readVersion(ctx context.Context, id SecretId, version int) (Secret, error) { 293 | listSecretVersionIdsInput := &secretsmanager.ListSecretVersionIdsInput{ 294 | SecretId: aws.String(id.Service), 295 | IncludeDeprecated: aws.Bool(false), 296 | } 297 | 298 | var result Secret 299 | resp, err := s.svc.ListSecretVersionIds(ctx, listSecretVersionIdsInput) 300 | if err != nil { 301 | return Secret{}, err 302 | } 303 | 304 | for _, history := range resp.Versions { 305 | h := history 306 | thisVersion := 0 307 | 308 | getSecretValueInput := &secretsmanager.GetSecretValueInput{ 309 | SecretId: aws.String(id.Service), 310 | VersionId: h.VersionId, 311 | } 312 | 313 | resp, err := s.svc.GetSecretValue(ctx, getSecretValueInput) 314 | 315 | if err != nil { 316 | return Secret{}, err 317 | } 318 | 319 | if len(*resp.SecretString) == 0 { 320 | continue 321 | } 322 | 323 | var historyItem secretValueObject 324 | if historyItem, err = jsonToSecretValueObject(*resp.SecretString); err != nil { 325 | return Secret{}, err 326 | } 327 | 328 | keyMetadata, err := getHydratedKeyMetadata(&historyItem, &id.Key) 329 | if err != nil { 330 | return Secret{}, err 331 | } 332 | 333 | thisVersion = keyMetadata.Version 334 | 335 | if thisVersion == version { 336 | thisValue, ok := historyItem[id.Key] 337 | if !ok { 338 | return Secret{}, ErrSecretNotFound 339 | } 340 | result = Secret{ 341 | Value: &thisValue, 342 | Meta: SecretMetadata{ 343 | Created: keyMetadata.Created, 344 | CreatedBy: keyMetadata.CreatedBy, 345 | Version: thisVersion, 346 | Key: id.Key, 347 | }, 348 | } 349 | break 350 | } 351 | } 352 | 353 | if result.Value != nil { 354 | return result, nil 355 | } 356 | 357 | return Secret{}, ErrSecretNotFound 358 | } 359 | 360 | func (s *SecretsManagerStore) readLatest(ctx context.Context, service string) (secretValueObject, error) { 361 | getSecretValueInput := &secretsmanager.GetSecretValueInput{ 362 | SecretId: aws.String(service), 363 | } 364 | 365 | resp, err := s.svc.GetSecretValue(ctx, getSecretValueInput) 366 | 367 | if err != nil { 368 | return secretValueObject{}, err 369 | } 370 | 371 | if len(*resp.SecretString) == 0 { 372 | return secretValueObject{}, ErrSecretNotFound 373 | } 374 | 375 | var obj secretValueObject 376 | if obj, err = jsonToSecretValueObject(*resp.SecretString); err != nil { 377 | return secretValueObject{}, err 378 | } 379 | 380 | return obj, nil 381 | } 382 | 383 | func (s *SecretsManagerStore) WriteTags(ctx context.Context, id SecretId, tags map[string]string, deleteOtherTags bool) error { 384 | return errors.New("Not implemented for Secrets Manager Store") 385 | } 386 | 387 | func (s *SecretsManagerStore) ReadTags(ctx context.Context, id SecretId) (map[string]string, error) { 388 | return nil, errors.New("Not implemented for Secrets Manager Store") 389 | } 390 | 391 | // ListServices (not implemented) 392 | func (s *SecretsManagerStore) ListServices(ctx context.Context, service string, includeSecretName bool) ([]string, error) { 393 | return nil, fmt.Errorf("Secrets Manager Backend is experimental and does not implement this command") 394 | } 395 | 396 | // List lists all secrets for a given service. If includeValues is true, 397 | // then those secrets are decrypted and returned, otherwise only the metadata 398 | // about a secret is returned. 399 | func (s *SecretsManagerStore) List(ctx context.Context, serviceName string, includeValues bool) ([]Secret, error) { 400 | secrets := map[string]Secret{} 401 | 402 | latest, err := s.readLatest(ctx, serviceName) 403 | if err != nil { 404 | return nil, err 405 | } 406 | 407 | metadata, err := getHydratedMetadata(&latest) 408 | if err != nil { 409 | return nil, err 410 | } 411 | 412 | for key, value := range latest { 413 | if key == metadataKey { 414 | continue 415 | } 416 | 417 | keyMetadata, ok := metadata[key] 418 | if !ok { 419 | keyMetadata = secretMetadata{} 420 | } 421 | 422 | secret := Secret{ 423 | Value: nil, 424 | Meta: SecretMetadata{ 425 | Created: keyMetadata.Created, 426 | CreatedBy: keyMetadata.CreatedBy, 427 | Version: keyMetadata.Version, 428 | Key: key, 429 | }, 430 | } 431 | if includeValues { 432 | v := value 433 | secret.Value = &v 434 | } 435 | secrets[key] = secret 436 | } 437 | 438 | return values(secrets), nil 439 | } 440 | 441 | // ListRaw lists all secrets keys and values for a given service. Does not include any 442 | // other metadata. Suitable for use in production environments. 443 | func (s *SecretsManagerStore) ListRaw(ctx context.Context, serviceName string) ([]RawSecret, error) { 444 | latest, err := s.readLatest(ctx, serviceName) 445 | if err != nil { 446 | return nil, err 447 | } 448 | 449 | rawSecrets := make([]RawSecret, len(latest)) 450 | i := 0 451 | for key, value := range latest { 452 | // v := value 453 | rawSecrets[i] = RawSecret{ 454 | Value: value, 455 | Key: key, 456 | } 457 | i++ 458 | } 459 | return rawSecrets, nil 460 | } 461 | 462 | // History returns a list of events that have occurred regarding the given 463 | // secret. 464 | func (s *SecretsManagerStore) History(ctx context.Context, id SecretId) ([]ChangeEvent, error) { 465 | events := []ChangeEvent{} 466 | 467 | listSecretVersionIdsInput := &secretsmanager.ListSecretVersionIdsInput{ 468 | SecretId: aws.String(id.Service), 469 | IncludeDeprecated: aws.Bool(false), 470 | } 471 | 472 | resp, err := s.svc.ListSecretVersionIds(ctx, listSecretVersionIdsInput) 473 | if err != nil { 474 | return events, err 475 | } 476 | 477 | // m is a temporary map to allow us to (1) deduplicate ChangeEvents, since 478 | // saving the secret only increments the Version of the Key being created or 479 | // modified, and (2) sort the ChangeEvents by Version 480 | m := make(map[int]*ChangeEvent) 481 | 482 | for _, history := range resp.Versions { 483 | h := history 484 | getSecretValueInput := &secretsmanager.GetSecretValueInput{ 485 | SecretId: aws.String(id.Service), 486 | VersionId: h.VersionId, 487 | } 488 | 489 | resp, err := s.svc.GetSecretValue(ctx, getSecretValueInput) 490 | 491 | if err != nil { 492 | return events, err 493 | } 494 | 495 | if len(*resp.SecretString) == 0 { 496 | continue 497 | } 498 | 499 | var historyItem secretValueObject 500 | if historyItem, err = jsonToSecretValueObject(*resp.SecretString); err != nil { 501 | return events, err 502 | } 503 | 504 | metadata, err := getHydratedMetadata(&historyItem) 505 | if err != nil { 506 | return nil, err 507 | } 508 | 509 | keyMetadata, ok := metadata[id.Key] 510 | if !ok { 511 | continue 512 | } 513 | 514 | thisVersion := keyMetadata.Version 515 | 516 | // This is where we deduplicate 517 | if _, ok := m[thisVersion]; !ok { 518 | m[thisVersion] = &ChangeEvent{ 519 | Type: getChangeType(thisVersion), 520 | Time: keyMetadata.Created, 521 | User: keyMetadata.CreatedBy, 522 | Version: thisVersion, 523 | } 524 | } 525 | } 526 | 527 | if len(m) == 0 { 528 | return events, ErrSecretNotFound 529 | } 530 | 531 | keys := make([]int, 0) 532 | for k := range m { 533 | keys = append(keys, k) 534 | } 535 | sort.Ints(keys) 536 | for _, k := range keys { 537 | events = append(events, *m[k]) 538 | } 539 | return events, nil 540 | } 541 | 542 | func (s *SecretsManagerStore) getCurrentUser(ctx context.Context) (string, error) { 543 | resp, err := s.stsSvc.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) 544 | if err != nil { 545 | return "", err 546 | } 547 | 548 | return *resp.Arn, nil 549 | } 550 | 551 | func getHydratedMetadata(raw *secretValueObject) (secretValueObjectMetadata, error) { 552 | r := *raw 553 | rawMetadata, ok := r[metadataKey] 554 | if !ok { 555 | return secretValueObjectMetadata{}, nil 556 | } 557 | return rehydrateMetadata(&rawMetadata) 558 | } 559 | 560 | func getHydratedKeyMetadata(raw *secretValueObject, key *string) (secretMetadata, error) { 561 | metadata, err := getHydratedMetadata(raw) 562 | if err != nil { 563 | return secretMetadata{}, err 564 | } 565 | 566 | keyMetadata, ok := metadata[*key] 567 | if !ok { 568 | return secretMetadata{}, nil 569 | } 570 | return keyMetadata, nil 571 | } 572 | 573 | func rehydrateMetadata(rawMetadata *string) (secretValueObjectMetadata, error) { 574 | var metadata secretValueObjectMetadata 575 | err := json.Unmarshal([]byte(*rawMetadata), &metadata) 576 | if err != nil { 577 | return secretValueObjectMetadata{}, err 578 | } 579 | return metadata, nil 580 | } 581 | 582 | func dehydrateMetadata(metadata *secretValueObjectMetadata) (string, error) { 583 | rawMetadata, err := json.Marshal(metadata) 584 | if err != nil { 585 | return "", err 586 | } 587 | return string(rawMetadata), nil 588 | } 589 | 590 | func jsonToSecretValueObject(s string) (secretValueObject, error) { 591 | var obj secretValueObject 592 | err := json.Unmarshal([]byte(s), &obj) 593 | if err != nil { 594 | return secretValueObject{}, err 595 | } 596 | return obj, nil 597 | } 598 | -------------------------------------------------------------------------------- /store/secretsmanagerstore_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "sort" 10 | "testing" 11 | 12 | "github.com/aws/aws-sdk-go-v2/aws" 13 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager" 14 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" 15 | "github.com/aws/aws-sdk-go-v2/service/sts" 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | type mockSecret struct { 21 | currentSecret *secretValueObject 22 | history map[string]*secretValueObject 23 | } 24 | 25 | func mockPutSecretValue(i *secretsmanager.PutSecretValueInput, secrets map[string]mockSecret) (*secretsmanager.PutSecretValueOutput, error) { 26 | current, ok := secrets[*i.SecretId] 27 | if !ok { 28 | return &secretsmanager.PutSecretValueOutput{}, ErrSecretNotFound 29 | } 30 | 31 | secret, err := jsonToSecretValueObject(*i.SecretString) 32 | if err != nil { 33 | return &secretsmanager.PutSecretValueOutput{}, err 34 | } 35 | 36 | current.currentSecret = &secret 37 | current.history[uniqueID()] = &secret 38 | 39 | secrets[*i.SecretId] = current 40 | 41 | return &secretsmanager.PutSecretValueOutput{}, nil 42 | } 43 | 44 | func mockCreateSecret(i *secretsmanager.CreateSecretInput, secrets map[string]mockSecret) (*secretsmanager.CreateSecretOutput, error) { 45 | secret, err := jsonToSecretValueObject(*i.SecretString) 46 | if err != nil { 47 | return &secretsmanager.CreateSecretOutput{}, err 48 | } 49 | 50 | current := mockSecret{ 51 | currentSecret: &secret, 52 | history: make(map[string]*secretValueObject), 53 | } 54 | current.history[uniqueID()] = &secret 55 | 56 | secrets[*i.Name] = current 57 | 58 | return &secretsmanager.CreateSecretOutput{}, nil 59 | } 60 | 61 | func mockGetSecretValue(i *secretsmanager.GetSecretValueInput, secrets map[string]mockSecret) (*secretsmanager.GetSecretValueOutput, error) { 62 | var version *secretValueObject 63 | 64 | if i.VersionId != nil { 65 | historyItem, ok := secrets[*i.SecretId].history[*i.VersionId] 66 | if !ok { 67 | return &secretsmanager.GetSecretValueOutput{}, 68 | &types.ResourceNotFoundException{ 69 | Message: aws.String("ResourceNotFoundException"), 70 | } 71 | } 72 | version = historyItem 73 | } else { 74 | current, ok := secrets[*i.SecretId] 75 | if !ok { 76 | return &secretsmanager.GetSecretValueOutput{}, 77 | &types.ResourceNotFoundException{ 78 | Message: aws.String("ResourceNotFoundException"), 79 | } 80 | } 81 | version = current.currentSecret 82 | } 83 | 84 | s, err := json.Marshal(version) 85 | if err != nil { 86 | panic(err) 87 | } 88 | 89 | return &secretsmanager.GetSecretValueOutput{ 90 | SecretString: aws.String(string(s)), 91 | }, nil 92 | } 93 | 94 | func mockListSecretVersionIds(i *secretsmanager.ListSecretVersionIdsInput, secrets map[string]mockSecret) (*secretsmanager.ListSecretVersionIdsOutput, error) { 95 | service, ok := secrets[*i.SecretId] 96 | if !ok || len(service.history) == 0 { 97 | return &secretsmanager.ListSecretVersionIdsOutput{}, ErrSecretNotFound 98 | } 99 | 100 | versions := make([]types.SecretVersionsListEntry, 0) 101 | for v := range service.history { 102 | versions = append(versions, types.SecretVersionsListEntry{VersionId: aws.String(v)}) 103 | } 104 | 105 | return &secretsmanager.ListSecretVersionIdsOutput{Versions: versions}, nil 106 | } 107 | 108 | func mockDescribeSecret(i *secretsmanager.DescribeSecretInput, outputs map[string]secretsmanager.DescribeSecretOutput) (*secretsmanager.DescribeSecretOutput, error) { 109 | output, ok := outputs[*i.SecretId] 110 | if !ok { 111 | return &secretsmanager.DescribeSecretOutput{RotationEnabled: aws.Bool(false)}, nil 112 | } 113 | return &output, nil 114 | } 115 | 116 | func mockGetCallerIdentity(_ *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) { 117 | return &sts.GetCallerIdentityOutput{ 118 | Arn: aws.String("currentuser"), 119 | }, nil 120 | } 121 | 122 | func NewTestSecretsManagerStore(secrets map[string]mockSecret, outputs map[string]secretsmanager.DescribeSecretOutput) *SecretsManagerStore { 123 | return &SecretsManagerStore{ 124 | svc: &apiSecretsManagerMock{ 125 | CreateSecretFunc: func(ctx context.Context, params *secretsmanager.CreateSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) { 126 | return mockCreateSecret(params, secrets) 127 | }, 128 | DescribeSecretFunc: func(ctx context.Context, params *secretsmanager.DescribeSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.DescribeSecretOutput, error) { 129 | return mockDescribeSecret(params, outputs) 130 | }, 131 | GetSecretValueFunc: func(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { 132 | return mockGetSecretValue(params, secrets) 133 | }, 134 | ListSecretVersionIdsFunc: func(ctx context.Context, params *secretsmanager.ListSecretVersionIdsInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretVersionIdsOutput, error) { 135 | return mockListSecretVersionIds(params, secrets) 136 | }, 137 | PutSecretValueFunc: func(ctx context.Context, params *secretsmanager.PutSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error) { 138 | return mockPutSecretValue(params, secrets) 139 | }, 140 | }, 141 | stsSvc: &apiSTSMock{ 142 | GetCallerIdentityFunc: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { 143 | return mockGetCallerIdentity(params) 144 | }, 145 | }, 146 | } 147 | } 148 | 149 | func TestSecretValueObjectUnmarshalling(t *testing.T) { 150 | t.Run("Unmarshalling JSON to a SecretValueObject converts non-string values", func(t *testing.T) { 151 | const j = ` 152 | { 153 | "dbInstanceIdentifier": "database-1", 154 | "port": 3306, 155 | "isPhony": true, 156 | "empty": null, 157 | "nested": { 158 | "foo": "bar" 159 | }, 160 | "array": [1,2,3] 161 | } 162 | ` 163 | obj, err := jsonToSecretValueObject(j) 164 | assert.Nil(t, err) 165 | assert.Equal(t, "database-1", obj["dbInstanceIdentifier"]) 166 | assert.Equal(t, "3306", obj["port"]) 167 | assert.Equal(t, "true", obj["isPhony"]) 168 | assert.Equal(t, "", obj["empty"]) 169 | assert.Equal(t, "", obj["nested"]) 170 | assert.Equal(t, "", obj["array"]) 171 | }) 172 | } 173 | 174 | func TestNewSecretsManagerStore(t *testing.T) { 175 | t.Run("Using region override should take precedence over other settings", func(t *testing.T) { 176 | os.Setenv("CHAMBER_AWS_REGION", "us-east-1") 177 | defer os.Unsetenv("CHAMBER_AWS_REGION") 178 | os.Setenv("AWS_REGION", "us-west-1") 179 | defer os.Unsetenv("AWS_REGION") 180 | os.Setenv("AWS_DEFAULT_REGION", "us-west-2") 181 | defer os.Unsetenv("AWS_DEFAULT_REGION") 182 | 183 | s, err := NewSecretsManagerStore(context.Background(), 1) 184 | assert.Nil(t, err) 185 | assert.Equal(t, "us-east-1", s.config.Region) 186 | }) 187 | 188 | t.Run("Should use AWS_REGION if it is set", func(t *testing.T) { 189 | os.Setenv("AWS_REGION", "us-west-1") 190 | defer os.Unsetenv("AWS_REGION") 191 | 192 | s, err := NewSecretsManagerStore(context.Background(), 1) 193 | assert.Nil(t, err) 194 | assert.Equal(t, "us-west-1", s.config.Region) 195 | }) 196 | 197 | t.Run("Should use CHAMBER_AWS_SECRETS_MANAGER_ENDPOINT if set", func(t *testing.T) { 198 | os.Setenv("CHAMBER_AWS_SECRETS_MANAGER_ENDPOINT", "mycustomendpoint") 199 | defer os.Unsetenv("CHAMBER_AWS_SECRETS_MANAGER_ENDPOINT") 200 | 201 | s, err := NewSecretsManagerStore(context.Background(), 1) 202 | assert.Nil(t, err) 203 | secretsmanagerClient := s.svc.(*secretsmanager.Client) 204 | assert.Equal(t, "mycustomendpoint", *secretsmanagerClient.Options().BaseEndpoint) 205 | // default endpoint resolution (v2) uses the client's BaseEndpoint 206 | }) 207 | 208 | t.Run("Should use CHAMBER_AWS_SSM_ENDPOINT if set (deprecated)", func(t *testing.T) { 209 | os.Setenv("CHAMBER_AWS_SSM_ENDPOINT", "mycustomendpoint") 210 | defer os.Unsetenv("CHAMBER_AWS_SSM_ENDPOINT") 211 | 212 | s, err := NewSecretsManagerStore(context.Background(), 1) 213 | assert.Nil(t, err) 214 | secretsmanagerClient := s.svc.(*secretsmanager.Client) 215 | assert.Equal(t, "mycustomendpoint", *secretsmanagerClient.Options().BaseEndpoint) 216 | // default endpoint resolution (v2) uses the client's BaseEndpoint 217 | }) 218 | 219 | t.Run("Should use default AWS secrets manager endpoint if CHAMBER_AWS_SECRETS_MANAGER_ENDPOINT not set", func(t *testing.T) { 220 | s, err := NewSecretsManagerStore(context.Background(), 1) 221 | assert.Nil(t, err) 222 | secretsmanagerClient := s.svc.(*secretsmanager.Client) 223 | assert.Nil(t, secretsmanagerClient.Options().BaseEndpoint) 224 | }) 225 | } 226 | 227 | func TestSecretsManagerWrite(t *testing.T) { 228 | ctx := context.Background() 229 | secrets := make(map[string]mockSecret) 230 | outputs := make(map[string]secretsmanager.DescribeSecretOutput) 231 | store := NewTestSecretsManagerStore(secrets, outputs) 232 | 233 | t.Run("Setting a new key should work", func(t *testing.T) { 234 | key := "mykey" 235 | secretId := SecretId{Service: "test", Key: key} 236 | err := store.Write(ctx, secretId, "value") 237 | assert.Nil(t, err) 238 | assert.Contains(t, secrets, secretId.Service) 239 | assert.Equal(t, "value", (*secrets[secretId.Service].currentSecret)[key]) 240 | keyMetadata, err := getHydratedKeyMetadata(secrets[secretId.Service].currentSecret, &key) 241 | assert.Nil(t, err) 242 | assert.Equal(t, 1, keyMetadata.Version) 243 | assert.Equal(t, 1, len(secrets[secretId.Service].history)) 244 | }) 245 | 246 | t.Run("Setting a key twice should create a new version", func(t *testing.T) { 247 | key := "multipleversions" 248 | secretId := SecretId{Service: "test", Key: key} 249 | err := store.Write(ctx, secretId, "value") 250 | assert.Nil(t, err) 251 | assert.Contains(t, secrets, secretId.Service) 252 | assert.Equal(t, "value", (*secrets[secretId.Service].currentSecret)[key]) 253 | keyMetadata, err := getHydratedKeyMetadata(secrets[secretId.Service].currentSecret, &key) 254 | assert.Nil(t, err) 255 | assert.Equal(t, 1, keyMetadata.Version) 256 | assert.Equal(t, 2, len(secrets[secretId.Service].history)) 257 | 258 | err = store.Write(ctx, secretId, "newvalue") 259 | assert.Nil(t, err) 260 | assert.Contains(t, secrets, secretId.Service) 261 | assert.Equal(t, "newvalue", (*secrets[secretId.Service].currentSecret)[key]) 262 | keyMetadata, err = getHydratedKeyMetadata(secrets[secretId.Service].currentSecret, &key) 263 | assert.Nil(t, err) 264 | assert.Equal(t, 2, keyMetadata.Version) 265 | assert.Equal(t, 3, len(secrets[secretId.Service].history)) 266 | }) 267 | 268 | t.Run("Setting a key on a secret with rotation enabled should fail", func(t *testing.T) { 269 | service := "rotationtest" 270 | secrets[service] = mockSecret{} 271 | outputs[service] = secretsmanager.DescribeSecretOutput{RotationEnabled: aws.Bool(true)} 272 | secretId := SecretId{Service: service, Key: "doesnotmatter"} 273 | err := store.Write(ctx, secretId, "value") 274 | assert.EqualError(t, err, "Cannot write to a secret with rotation enabled") 275 | }) 276 | } 277 | 278 | func TestSecretsManagerRead(t *testing.T) { 279 | ctx := context.Background() 280 | secrets := make(map[string]mockSecret) 281 | outputs := make(map[string]secretsmanager.DescribeSecretOutput) 282 | store := NewTestSecretsManagerStore(secrets, outputs) 283 | secretId := SecretId{Service: "test", Key: "key"} 284 | require.NoError(t, store.Write(ctx, secretId, "value")) 285 | require.NoError(t, store.Write(ctx, secretId, "second value")) 286 | require.NoError(t, store.Write(ctx, secretId, "third value")) 287 | 288 | t.Run("Reading the latest value should work", func(t *testing.T) { 289 | s, err := store.Read(ctx, secretId, -1) 290 | assert.Nil(t, err) 291 | assert.Equal(t, "third value", *s.Value) 292 | }) 293 | 294 | t.Run("Reading specific versiosn should work", func(t *testing.T) { 295 | first, err := store.Read(ctx, secretId, 1) 296 | assert.Nil(t, err) 297 | assert.Equal(t, "value", *first.Value) 298 | 299 | second, err := store.Read(ctx, secretId, 2) 300 | assert.Nil(t, err) 301 | assert.Equal(t, "second value", *second.Value) 302 | 303 | third, err := store.Read(ctx, secretId, 3) 304 | assert.Nil(t, err) 305 | assert.Equal(t, "third value", *third.Value) 306 | }) 307 | 308 | t.Run("Reading a non-existent key should give not found err", func(t *testing.T) { 309 | _, err := store.Read(ctx, SecretId{Service: "test", Key: "nope"}, -1) 310 | assert.Equal(t, ErrSecretNotFound, err) 311 | }) 312 | 313 | t.Run("Reading a non-existent version should give not found error", func(t *testing.T) { 314 | _, err := store.Read(ctx, secretId, 30) 315 | assert.Equal(t, ErrSecretNotFound, err) 316 | }) 317 | } 318 | 319 | func TestSecretsManagerList(t *testing.T) { 320 | ctx := context.Background() 321 | secrets := make(map[string]mockSecret) 322 | outputs := make(map[string]secretsmanager.DescribeSecretOutput) 323 | store := NewTestSecretsManagerStore(secrets, outputs) 324 | 325 | testSecrets := []SecretId{ 326 | {Service: "test", Key: "a"}, 327 | {Service: "test", Key: "b"}, 328 | {Service: "test", Key: "c"}, 329 | } 330 | for _, secret := range testSecrets { 331 | require.NoError(t, store.Write(ctx, secret, "value")) 332 | } 333 | 334 | t.Run("List should return all keys for a service", func(t *testing.T) { 335 | s, err := store.List(ctx, "test", false) 336 | assert.Nil(t, err) 337 | assert.Equal(t, 3, len(s)) 338 | sort.Sort(ByKey(s)) 339 | assert.Equal(t, "a", s[0].Meta.Key) 340 | assert.Equal(t, "b", s[1].Meta.Key) 341 | assert.Equal(t, "c", s[2].Meta.Key) 342 | }) 343 | 344 | t.Run("List should not return values if includeValues is false", func(t *testing.T) { 345 | s, err := store.List(ctx, "test", false) 346 | assert.Nil(t, err) 347 | for _, secret := range s { 348 | assert.Nil(t, secret.Value) 349 | } 350 | }) 351 | 352 | t.Run("List should return values if includeValues is true", func(t *testing.T) { 353 | s, err := store.List(ctx, "test", true) 354 | assert.Nil(t, err) 355 | for _, secret := range s { 356 | assert.Equal(t, "value", *secret.Value) 357 | } 358 | }) 359 | 360 | t.Run("List should only return exact matches on service name", func(t *testing.T) { 361 | require.NoError(t, store.Write(ctx, SecretId{Service: "match", Key: "a"}, "val")) 362 | require.NoError(t, store.Write(ctx, SecretId{Service: "matchlonger", Key: "a"}, "val")) 363 | 364 | s, err := store.List(ctx, "match", false) 365 | assert.Nil(t, err) 366 | assert.Equal(t, 1, len(s)) 367 | assert.Equal(t, "a", s[0].Meta.Key) 368 | }) 369 | } 370 | 371 | func TestSecretsManagerListRaw(t *testing.T) { 372 | ctx := context.Background() 373 | secrets := make(map[string]mockSecret) 374 | outputs := make(map[string]secretsmanager.DescribeSecretOutput) 375 | store := NewTestSecretsManagerStore(secrets, outputs) 376 | 377 | testSecrets := []SecretId{ 378 | {Service: "test", Key: "a"}, 379 | {Service: "test", Key: "b"}, 380 | {Service: "test", Key: "c"}, 381 | } 382 | for _, secret := range testSecrets { 383 | require.NoError(t, store.Write(ctx, secret, "value")) 384 | } 385 | 386 | t.Run("ListRaw should return all keys and values for a service", func(t *testing.T) { 387 | s, err := store.ListRaw(ctx, "test") 388 | assert.Nil(t, err) 389 | sort.Sort(ByKeyRaw(s)) 390 | s = s[1:] 391 | assert.Equal(t, 3, len(s)) 392 | assert.Equal(t, "a", s[0].Key) 393 | assert.Equal(t, "b", s[1].Key) 394 | assert.Equal(t, "c", s[2].Key) 395 | 396 | assert.Equal(t, "value", s[0].Value) 397 | assert.Equal(t, "value", s[1].Value) 398 | assert.Equal(t, "value", s[2].Value) 399 | }) 400 | 401 | t.Run("List should only return exact matches on service name", func(t *testing.T) { 402 | require.NoError(t, store.Write(ctx, SecretId{Service: "match", Key: "a"}, "val")) 403 | require.NoError(t, store.Write(ctx, SecretId{Service: "matchlonger", Key: "a"}, "val")) 404 | 405 | s, err := store.ListRaw(ctx, "match") 406 | sort.Sort(ByKeyRaw(s)) 407 | s = s[1:] 408 | assert.Nil(t, err) 409 | assert.Equal(t, 1, len(s)) 410 | assert.Equal(t, "a", s[0].Key) 411 | }) 412 | } 413 | 414 | func TestSecretsManagerHistory(t *testing.T) { 415 | ctx := context.Background() 416 | secrets := make(map[string]mockSecret) 417 | outputs := make(map[string]secretsmanager.DescribeSecretOutput) 418 | store := NewTestSecretsManagerStore(secrets, outputs) 419 | 420 | testSecrets := []SecretId{ 421 | {Service: "test", Key: "new"}, 422 | {Service: "test", Key: "update"}, 423 | {Service: "test", Key: "update"}, 424 | {Service: "test", Key: "update"}, 425 | } 426 | 427 | for _, s := range testSecrets { 428 | require.NoError(t, store.Write(ctx, s, "value")) 429 | } 430 | 431 | t.Run("History for a non-existent key should return not found error", func(t *testing.T) { 432 | _, err := store.History(ctx, SecretId{Service: "test", Key: "nope"}) 433 | assert.Equal(t, ErrSecretNotFound, err) 434 | }) 435 | 436 | t.Run("History should return a single created event for new keys", func(t *testing.T) { 437 | events, err := store.History(ctx, SecretId{Service: "test", Key: "new"}) 438 | assert.Nil(t, err) 439 | assert.Equal(t, 1, len(events)) 440 | assert.Equal(t, Created, events[0].Type) 441 | }) 442 | 443 | t.Run("History should return create followed by updates for keys that have been updated", func(t *testing.T) { 444 | events, err := store.History(ctx, SecretId{Service: "test", Key: "update"}) 445 | assert.Nil(t, err) 446 | assert.Equal(t, 3, len(events)) 447 | assert.Equal(t, Created, events[0].Type) 448 | assert.Equal(t, Updated, events[1].Type) 449 | assert.Equal(t, Updated, events[2].Type) 450 | }) 451 | } 452 | 453 | func TestSecretsManagerDelete(t *testing.T) { 454 | ctx := context.Background() 455 | secrets := make(map[string]mockSecret) 456 | outputs := make(map[string]secretsmanager.DescribeSecretOutput) 457 | store := NewTestSecretsManagerStore(secrets, outputs) 458 | 459 | secretId := SecretId{Service: "test", Key: "key"} 460 | require.NoError(t, store.Write(ctx, secretId, "value")) 461 | 462 | t.Run("Deleting secret should work", func(t *testing.T) { 463 | err := store.Delete(ctx, secretId) 464 | assert.Nil(t, err) 465 | err = store.Delete(ctx, secretId) 466 | assert.Equal(t, ErrSecretNotFound, err) 467 | }) 468 | 469 | t.Run("Deleting missing secret should fail", func(t *testing.T) { 470 | err := store.Delete(ctx, SecretId{Service: "test", Key: "nonkey"}) 471 | assert.Equal(t, ErrSecretNotFound, err) 472 | }) 473 | } 474 | 475 | func uniqueID() string { 476 | uuid := make([]byte, 16) 477 | _, _ = rand.Read(uuid) 478 | return fmt.Sprintf("%x", uuid) 479 | } 480 | 481 | func TestSecretsManagerStoreConfig(t *testing.T) { 482 | store := &SecretsManagerStore{} 483 | 484 | config, err := store.Config(context.Background()) 485 | 486 | assert.NoError(t, err) 487 | assert.Equal(t, LatestStoreConfigVersion, config.Version) 488 | assert.Empty(t, config.RequiredTags) 489 | } 490 | -------------------------------------------------------------------------------- /store/shared.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/config" 9 | "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" 10 | ) 11 | 12 | const ( 13 | RegionEnvVar = "CHAMBER_AWS_REGION" 14 | ) 15 | 16 | func getConfig(ctx context.Context, numRetries int, retryMode aws.RetryMode) (aws.Config, string, error) { 17 | var region string 18 | if regionOverride, ok := os.LookupEnv(RegionEnvVar); ok { 19 | region = regionOverride 20 | } 21 | 22 | cfg, err := config.LoadDefaultConfig(ctx, 23 | config.WithRegion(region), 24 | config.WithRetryMaxAttempts(numRetries), 25 | config.WithRetryMode(retryMode), 26 | ) 27 | if err != nil { 28 | return aws.Config{}, "", err 29 | } 30 | 31 | // If region is still not set, attempt to determine it via ec2 metadata API 32 | if cfg.Region == "" { 33 | imdsConfig, err := config.LoadDefaultConfig(ctx) 34 | if err == nil { 35 | ec2metadataSvc := imds.NewFromConfig(imdsConfig) 36 | if regionOverride, err := ec2metadataSvc.GetRegion(ctx, &imds.GetRegionInput{}); err == nil { 37 | region = regionOverride.Region 38 | cfg.Region = region 39 | } 40 | } 41 | } 42 | 43 | return cfg, region, err 44 | } 45 | 46 | func uniqueStringSlice(slice []string) []string { 47 | unique := make(map[string]struct{}, len(slice)) 48 | j := 0 49 | for _, value := range slice { 50 | if _, ok := unique[value]; ok { 51 | continue 52 | } 53 | unique[value] = struct{}{} 54 | slice[j] = value 55 | j++ 56 | } 57 | return slice[:j] 58 | } 59 | -------------------------------------------------------------------------------- /store/shared_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGetConfig(t *testing.T) { 13 | originalRegion := os.Getenv(RegionEnvVar) 14 | os.Setenv(RegionEnvVar, "us-west-2") 15 | if originalRegion != "" { 16 | defer os.Setenv(RegionEnvVar, originalRegion) 17 | } else { 18 | defer os.Unsetenv(RegionEnvVar) 19 | } 20 | 21 | config, region, err := getConfig(context.Background(), 3, aws.RetryModeStandard) 22 | 23 | assert.NoError(t, err) 24 | assert.Equal(t, "us-west-2", region) 25 | 26 | assert.Equal(t, 3, config.RetryMaxAttempts) 27 | assert.Equal(t, aws.RetryModeStandard, config.RetryMode) 28 | } 29 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | const ( 10 | // ChamberService is the name of the service reserved for chamber's own use. 11 | ChamberService = "_chamber" 12 | ) 13 | 14 | func ReservedService(service string) bool { 15 | return service == ChamberService 16 | } 17 | 18 | const ( 19 | LatestStoreConfigVersion = "1" 20 | ) 21 | 22 | // StoreConfig holds configuration information for a store. WARNING: Despite 23 | // its public visibility, the contents of this struct are subject to change at 24 | // any time, and are not part of the public interface for chamber. 25 | type StoreConfig struct { 26 | Version string `json:"version"` 27 | RequiredTags []string `json:"requiredTags,omitempty"` 28 | } 29 | 30 | type ChangeEventType int 31 | 32 | const ( 33 | Created ChangeEventType = iota 34 | Updated 35 | ) 36 | 37 | func (c ChangeEventType) String() string { 38 | switch c { 39 | case Created: 40 | return "Created" 41 | case Updated: 42 | return "Updated" 43 | } 44 | return "unknown" 45 | } 46 | 47 | var ( 48 | // ErrSecretNotFound is returned if the specified secret is not found in the 49 | // parameter store 50 | ErrSecretNotFound = errors.New("secret not found") 51 | ) 52 | 53 | // SecretId is the compound key for a secret. 54 | type SecretId struct { 55 | Service string 56 | Key string 57 | } 58 | 59 | // Secret is a secret with metadata. 60 | type Secret struct { 61 | Value *string 62 | Meta SecretMetadata 63 | } 64 | 65 | // RawSecret is a secret without any metadata. 66 | type RawSecret struct { 67 | Value string 68 | Key string 69 | } 70 | 71 | // SecretMetadata is metadata about a secret. 72 | type SecretMetadata struct { 73 | Created time.Time 74 | CreatedBy string 75 | Version int 76 | Key string 77 | } 78 | 79 | type ChangeEvent struct { 80 | Type ChangeEventType 81 | Time time.Time 82 | User string 83 | Version int 84 | } 85 | 86 | // Store is an interface for a secret store. 87 | type Store interface { 88 | Config(ctx context.Context) (StoreConfig, error) 89 | SetConfig(ctx context.Context, config StoreConfig) error 90 | Write(ctx context.Context, id SecretId, value string) error 91 | WriteWithTags(ctx context.Context, id SecretId, value string, tags map[string]string) error 92 | Read(ctx context.Context, id SecretId, version int) (Secret, error) 93 | WriteTags(ctx context.Context, id SecretId, tags map[string]string, deleteOtherTags bool) error 94 | ReadTags(ctx context.Context, id SecretId) (map[string]string, error) 95 | List(ctx context.Context, service string, includeValues bool) ([]Secret, error) 96 | ListRaw(ctx context.Context, service string) ([]RawSecret, error) 97 | ListServices(ctx context.Context, service string, includeSecretName bool) ([]string, error) 98 | History(ctx context.Context, id SecretId) ([]ChangeEvent, error) 99 | Delete(ctx context.Context, id SecretId) error 100 | DeleteTags(ctx context.Context, id SecretId, tagKeys []string) error 101 | } 102 | 103 | func requiredTags(ctx context.Context, s Store) ([]string, error) { 104 | config, err := s.Config(ctx) 105 | if err != nil { 106 | return nil, err 107 | } 108 | return config.RequiredTags, nil 109 | } 110 | -------------------------------------------------------------------------------- /store/store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestReservedService(t *testing.T) { 10 | assert.True(t, ReservedService(ChamberService)) 11 | assert.False(t, ReservedService("not-reserved")) 12 | } 13 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // NormalizeService normalizes a provided service to a common format 8 | func NormalizeService(service string) string { 9 | return strings.ToLower(service) 10 | } 11 | 12 | // NormalizeKey normalizes a provided secret key to a common format 13 | func NormalizeKey(key string) string { 14 | return strings.ToLower(key) 15 | } 16 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNormalizeService(t *testing.T) { 10 | testCases := []struct { 11 | service string 12 | expected string 13 | }{ 14 | {"service", "service"}, 15 | {"service-with-hyphens", "service-with-hyphens"}, 16 | {"service_with_underscores", "service_with_underscores"}, 17 | {"UPPERCASE_SERVICE", "uppercase_service"}, 18 | {"mIXedcase-SERvice", "mixedcase-service"}, 19 | {".complex/service-CASE", ".complex/service-case"}, 20 | } 21 | 22 | for _, testCase := range testCases { 23 | t.Run(testCase.service, func(t *testing.T) { 24 | assert.Equal(t, testCase.expected, NormalizeService(testCase.service)) 25 | }) 26 | } 27 | } 28 | 29 | func TestNormalizeKey(t *testing.T) { 30 | testCases := []struct { 31 | key string 32 | expected string 33 | }{ 34 | {"key", "key"}, 35 | {"key-with-hyphens", "key-with-hyphens"}, 36 | {"key_with_underscores", "key_with_underscores"}, 37 | {"UPPERCASE_KEY", "uppercase_key"}, 38 | {"mIXedcase-Key", "mixedcase-key"}, 39 | } 40 | 41 | for _, testCase := range testCases { 42 | t.Run(testCase.key, func(t *testing.T) { 43 | assert.Equal(t, testCase.expected, NormalizeKey(testCase.key)) 44 | }) 45 | } 46 | } 47 | --------------------------------------------------------------------------------