Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d039f1a4 | ||
|
|
4e562f834f | ||
|
|
641d5659ec |
+2
-1
@@ -9,7 +9,8 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
# Not make lint: script/lint is a docker build, which cannot run in here.
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Build stage — tests and compilation
|
||||
# golang 1.24.13-alpine (2026-03-10)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Lint image, built by script/lint: golangci-lint runs as a build step, so a
|
||||
# successful build is a clean lint. Works where the docker daemon is remote
|
||||
# and bind mounts are impossible.
|
||||
|
||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# script/lint rebuilds this stage on every run, by this name; the module
|
||||
# download above stays cached.
|
||||
FROM deps AS lint
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
@@ -499,15 +499,18 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow, and the Makefile targets are thin shims that call
|
||||
them. We provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
|
||||
module download), idempotently
|
||||
- `script/bootstrap` — install all dependencies (Go, Go module
|
||||
download), idempotently; golangci-lint is not installed, it runs in
|
||||
docker
|
||||
- `script/setup` — make a fresh clone ready for development: runs
|
||||
`script/bootstrap`, then `script/install-precommit`
|
||||
- `script/projectname` — output the project name (`secret`); used by
|
||||
other scripts such as `script/docker`
|
||||
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
||||
failure)
|
||||
- `script/lint` — run `golangci-lint`
|
||||
- `script/lint` — run `golangci-lint` in docker only: builds
|
||||
`Dockerfile.lint`, where the linter is a build step that runs on every
|
||||
call, also on an unchanged tree
|
||||
- `script/fmt` — format all Go code (writes)
|
||||
- `script/fmt-check` — check formatting without writing
|
||||
- `script/check` — run `script/test`, `script/lint`, and
|
||||
|
||||
@@ -32,6 +32,19 @@ Bring the repo into policy compliance in one commit:
|
||||
vault and between vaults; before, `--force` removed the destination and
|
||||
so deleted the secret. A rename that changes only letter case works on a
|
||||
case-sensitive filesystem as before.
|
||||
- 2026-10-04: Lint runs only in docker: `script/lint` builds
|
||||
`Dockerfile.lint`, where golangci-lint is a build step rebuilt on
|
||||
every run (`--no-cache-filter`), so an unchanged tree is linted too;
|
||||
the module download stays cached. `script/bootstrap` no longer
|
||||
installs golangci-lint, and the `Dockerfile` lint stage calls it
|
||||
directly instead of `make lint`. `golangci-lint config verify` is not
|
||||
run: it fetches its schema live over unpinned HTTPS.
|
||||
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
|
||||
no longer panics: `GetID()` warns with the unlocker's directory and
|
||||
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
|
||||
unlocker whose metadata file cannot be checked for, read or parsed
|
||||
instead of failing, so `secret unlocker list` still lists the others;
|
||||
the listing's ID lookup no longer warns about that directory again.
|
||||
- 2026-10-03: `secret mv` rejects a move whose destination is the
|
||||
source (`mv --force x x`, `mv --force work:x work:`, or an empty
|
||||
destination, which defaults to the source name) before changing
|
||||
@@ -167,8 +180,6 @@ Bring the repo into policy compliance in one commit:
|
||||
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
||||
209-216); non-constant-time public key compare (vault.go:95-100).
|
||||
- High priority:
|
||||
- Return errors instead of panicking on corrupted metadata
|
||||
(pgpunlocker.go:116, keychainunlocker.go:141).
|
||||
- Secure temporary file handling and cleanup.
|
||||
- Print cobra usage only for argument errors, not internal
|
||||
failures.
|
||||
|
||||
@@ -349,6 +349,10 @@ func unlockerIDFromDir(
|
||||
// itself cannot be read. Callers must distinguish the two: an unreadable
|
||||
// directory means the unlocker's real ID is unknowable, so the entry has
|
||||
// to be skipped rather than reported under a synthesized ID.
|
||||
//
|
||||
// A metadata file that cannot be read or parsed is skipped without a
|
||||
// warning: every caller gets metadata from vault.ListUnlockers first,
|
||||
// which has already warned about that directory.
|
||||
func findUnlockerIDByMetadata(
|
||||
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
||||
includeSecureEnclave bool,
|
||||
@@ -371,9 +375,6 @@ func findUnlockerIDByMetadata(
|
||||
// Check if this is the right unlocker by comparing metadata
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlocker metadata file",
|
||||
"path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -381,9 +382,6 @@ func findUnlockerIDByMetadata(
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
||||
if err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata file",
|
||||
"path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
// Unlocker List Tests
|
||||
//
|
||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory
|
||||
// cannot be read while the listing is being rendered:
|
||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
||||
// or an unlocker's metadata in it, cannot be read while the listing is
|
||||
// being rendered:
|
||||
//
|
||||
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
||||
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
||||
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
||||
// still listed, with its real ID and its current-unlocker marker,
|
||||
// when a later entry's scan fails.
|
||||
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||
// metadata does not stop the others from being listed.
|
||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||
// file cannot be checked for or read is left out, and the other is
|
||||
// still listed.
|
||||
//
|
||||
// The listing resolves each unlocker's real ID by rescanning unlockers.d
|
||||
// after the vault has already enumerated it. If that rescan fails the ID
|
||||
@@ -22,6 +28,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -92,6 +99,49 @@ func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// errMetadataUnreadable is returned by the test filesystem in place of a
|
||||
// successful open of one unlocker's metadata file.
|
||||
var errMetadataUnreadable = errors.New("input/output error")
|
||||
|
||||
// metadataReadFailFs fails every open of the file at unreadablePath. The
|
||||
// file still exists, so checking for it succeeds and only reading it fails.
|
||||
type metadataReadFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
unreadablePath string
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
|
||||
if name == f.unreadablePath {
|
||||
return nil, errMetadataUnreadable
|
||||
}
|
||||
|
||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// errMetadataUncheckable is returned by the test filesystem in place of a
|
||||
// successful check for one unlocker's metadata file.
|
||||
var errMetadataUncheckable = errors.New("permission denied")
|
||||
|
||||
// metadataStatFailFs fails every check for whether the file at
|
||||
// uncheckablePath exists, as when its unlocker directory cannot be entered.
|
||||
type metadataStatFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
uncheckablePath string
|
||||
}
|
||||
|
||||
func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
|
||||
if name == f.uncheckablePath {
|
||||
return nil, errMetadataUncheckable
|
||||
}
|
||||
|
||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||
return f.Fs.Stat(name)
|
||||
}
|
||||
|
||||
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
||||
// yields the real ID "pgp-<keyID>".
|
||||
func writePGPUnlocker(
|
||||
@@ -227,3 +277,102 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||
assert.True(t, unlockers[0].IsCurrent)
|
||||
assert.False(t, unlockers[1].IsCurrent)
|
||||
}
|
||||
|
||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
||||
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
|
||||
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
||||
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
healthyID := "pgp-" + listTestGPGKeyID + "A"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
metadata string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "not JSON",
|
||||
metadata: "not json",
|
||||
wantIDs: []string{healthyID},
|
||||
},
|
||||
{
|
||||
name: "GPG key ID of the wrong type",
|
||||
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
||||
},
|
||||
{
|
||||
name: "GPG key ID missing",
|
||||
metadata: `{"type": "pgp"}`,
|
||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newListTestVault(t, 2)
|
||||
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
|
||||
listTestVaultName, listTestUnlockersDirName,
|
||||
listTestUnlockerDirTwo, listTestMetadataFileName)
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
|
||||
))
|
||||
|
||||
unlockers := listUnlockersJSON(t, fs)
|
||||
require.Len(t, unlockers, len(tt.wantIDs))
|
||||
|
||||
for i, wantID := range tt.wantIDs {
|
||||
assert.Equal(t, wantID, unlockers[i].ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose
|
||||
// metadata file cannot be checked for or cannot be read is left out of the
|
||||
// listing, and the other unlocker is still listed with its real ID. The
|
||||
// failing one sorts first, so finding the other's ID has to step past it
|
||||
// as well.
|
||||
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failingPath := filepath.Join(listTestStateDir, "vaults.d",
|
||||
listTestVaultName, listTestUnlockersDirName,
|
||||
listTestUnlockerDirOne, listTestMetadataFileName)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wrap func(base afero.Fs) afero.Fs
|
||||
}{
|
||||
{
|
||||
name: "checking for the file fails",
|
||||
wrap: func(base afero.Fs) afero.Fs {
|
||||
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reading the file fails",
|
||||
wrap: func(base afero.Fs) afero.Fs {
|
||||
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := tt.wrap(newListTestVault(t, 2))
|
||||
|
||||
unlockers := listUnlockersJSON(t, fs)
|
||||
|
||||
require.Len(t, unlockers, 1,
|
||||
"only the unlocker with usable metadata may be listed")
|
||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
|
||||
"the listed row must carry the real unlocker ID")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,14 +155,18 @@ func (p *PGPUnlocker) GetDirectory() string {
|
||||
return p.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface - generates ID from GPG key ID
|
||||
// GetID implements Unlocker interface - generates ID from GPG key ID.
|
||||
// If the metadata has no usable GPG key ID, it warns with the unlocker's
|
||||
// directory and returns "pgp-unknown", so listing the other unlockers
|
||||
// still works.
|
||||
func (p *PGPUnlocker) GetID() string {
|
||||
// Generate ID using GPG key ID: pgp-<keyid>
|
||||
gpgKeyID, err := p.GetGPGKeyID()
|
||||
if err != nil {
|
||||
// The vault metadata is corrupt - this is a fatal error
|
||||
// We cannot continue with a fallback ID as that would mask data corruption
|
||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
|
||||
"directory", p.Directory, "error", err)
|
||||
|
||||
return "pgp-unknown"
|
||||
}
|
||||
|
||||
return "pgp-" + gpgKeyID
|
||||
@@ -197,6 +201,10 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||
}
|
||||
|
||||
if pgpMetadata.GPGKeyID == "" {
|
||||
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
||||
}
|
||||
|
||||
return pgpMetadata.GPGKeyID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -233,9 +233,10 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
||||
|
||||
exists, err := afero.Exists(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to check if metadata exists for unlocker %s: %w",
|
||||
file.Name(), err)
|
||||
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
|
||||
"directory", file.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !exists {
|
||||
@@ -247,16 +248,20 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
||||
|
||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||
secret.Warn("Skipping unlocker directory with unreadable metadata file",
|
||||
"directory", file.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata UnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||
secret.Warn("Skipping unlocker directory with corrupt metadata file",
|
||||
"directory", file.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
unlockers = append(unlockers, metadata)
|
||||
|
||||
+1
-6
@@ -6,6 +6,7 @@
|
||||
# make, node, yarn, go, or python). Node is used directly if installed;
|
||||
# otherwise a pinned version is installed via nvm (installing nvm
|
||||
# itself first, from a hash-verified release archive, never curl | sh).
|
||||
# golangci-lint is never installed: script/lint runs it in docker.
|
||||
#
|
||||
# Uncomment the language sections in main() that apply to this repo.
|
||||
set -eu
|
||||
@@ -136,12 +137,6 @@ main() {
|
||||
|
||||
# ---- Go repos ----
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
# golangci-lint: packaged in nix, brew, and apk. On apt there is no
|
||||
# package: download a specific release archive from GitHub and
|
||||
# verify its hash (verify_sha256), never curl | sh.
|
||||
if missing golangci-lint; then
|
||||
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
|
||||
fi
|
||||
go mod download
|
||||
|
||||
# ---- Python repos ----
|
||||
|
||||
+14
-4
@@ -1,14 +1,24 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter.
|
||||
# script/lint: run the linter, in docker only. Builds Dockerfile.lint,
|
||||
# where golangci-lint runs as a build step.
|
||||
#
|
||||
# A cached build lints nothing, so --no-cache-filter rebuilds the lint
|
||||
# stage on every run, an unchanged tree included. It ignores a stage name
|
||||
# that does not exist, so --target names the same stage: a rename then
|
||||
# fails the build instead of serving the lint from cache. cacheonly keeps
|
||||
# no image; only the build's success matters.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# CGO is required (Makefile exports this too)
|
||||
export CGO_ENABLED=1
|
||||
golangci-lint run --timeout 5m
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--target lint \
|
||||
--no-cache-filter=lint \
|
||||
--output=type=cacheonly \
|
||||
-f Dockerfile.lint .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user