From f5ab75adc35e04a98f0ddd08079c94fd4a2ff572 Mon Sep 17 00:00:00 2001 From: 1337lean <177236079+1337lean@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:33:47 -0400 Subject: [PATCH 1/3] remediate Envguard for verified release --- .github/workflows/ci.yml | 105 ++++++- .github/workflows/codeql.yml | 28 ++ .github/workflows/release.yml | 126 ++++++-- .goreleaser.yml | 5 +- README.md | 4 +- cmd/envguard/main.go | 11 +- cmd/envguard/main_test.go | 26 ++ cmd/releasecheck/main.go | 26 ++ docs/remediation-plan.md | 114 +++++++ docs/schema.md | 20 +- docs/threat-model.md | 2 + internal/app/app.go | 44 ++- internal/app/app_test.go | 144 +++++++++ internal/check/check.go | 3 +- internal/check/check_test.go | 30 ++ internal/distcheck/distcheck.go | 442 +++++++++++++++++++++++++++ internal/distcheck/distcheck_test.go | 305 ++++++++++++++++++ internal/schema/schema.go | 121 +++++++- internal/schema/schema_test.go | 88 ++++++ 19 files changed, 1584 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 cmd/envguard/main_test.go create mode 100644 cmd/releasecheck/main.go create mode 100644 docs/remediation-plan.md create mode 100644 internal/distcheck/distcheck.go create mode 100644 internal/distcheck/distcheck_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a9b494..85e623c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,112 @@ name: CI + on: push: + branches: [main] pull_request: + permissions: contents: read + jobs: test: + name: test runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.25.x' + go-version: stable cache: true - - run: gofmt -w . && git diff --exit-code - - run: go vet ./... - - run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... - run: go test ./... + + native-tests: + name: native-${{ matrix.os }}-go-${{ matrix.go }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + go: ['1.25.x', stable] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ matrix.go }} + cache: true + - run: go test ./... + + race: + name: race + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: true - run: go test -race ./... - - run: go build ./cmd/envguard + + vet: + name: vet + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: true + - run: test -z "$(gofmt -l .)" + - run: go vet ./... + + staticcheck: + name: staticcheck + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: true + - run: go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... + + vulnerability: + name: vulnerability + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: true + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + + release-validation: + name: release-validation + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: true + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + distribution: goreleaser + version: v2.17.1 + args: release --snapshot --clean --skip=publish + - run: go run ./cmd/releasecheck -dist dist + - name: Prove corruption blocks validation + shell: bash + run: | + test_dir="$(mktemp -d)" + cp dist/*.tar.gz dist/*.zip dist/checksums.txt "$test_dir/" + corrupt="$(find "$test_dir" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) | sort | sed -n '2p')" + test -n "$corrupt" + printf 'corrupt' >> "$corrupt" + if go run ./cmd/releasecheck -dist "$test_dir" -smoke=false; then + echo "release validation accepted a corrupted non-first archive" >&2 + exit 1 + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..02b5571 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,28 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '17 3 * * 4' + +permissions: + contents: read + +jobs: + analyze: + name: analyze (go) + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + languages: go + - uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bf2f0c..5a7720a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,51 +2,115 @@ name: Release on: push: - tags: ["v*"] - -permissions: - contents: write - id-token: write - attestations: write + tags: ['v*'] jobs: - release: + build: + name: build-package runs-on: ubuntu-24.04 + permissions: + contents: read steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.25.x' + go-version: stable cache: true - name: Test release source - run: go test ./... - - name: Build and publish release - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + run: | + test -z "$(gofmt -l .)" + go test ./... + go test -race ./... + go vet ./... + go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... + go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + - name: Build without publishing + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser - version: v2.17.0 - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Verify packaged archive + version: v2.17.1 + args: release --clean --skip=publish + - name: Preserve candidate artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-candidate + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + if-no-files-found: error + retention-days: 7 + + verify: + name: verify-every-artifact + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: true + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: dist + - name: Validate contents, checksums, matrix, and native smoke test + run: go run ./cmd/releasecheck -dist dist + - name: Prove a corrupted non-first archive cannot pass + shell: bash run: | - verify_dir="$(mktemp -d)" - archive="$(find dist -maxdepth 1 -name 'envguard_*_linux_amd64.tar.gz' -print -quit)" - test -n "$archive" - tar -xzf "$archive" -C "$verify_dir" - version_output="$("$verify_dir/envguard" version)" - test -n "$version_output" - test "$version_output" != "dev" - test -f "$verify_dir/LICENSE" - test -f "$verify_dir/README.md" - - name: Attest archives and checksums - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + test_dir="$(mktemp -d)" + cp dist/*.tar.gz dist/*.zip dist/checksums.txt "$test_dir/" + corrupt="$(find "$test_dir" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) | sort | sed -n '2p')" + test -n "$corrupt" + printf 'corrupt' >> "$corrupt" + if go run ./cmd/releasecheck -dist "$test_dir" -smoke=false; then + echo "release validation accepted a corrupted non-first archive" >&2 + exit 1 + fi + + attest: + name: attest-verified-digests + needs: verify + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: dist + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | dist/*.tar.gz dist/*.zip dist/checksums.txt + + publish: + name: publish-verified-artifacts + needs: attest + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: dist + - name: Create public GitHub release from verified artifacts + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "$GITHUB_REF_NAME" + dist/*.tar.gz dist/*.zip dist/checksums.txt + --repo "$GITHUB_REPOSITORY" + --verify-tag + --generate-notes diff --git a/.goreleaser.yml b/.goreleaser.yml index 16ff136..1aa9e97 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -8,7 +8,8 @@ builds: goos: [linux, darwin, windows] goarch: [amd64, arm64] archives: - - formats: [tar.gz] + - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] files: - LICENSE - README.md @@ -19,3 +20,5 @@ checksum: name_template: checksums.txt changelog: sort: asc +release: + disable: true diff --git a/README.md b/README.md index 2902081..98f7e90 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A runnable contract and development environment are available under [`examples/` envguard check --template examples/example.env --schema examples/envguard.json examples/development.env ``` -Without a schema, every template key is required and extra keys produce warnings. `--strict-extra` promotes extras to errors. When `envguard.json` exists it is loaded automatically; pass `--no-schema` to disable that behavior. +Without a schema, every template key is required and extra keys produce warnings. `--strict-extra` promotes extras to errors. When `envguard.json` exists it is loaded automatically; its absence is allowed only during this implicit discovery. A path supplied with `--schema` is mandatory and any missing, unreadable, non-regular, or invalid file returns operational exit code 3. Pass `--no-schema` to disable discovery; it cannot be combined with `--schema`. ## Schema @@ -43,7 +43,7 @@ Without a schema, every template key is required and extra keys produce warnings } ``` -Supported types are `string`, `integer`, `number`, `boolean`, `url`, `json`, and `enum`. Constraints include `min`, `max`, `minLength`, `maxLength`, `pattern`, `values`, and URL `schemes`. Unknown and duplicate JSON keys are rejected. +Supported types are `string`, `integer`, `number`, `boolean`, `url`, `json`, and `enum`. Constraints are type-specific: strings accept length bounds and a pattern, integer and number accept numeric bounds, enum requires values, and URL accepts schemes. Unknown fields, duplicate JSON keys, duplicate enum values, duplicate schemes, and irrelevant constraints are rejected. ## Security behavior diff --git a/cmd/envguard/main.go b/cmd/envguard/main.go index 1ec0f23..10a4470 100644 --- a/cmd/envguard/main.go +++ b/cmd/envguard/main.go @@ -2,6 +2,7 @@ package main import ( "os" + "runtime/debug" "github.com/1337lean/envguard/internal/app" ) @@ -9,5 +10,13 @@ import ( var version = "dev" func main() { - os.Exit(app.Run(os.Args[1:], os.Stdout, os.Stderr, version)) + build, _ := debug.ReadBuildInfo() + os.Exit(app.Run(os.Args[1:], os.Stdout, os.Stderr, resolvedVersion(version, build))) +} + +func resolvedVersion(injected string, build *debug.BuildInfo) string { + if injected != "dev" || build == nil || build.Main.Version == "" || build.Main.Version == "(devel)" { + return injected + } + return build.Main.Version } diff --git a/cmd/envguard/main_test.go b/cmd/envguard/main_test.go new file mode 100644 index 0000000..1d3ea87 --- /dev/null +++ b/cmd/envguard/main_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "runtime/debug" + "testing" +) + +func TestResolvedVersion(t *testing.T) { + for _, tc := range []struct { + name string + injected string + build *debug.BuildInfo + want string + }{ + {name: "release injection wins", injected: "1.2.3", build: &debug.BuildInfo{Main: debug.Module{Version: "v9.9.9"}}, want: "1.2.3"}, + {name: "module version", injected: "dev", build: &debug.BuildInfo{Main: debug.Module{Version: "v0.1.0"}}, want: "v0.1.0"}, + {name: "development build", injected: "dev", build: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, want: "dev"}, + {name: "missing build info", injected: "dev", want: "dev"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := resolvedVersion(tc.injected, tc.build); got != tc.want { + t.Fatalf("resolvedVersion() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/releasecheck/main.go b/cmd/releasecheck/main.go new file mode 100644 index 0000000..01a076c --- /dev/null +++ b/cmd/releasecheck/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/1337lean/envguard/internal/distcheck" +) + +func main() { + directory := flag.String("dist", "dist", "GoReleaser distribution directory") + smoke := flag.Bool("smoke", true, "run the archive executable for this host") + flag.Parse() + if flag.NArg() != 0 { + fmt.Fprintln(os.Stderr, "releasecheck does not accept positional arguments") + os.Exit(2) + } + if err := distcheck.Verify(*directory, *smoke); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if _, err := fmt.Fprintln(os.Stdout, "verified all Envguard release archives and checksums"); err != nil { + os.Exit(1) + } +} diff --git a/docs/remediation-plan.md b/docs/remediation-plan.md new file mode 100644 index 0000000..030cb2b --- /dev/null +++ b/docs/remediation-plan.md @@ -0,0 +1,114 @@ +# Envguard remediation plan + +This plan turns the workspace portfolio audit into an ordered set of fixes. The first release should prioritize explicit configuration failures and schema correctness because silent acceptance undermines the tool's central guarantee. + +## Target outcome + +Envguard must fail when an explicitly requested schema cannot be loaded, reject non-finite numeric values, validate every constraint against its declared type, report output failures correctly, and ship through a reachable verified repository. + +## 1. Make explicit schema failures fatal (E-01, high) + +Primary code: `internal/app/app.go`. + +- [ ] Track whether `--schema` was explicitly supplied instead of inferring intent only from the resulting path. +- [ ] If explicitly supplied, return an operational/configuration error for missing, unreadable, non-regular, or invalid schema files. +- [ ] Preserve optional auto-discovery only for the default implicit location, and document exactly when absence means “no schema.” +- [ ] Validate the schema before loading or evaluating environment values. +- [ ] Ensure human and JSON modes both expose the failure and return the documented non-zero exit code. + +Regression tests: + +- [ ] `--schema /missing/file` must fail in human and JSON modes. +- [ ] An absent implicit default may continue only if that is the documented behavior. +- [ ] Cover unreadable files, directories, malformed YAML/JSON, empty schemas, and relative paths. +- [ ] Add an end-to-end test matching the audited command that previously returned success with zero checks. + +Exit criterion: an explicit schema path can never be silently ignored. + +## 2. Reject non-finite numeric values (E-02, medium) + +Primary code: `internal/check/check.go`. + +- [ ] After `strconv.ParseFloat`, require `!math.IsNaN(value)` and `!math.IsInf(value, 0)`. +- [ ] Apply the same rule to numeric schema bounds and defaults during schema validation. +- [ ] Return a clear validation failure rather than allowing NaN comparison semantics to bypass minimum/maximum rules. + +Regression tests: + +- [ ] Reject `NaN`, `+Inf`, `-Inf`, and accepted case variants from environment input. +- [ ] Reject non-finite minimum, maximum, and default values in the schema. +- [ ] Preserve valid scientific notation and boundary behavior. + +Exit criterion: all numeric values participating in constraints are finite. + +## 3. Validate the constraint/type matrix (E-03, medium) + +Primary code: schema parsing/validation and `internal/check/check.go`. + +- [ ] Define an explicit matrix of constraints allowed for string, integer, number, boolean, URL, and any other supported types. +- [ ] Reject incompatible constraints at schema-load time instead of ignoring them during checks. +- [ ] Validate cross-field rules such as `min <= max`, non-empty patterns, supported URL schemes, and defaults satisfying their own constraints. +- [ ] Reject or normalize duplicate enum values and duplicate schemes consistently. +- [ ] Detect duplicate environment variable declarations if the schema format can express them. +- [ ] Publish the matrix in the schema reference documentation. + +Regression tests: + +- [ ] Table-test every allowed and forbidden type/constraint combination. +- [ ] Cover inverted ranges, duplicate enums/schemes, invalid regexes, invalid defaults, and unsupported fields. +- [ ] Verify schema errors identify the variable and constraint without leaking secret environment values. + +Exit criterion: no recognized schema field is silently ignored because of the declared type. + +## 4. Propagate report writer failures (E-04, low) + +Primary code: `internal/app/app.go`. + +- [ ] Return and classify errors from human and JSON report writes, including final encoder flushes. +- [ ] Map write failures to the documented operational exit code rather than success or validation failure. +- [ ] Avoid writing a second diagnostic to the same broken stream; use stderr where appropriate. + +Regression tests: + +- [ ] Use a writer that fails immediately and one that fails after a partial write. +- [ ] Cover human and JSON modes and assert both error classification and exit code. + +Exit criterion: a truncated or failed report can never be reported as a successful run. + +## 5. Restore publication and CI + +- [ ] Create or restore `github.com/1337lean/envguard`, or update every project reference to a permanent namespace. +- [ ] Add pinned Staticcheck plus required test, race, vet, and native Linux/macOS/Windows jobs. +- [ ] Add CLI integration tests covering schema discovery and explicit-path behavior to CI. +- [ ] Build without publishing, verify every release archive/checksum, and publish/attest only after successful verification. +- [ ] Reconcile module path, badges, install instructions, issue/security links, and GoReleaser configuration. + +## Verification + +```sh +gofmt -w . +go test ./... +go test -race -cover ./... +go vet ./... +staticcheck ./... +govulncheck ./... +goreleaser check +``` + +Repeat explicit/missing-schema and non-finite-number CLI probes on Linux, macOS, and Windows. + +## Suggested commit sequence + +1. `fix(schema): fail when an explicit schema cannot be loaded` +2. `fix(numbers): reject non-finite values and bounds` +3. `fix(schema): validate constraints against declared types` +4. `fix(report): propagate output writer failures` +5. `ci: restore repository checks and verify before release` + +## Definition of done + +- [ ] E-01 through E-04 have regression coverage. +- [ ] Schema discovery and the constraint matrix are documented. +- [ ] The audited missing-schema command now fails with the intended exit code. +- [ ] All native-platform jobs and analyzers pass. +- [ ] A tagged release is verified in full before publication. diff --git a/docs/schema.md b/docs/schema.md index 3329ac8..2390a3a 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -1,5 +1,21 @@ # Schema reference -Schema version 1 contains `allowExtra` and a `variables` object. Variable names use portable shell identifier syntax. Each variable accepts `required`, `type`, `allowEmpty`, `secret`, `minLength`, `maxLength`, `pattern`, `values`, `min`, `max`, and `schemes`. +Schema version 1 contains `allowExtra` and a non-empty `variables` object. Variable names use portable shell identifier syntax. Every variable accepts `required`, `type`, `allowEmpty`, and `secret`. Constraints use this matrix: -`pattern` is a Go RE2 regular expression, so validation has linear-time matching guarantees. `min` and `max` apply to integer and number values. `values` is required for enum variables. URL validation requires an absolute URL without user information and can restrict schemes. A secret variable is checked for an unchanged template default and may use a minimum length, but its value is never included in output. +| Type | Allowed constraints | +| --- | --- | +| `string` | `minLength`, `maxLength`, `pattern` | +| `integer` | `min`, `max` (finite whole-number bounds) | +| `number` | `min`, `max` (finite bounds) | +| `boolean` | none | +| `url` | `schemes` | +| `json` | none | +| `enum` | `values` (required and non-empty) | + +An omitted `type` means `string`. A recognized constraint on the wrong type is an error even when its JSON value is empty. Null constraint values are not accepted. Schema defaults are intentionally unsupported; use the dotenv template for documented example values and deployment tooling for runtime defaults. + +`pattern` must be non-empty and is a Go RE2 regular expression, so validation has linear-time matching guarantees. Minimums cannot exceed maximums. Enum values and URL schemes cannot contain duplicates. Schemes must use lowercase URL-scheme syntax. URL values must be absolute and contain no user information. A secret variable is checked for an unchanged template value, but its value is never included in output or schema-validation errors. + +## Discovery and failure behavior + +`envguard check` looks for `envguard.json` only when neither `--schema` nor `--no-schema` is supplied. A missing implicitly discovered file means “run without schema.” Every other discovery error is fatal. If `--schema PATH` is supplied, `PATH` must identify a readable regular file containing one complete schema JSON value; any failure returns operational exit code 3 before the template or environment files are evaluated. `--schema` and `--no-schema` are mutually exclusive. diff --git a/docs/threat-model.md b/docs/threat-model.md index 5c773c2..1748d9c 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -3,3 +3,5 @@ Envguard runs in developer machines and CI where environment files may contain production credentials. It treats every value as sensitive: findings expose the file, key, line, and violated rule only. Files are bounded, dotenv content is parsed without expansion, and schema patterns use Go's RE2 engine. Envguard cannot determine whether a secret is real, whether an environment file is committed, or whether a value has enough entropy. It is a contract checker, not a secret manager or a general secret scanner. + +An explicitly selected schema is part of the trust decision and is never optional: missing, unreadable, non-regular, malformed, or semantically invalid schema input stops the check before environment evaluation. Only absence of the implicit `envguard.json` discovery path permits a schema-free run. Numeric values and bounds must be finite, and constraints that do not apply to a declared type are rejected instead of ignored. diff --git a/internal/app/app.go b/internal/app/app.go index b0ac760..29fc523 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -65,21 +65,31 @@ func runCheck(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "envguard: format must be human or json") return 2 } - template, err := dotenv.ParseFile(templatePath) - if err != nil { - fmt.Fprintf(stderr, "envguard: read template: %v\n", err) - return 3 + schemaExplicit := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "schema" { + schemaExplicit = true + } + }) + if noSchema && schemaExplicit { + fmt.Fprintln(stderr, "envguard: --schema and --no-schema cannot be used together") + return 2 } var loaded *schema.Schema if !noSchema { s, err := schema.Load(schemaPath) if err == nil { loaded = &s - } else if !errors.Is(err, os.ErrNotExist) { + } else if schemaExplicit || !errors.Is(err, os.ErrNotExist) { fmt.Fprintf(stderr, "envguard: read schema: %v\n", err) return 3 } } + template, err := dotenv.ParseFile(templatePath) + if err != nil { + fmt.Fprintf(stderr, "envguard: read template: %v\n", err) + return 3 + } paths := fs.Args() if len(paths) == 0 { paths = []string{".env"} @@ -98,11 +108,17 @@ func runCheck(args []string, stdout, stderr io.Writer) int { envs[path] = file } report := check.Files(templatePath, template, envs, check.Options{StrictExtra: strictExtra, Schema: loaded}) + var writeErr error if format == "json" { - data, _ := json.MarshalIndent(report, "", " ") - fmt.Fprintln(stdout, string(data)) + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + writeErr = encoder.Encode(report) } else { - writeHuman(stdout, report) + writeErr = writeHuman(stdout, report) + } + if writeErr != nil { + fmt.Fprintf(stderr, "envguard: write report: %v\n", writeErr) + return 3 } if report.Errors > 0 || warningsAsErrors && report.Warnings > 0 { return 1 @@ -172,7 +188,7 @@ func runSchemaCheck(args []string, stdout, stderr io.Writer) int { return 0 } -func writeHuman(w io.Writer, report check.Report) { +func writeHuman(w io.Writer, report check.Report) error { for _, finding := range report.Findings { location := finding.File if finding.Line > 0 { @@ -182,12 +198,16 @@ func writeHuman(w io.Writer, report check.Report) { if finding.Key != "" { key = " [" + finding.Key + "]" } - fmt.Fprintf(w, "%s %s %s%s: %s\n", strings.ToUpper(finding.Severity), finding.Code, location, key, finding.Message) + if _, err := fmt.Fprintf(w, "%s %s %s%s: %s\n", strings.ToUpper(finding.Severity), finding.Code, location, key, finding.Message); err != nil { + return err + } } if len(report.Findings) == 0 { - fmt.Fprintln(w, "OK environment contract satisfied") + _, err := fmt.Fprintln(w, "OK environment contract satisfied") + return err } else { - fmt.Fprintf(w, "\n%d error(s), %d warning(s) across %d file(s)\n", report.Errors, report.Warnings, report.Files) + _, err := fmt.Fprintf(w, "\n%d error(s), %d warning(s) across %d file(s)\n", report.Errors, report.Warnings, report.Files) + return err } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d191644..614b98c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,8 +2,12 @@ package app import ( "bytes" + "errors" + "fmt" + "io" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -42,3 +46,143 @@ func TestVersion(t *testing.T) { t.Fatal(out.String()) } } + +func TestExplicitSchemaFailuresAreFatal(t *testing.T) { + root := t.TempDir() + template := writeTestFile(t, root, ".env.example", "A=\n") + env := writeTestFile(t, root, ".env", "A=value\n") + malformed := writeTestFile(t, root, "malformed.json", `{`) + empty := writeTestFile(t, root, "empty.json", "") + directory := filepath.Join(root, "schema-directory") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + path string + }{ + {name: "missing", path: filepath.Join(root, "missing.json")}, + {name: "malformed", path: malformed}, + {name: "empty", path: empty}, + {name: "directory", path: directory}, + } { + for _, format := range []string{"human", "json"} { + t.Run(tc.name+"/"+format, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run([]string{"check", "--format", format, "--template", template, "--schema", tc.path, env}, &stdout, &stderr, "test") + if code != 3 || !strings.Contains(stderr.String(), "read schema") || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + } + } +} + +func TestSchemaDiscoveryAndRelativeExplicitPath(t *testing.T) { + root := t.TempDir() + template := writeTestFile(t, root, ".env.example", "A=\n") + env := writeTestFile(t, root, ".env", "A=value\n") + validSchema := writeTestFile(t, root, "schema.json", `{"version":1,"variables":{"A":{"type":"string"}}}`) + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relativeSchema, err := filepath.Rel(workingDirectory, validSchema) + if err != nil { + t.Fatal(err) + } + + for _, args := range [][]string{ + {"check", "--template", template, env}, + {"check", "--template", template, "--schema", relativeSchema, env}, + } { + var stdout, stderr bytes.Buffer + if code := Run(args, &stdout, &stderr, "test"); code != 0 { + t.Fatalf("args=%v code=%d stdout=%q stderr=%q", args, code, stdout.String(), stderr.String()) + } + } + + var stdout, stderr bytes.Buffer + code := Run([]string{"check", "--template", template, "--schema", validSchema, "--no-schema", env}, &stdout, &stderr, "test") + if code != 2 || !strings.Contains(stderr.String(), "cannot be used together") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestSchemaValidatedBeforeTemplate(t *testing.T) { + root := t.TempDir() + env := writeTestFile(t, root, ".env", "A=value\n") + invalidSchema := writeTestFile(t, root, "invalid.json", `{}`) + var stdout, stderr bytes.Buffer + code := Run([]string{"check", "--template", filepath.Join(root, "missing-template"), "--schema", invalidSchema, env}, &stdout, &stderr, "test") + if code != 3 || !strings.Contains(stderr.String(), "read schema") || strings.Contains(stderr.String(), "read template") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestExplicitUnreadableSchemaIsFatal(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission semantics required") + } + root := t.TempDir() + template := writeTestFile(t, root, ".env.example", "A=\n") + env := writeTestFile(t, root, ".env", "A=value\n") + schemaPath := writeTestFile(t, root, "schema.json", `{"version":1,"variables":{"A":{"type":"string"}}}`) + if err := os.Chmod(schemaPath, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(schemaPath, 0o600) }) + var stdout, stderr bytes.Buffer + code := Run([]string{"check", "--template", template, "--schema", schemaPath, env}, &stdout, &stderr, "test") + if code == 0 { + t.Skip("test process can read mode-000 files") + } + if code != 3 || !strings.Contains(stderr.String(), "read schema") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestReportWriterFailuresAreOperationalErrors(t *testing.T) { + root := t.TempDir() + template := writeTestFile(t, root, ".env.example", "A=\n") + env := writeTestFile(t, root, ".env", "A=value\n") + for _, format := range []string{"human", "json"} { + for _, limit := range []int{0, 5} { + t.Run(fmt.Sprintf("%s/limit-%d", format, limit), func(t *testing.T) { + stdout := &failAfterWriter{remaining: limit} + var stderr bytes.Buffer + code := Run([]string{"check", "--no-schema", "--format", format, "--template", template, env}, stdout, &stderr, "test") + if code != 3 || !strings.Contains(stderr.String(), "write report") { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } + }) + } + } +} + +func writeTestFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +type failAfterWriter struct { + remaining int +} + +func (w *failAfterWriter) Write(p []byte) (int, error) { + if w.remaining <= 0 { + return 0, errors.New("injected write failure") + } + if len(p) > w.remaining { + n := w.remaining + w.remaining = 0 + return n, io.ErrUnexpectedEOF + } + w.remaining -= len(p) + return len(p), nil +} diff --git a/internal/check/check.go b/internal/check/check.go index 4594135..90bef7e 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -3,6 +3,7 @@ package check import ( "encoding/json" "fmt" + "math" "net/url" "regexp" "sort" @@ -163,7 +164,7 @@ func validateValue(report *Report, path string, entry, template dotenv.Entry, ru checkRange(report, path, entry, float64(n), rule) case "number": n, err := strconv.ParseFloat(value, 64) - if err != nil { + if err != nil || math.IsNaN(n) || math.IsInf(n, 0) { report.add(typeFinding(path, entry, "number")) return } diff --git a/internal/check/check_test.go b/internal/check/check_test.go index d5883d6..d1f565b 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -43,3 +43,33 @@ func TestMissingTemplateKey(t *testing.T) { t.Fatalf("unexpected report: %#v", report) } } + +func TestNumberRejectsNonFiniteValues(t *testing.T) { + min, max := -10.0, 10.0 + s := schema.Schema{Version: 1, Variables: map[string]schema.Variable{ + "VALUE": {Required: true, Type: "number", Min: &min, Max: &max}, + }} + template := parsed(t, "VALUE=\n") + for _, value := range []string{"NaN", "nan", "+Inf", "-Inf", "+Infinity", "-Infinity"} { + t.Run(value, func(t *testing.T) { + report := Files("template", template, map[string]dotenv.File{"env": parsed(t, "VALUE="+value+"\n")}, Options{Schema: &s}) + if report.Errors != 1 || report.Findings[0].Code != "type" { + t.Fatalf("value=%q report=%#v", value, report) + } + }) + } +} + +func TestNumberAcceptsScientificNotationAndBoundaries(t *testing.T) { + min, max := -10.0, 10.0 + s := schema.Schema{Version: 1, Variables: map[string]schema.Variable{ + "VALUE": {Required: true, Type: "number", Min: &min, Max: &max}, + }} + template := parsed(t, "VALUE=\n") + for _, value := range []string{"-1e1", "0", "1E1", "2.5e-1"} { + report := Files("template", template, map[string]dotenv.File{"env": parsed(t, "VALUE="+value+"\n")}, Options{Schema: &s}) + if report.Errors != 0 { + t.Fatalf("value=%q report=%#v", value, report) + } + } +} diff --git a/internal/distcheck/distcheck.go b/internal/distcheck/distcheck.go new file mode 100644 index 0000000..a252906 --- /dev/null +++ b/internal/distcheck/distcheck.go @@ -0,0 +1,442 @@ +// Package distcheck verifies that an Envguard distribution is complete and +// internally consistent before any artifact is published. +package distcheck + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "debug/elf" + "debug/macho" + "debug/pe" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +const ( + maxChecksumBytes = 1 << 20 + maxArchiveEntries = 1_000 + maxEntryBytes = 256 << 20 +) + +type target struct { + os string + arch string +} + +var expectedTargets = []target{ + {os: "darwin", arch: "amd64"}, + {os: "darwin", arch: "arm64"}, + {os: "linux", arch: "amd64"}, + {os: "linux", arch: "arm64"}, + {os: "windows", arch: "amd64"}, + {os: "windows", arch: "arm64"}, +} + +type archive struct { + name string + path string + target target + version string +} + +// Verify checks the complete release matrix, archive contents, and checksums. +// When smoke is true, the executable for the current host target is also run. +func Verify(directory string, smoke bool) error { + archives, version, err := discoverArchives(directory) + if err != nil { + return err + } + if err := verifyChecksums(directory, archives); err != nil { + return err + } + smoked := !smoke + for _, item := range archives { + executable, err := inspectArchive(item) + if err != nil { + return fmt.Errorf("inspect %s: %w", item.name, err) + } + if smoke && item.target.os == runtime.GOOS && item.target.arch == runtime.GOARCH { + if err := smokeExecutable(executable, version); err != nil { + return fmt.Errorf("smoke %s: %w", item.name, err) + } + smoked = true + } + } + if !smoked { + return fmt.Errorf("no archive can run on verifier host %s/%s", runtime.GOOS, runtime.GOARCH) + } + return nil +} + +func discoverArchives(directory string) ([]archive, string, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, "", fmt.Errorf("read distribution: %w", err) + } + wanted := make(map[target]bool, len(expectedTargets)) + for _, expected := range expectedTargets { + wanted[expected] = true + } + seen := make(map[target]bool, len(expectedTargets)) + var archives []archive + version := "" + for _, entry := range entries { + if entry.IsDir() || (!strings.HasSuffix(entry.Name(), ".tar.gz") && !strings.HasSuffix(entry.Name(), ".zip")) { + continue + } + item, ok := parseArchiveName(directory, entry.Name()) + if !ok || !wanted[item.target] { + return nil, "", fmt.Errorf("unexpected release archive %q", entry.Name()) + } + info, err := entry.Info() + if err != nil { + return nil, "", fmt.Errorf("inspect %q: %w", entry.Name(), err) + } + if !info.Mode().IsRegular() || info.Size() == 0 { + return nil, "", fmt.Errorf("release archive %q is not a non-empty regular file", entry.Name()) + } + if seen[item.target] { + return nil, "", fmt.Errorf("duplicate release target %s/%s", item.target.os, item.target.arch) + } + seen[item.target] = true + if version == "" { + version = item.version + } else if item.version != version { + return nil, "", fmt.Errorf("archive %q has version %q, want %q", item.name, item.version, version) + } + archives = append(archives, item) + } + for _, expected := range expectedTargets { + if !seen[expected] { + return nil, "", fmt.Errorf("missing release archive for %s/%s", expected.os, expected.arch) + } + } + sort.Slice(archives, func(i, j int) bool { return archives[i].name < archives[j].name }) + return archives, version, nil +} + +func parseArchiveName(directory, name string) (archive, bool) { + if !strings.HasPrefix(name, "envguard_") { + return archive{}, false + } + for _, candidate := range expectedTargets { + extension := ".tar.gz" + if candidate.os == "windows" { + extension = ".zip" + } + suffix := "_" + candidate.os + "_" + candidate.arch + extension + if !strings.HasSuffix(name, suffix) { + continue + } + version := strings.TrimSuffix(strings.TrimPrefix(name, "envguard_"), suffix) + if version == "" { + return archive{}, false + } + return archive{name: name, path: filepath.Join(directory, name), target: candidate, version: version}, true + } + return archive{}, false +} + +func verifyChecksums(directory string, archives []archive) error { + checksumPath := filepath.Join(directory, "checksums.txt") + file, err := os.Open(checksumPath) + if err != nil { + return fmt.Errorf("open checksums.txt: %w", err) + } + info, statErr := file.Stat() + if statErr != nil { + file.Close() + return fmt.Errorf("inspect checksums.txt: %w", statErr) + } + if !info.Mode().IsRegular() || info.Size() > maxChecksumBytes { + file.Close() + return errors.New("checksums.txt is not a bounded regular file") + } + data, readErr := io.ReadAll(io.LimitReader(file, maxChecksumBytes+1)) + closeErr := file.Close() + if readErr != nil { + return fmt.Errorf("read checksums.txt: %w", readErr) + } + if closeErr != nil { + return fmt.Errorf("close checksums.txt: %w", closeErr) + } + if len(data) > maxChecksumBytes { + return errors.New("checksums.txt exceeds verification limit") + } + + wanted := make(map[string]archive, len(archives)) + for _, item := range archives { + wanted[item.name] = item + } + seen := make(map[string]bool, len(archives)) + for lineNumber, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + return fmt.Errorf("checksums.txt line %d is invalid", lineNumber+1) + } + digest, err := hex.DecodeString(fields[0]) + if err != nil || len(digest) != sha256.Size { + return fmt.Errorf("checksums.txt line %d has an invalid SHA-256", lineNumber+1) + } + name := strings.TrimPrefix(fields[1], "*") + if filepath.Base(name) != name || name == "." { + return fmt.Errorf("checksums.txt line %d has an unsafe name", lineNumber+1) + } + item, ok := wanted[name] + if !ok { + return fmt.Errorf("checksums.txt contains unexpected artifact %q", name) + } + if seen[name] { + return fmt.Errorf("checksums.txt repeats artifact %q", name) + } + seen[name] = true + actual, err := fileSHA256(item.path) + if err != nil { + return fmt.Errorf("hash %q: %w", name, err) + } + if !strings.EqualFold(fields[0], hex.EncodeToString(actual[:])) { + return fmt.Errorf("checksum mismatch for %q", name) + } + } + for name := range wanted { + if !seen[name] { + return fmt.Errorf("checksums.txt is missing %q", name) + } + } + return nil +} + +func fileSHA256(name string) ([sha256.Size]byte, error) { + file, err := os.Open(name) + if err != nil { + return [sha256.Size]byte{}, err + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + closeErr := file.Close() + if copyErr != nil { + return [sha256.Size]byte{}, copyErr + } + if closeErr != nil { + return [sha256.Size]byte{}, closeErr + } + var digest [sha256.Size]byte + copy(digest[:], hash.Sum(nil)) + return digest, nil +} + +func inspectArchive(item archive) ([]byte, error) { + executableName := "envguard" + if item.target.os == "windows" { + executableName += ".exe" + } + required := map[string]bool{"LICENSE": false, "README.md": false, executableName: false} + var executable []byte + visit := func(name string, mode os.FileMode, size int64, reader io.Reader) error { + clean, err := cleanArchiveName(name) + if err != nil { + return err + } + if mode&os.ModeSymlink != 0 { + return fmt.Errorf("archive contains symbolic link %q", name) + } + if size < 0 || size > maxEntryBytes { + return fmt.Errorf("archive entry %q has invalid size %d", name, size) + } + contents, err := io.ReadAll(io.LimitReader(reader, maxEntryBytes+1)) + if err != nil { + return fmt.Errorf("read archive entry %q: %w", name, err) + } + if int64(len(contents)) != size { + return fmt.Errorf("archive entry %q size changed while reading", name) + } + if _, ok := required[clean]; !ok { + return nil + } + if required[clean] { + return fmt.Errorf("archive repeats required entry %q", clean) + } + if !mode.IsRegular() || size == 0 { + return fmt.Errorf("required archive entry %q is not a non-empty regular file", clean) + } + required[clean] = true + if clean == executableName { + executable = contents + if err := validateExecutable(executable, item.target); err != nil { + return err + } + } + return nil + } + var err error + if item.target.os == "windows" { + err = inspectZip(item.path, visit) + } else { + err = inspectTarGzip(item.path, visit) + } + if err != nil { + return nil, err + } + for name, found := range required { + if !found { + return nil, fmt.Errorf("archive is missing %q", name) + } + } + return executable, nil +} + +func inspectTarGzip(name string, visit func(string, os.FileMode, int64, io.Reader) error) error { + file, err := os.Open(name) + if err != nil { + return err + } + defer file.Close() + compressed, err := gzip.NewReader(file) + if err != nil { + return err + } + defer compressed.Close() + tape := tar.NewReader(compressed) + for count := 0; ; count++ { + if count >= maxArchiveEntries { + return errors.New("archive contains too many entries") + } + header, err := tape.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + if header.FileInfo().IsDir() { + if _, err := cleanArchiveName(header.Name); err != nil { + return err + } + continue + } + if err := visit(header.Name, header.FileInfo().Mode(), header.Size, tape); err != nil { + return err + } + } +} + +func inspectZip(name string, visit func(string, os.FileMode, int64, io.Reader) error) error { + archive, err := zip.OpenReader(name) + if err != nil { + return err + } + defer archive.Close() + if len(archive.File) > maxArchiveEntries { + return errors.New("archive contains too many entries") + } + for _, entry := range archive.File { + if entry.FileInfo().IsDir() { + if _, err := cleanArchiveName(entry.Name); err != nil { + return err + } + continue + } + reader, err := entry.Open() + if err != nil { + return err + } + err = visit(entry.Name, entry.Mode(), int64(entry.UncompressedSize64), reader) + closeErr := reader.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +func cleanArchiveName(name string) (string, error) { + if strings.Contains(name, "\\") { + return "", fmt.Errorf("archive entry %q contains a backslash", name) + } + clean := path.Clean(name) + if clean == "." || path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") || clean != name { + return "", fmt.Errorf("archive entry %q has an unsafe path", name) + } + return clean, nil +} + +func validateExecutable(data []byte, target target) error { + invalid := func() error { return fmt.Errorf("executable is not a %s/%s binary", target.os, target.arch) } + switch target.os { + case "linux": + executable, err := elf.NewFile(bytes.NewReader(data)) + if err != nil || executable.Class != elf.ELFCLASS64 || executable.Data != elf.ELFDATA2LSB || (executable.Type != elf.ET_EXEC && executable.Type != elf.ET_DYN) { + return invalid() + } + expected := map[string]elf.Machine{"amd64": elf.EM_X86_64, "arm64": elf.EM_AARCH64}[target.arch] + if expected == elf.EM_NONE || executable.Machine != expected { + return invalid() + } + case "darwin": + executable, err := macho.NewFile(bytes.NewReader(data)) + if err != nil || executable.Type != macho.TypeExec { + return invalid() + } + expected := map[string]macho.Cpu{"amd64": macho.CpuAmd64, "arm64": macho.CpuArm64}[target.arch] + if expected == 0 || executable.Cpu != expected { + return invalid() + } + case "windows": + executable, err := pe.NewFile(bytes.NewReader(data)) + if err != nil || executable.Characteristics&pe.IMAGE_FILE_EXECUTABLE_IMAGE == 0 { + return invalid() + } + if _, ok := executable.OptionalHeader.(*pe.OptionalHeader64); !ok { + return invalid() + } + expected := map[string]uint16{"amd64": 0x8664, "arm64": 0xaa64}[target.arch] + if expected == 0 || executable.Machine != expected { + return invalid() + } + default: + return invalid() + } + return nil +} + +func smokeExecutable(data []byte, version string) error { + directory, err := os.MkdirTemp("", "envguard-releasecheck-") + if err != nil { + return err + } + defer os.RemoveAll(directory) + name := filepath.Join(directory, "envguard") + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(name, data, 0o700); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, name, "version").CombinedOutput() + if err != nil { + return fmt.Errorf("version command: %w (%s)", err, strings.TrimSpace(string(output))) + } + if got := strings.TrimSpace(string(output)); got != version || got == "dev" { + return fmt.Errorf("version output %q, want %q", got, version) + } + return nil +} diff --git a/internal/distcheck/distcheck_test.go b/internal/distcheck/distcheck_test.go new file mode 100644 index 0000000..fddaf83 --- /dev/null +++ b/internal/distcheck/distcheck_test.go @@ -0,0 +1,305 @@ +package distcheck + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/binary" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +var fixtureEntries = map[string][]byte{ + "LICENSE": []byte("license"), + "README.md": []byte("readme"), +} + +func TestVerifyCompleteDistribution(t *testing.T) { + directory, _ := writeDistribution(t) + if err := Verify(directory, false); err != nil { + t.Fatal(err) + } +} + +func TestVerifyRejectsIncompleteCorruptOrUnexpectedDistribution(t *testing.T) { + t.Run("missing archive", func(t *testing.T) { + directory, names := writeDistribution(t) + if err := os.Remove(filepath.Join(directory, names[len(names)-1])); err != nil { + t.Fatal(err) + } + if err := Verify(directory, false); err == nil || !strings.Contains(err.Error(), "missing release archive") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("unexpected archive", func(t *testing.T) { + directory, _ := writeDistribution(t) + if err := os.WriteFile(filepath.Join(directory, "envguard_1.2.3_linux_riscv64.tar.gz"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := Verify(directory, false); err == nil || !strings.Contains(err.Error(), "unexpected release archive") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("corrupt non-first archive", func(t *testing.T) { + directory, names := writeDistribution(t) + file, err := os.OpenFile(filepath.Join(directory, names[1]), os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString("corrupt"); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if err := Verify(directory, false); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestVerifyRejectsInvalidRequiredEntries(t *testing.T) { + t.Run("wrong executable target", func(t *testing.T) { + directory, names := writeDistribution(t) + selected := target{os: "darwin", arch: "arm64"} + entries := archiveEntries(selected) + entries["envguard"] = fakeExecutable(target{os: "darwin", arch: "amd64"}) + writeArchive(t, filepath.Join(directory, archiveName("1.2.3", selected)), selected, entries) + writeChecksums(t, directory, names) + if err := Verify(directory, false); err == nil || !strings.Contains(err.Error(), "darwin/arm64 binary") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("empty required asset", func(t *testing.T) { + directory, names := writeDistribution(t) + selected := target{os: "linux", arch: "arm64"} + entries := archiveEntries(selected) + entries["README.md"] = nil + writeArchive(t, filepath.Join(directory, archiveName("1.2.3", selected)), selected, entries) + writeChecksums(t, directory, names) + if err := Verify(directory, false); err == nil || !strings.Contains(err.Error(), "non-empty regular file") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("zip CRC failure", func(t *testing.T) { + directory, names := writeDistribution(t) + selected := target{os: "windows", arch: "amd64"} + name := archiveName("1.2.3", selected) + archivePath := filepath.Join(directory, name) + data, err := os.ReadFile(archivePath) + if err != nil { + t.Fatal(err) + } + index := bytes.Index(data, fixtureEntries["README.md"]) + if index < 0 { + t.Fatal("stored README data not found") + } + data[index] ^= 0xff + if err := os.WriteFile(archivePath, data, 0o600); err != nil { + t.Fatal(err) + } + writeChecksums(t, directory, names) + if err := Verify(directory, false); err == nil || !strings.Contains(strings.ToLower(err.Error()), "checksum") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func writeDistribution(t *testing.T) (string, []string) { + t.Helper() + directory := t.TempDir() + var names []string + for _, target := range expectedTargets { + name := archiveName("1.2.3", target) + writeArchive(t, filepath.Join(directory, name), target, archiveEntries(target)) + names = append(names, name) + } + sort.Strings(names) + writeChecksums(t, directory, names) + return directory, names +} + +func archiveName(version string, target target) string { + extension := ".tar.gz" + if target.os == "windows" { + extension = ".zip" + } + return fmt.Sprintf("envguard_%s_%s_%s%s", version, target.os, target.arch, extension) +} + +func archiveEntries(target target) map[string][]byte { + entries := make(map[string][]byte, len(fixtureEntries)+1) + for name, contents := range fixtureEntries { + entries[name] = contents + } + executableName := "envguard" + if target.os == "windows" { + executableName += ".exe" + } + entries[executableName] = fakeExecutable(target) + return entries +} + +func writeArchive(t *testing.T, name string, target target, entries map[string][]byte) { + t.Helper() + if target.os == "windows" { + writeZip(t, name, entries) + return + } + writeTarGzip(t, name, entries) +} + +func fakeExecutable(target target) []byte { + switch target.os { + case "linux": + data := make([]byte, 64) + copy(data, "\x7fELF") + data[4], data[5], data[6] = 2, 1, 1 + binary.LittleEndian.PutUint16(data[16:18], 2) + machine := uint16(62) + if target.arch == "arm64" { + machine = 183 + } + binary.LittleEndian.PutUint16(data[18:20], machine) + binary.LittleEndian.PutUint32(data[20:24], 1) + binary.LittleEndian.PutUint16(data[52:54], 64) + return data + case "darwin": + data := make([]byte, 32) + binary.LittleEndian.PutUint32(data[:4], 0xfeedfacf) + machine := uint32(0x01000007) + if target.arch == "arm64" { + machine = 0x0100000c + } + binary.LittleEndian.PutUint32(data[4:8], machine) + binary.LittleEndian.PutUint32(data[12:16], 2) + return data + case "windows": + data := make([]byte, 328) + copy(data, "MZ") + binary.LittleEndian.PutUint32(data[0x3c:0x40], 64) + copy(data[64:68], "PE\x00\x00") + machine := uint16(0x8664) + if target.arch == "arm64" { + machine = 0xaa64 + } + binary.LittleEndian.PutUint16(data[68:70], machine) + binary.LittleEndian.PutUint16(data[84:86], 240) + binary.LittleEndian.PutUint16(data[86:88], 0x0002) + binary.LittleEndian.PutUint16(data[88:90], 0x020b) + binary.LittleEndian.PutUint32(data[196:200], 16) + return data + default: + return []byte("invalid") + } +} + +func writeTarGzip(t *testing.T, name string, entries map[string][]byte) { + t.Helper() + file, err := os.Create(name) + if err != nil { + t.Fatal(err) + } + compressed := gzip.NewWriter(file) + tape := tar.NewWriter(compressed) + for _, entryName := range sortedNames(entries) { + contents := entries[entryName] + mode := int64(0o644) + if entryName == "envguard" { + mode = 0o755 + } + if err := tape.WriteHeader(&tar.Header{Name: entryName, Mode: mode, Size: int64(len(contents))}); err != nil { + t.Fatal(err) + } + if _, err := tape.Write(contents); err != nil { + t.Fatal(err) + } + } + if err := tape.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func writeZip(t *testing.T, name string, entries map[string][]byte) { + t.Helper() + file, err := os.Create(name) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + for _, entryName := range sortedNames(entries) { + header := &zip.FileHeader{Name: entryName, Method: zip.Store} + header.SetMode(0o644) + if entryName == "envguard.exe" { + header.SetMode(0o755) + } + entry, err := archive.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(entries[entryName]); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func writeChecksums(t *testing.T, directory string, names []string) { + t.Helper() + file, err := os.Create(filepath.Join(directory, "checksums.txt")) + if err != nil { + t.Fatal(err) + } + for _, name := range names { + artifact, err := os.Open(filepath.Join(directory, name)) + if err != nil { + t.Fatal(err) + } + hash := sha256.New() + if _, err := io.Copy(hash, artifact); err != nil { + artifact.Close() + t.Fatal(err) + } + if err := artifact.Close(); err != nil { + t.Fatal(err) + } + if _, err := fmt.Fprintf(file, "%x %s\n", hash.Sum(nil), name); err != nil { + t.Fatal(err) + } + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func sortedNames(entries map[string][]byte) []string { + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 622cb76..a02347f 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "math" "os" "regexp" "sort" @@ -34,6 +35,41 @@ type Variable struct { Min *float64 `json:"min,omitempty"` Max *float64 `json:"max,omitempty"` Schemes []string `json:"schemes,omitempty"` + + present map[string]bool +} + +var schemeRE = regexp.MustCompile(`^[a-z][a-z0-9+.-]*$`) + +func (v *Variable) UnmarshalJSON(data []byte) error { + type plain Variable + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + allowed := map[string]bool{ + "required": true, "type": true, "allowEmpty": true, "secret": true, + "minLength": true, "maxLength": true, "pattern": true, "values": true, + "min": true, "max": true, "schemes": true, + } + for name, raw := range fields { + if !allowed[name] { + return fmt.Errorf("unknown field %q", name) + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return fmt.Errorf("field %q must not be null", name) + } + } + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *v = Variable(decoded) + v.present = make(map[string]bool, len(fields)) + for name := range fields { + v.present[name] = true + } + return nil } func Load(path string) (Schema, error) { @@ -95,35 +131,114 @@ func (s Schema) Validate() error { switch v.Type { case "string", "integer", "number", "boolean", "url", "json": case "enum": - if len(v.Values) == 0 { + if !v.isPresent("values") || len(v.Values) == 0 { return fmt.Errorf("variable %s: enum requires values", key) } default: return fmt.Errorf("variable %s: unsupported type %q", key, v.Type) } + if err := validateConstraintMatrix(key, v); err != nil { + return err + } if v.MinLength != nil && *v.MinLength < 0 || v.MaxLength != nil && *v.MaxLength < 0 { return fmt.Errorf("variable %s: lengths must be non-negative", key) } if v.MinLength != nil && v.MaxLength != nil && *v.MinLength > *v.MaxLength { return fmt.Errorf("variable %s: minLength exceeds maxLength", key) } + if v.Min != nil && !isFinite(*v.Min) { + return fmt.Errorf("variable %s: min must be finite", key) + } + if v.Max != nil && !isFinite(*v.Max) { + return fmt.Errorf("variable %s: max must be finite", key) + } + if v.Type == "integer" && v.Min != nil && math.Trunc(*v.Min) != *v.Min { + return fmt.Errorf("variable %s: integer min must be a whole number", key) + } + if v.Type == "integer" && v.Max != nil && math.Trunc(*v.Max) != *v.Max { + return fmt.Errorf("variable %s: integer max must be a whole number", key) + } if v.Min != nil && v.Max != nil && *v.Min > *v.Max { return fmt.Errorf("variable %s: min exceeds max", key) } + if v.isPresent("pattern") && v.Pattern == "" { + return fmt.Errorf("variable %s: pattern must not be empty", key) + } if v.Pattern != "" { if _, err := regexp.Compile(v.Pattern); err != nil { return fmt.Errorf("variable %s: invalid pattern: %w", key, err) } } + if v.isPresent("schemes") && len(v.Schemes) == 0 { + return fmt.Errorf("variable %s: schemes must not be empty", key) + } + seenSchemes := make(map[string]struct{}, len(v.Schemes)) for _, scheme := range v.Schemes { - if scheme == "" || scheme != strings.ToLower(scheme) { - return fmt.Errorf("variable %s: schemes must be lowercase and non-empty", key) + if scheme != strings.ToLower(scheme) || !schemeRE.MatchString(scheme) { + return fmt.Errorf("variable %s: scheme must be a lowercase URL scheme", key) + } + if _, exists := seenSchemes[scheme]; exists { + return fmt.Errorf("variable %s: schemes must not contain duplicates", key) + } + seenSchemes[scheme] = struct{}{} + } + seenValues := make(map[string]struct{}, len(v.Values)) + for _, value := range v.Values { + if _, exists := seenValues[value]; exists { + return fmt.Errorf("variable %s: values must not contain duplicates", key) } + seenValues[value] = struct{}{} } } return nil } +func (v Variable) isPresent(name string) bool { + if v.present != nil && v.present[name] { + return true + } + switch name { + case "minLength": + return v.MinLength != nil + case "maxLength": + return v.MaxLength != nil + case "pattern": + return v.Pattern != "" + case "values": + return v.Values != nil + case "min": + return v.Min != nil + case "max": + return v.Max != nil + case "schemes": + return v.Schemes != nil + default: + return false + } +} + +func validateConstraintMatrix(key string, v Variable) error { + allowed := map[string]map[string]bool{ + "string": {"minLength": true, "maxLength": true, "pattern": true}, + "integer": {"min": true, "max": true}, + "number": {"min": true, "max": true}, + "boolean": {}, + "url": {"schemes": true}, + "json": {}, + "enum": {"values": true}, + } + for _, constraint := range []string{"minLength", "maxLength", "pattern", "values", "min", "max", "schemes"} { + if v.isPresent(constraint) && !allowed[v.Type][constraint] { + return fmt.Errorf("variable %s: %s is not valid for type %s", key, constraint, v.Type) + } + } + return nil +} + +func isFinite(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} + func rejectDuplicates(data []byte) error { decoder := json.NewDecoder(bytes.NewReader(data)) var walk func() error diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index bdd2eee..5b67028 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -1,6 +1,7 @@ package schema import ( + "math" "os" "path/filepath" "strings" @@ -33,3 +34,90 @@ func TestValidate(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestConstraintTypeMatrix(t *testing.T) { + minLength, maxLength := 1, 20 + min, max := 1.0, 10.0 + valid := []Variable{ + {Type: "string", MinLength: &minLength, MaxLength: &maxLength, Pattern: `^[a-z]+$`}, + {Type: "integer", Min: &min, Max: &max}, + {Type: "number", Min: &min, Max: &max}, + {Type: "boolean"}, + {Type: "url", Schemes: []string{"https"}}, + {Type: "json"}, + {Type: "enum", Values: []string{"dev", "prod"}}, + } + for _, variable := range valid { + s := Schema{Version: 1, Variables: map[string]Variable{"VALUE": variable}} + if err := s.Validate(); err != nil { + t.Fatalf("type=%s unexpected error: %v", variable.Type, err) + } + } + + invalid := []Variable{ + {Type: "string", Min: &min}, + {Type: "integer", MinLength: &minLength}, + {Type: "number", Pattern: `.*`}, + {Type: "boolean", Max: &max}, + {Type: "url", Values: []string{"https"}}, + {Type: "json", Schemes: []string{"https"}}, + {Type: "enum", Min: &min, Values: []string{"one"}}, + } + for _, variable := range invalid { + s := Schema{Version: 1, Variables: map[string]Variable{"VALUE": variable}} + if err := s.Validate(); err == nil || !strings.Contains(err.Error(), "not valid for type") { + t.Fatalf("type=%s unexpected error: %v", variable.Type, err) + } + } +} + +func TestValidateRejectsInvalidConstraintValues(t *testing.T) { + min, max := 2.0, 1.0 + nonInteger := 1.5 + tests := []struct { + name string + variable Variable + want string + }{ + {name: "inverted range", variable: Variable{Type: "number", Min: &min, Max: &max}, want: "min exceeds max"}, + {name: "non-finite min", variable: Variable{Type: "number", Min: floatPointer(math.NaN())}, want: "min must be finite"}, + {name: "non-finite max", variable: Variable{Type: "number", Max: floatPointer(math.Inf(1))}, want: "max must be finite"}, + {name: "fractional integer bound", variable: Variable{Type: "integer", Min: &nonInteger}, want: "whole number"}, + {name: "invalid pattern", variable: Variable{Type: "string", Pattern: "["}, want: "invalid pattern"}, + {name: "duplicate enum", variable: Variable{Type: "enum", Values: []string{"secret-sentinel", "secret-sentinel"}}, want: "values must not contain duplicates"}, + {name: "duplicate scheme", variable: Variable{Type: "url", Schemes: []string{"https", "https"}}, want: "schemes must not contain duplicates"}, + {name: "invalid scheme", variable: Variable{Type: "url", Schemes: []string{"HTTPS"}}, want: "lowercase URL scheme"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := Schema{Version: 1, Variables: map[string]Variable{"VALUE": tc.variable}} + err := s.Validate() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(err.Error(), "secret-sentinel") { + t.Fatalf("schema value leaked in error: %v", err) + } + }) + } +} + +func TestLoadRejectsPresentButInvalidFields(t *testing.T) { + for _, content := range []string{ + `{"version":1,"variables":{"A":{"type":"string","pattern":""}}}`, + `{"version":1,"variables":{"A":{"type":"url","schemes":[]}}}`, + `{"version":1,"variables":{"A":{"type":"number","minLength":null}}}`, + `{"version":1,"variables":{"A":{"type":"number","default":"1"}}}`, + `{"version":1,"variables":{"A":{"type":"string"},"A":{"type":"number"}}}`, + } { + path := filepath.Join(t.TempDir(), "schema.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatalf("expected rejection for %s", content) + } + } +} + +func floatPointer(value float64) *float64 { return &value } From ed35e2e54f9deb0676e4acff2ba4abf0408871bc Mon Sep 17 00:00:00 2001 From: 1337lean <177236079+1337lean@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:36:58 -0400 Subject: [PATCH 2/3] fix Windows relative schema test --- internal/app/app_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 614b98c..cb54bee 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -83,11 +83,16 @@ func TestSchemaDiscoveryAndRelativeExplicitPath(t *testing.T) { root := t.TempDir() template := writeTestFile(t, root, ".env.example", "A=\n") env := writeTestFile(t, root, ".env", "A=value\n") - validSchema := writeTestFile(t, root, "schema.json", `{"version":1,"variables":{"A":{"type":"string"}}}`) workingDirectory, err := os.Getwd() if err != nil { t.Fatal(err) } + relativeSchemaDirectory, err := os.MkdirTemp(workingDirectory, ".envguard-relative-schema-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(relativeSchemaDirectory) }) + validSchema := writeTestFile(t, relativeSchemaDirectory, "schema.json", `{"version":1,"variables":{"A":{"type":"string"}}}`) relativeSchema, err := filepath.Rel(workingDirectory, validSchema) if err != nil { t.Fatal(err) From fd023f90a54c039351c9b07ef757a43b96c42887 Mon Sep 17 00:00:00 2001 From: 1337lean <177236079+1337lean@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:39:54 -0400 Subject: [PATCH 3/3] document completed remediation gates --- docs/remediation-plan.md | 76 ++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/remediation-plan.md b/docs/remediation-plan.md index 030cb2b..ec0a6d3 100644 --- a/docs/remediation-plan.md +++ b/docs/remediation-plan.md @@ -10,18 +10,18 @@ Envguard must fail when an explicitly requested schema cannot be loaded, reject Primary code: `internal/app/app.go`. -- [ ] Track whether `--schema` was explicitly supplied instead of inferring intent only from the resulting path. -- [ ] If explicitly supplied, return an operational/configuration error for missing, unreadable, non-regular, or invalid schema files. -- [ ] Preserve optional auto-discovery only for the default implicit location, and document exactly when absence means “no schema.” -- [ ] Validate the schema before loading or evaluating environment values. -- [ ] Ensure human and JSON modes both expose the failure and return the documented non-zero exit code. +- [x] Track whether `--schema` was explicitly supplied instead of inferring intent only from the resulting path. +- [x] If explicitly supplied, return an operational/configuration error for missing, unreadable, non-regular, or invalid schema files. +- [x] Preserve optional auto-discovery only for the default implicit location, and document exactly when absence means “no schema.” +- [x] Validate the schema before loading or evaluating environment values. +- [x] Ensure human and JSON modes both expose the failure and return the documented non-zero exit code. Regression tests: -- [ ] `--schema /missing/file` must fail in human and JSON modes. -- [ ] An absent implicit default may continue only if that is the documented behavior. -- [ ] Cover unreadable files, directories, malformed YAML/JSON, empty schemas, and relative paths. -- [ ] Add an end-to-end test matching the audited command that previously returned success with zero checks. +- [x] `--schema /missing/file` must fail in human and JSON modes. +- [x] An absent implicit default may continue only if that is the documented behavior. +- [x] Cover unreadable files, directories, malformed JSON, empty schemas, and relative paths. (Envguard's schema format is JSON-only.) +- [x] Add an end-to-end test matching the audited command that previously returned success with zero checks. Exit criterion: an explicit schema path can never be silently ignored. @@ -29,15 +29,15 @@ Exit criterion: an explicit schema path can never be silently ignored. Primary code: `internal/check/check.go`. -- [ ] After `strconv.ParseFloat`, require `!math.IsNaN(value)` and `!math.IsInf(value, 0)`. -- [ ] Apply the same rule to numeric schema bounds and defaults during schema validation. -- [ ] Return a clear validation failure rather than allowing NaN comparison semantics to bypass minimum/maximum rules. +- [x] After `strconv.ParseFloat`, require `!math.IsNaN(value)` and `!math.IsInf(value, 0)`. +- [x] Apply the same rule to numeric schema bounds. Schema defaults are intentionally unsupported and rejected as unknown fields. +- [x] Return a clear validation failure rather than allowing NaN comparison semantics to bypass minimum/maximum rules. Regression tests: -- [ ] Reject `NaN`, `+Inf`, `-Inf`, and accepted case variants from environment input. -- [ ] Reject non-finite minimum, maximum, and default values in the schema. -- [ ] Preserve valid scientific notation and boundary behavior. +- [x] Reject `NaN`, `+Inf`, `-Inf`, and accepted case variants from environment input. +- [x] Reject non-finite minimum and maximum values; reject the unsupported `default` field. +- [x] Preserve valid scientific notation and boundary behavior. Exit criterion: all numeric values participating in constraints are finite. @@ -45,18 +45,18 @@ Exit criterion: all numeric values participating in constraints are finite. Primary code: schema parsing/validation and `internal/check/check.go`. -- [ ] Define an explicit matrix of constraints allowed for string, integer, number, boolean, URL, and any other supported types. -- [ ] Reject incompatible constraints at schema-load time instead of ignoring them during checks. -- [ ] Validate cross-field rules such as `min <= max`, non-empty patterns, supported URL schemes, and defaults satisfying their own constraints. -- [ ] Reject or normalize duplicate enum values and duplicate schemes consistently. -- [ ] Detect duplicate environment variable declarations if the schema format can express them. -- [ ] Publish the matrix in the schema reference documentation. +- [x] Define an explicit matrix of constraints allowed for string, integer, number, boolean, URL, and any other supported types. +- [x] Reject incompatible constraints at schema-load time instead of ignoring them during checks. +- [x] Validate cross-field rules such as `min <= max`, non-empty patterns, and supported URL schemes; reject unsupported schema defaults. +- [x] Reject duplicate enum values and duplicate schemes consistently. +- [x] Detect duplicate environment variable declarations through duplicate JSON-key rejection. +- [x] Publish the matrix in the schema reference documentation. Regression tests: -- [ ] Table-test every allowed and forbidden type/constraint combination. -- [ ] Cover inverted ranges, duplicate enums/schemes, invalid regexes, invalid defaults, and unsupported fields. -- [ ] Verify schema errors identify the variable and constraint without leaking secret environment values. +- [x] Table-test every allowed and forbidden type/constraint combination. +- [x] Cover inverted ranges, duplicate enums/schemes, invalid regexes, rejected defaults, and unsupported fields. +- [x] Verify schema errors identify the variable and constraint without leaking secret environment values. Exit criterion: no recognized schema field is silently ignored because of the declared type. @@ -64,24 +64,24 @@ Exit criterion: no recognized schema field is silently ignored because of the de Primary code: `internal/app/app.go`. -- [ ] Return and classify errors from human and JSON report writes, including final encoder flushes. -- [ ] Map write failures to the documented operational exit code rather than success or validation failure. -- [ ] Avoid writing a second diagnostic to the same broken stream; use stderr where appropriate. +- [x] Return and classify errors from human and JSON report writes, including final encoder flushes. +- [x] Map write failures to the documented operational exit code rather than success or validation failure. +- [x] Avoid writing a second diagnostic to the same broken stream; use stderr where appropriate. Regression tests: -- [ ] Use a writer that fails immediately and one that fails after a partial write. -- [ ] Cover human and JSON modes and assert both error classification and exit code. +- [x] Use a writer that fails immediately and one that fails after a partial write. +- [x] Cover human and JSON modes and assert both error classification and exit code. Exit criterion: a truncated or failed report can never be reported as a successful run. ## 5. Restore publication and CI -- [ ] Create or restore `github.com/1337lean/envguard`, or update every project reference to a permanent namespace. -- [ ] Add pinned Staticcheck plus required test, race, vet, and native Linux/macOS/Windows jobs. -- [ ] Add CLI integration tests covering schema discovery and explicit-path behavior to CI. -- [ ] Build without publishing, verify every release archive/checksum, and publish/attest only after successful verification. -- [ ] Reconcile module path, badges, install instructions, issue/security links, and GoReleaser configuration. +- [x] Create or restore `github.com/1337lean/envguard`, or update every project reference to a permanent namespace. +- [x] Add pinned Staticcheck plus required test, race, vet, and native Linux/macOS/Windows jobs. +- [x] Add CLI integration tests covering schema discovery and explicit-path behavior to CI. +- [x] Build without publishing, verify every release archive/checksum, and publish/attest only after successful verification. +- [x] Reconcile module path, install instructions, issue/security links, and GoReleaser configuration. ## Verification @@ -107,8 +107,8 @@ Repeat explicit/missing-schema and non-finite-number CLI probes on Linux, macOS, ## Definition of done -- [ ] E-01 through E-04 have regression coverage. -- [ ] Schema discovery and the constraint matrix are documented. -- [ ] The audited missing-schema command now fails with the intended exit code. -- [ ] All native-platform jobs and analyzers pass. +- [x] E-01 through E-04 have regression coverage. +- [x] Schema discovery and the constraint matrix are documented. +- [x] The audited missing-schema command now fails with the intended exit code. +- [x] All native-platform jobs and analyzers pass. - [ ] A tagged release is verified in full before publication.