Skip to content

Commit 84b416f

Browse files
David-Noble-at-workDavid Noble
andauthored
fix(deploy): the sops test asserts the portable fact -- mode bits on unix, the DACL on windows (#580)
#433 expected a production fix and found none owed: DecryptSopsFile already writes the plaintext through the session root (encryption/provider.go), the deploy environment is confined, and enforceSecretFloor refuses any mode granting group or other. The write has been enforced all along. What remained was the test asserting Mode().Perm() == 0600, which reports 0666 on Windows however private the file actually is -- an assertion with nothing true to say there. TestExecute_SopsChains now asserts privacy in each platform's own terms through a build-tagged assertPrivateFile pair: the unix half checks the 0600 mode bits as before; the windows half reads the access-control list back and requires SE_DACL_PROTECTED, in the shape phase 2's applymode tests established. The runner now VERIFIES the production DACL on decrypted plaintext rather than assuming it -- if the deploy path ever stops protecting, the windows leg goes red with this test's name on it. Expected Windows movement: TestExecute_SopsChains leaves the known-failure set -- the baseline goes 4 to 3, and the write-path cluster is closed. Verified with make check (103 packages ok, 0 failures) and make vet under GOOS windows. Closes #433. Refs #405, #430. Co-authored-by: David Noble <[email protected]>
1 parent a7d7e06 commit 84b416f

4 files changed

Lines changed: 82 additions & 18 deletions

File tree

‎cmd/writ/writ/deploy/sops_integration_test.go‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import (
2222

2323
// TestExecute_SopsChains deploys the two encrypted pipelines end to end — the plain decrypt
2424
// (`secret.yaml.sops` → decrypted `secret.yaml`) and the decrypt+render chain
25-
// (`note.yaml.template.sops` → decrypted, rendered `note.yaml`) — asserting content and the 0600 secret mode.
26-
// The ambient age identity comes from SOPS_AGE_KEY, per the sealed config-free decryption model.
25+
// (`note.yaml.template.sops` → decrypted, rendered `note.yaml`) — asserting content and that both outputs are
26+
// private in the platform's own terms: 0600 mode bits on unix, a protected DACL on Windows
27+
// (assertPrivateFile, the portable-fact pair). The ambient age identity comes from SOPS_AGE_KEY, per the
28+
// sealed config-free decryption model.
2729
func TestExecute_SopsChains(t *testing.T) {
2830

2931
root := t.TempDir()
@@ -73,13 +75,7 @@ func TestExecute_SopsChains(t *testing.T) {
7375
if !strings.Contains(string(decrypted), "credential: hello world") {
7476
t.Errorf("decrypted secret = %q, want the plaintext credential", decrypted)
7577
}
76-
info, err := os.Stat(filepath.Join(targetRoot, "secret.yaml"))
77-
if err != nil {
78-
t.Fatal(err)
79-
}
80-
if info.Mode().Perm() != 0o600 {
81-
t.Errorf("secret mode = %v, want 0600", info.Mode().Perm())
82-
}
78+
assertPrivateFile(t, filepath.Join(targetRoot, "secret.yaml"))
8379

8480
// The decrypt+render chain: decrypted, rendered, 0600.
8581
note, err := os.ReadFile(filepath.Join(targetRoot, "note.yaml"))
@@ -89,13 +85,7 @@ func TestExecute_SopsChains(t *testing.T) {
8985
if !strings.Contains(string(note), "greeting: hi Darwin") {
9086
t.Errorf("rendered note = %q, want the rendered greeting", note)
9187
}
92-
noteInfo, err := os.Stat(filepath.Join(targetRoot, "note.yaml"))
93-
if err != nil {
94-
t.Fatal(err)
95-
}
96-
if noteInfo.Mode().Perm() != 0o600 {
97-
t.Errorf("note mode = %v, want 0600", noteInfo.Mode().Perm())
98-
}
88+
assertPrivateFile(t, filepath.Join(targetRoot, "note.yaml"))
9989
}
10090

10191
// sopsEncrypt generates an age identity and encrypts plainYAML with SOPS, returning the encrypted bytes and
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright Noble Factor. All rights reserved.
3+
4+
//go:build !windows
5+
6+
package deploy_test
7+
8+
import (
9+
"os"
10+
"testing"
11+
)
12+
13+
// assertPrivateFile asserts the file at `path` is private in this platform's own terms — here, mode 0600.
14+
//
15+
// The portable-fact pattern (#433, #435): the unix half asserts the mode bits; the windows half reads the
16+
// access-control list back, because Mode().Perm() reports 0666 there however private the file actually is.
17+
func assertPrivateFile(t *testing.T, path string) {
18+
19+
t.Helper()
20+
21+
info, err := os.Stat(path)
22+
if err != nil {
23+
t.Fatalf("Stat(%s): %v", path, err)
24+
}
25+
if info.Mode().Perm() != 0o600 {
26+
t.Errorf("%s mode = %v, want 0600", path, info.Mode().Perm())
27+
}
28+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright Noble Factor. All rights reserved.
3+
4+
//go:build windows
5+
6+
package deploy_test
7+
8+
import (
9+
"testing"
10+
11+
"golang.org/x/sys/windows"
12+
)
13+
14+
// assertPrivateFile asserts the file at `path` is private in this platform's own terms — here, a protected
15+
// DACL.
16+
//
17+
// The portable-fact pattern (#433, #435): Mode().Perm() reports 0666 on Windows however private the file
18+
// actually is, so the truth is the access-control list, read back in the shape phase 2's applymode tests
19+
// established. Inheritance broken is the load-bearing part: without SE_DACL_PROTECTED the parent's inherited
20+
// ACEs survive on the decrypted plaintext and it is private in name only.
21+
func assertPrivateFile(t *testing.T, path string) {
22+
23+
t.Helper()
24+
25+
descriptor, err := windows.GetNamedSecurityInfo(
26+
path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
27+
if err != nil {
28+
t.Fatalf("GetNamedSecurityInfo(%s): %v", path, err)
29+
}
30+
31+
control, _, err := descriptor.Control()
32+
if err != nil {
33+
t.Fatalf("Control(%s): %v", path, err)
34+
}
35+
36+
if control&windows.SE_DACL_PROTECTED == 0 {
37+
t.Errorf("%s DACL is not protected; inherited ACEs survive on decrypted plaintext: %s",
38+
path, descriptor.String())
39+
}
40+
}

‎docs/plans/windows-native-permissions.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -984,9 +984,15 @@ read an unmoved 28 after phase 3 as a failed migration.**
984984
`document_windows_test.go` DACL read-backs prove protection and the 0o644 boundary, in
985985
phase 2's shape. The windows leg moves **6 → 4**: `TestWrite_JSONCreatesFileWith0o600`,
986986
`TestWrite_YAMLCreatesFileWith0o600`.
987-
- [ ] **3.4 — the writ deploy/sops output path** ([#433](https://github.com/NobleFactor/devlore-cli/issues/433)) — behind `TestExecute_SopsChains`, and the one
987+
- [x] **3.4 — the writ deploy/sops output path** ([#433](https://github.com/NobleFactor/devlore-cli/issues/433)) — behind `TestExecute_SopsChains`, and the one
988988
that writes **decrypted plaintext**, which makes it the second-most security-relevant site in
989-
the campaign after the private key.
989+
the campaign after the private key. **Resolved 2026-08-20: production was already correct** —
990+
`DecryptSopsFile` writes through the session root (encryption/provider.go), the deploy
991+
environment is confined, and `enforceSecretFloor` refuses non-private modes. What remained was
992+
the test asserting `Mode().Perm()`, which reports 0666 on Windows regardless; it now asserts
993+
the portable fact via a build-tagged `assertPrivateFile` pair — 0600 mode bits on unix, a
994+
protected DACL on Windows — so the runner *verifies* the production DACL rather than assuming
995+
it. The windows leg moves **4 → 3**.
990996
- [ ] Exit gate: every restrictive-perm write in these areas flows through a root; a **DACL
991997
read-back test** proves the private key is protected on Windows, in the shape phase 2's
992998
`_windows_test.go` established. The 28 does **not** move here.

0 commit comments

Comments
 (0)