Compare commits

..

10 Commits

Author SHA1 Message Date
Jon Chery b4a0ada87e fix(P21): SQLite file-mode 0600 (REQ-136, F8, C-31)
---ci---
project: orca
phase: 21
milestone: v0.12
status: execute
---/ci---

store.Open now chmod's the DB file to 0600 after open+ping (SQLite
creates it at umask, typically 0644). Non-fatal if chmod fails (C-31:
no CGO-free SQLCipher; file-mode 0600 is the at-rest control).
Build + tests green.
2026-08-07 11:30:56 +00:00
Jon Chery ced2182322 fix(P20): system user consistency (REQ-135, F23)
---ci---
project: orca
phase: 20
milestone: v0.12
status: execute
---/ci---

Proxmox bootstrap now creates a nologin system user (-r -s
/usr/sbin/nologin), matching peer-setup. Previously it created a
login user (-m -s /bin/bash) with more privilege. Build + tests green.
2026-08-07 11:29:56 +00:00
Jon Chery da682f1017 fix(P19): sudoers hardening — remove apt-get/dpkg (REQ-134, F22)
---ci---
project: orca
phase: 19
milestone: v0.12
status: execute
---/ci---

apt-get/dpkg removed from sudoers entirely (NOEXEC breaks maintainer
scripts; operator runs apt-get/dpkg out-of-band). Only pct + qm remain
(both NOEXEC). Tests updated. Build green.
2026-08-07 11:29:26 +00:00
Jon Chery 3269e1cb1d fix(P19): sudoers hardening — NOEXEC on apt-get/dpkg (REQ-134, F22)
---ci---
project: orca
phase: 19
milestone: v0.12
status: execute
---/ci---

All sudoers commands now have NOEXEC (pct, qm, apt-get, dpkg) to
block shell escapes (REQ-134, F22). Previously apt-get/dpkg lacked
NOEXEC. Tests pass. Build green.
2026-08-07 11:28:24 +00:00
Jon Chery a6bd1385ab fix(P18): nftables ruleset hardening (REQ-133, F21)
---ci---
project: orca
phase: 18
milestone: v0.12
status: execute
---/ci---

nft input chain hardened: ct state invalid drop + ct state
established,related accept (conntrack bounds + defense-in-depth).
Tests pass. Build green.
2026-08-07 11:27:33 +00:00
Jon Chery b765cca0ed fix(P17): install.sh checksum verification (REQ-132, F14)
---ci---
project: orca
phase: 17
milestone: v0.12
status: execute
---/ci---

install.sh now fetches SHA256SUMS from the release and verifies the
tarball checksum before extraction. Fail closed on mismatch. Warns
if SHA256SUMS is absent (insecure). Build green.
2026-08-07 11:26:35 +00:00
Jon Chery bfe92661ec fix(P16): aggregate.sh JSON injection + drift-gate fix (REQ-131, F11, F18)
---ci---
project: orca
phase: 16
milestone: v0.12
status: execute
---/ci---

orca-aggregate.sh: peer output validated via jq before JSON
interpolation (prevents injection from malicious peer). Peer name
escaped. Fallback: JSON shape validation via grep.
orca-pull.sh: R-020 drift gate now uses jq for accurate JSON parsing
(replaces fragile grep-based parsing). Fallback to grep if jq absent.
Build green.
2026-08-07 11:26:04 +00:00
Jon Chery c5ce851fc7 docs(checkpoint): P13-P15 shipped (step-ca tmp, master key rotation, file-mode audit)
---ci---
project: orca
phase: 15
milestone: v0.12
status: complete
---/ci---
2026-08-07 11:25:10 +00:00
Jon Chery 10bcb49514 fix(P15): file-mode audit expansion (REQ-130, F13)
---ci---
project: orca
phase: 15
milestone: v0.12
status: execute
---/ci---

EnforceFileModes now checks SSH key, known_hosts, master.key,
master.key.sealed, server.key (0600) + orca_ssh_key.pub, server.crt
(0644). Missing files skipped (may not exist before init or after
step-ca migration). All security tests pass. Build green.
2026-08-07 11:25:02 +00:00
Jon Chery 50c4e910ed fix(P14): master key rotation (REQ-129, F12, C-30)
---ci---
project: orca
phase: 14
milestone: v0.12
status: execute
---/ci---

orca secrets rotate-master: generates new master key, re-encrypts all
namespace secrets under new key, saves new key. --dry-run reports
affected namespaces. Atomic per-namespace; automatic rollback to old
key on any failure (C-30). Fixed unused nsKey in get+list (pre-existing
vet issue). Build + vet + tests green.
2026-08-07 11:24:19 +00:00
10 changed files with 224 additions and 41 deletions
+5 -5
View File
@@ -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"
+107
View File
@@ -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)
}
+2
View File
@@ -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")
+4 -4
View File
@@ -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
+4 -10
View File
@@ -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
View File
@@ -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
}
+9
View File
@@ -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)
+17
View File
@@ -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"
+19 -1
View File
@@ -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
View File
@@ -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}"