Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19b52f6c9b | |||
| 9c65833954 | |||
| 6f5705fe02 | |||
| b4a0ada87e | |||
| ced2182322 | |||
| da682f1017 | |||
| 3269e1cb1d | |||
| a6bd1385ab | |||
| b765cca0ed | |||
| bfe92661ec | |||
| c5ce851fc7 | |||
| 10bcb49514 | |||
| 50c4e910ed | |||
| 0d5ff663b4 | |||
| 7f81042abd |
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"phase": 10,
|
||||
"phase": 15,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.12",
|
||||
"milestone_slug": "security-hardening",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-07T11:19:00Z",
|
||||
"updated_at": "2026-08-07T11:25:00Z",
|
||||
"milestone_complete": false,
|
||||
"previous_milestone": "v0.11",
|
||||
"wave": "C (P11 SVID chain, P12 backup symlink) next",
|
||||
"phases_shipped": ["P0","P1","P2","P3","P4","P5","P6","P7","P8","P9","P10"],
|
||||
"tags_shipped": ["v0.11.0","v0.11.1","v0.11.2","v0.11.3","v0.11.4","v0.11.5","v0.11.6","v0.11.7","v0.11.8","v0.11.9","v0.11.10"],
|
||||
"wave": "D done (P13 step-ca tmp, P14 master key rotation, P15 file-mode audit). E next (P16 aggregate.sh, P17 install.sh, P18 nft, P19 sudoers, P20 system user)",
|
||||
"phases_shipped": ["P0","P1","P2","P3","P4","P5","P6","P7","P8","P9","P10","P11","P12","P13","P14","P15"],
|
||||
"tags_shipped": ["v0.11.0","v0.11.1","v0.11.2","v0.11.3","v0.11.4","v0.11.5","v0.11.6","v0.11.7","v0.11.8","v0.11.9","v0.11.10","v0.11.11","v0.11.12","v0.11.13","v0.11.14","v0.11.15"],
|
||||
"binding_conditions": ["C-29","C-30","C-31","C-32","C-33","C-34","C-35","C-36","C-37","C-38"],
|
||||
"phase_count": 29,
|
||||
"load_bearing_rule": "R-021"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# P23 Dual-Write Closure — Decision (v0.12)
|
||||
|
||||
**Status**: DEFERRED to v1.x. The full deletion of the legacy CA
|
||||
(`internal/security/ca.go`), mTLS transport (`internal/transport/mtls.go`),
|
||||
and daemon plaintext mode is too large a refactor for v0.12 without
|
||||
risking build stability. The legacy code is already marked Deprecated;
|
||||
the step-ca + OIDC path (P04/P05/P07) is the primary identity layer.
|
||||
|
||||
## What v0.12 did close
|
||||
|
||||
- P07 removed all password paths (step-ca `--password-file`, Proxmox
|
||||
`--password`, KindToken always-denies).
|
||||
- P09 removed daemon plaintext mode (Start() requires mTLS).
|
||||
- P11 added SVID chain validation (VerifySVIDWithChain).
|
||||
- P06 rewrote ACL to OIDC (KindToken deprecated).
|
||||
|
||||
## What remains for v1.x
|
||||
|
||||
- Delete `internal/security/ca.go` legacy CA (requires migrating
|
||||
`orca init` + `orca cert *` to step-ca exclusively).
|
||||
- Delete `internal/transport/mtls.go` deprecated path.
|
||||
- Delete `internal/certpaths/` (v0.8 flat layout); `internal/paths/`
|
||||
is the only layout.
|
||||
- Migrate `rotate-lead`, `drain`, `cutover`, `recovery` from
|
||||
`certpaths` to `paths`.
|
||||
|
||||
## Why not in v0.12
|
||||
|
||||
The legacy CA is load-bearing for `orca init` and 6+ CLI commands. A
|
||||
big-bang deletion would require migrating all of them to step-ca in a
|
||||
single phase, with high risk of breaking the build. v0.12 is a
|
||||
security-hardening milestone; the dual-write window is a code-hygiene
|
||||
issue, not a security vulnerability (the legacy CA is deprecated and
|
||||
the new path is primary). v1.x will close it as a focused refactor.
|
||||
@@ -310,6 +310,26 @@ func Restore(opts RestoreOptions) error {
|
||||
}
|
||||
continue
|
||||
case tar.TypeSymlink:
|
||||
// REQ-127 / F7: validate Linkname to prevent symlink attacks.
|
||||
// Reject absolute links, .. traversal, and links outside
|
||||
// the target dir (which could point to /etc/shadow etc.).
|
||||
link := hdr.Linkname
|
||||
if link == "" {
|
||||
return fmt.Errorf("restore: empty symlink linkname for %q", name)
|
||||
}
|
||||
if strings.HasPrefix(link, "/") {
|
||||
return fmt.Errorf("restore: symlink %q has absolute linkname %q (REQ-127: path traversal)", name, link)
|
||||
}
|
||||
if strings.Contains(link, "..") {
|
||||
// Resolve the link relative to the dest dir; if it
|
||||
// escapes the target, reject.
|
||||
linkDest := filepath.Join(filepath.Dir(dest), link)
|
||||
linkClean := filepath.Clean(linkDest)
|
||||
targetClean := filepath.Clean(target)
|
||||
if !strings.HasPrefix(linkClean, targetClean+string(filepath.Separator)) && linkClean != targetClean {
|
||||
return fmt.Errorf("restore: symlink %q linkname %q escapes target (REQ-127)", name, link)
|
||||
}
|
||||
}
|
||||
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("restore: clear symlink %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -314,3 +318,96 @@ func TestBackupSignatureFileContent(t *testing.T) {
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
return hex.DecodeString(s)
|
||||
}
|
||||
|
||||
// --- REQ-127 / F7 backup symlink validation tests ---
|
||||
|
||||
// TestRestoreRejectsAbsoluteSymlink verifies a tarball with an absolute
|
||||
// symlink linkname is rejected.
|
||||
func TestRestoreRejectsAbsoluteSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a crafted tarball with an absolute symlink.
|
||||
tarPath := filepath.Join(dir, "evil.tar.gz")
|
||||
sigPath := tarPath + ".sig"
|
||||
if err := createCraftedTarball(tarPath, "link", "/etc/shadow"); err != nil {
|
||||
t.Fatalf("create tarball: %v", err)
|
||||
}
|
||||
// Create a valid signature (the signature verifies, but the symlink
|
||||
// validation should still reject the restore).
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
data, _ := os.ReadFile(tarPath)
|
||||
mac.Write(data)
|
||||
if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
|
||||
t.Fatalf("write sig: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, "restore")
|
||||
os.MkdirAll(target, 0o755)
|
||||
err := Restore(RestoreOptions{
|
||||
InputPath: tarPath,
|
||||
TargetDir: target,
|
||||
MasterKey: key,
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Restore should reject absolute symlink (REQ-127)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absolute") {
|
||||
t.Errorf("error should mention absolute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreRejectsTraversalSymlink verifies a tarball with a .. symlink
|
||||
// that escapes the target is rejected.
|
||||
func TestRestoreRejectsTraversalSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tarPath := filepath.Join(dir, "evil2.tar.gz")
|
||||
sigPath := tarPath + ".sig"
|
||||
if err := createCraftedTarball(tarPath, "link", "../../etc/shadow"); err != nil {
|
||||
t.Fatalf("create tarball: %v", err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
data, _ := os.ReadFile(tarPath)
|
||||
mac.Write(data)
|
||||
if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
|
||||
t.Fatalf("write sig: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, "restore2")
|
||||
os.MkdirAll(target, 0o755)
|
||||
err := Restore(RestoreOptions{
|
||||
InputPath: tarPath,
|
||||
TargetDir: target,
|
||||
MasterKey: key,
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Restore should reject traversal symlink (REQ-127)")
|
||||
}
|
||||
}
|
||||
|
||||
// createCraftedTarball creates a tar.gz containing a single symlink
|
||||
// entry with the given linkname. Used to test symlink validation.
|
||||
func createCraftedTarball(path, name, linkname string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Typeflag: tar.TypeSymlink,
|
||||
Linkname: linkname,
|
||||
Mode: 0o644,
|
||||
}
|
||||
return tw.WriteHeader(hdr)
|
||||
}
|
||||
|
||||
@@ -276,11 +276,118 @@ var secretsDeleteCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var secretsRotateMasterDryRun bool
|
||||
|
||||
var secretsRotateMasterCmd = &cobra.Command{
|
||||
Use: "rotate-master",
|
||||
Short: "Generate a new master key + re-encrypt all namespace secrets (REQ-129, C-30)",
|
||||
Long: `Generate a new master key, re-encrypt every namespace's .env.secrets
|
||||
under the new key, and re-seal the master key to OIDC. With --dry-run,
|
||||
reports the affected namespaces without writing. Atomic per-namespace;
|
||||
automatic rollback to the old key on any failure (C-30).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mkPath := paths.MasterKeyPath()
|
||||
oldKey, err := secrets.LoadMasterKey(mkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load current master key: %w", err)
|
||||
}
|
||||
|
||||
// Find all namespaces with .env.secrets files.
|
||||
root := paths.Root()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ORCA_HOME: %w", err)
|
||||
}
|
||||
var namespaces []string
|
||||
for _, ent := range entries {
|
||||
if !ent.IsDir() || ent.Name() == "cluster" {
|
||||
continue
|
||||
}
|
||||
secPath := paths.NSSecrets(ent.Name())
|
||||
if _, err := os.Stat(secPath); err == nil {
|
||||
namespaces = append(namespaces, ent.Name())
|
||||
}
|
||||
}
|
||||
if secretsRotateMasterDryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "dry-run: would re-encrypt %d namespace(s) under a new master key:\n", len(namespaces))
|
||||
for _, ns := range namespaces {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", ns)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate new master key.
|
||||
newKey, err := secrets.GenerateMasterKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate new master key: %w", err)
|
||||
}
|
||||
|
||||
// Re-encrypt each namespace. On any failure, rollback.
|
||||
rolled := make(map[string][]string) // ns -> old encrypted (for rollback)
|
||||
for _, ns := range namespaces {
|
||||
_, lines, err := loadMasterAndNSSecrets(ns)
|
||||
if err != nil {
|
||||
// Rollback already-processed namespaces.
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("load secrets for ns %s: %w", ns, err)
|
||||
}
|
||||
// Save the old encrypted content for rollback.
|
||||
secPath := paths.NSSecrets(ns)
|
||||
oldEnc, _ := os.ReadFile(secPath)
|
||||
rolled[ns] = []string{string(oldEnc)}
|
||||
|
||||
// Re-encrypt under the new key.
|
||||
newNSKey, err := secrets.DeriveNamespaceKey(newKey, ns)
|
||||
if err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("derive new ns key for %s: %w", ns, err)
|
||||
}
|
||||
enc, err := secrets.EncryptEnvFile(newNSKey, lines)
|
||||
if err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("re-encrypt ns %s: %w", ns, err)
|
||||
}
|
||||
if err := writeAtomicFile(secPath, []byte(enc), 0o600); err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("write ns %s: %w", ns, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the new master key.
|
||||
if err := secrets.SaveMasterKey(mkPath, newKey); err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("save new master key (rolled back): %w", err)
|
||||
}
|
||||
|
||||
slog.Info("secrets rotate-master", "namespaces", len(namespaces))
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted\n", len(namespaces))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// rollbackRotation restores old encrypted secrets for already-processed
|
||||
// namespaces (C-30: automatic rollback on failure).
|
||||
func rollbackRotation(rolled map[string][]string, oldKey []byte) {
|
||||
mkPath := paths.MasterKeyPath()
|
||||
_ = secrets.SaveMasterKey(mkPath, oldKey) // restore old key
|
||||
for ns, oldEnc := range rolled {
|
||||
if len(oldEnc) > 0 {
|
||||
_ = writeAtomicFile(paths.NSSecrets(ns), []byte(oldEnc[0]), 0o600)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
secretsCmd.AddCommand(secretsSetCmd)
|
||||
secretsCmd.AddCommand(secretsGetCmd)
|
||||
secretsCmd.AddCommand(secretsListCmd)
|
||||
secretsCmd.AddCommand(secretsRotateCmd)
|
||||
secretsCmd.AddCommand(secretsDeleteCmd)
|
||||
secretsRotateMasterCmd.Flags().BoolVar(&secretsRotateMasterDryRun, "dry-run", false, "report affected namespaces without writing (C-30)")
|
||||
secretsCmd.AddCommand(secretsRotateMasterCmd)
|
||||
rootCmd.AddCommand(secretsCmd)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ func renderNftRuleset(cfg NftClusterConfig) string {
|
||||
b.WriteString("\t}\n\n")
|
||||
b.WriteString("\tchain input {\n")
|
||||
b.WriteString("\t\ttype filter hook input priority filter; policy accept;\n")
|
||||
b.WriteString("\t\tct state invalid drop\n")
|
||||
b.WriteString("\t\tct state established,related accept\n")
|
||||
b.WriteString("\t\ttcp dport 443 tcp-flags != syn,rst,ack,fin notrack drop\n")
|
||||
b.WriteString("\t}\n\n")
|
||||
b.WriteString("\tchain prerouting {\n")
|
||||
|
||||
@@ -37,8 +37,8 @@ func MintSVID(ctx context.Context, transport execer, leadPeer, namespace, sa, al
|
||||
return nil, nil, errors.New("identity: lead peer not set")
|
||||
}
|
||||
spiffeID := SpiffeURI(namespace, sa, allocID)
|
||||
certOut := "/tmp/orca-svid-" + sanitize(spiffeID) + ".crt"
|
||||
keyOut := "/tmp/orca-svid-" + sanitize(spiffeID) + ".key"
|
||||
certOut := "/etc/orca/step-tmp/orca-svid-" + sanitize(spiffeID) + ".crt"
|
||||
keyOut := "/etc/orca/step-tmp/orca-svid-" + sanitize(spiffeID) + ".key"
|
||||
var sb strings.Builder
|
||||
sb.WriteString("step ca certificate ")
|
||||
sb.WriteString(shellQuote(spiffeID))
|
||||
|
||||
@@ -155,8 +155,8 @@ func TestMintSVID_Success(t *testing.T) {
|
||||
keyPEM := []byte("-----BEGIN PRIVATE KEY-----\nFAKE\n-----END PRIVATE KEY-----\n")
|
||||
mx := &mockExec{responses: []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: keyPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}}
|
||||
gotCert, gotKey, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
|
||||
@@ -203,8 +203,8 @@ func TestMintSVID_EmptyLead(t *testing.T) {
|
||||
func TestMintSVID_EmptyCert(t *testing.T) {
|
||||
mx := &mockExec{responses: []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: nil, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}}
|
||||
_, _, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
|
||||
@@ -217,8 +217,8 @@ func TestMintSVID_URISANMissing(t *testing.T) {
|
||||
wrongCert := mintTestSVIDCert(t, "spiffe://orca.local/ns/other/sa/api/0")
|
||||
mx := &mockExec{responses: []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: wrongCert, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: wrongCert, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}}
|
||||
_, _, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
|
||||
|
||||
@@ -189,7 +189,7 @@ func alreadyMigrated(dir string) bool {
|
||||
// added it; v0.11 is single-namespace-per-DB). This mirrors the
|
||||
// internal/store/migrate.go pattern but operates on a copied DB.
|
||||
func migrateDBSchema(dbPath string) error {
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)")
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", dbPath, err)
|
||||
}
|
||||
@@ -273,6 +273,8 @@ func fileExists(path string) bool {
|
||||
}
|
||||
|
||||
// copyFile copies src to dst preserving the file mode.
|
||||
// copyFile copies src to dst atomically (temp + rename). REQ-137/F19:
|
||||
// a crash mid-copy must not leave a partial DB file.
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
@@ -282,7 +284,11 @@ func copyFile(src, dst string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, info.Mode().Perm())
|
||||
tmp := dst + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, info.Mode().Perm()); err != nil {
|
||||
return fmt.Errorf("copyFile: write tmp: %w", err)
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
// GetCAImporter returns the package-level CA importer (set via
|
||||
|
||||
@@ -392,7 +392,7 @@ func deployPubKey(user, pubLine string) error {
|
||||
// createLinuxUser creates the orca system user if it doesn't already
|
||||
// exist. Idempotent: `id -u` check before `useradd`.
|
||||
func createLinuxUser(user string) error {
|
||||
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -m -s /bin/bash %s", user, user)
|
||||
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -r -s /usr/sbin/nologin %s", user, user)
|
||||
if _, err := runRemote(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -449,9 +449,9 @@ func sudoersContent(user string) string {
|
||||
# pvesh is EXCLUDED (AD-020: pvesh can bypass NOEXEC via API execute).
|
||||
%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct
|
||||
%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/qm
|
||||
%s ALL=(root) NOPASSWD: /usr/bin/apt-get
|
||||
%s ALL=(root) NOPASSWD: /usr/bin/dpkg
|
||||
`, user, user, user, user)
|
||||
|
||||
|
||||
`, user, user)
|
||||
}
|
||||
|
||||
// writeSudoers writes the /etc/sudoers.d/orca file on the remote host
|
||||
|
||||
@@ -33,17 +33,11 @@ func TestSudoersContent(t *testing.T) {
|
||||
t.Error("missing NOEXEC on qm (AD-020)")
|
||||
}
|
||||
|
||||
if !strings.Contains(content, "NOPASSWD: /usr/bin/apt-get") {
|
||||
t.Error("missing NOPASSWD on apt-get")
|
||||
if strings.Contains(content, "apt-get") {
|
||||
t.Error("apt-get must NOT be in sudoers (REQ-134/F22: operator runs apt-get out-of-band)")
|
||||
}
|
||||
if !strings.Contains(content, "NOPASSWD: /usr/bin/dpkg") {
|
||||
t.Error("missing NOPASSWD on dpkg")
|
||||
}
|
||||
if strings.Contains(content, "NOEXEC: /usr/bin/apt-get") {
|
||||
t.Error("apt-get must NOT have NOEXEC (breaks maintainer scripts)")
|
||||
}
|
||||
if strings.Contains(content, "NOEXEC: /usr/bin/dpkg") {
|
||||
t.Error("dpkg must NOT have NOEXEC (breaks maintainer scripts)")
|
||||
if strings.Contains(content, "dpkg") {
|
||||
t.Error("dpkg must NOT be in sudoers (REQ-134/F22: operator runs dpkg out-of-band)")
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
|
||||
+43
-18
@@ -227,28 +227,53 @@ func LoadCA(dir string) (*CA, error) {
|
||||
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
|
||||
// (D-101/REQ-076). EnforceFileModes is retained for the dual-write window
|
||||
// and scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
|
||||
// EnforceFileModes checks that all security-sensitive files in dir have
|
||||
// the correct permissions (REQ-033 + REQ-130, F13). Checks: ca.crt
|
||||
// (0644), ca.key (0600), orca_ssh_key (0600), orca_ssh_key.pub (0644),
|
||||
// known_hosts (0600), master.key (0600), master.key.sealed (0600),
|
||||
// server.crt (0644), server.key (0600). Missing files are skipped (they
|
||||
// may not exist yet — e.g. before init or after migration to step-ca).
|
||||
func EnforceFileModes(dir string) error {
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
certInfo, err := os.Stat(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", certPath, err)
|
||||
// Files that must be 0600 (secrets/keys).
|
||||
secretFiles := []string{
|
||||
CAKeyFile,
|
||||
"orca_ssh_key",
|
||||
"known_hosts",
|
||||
"master.key",
|
||||
"master.key.sealed",
|
||||
"server.key",
|
||||
}
|
||||
keyInfo, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", keyPath, err)
|
||||
// Files that must be 0644 (certs/public keys).
|
||||
publicFiles := []string{
|
||||
CACertFile,
|
||||
"orca_ssh_key.pub",
|
||||
"server.crt",
|
||||
}
|
||||
if certInfo.Mode().Perm() != CACPEMMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
certPath, certInfo.Mode().Perm(), CACPEMMode, CACPEMMode, certPath,
|
||||
)
|
||||
for _, name := range secretFiles {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue // skip missing
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
return fmt.Errorf(
|
||||
"REQ-033/130 violation: %s has mode %04o, want 0600 — fix with `chmod 0600 %s`",
|
||||
path, info.Mode().Perm(), path,
|
||||
)
|
||||
}
|
||||
}
|
||||
if keyInfo.Mode().Perm() != CAMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
keyPath, keyInfo.Mode().Perm(), CAMode, CAMode, keyPath,
|
||||
)
|
||||
for _, name := range publicFiles {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue // skip missing
|
||||
}
|
||||
if info.Mode().Perm() != 0o644 {
|
||||
return fmt.Errorf(
|
||||
"REQ-033/130 violation: %s has mode %04o, want 0644 — fix with `chmod 0644 %s`",
|
||||
path, info.Mode().Perm(), path,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ func Flock(path string) (release func(), err error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// REQ-139 / F15: tighten pre-existing looser perms to 0600.
|
||||
// OpenFile with O_CREATE only sets the mode on creation; if the
|
||||
// file already exists with looser perms, they persist. Chmod
|
||||
// ensures 0600 regardless.
|
||||
_ = os.Chmod(path, 0o600)
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
|
||||
@@ -141,8 +141,8 @@ func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string)
|
||||
// the duration string passed verbatim to `--not-after`. provisioner,
|
||||
// when non-empty, is passed as `--provisioner`.
|
||||
func (c *Client) issueCert(ctx context.Context, subject string, sans []string, notAfter string, provisioner string) (string, string, error) {
|
||||
certOut := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(subject))
|
||||
keyOut := fmt.Sprintf("/tmp/orca-%s.key", sanitize(subject))
|
||||
certOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(subject))
|
||||
keyOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(subject))
|
||||
var sb strings.Builder
|
||||
sb.WriteString("step ca certificate ")
|
||||
sb.WriteString(shellQuote(subject))
|
||||
@@ -164,6 +164,11 @@ func (c *Client) issueCert(ctx context.Context, subject string, sans []string, n
|
||||
}
|
||||
sb.WriteString(" --force")
|
||||
cmd := sb.String()
|
||||
// REQ-128 / F10: ensure the step-tmp dir exists at 0700 before
|
||||
// writing certs/keys there (not world-readable /tmp).
|
||||
if _, err := c.run(ctx, "mkdir -p /etc/orca/step-tmp && chmod 700 /etc/orca/step-tmp"); err != nil {
|
||||
return "", "", fmt.Errorf("stepca: mkdir step-tmp: %w", err)
|
||||
}
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return "", "", fmt.Errorf("stepca: issue %s: %w", subject, err)
|
||||
}
|
||||
@@ -190,8 +195,8 @@ func (c *Client) RenewServerCert(ctx context.Context, peer string) error {
|
||||
if perr := c.preflight(); perr != nil {
|
||||
return perr
|
||||
}
|
||||
certPath := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(peer))
|
||||
keyPath := fmt.Sprintf("/tmp/orca-%s.key", sanitize(peer))
|
||||
certPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(peer))
|
||||
keyPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(peer))
|
||||
cmd := fmt.Sprintf("step ca renew %s %s --force", shellQuote(certPath), shellQuote(keyPath))
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return fmt.Errorf("stepca: renew %s: %w", peer, err)
|
||||
|
||||
@@ -166,8 +166,8 @@ func TestIssueServerCert_Success(t *testing.T) {
|
||||
keyPEM := []byte("SERVER-KEY-PEM")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: keyPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"})
|
||||
@@ -199,8 +199,8 @@ func TestIssueSVID_Success(t *testing.T) {
|
||||
keyPEM := []byte("SVID-KEY-PEM")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"})
|
||||
@@ -232,8 +232,8 @@ func TestIssueServerCert_ReadCertFails(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: nil, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: nil, err: nil},
|
||||
}
|
||||
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "read") {
|
||||
@@ -245,8 +245,8 @@ func TestIssueServerCert_EmptyCert(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
||||
@@ -263,7 +263,7 @@ func TestRenewServerCert_Success(t *testing.T) {
|
||||
if err := c.RenewServerCert(context.Background(), "peer1"); err != nil {
|
||||
t.Fatalf("RenewServerCert: %v", err)
|
||||
}
|
||||
containsCall(t, mx, "step ca renew '/tmp/orca-peer1.crt' '/tmp/orca-peer1.key' --force")
|
||||
containsCall(t, mx, "step ca renew '/etc/orca/step-tmp/orca-peer1.crt' '/etc/orca/step-tmp/orca-peer1.key' --force")
|
||||
}
|
||||
|
||||
func TestRenewServerCert_Fails(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,15 @@ func Open(path string) (*sql.DB, error) {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
// REQ-136 / F8: enforce 0600 on the DB file (SQLite creates it
|
||||
// at umask, typically 0644). We chmod after open+ping (the file
|
||||
// exists at this point). Non-fatal if chmod fails (e.g. the DB
|
||||
// is at a path we don't own); the caller is warned via vet.
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
// Non-fatal: warn but don't fail (the DB may be at a
|
||||
// read-only location or we may not own it).
|
||||
_ = err
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
|
||||
@@ -36,6 +36,10 @@ CHECK=false
|
||||
INSTALL_BIN=""
|
||||
NAMESPACE_DIR=""
|
||||
|
||||
warn() {
|
||||
printf " \033[1;33m!\033[0m %s\n" "$*" >&2
|
||||
}
|
||||
|
||||
err() { echo "install: error: $*" >&2; exit 1; }
|
||||
info() { echo "install: $*"; }
|
||||
|
||||
@@ -173,6 +177,19 @@ trap 'rm -rf "$TMPDIR"' EXIT
|
||||
info "downloading..."
|
||||
curl -fsSL -o "${TMPDIR}/${TARBALL}" "$ASSET_URL"
|
||||
|
||||
# REQ-132 / F14: verify tarball checksum before extraction.
|
||||
# Fetch SHA256SUMS from the same release; fail closed on mismatch.
|
||||
SHA256SUMS_URL="$(dirname "$ASSET_URL")/SHA256SUMS"
|
||||
if curl -fsSL -o "${TMPDIR}/SHA256SUMS" "$SHA256SUMS_URL" 2>/dev/null; then
|
||||
info "verifying checksum..."
|
||||
(cd "$TMPDIR" && grep -F "$TARBALL" SHA256SUMS | sha256sum -c -) || {
|
||||
err "checksum verification failed (REQ-132); refusing to install"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
warn "no SHA256SUMS found at $SHA256SUMS_URL; skipping checksum (insecure)"
|
||||
fi
|
||||
|
||||
info "extracting..."
|
||||
tar -xzf "${TMPDIR}/${TARBALL}" -C "$TMPDIR"
|
||||
|
||||
|
||||
@@ -69,7 +69,25 @@ while IFS= read -r peer; do
|
||||
orca_log_warn "aggregate" "$peer" "skipped" "failed to read state snapshot"
|
||||
continue
|
||||
fi
|
||||
printf '{"peer":"%s","state":%s}\n' "$peer" "$snapshot" >>"$merge_tmp"
|
||||
# REQ-131 / F11: use jq to safely construct JSON (prevents JSON
|
||||
# injection from malicious peer output). $peer is sanitized; $snapshot
|
||||
# is parsed as raw JSON by jq, so control chars can't break out.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
snapshot_json="$(printf '%s' "$snapshot" | jq -c '.' 2>/dev/null)" || {
|
||||
orca_log_warn "aggregate" "$peer" "skipped" "peer returned invalid JSON"
|
||||
continue
|
||||
}
|
||||
peer_escaped="${peer//\"/\\\"}"
|
||||
printf '{"peer":"%s","state":%s}\n' "$peer_escaped" "$snapshot_json" >>"$merge_tmp"
|
||||
else
|
||||
# Fallback: validate $snapshot looks like JSON before interpolation.
|
||||
if ! printf '%s' "$snapshot" | grep -qE '^\s*\{.*\}\s*$'; then
|
||||
orca_log_warn "aggregate" "$peer" "skipped" "peer returned non-JSON"
|
||||
continue
|
||||
fi
|
||||
peer_escaped="${peer//\"/\\\"}"
|
||||
printf '{"peer":"%s","state":%s}\n' "$peer_escaped" "$snapshot" >>"$merge_tmp"
|
||||
fi
|
||||
orca_log_info "aggregate" "$peer" "ok" "snapshot=$latest_json"
|
||||
done < <(read_peers)
|
||||
|
||||
|
||||
+14
-3
@@ -115,10 +115,21 @@ if [ "$FORCE" != "true" ]; then
|
||||
if [ -f "$DRIFT_AGG_JSON" ]; then
|
||||
NS_FILTER="${NAMESPACE:-}"
|
||||
NS_REGEX="${NS_FILTER//\//.}"
|
||||
if [ -n "$NS_FILTER" ]; then
|
||||
DRIFT_HITS="$(grep -o '"path"[[:space:]]*:[[:space:]]*"[^"]*"' "$DRIFT_AGG_JSON" 2>/dev/null | sed 's/.*: *"//;s/"//' | grep -E "/etc/orca/actual/${NS_REGEX}/" | grep -v '"action"[[:space:]]*:[[:space:]]*"acknowledged"' || true)"
|
||||
# REQ-131 / F18: use jq for drift-gate JSON parsing (not grep).
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if [ -n "$NS_FILTER" ]; then
|
||||
DRIFT_HITS="$(jq -r --arg ns "$NS_FILTER" '[.events[]? | select((.path|test("/etc/orca/actual/\($ns)/")) and (.action != "acknowledged"))] | length' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
else
|
||||
DRIFT_HITS="$(jq -r '[.events[]? | select(.drift_confirmed == true and .action != "acknowledged")] | length' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
fi
|
||||
else
|
||||
DRIFT_HITS="$(grep -o '"drift_confirmed"[[:space:]]*:[[:space:]]*true' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
# Fallback: grep (less accurate; the ack filter may not
|
||||
# match the same line as the path filter).
|
||||
if [ -n "$NS_FILTER" ]; then
|
||||
DRIFT_HITS="$(grep -o '"path"[[:space:]]*:[[:space:]]*"[^"]*"' "$DRIFT_AGG_JSON" 2>/dev/null | sed 's/.*: *"//;s/"//' | grep -E "/etc/orca/actual/${NS_REGEX}/" || true)"
|
||||
else
|
||||
DRIFT_HITS="$(grep -o '"drift_confirmed"[[:space:]]*:[[:space:]]*true' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$DRIFT_HITS" ]; then
|
||||
orca_log_error "orca-pull" "$TXN_DIR" "drift-detected" "namespace=${NAMESPACE:-cluster-wide}"
|
||||
|
||||
Reference in New Issue
Block a user