Audit date: 2026-03-26 Methodology: SAST + DAST + working exploits + QA validation (13/13) Last updated: 2026-05-18
| ID | Title | Severity | CVSS | Status |
|---|---|---|---|---|
| SEC-001 | SSH InsecureIgnoreHostKey() β MITM of remote deploy |
π΄ CRITICAL | 8.8 | β Fixed |
| SEC-002 | Hook timeout parameter silently ignored β DoS | π HIGH | 7.1 | β Fixed |
| SEC-003 | Hook allowlist bypass via filepath.Base() |
π HIGH | 7.5 | β Fixed |
| SEC-004 | Sensitive data in /tmp via os.CreateTemp |
π‘ MEDIUM | 5.3 | β Fixed |
| SEC-005 | Backup directory created 0755 not 0700 |
π‘ MEDIUM | 5.3 | β Fixed |
| SEC-006 | Incomplete denylist β sudoers.d/pam.d/polkit unprotected |
π‘ MEDIUM | 5.4 | β Fixed |
| SEC-007 | GitHub Actions pinned to mutable tags β supply chain | π‘ MEDIUM | 5.9 | π² Open |
| SEC-008 | {{env.NAME}} reads arbitrary env vars into git history |
π‘ MEDIUM | 5.0 | β Fixed |
| FUN-002 | state.json read without lock β concurrent corruption |
π’ LOW | 3.1 | β Fixed |
| FUN-003 | needsSudo heuristic wrong for non-/home/ users |
π’ LOW | 2.5 | β Fixed |
| DES-001 | Denylist blocked --hash-only (design gap, not a vuln) |
β | β | β Fixed |
| SEC-009 | SSH key file world-readable β credential leak | π’ LOW | 3.3 | β Fixed |
| SEC-010 | Passphrase-protected SSH key gives opaque error | π’ LOW | 2.0 | β Fixed |
| SEC-011 | MkdirAll creates 0755 dirs for 0600 secret files |
π‘ MEDIUM | 5.3 | β Fixed |
| SEC-012 | bech32 decoder truncates multi-byte runes β bypass possible | π’ LOW | 3.7 | β Fixed |
| Location | internal/core/remote_deploy.go β buildSSHClientConfig |
| CVSS | 8.8 (AV:N/AC:M/Au:N/C:H/I:H/A:N) |
| Impact | Full MITM of remote deployment; decrypted secrets delivered to attacker server |
Vulnerability: buildSSHClientConfig used gossh.InsecureIgnoreHostKey() as the HostKeyCallback. This accepts any server key unconditionally β including an attacker's rogue server. sysfig decrypts age-encrypted files before opening the SSH connection, so plaintext secrets were delivered to whoever answered on the target port.
Fix: Replaced with loadHostKeyCallback(), which reads the expected host public key from $SYSFIG_SSH_HOST_KEY and returns gossh.FixedHostKey(pub). The connection is rejected if the server presents a different key.
# Required: point to the remote host's public key before deploying
export SYSFIG_SSH_HOST_KEY=/etc/ssh/ssh_host_ed25519_key.pub
sysfig deploy --host user@target --allRegression tests: TestSEC001_HostKeyCallback_AcceptsConfiguredKey, TestSEC001_HostKeyCallback_RejectsAttackerKey (sec_regression_test.go)
| Location | internal/core/hooks.go β runCmd |
| CVSS | 7.1 (AV:L/AC:L/Au:N/C:N/I:N/A:H) |
| Impact | sysfig apply / sysfig deploy hangs permanently if any hook blocks |
Vulnerability: runCmd(timeout, name, args...) accepted a timeout parameter but used exec.Command (no context), so the timeout was never applied. A hook that calls a blocking binary or hangs on a slow systemctl could stall the entire pipeline indefinitely.
Fix: Replaced exec.Command with exec.CommandContext using context.WithTimeout:
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)Regression test: TestSEC002_RunCmdTimeoutEnforced (sec_regression_test.go)
| Location | internal/core/hooks.go β LoadHooks, runExec |
| CVSS | 7.5 (AV:L/AC:L/Au:L/C:H/I:H/A:H) |
| Impact | Any binary on disk named after an allowlisted tool can be executed |
Vulnerability: The allowlist was built from basenames (filepath.Base(entry)) and checked against filepath.Base(cmd[0]). A hooks.yaml entry allowlist: [nginx] intended to allow /usr/sbin/nginx also permitted /tmp/nginx, /home/user/nginx, or any binary named nginx anywhere on the filesystem.
Combined with SEC-005, this formed a complete chain exploit: an attacker with write access to a shared hooks repo injects /tmp/nginx as the hook binary. It executes without warning, reads ~/.sysfig/keys/master.key from the world-traversable backup dir, and POSTs it to a remote server.
Fix: The allowlist now stores full paths as-is, and runExec resolves cmd[0] via exec.LookPath + filepath.Abs before comparing:
resolved, err := exec.LookPath(cmd[0])
absPath, _ := filepath.Abs(resolved)
if !allowlist[absPath] {
return "", fmt.Errorf("hooks: exec: %q is not in the allowlist", absPath)
}Breaking change: hooks.yaml allowlist entries must now be full absolute paths:
# Before (no longer accepted)
allowlist: [nginx, systemctl]
# After (required)
allowlist:
- /usr/sbin/nginx
- /usr/bin/systemctlRegression tests: TestSEC003_AllowlistPathTraversalIsBlocked, TestSEC003_AllowlistFullPathIsAccepted, TestSEC003_LoadHooks_AllowlistStoresFullPath (hooks_test.go)
| Location | internal/backup/backup.go β Backup |
| CVSS | 5.3 (AV:L/AC:L/Au:N/C:M/I:N/A:N) |
| Impact | Any local user can traverse ~/.sysfig/backups/ and read backed-up secrets |
Vulnerability: os.MkdirAll(dir, 0o755) made the per-file backup directory world-traversable. On a multi-user system, any local user could ls ~/.sysfig/backups/ and read the contents of backed-up config files, including /etc/wireguard/wg0.conf or any other encrypted file that sysfig has a backup of.
Fix: Changed to 0o700.
Note: The ~/.sysfig/ root and its keys/ subdirectory were already created with 0700 in init.go and clone.go. This was only the per-file backup subdirectory inside backups/.
Regression test: TestSEC005_BackupDirCreatedWith0700 (backup_test.go)
| Location | internal/core/track.go β systemDenylist, IsDenied |
| CVSS | 5.4 (AV:N/AC:H/Au:L/C:H/I:N/A:N) |
| Impact | Privilege-escalation paths (sudoers.d, pam.d, polkit) could be tracked and pushed to a remote git repo |
Vulnerability: The original denylist covered /etc/sudoers but not the drop-in directory /etc/sudoers.d/*. Similarly, /etc/pam.d/*, /etc/polkit-1/**, /etc/cron.d/*, /run/secrets/*, and nested SSL keys under /etc/ssl/private/sub/ were all reachable. The filepath.Match glob engine only matches one directory level, so private/* did not cover private/sub/key.pem.
Fix: Extended systemDenylist and added /** recursive matching to IsDenied:
"/etc/sudoers.d/*",
"/etc/pam.d/*",
"/etc/security/*",
"/etc/polkit-1/**", // recursive β any depth
"/etc/cron.d/*",
"/etc/cron.daily/*",
"/run/secrets/*",
"/etc/ssl/private/**", // upgraded from /* to cover nested certsIsDenied now handles /** patterns via strings.HasPrefix before falling through to filepath.Match.
Regression tests: TestSEC006_SudoersDotD_IsDenied, TestSEC006_PamD_IsDenied, TestSEC006_Polkit_IsDenied, TestSEC006_CronD_IsDenied, TestSEC006_RunSecrets_IsDenied, TestSEC006_SslPrivateNested_IsDenied (track_test.go)
| Location | internal/core/template.go β resolveVar |
| CVSS | 5.0 (AV:N/AC:H/Au:L/C:H/I:N/A:N) |
| Impact | Cloud credentials / tokens leaked into git commit history |
Vulnerability: {{env.NAME}} in a template first checked TemplateVars.Extra, then fell back to os.Getenv(name). An attacker with write access to a shared profile repo could add {{env.AWS_ACCESS_KEY_ID}} to any template. When a victim runs sysfig apply or sysfig source render, the rendered file (containing the live credential value) is committed into the local git repo. If --push is set, it reaches the remote permanently.
Fix: Removed the os.Getenv fallback. {{env.NAME}} now returns an error unless the key is explicitly present in TemplateVars.Extra. Callers opt in by declaring variables in the profile's variables: block or populating Extra directly.
// Before β fell back to OS environment
return os.Getenv(envKey), nil
// After β only declared vars are allowed
return "", fmt.Errorf("core: template: env var %q not in allowed vars β declare it in profile variables or TemplateVars.Extra", envKey)Breaking change: Profile templates using {{env.VAR}} must declare the variable in the profile's variables: block. Variables populated via sysfig source use --var KEY=VALUE or profile defaults are automatically placed in Extra and continue to work.
Regression tests: TestRenderTemplate_EnvVarNotInExtra_ReturnsError, TestSEC008_EnvVarFromOSEnvironmentIsRejected (template_test.go)
| Location | internal/core/track.go β Track, TrackDir |
| Type | Design gap (not a vulnerability) |
Issue: IsDenied was checked unconditionally, preventing sysfig track --hash-only /etc/shadow. This made it impossible to use sysfig for integrity monitoring of sensitive files β a legitimate security use case β even though hash-only mode stores no content in git.
Fix: The denylist is now bypassed when opts.HashOnly is true, in both Track and TrackDir. The --local mode still enforces the denylist (content is committed into a local git branch).
# Now works β monitors /etc/shadow for unexpected changes, stores no content
sysfig track --hash-only /etc/shadow
sysfig track --hash-only /etc/sudoers /etc/pam.d/sshdRegression tests: TestTrack_HashOnly_DeniedPathAllowed, TestTrack_LocalOnly_DeniedPathBlocked (track_test.go)
| Location | internal/core/track.go, sync.go, diff.go, bundle.go, git.go |
| CVSS | 5.3 (AV:L/AC:L/Au:N/C:M/I:N/A:N) |
os.CreateTemp("", "sysfig-*") creates files in the system-wide /tmp directory. On multi-user systems, /tmp is world-readable (sticky-bit prevents deletion, not reading). Temporary files containing decrypted config content or git index blobs may be visible to other local users until they are removed.
Fix: Added internal/fs.SecureTempDir() which creates and returns $HOME/.sysfig/tmp (mode 0700), falling back to os.TempDir() only when $HOME is unavailable. All os.CreateTemp("", ...) calls in track.go, sync.go, diff.go, bundle.go, and git.go now use sysfigfs.SecureTempDir() as the directory argument.
Regression test: TestSecureTempDir_IsPrivate (tempdir_test.go)
| Location | internal/state/state.go β Manager.Load |
| CVSS | 3.1 (AV:L/AC:H/Au:N/C:N/I:M/A:N) |
Manager.Load() reads state.json without acquiring any lock. WithLock() correctly uses LOCK_EX for mutations, but a concurrent write (e.g. sysfig watch committing while sysfig status reads) can observe a partial write on filesystems where rename(2) atomicity is not guaranteed (overlayfs, some FUSE mounts).
Fix: Split Load() into a private load() (no lock, used internally by WithLock) and a public Load() that acquires LOCK_SH before reading and releases it after. WithLock continues to hold LOCK_EX and calls load() directly to avoid double-locking on the same fd.
| Location | internal/core/remote_deploy.go β needsSudo |
| CVSS | 2.5 (AV:L/AC:H/Au:N/C:N/I:L/A:N) |
needsSudo checked strings.HasPrefix(dstPath, "/home/<sshUser>/"). This fails for macOS (/Users/<user>), system accounts (/var/, /srv/), or custom $HOME from /etc/passwd. On macOS targets, all home-directory files incorrectly triggered sudo, causing unnecessary privilege escalation.
Fix: Added queryRemoteHome(client, sshUser) which runs echo $HOME via a one-shot SSH session before the deploy loop. Both RemoteDeploy and RemoteDeployRendered now call this once and pass remoteHome to needsSudo instead of the hardcoded prefix. Falls back to /home/<sshUser> if the remote command fails.
| Location | internal/core/remote_deploy.go β buildSSHClientConfig |
| CVSS | 3.3 (AV:L/AC:L/Au:N/C:L/I:N/A:N) |
Vulnerability: buildSSHClientConfig read the identity file without checking its permissions. A 0644 key file is readable by all local users β the private key could be trivially copied by any process running as a different user on the same host.
Fix: Before reading the file, os.Stat checks that no group or other bits are set. Any key file with perm & 0o077 != 0 is rejected with a message naming the problem and the remediation:
ssh key /home/user/.ssh/id_ed25519 has unsafe permissions 0644 β run: chmod 600 /home/user/.ssh/id_ed25519
Regression tests: TestSEC009_SSHKey_WorldReadable_Rejected, TestSEC009_SSHKey_RestrictedPermissions_Accepted (sec_regression_test.go)
| Location | internal/core/remote_deploy.go β buildSSHClientConfig |
| CVSS | 2.0 (AV:L/AC:H/Au:N/C:N/I:N/A:L) |
Vulnerability: gossh.ParsePrivateKey returns *gossh.PassphraseMissingError for encrypted keys, but the error was propagated as-is. Operators saw a raw Go type name instead of actionable guidance, leading to confusion and potentially insecure workarounds.
Fix: errors.As detects *gossh.PassphraseMissingError and wraps it with a clear message directing the user to ssh-agent:
ssh key /home/user/.ssh/id_ed25519 is passphrase-protected β use ssh-agent (SSH_AUTH_SOCK) instead
Regression test: TestSEC010_SSHKey_Passphrase_HelpfulError (sec_regression_test.go)
| Location | internal/fs/atomic.go β WriteFileAtomic |
| CVSS | 5.3 (AV:L/AC:L/Au:N/C:M/I:N/A:N) |
Vulnerability: WriteFileAtomic called os.MkdirAll(dir, 0o755) unconditionally. When creating a new directory to hold a 0600 secret file (e.g. ~/.sysfig/keys/), the directory itself was created world-traversable, defeating the file's restricted permissions β any local user could ls the directory and stat its contents.
Fix: The directory mode is now derived from the file mode: if the file's world-read bit is unset (perm & 0o004 == 0), MkdirAll uses 0o700; otherwise 0o755.
dirMode := os.FileMode(0o755)
if perm&0o004 == 0 {
dirMode = 0o700
}
os.MkdirAll(dir, dirMode)Regression tests: TestSEC011_WriteFileAtomic_SecretFile_GetsRestrictedDir, TestSEC011_WriteFileAtomic_PublicFile_Gets755Dir (atomic_test.go)
| Location | internal/crypto/keyder.go β decodeBech32Payload |
| CVSS | 3.7 (AV:N/AC:H/Au:N/C:L/I:L/A:N) |
Vulnerability: decodeBech32Payload iterated with for i, c := range data (rune iteration) and cast each character to byte(c). For a multi-byte Unicode rune such as Ε± (U+0171), byte(0x0171) = 0x71 = 'q' β a valid bech32 character. A carefully crafted non-ASCII string could therefore pass character validation and produce a key whose payload silently differed from what was shown to the user.
Fix: Changed to for i, c := range []byte(data) so the loop processes raw bytes. Non-ASCII bytes (> 0x7F) now fail the bech32 alphabet check immediately.
Regression test: TestSEC012_Bech32_NonASCII_Rejected (keyder_test.go)
| Location | .github/workflows/ci.yml, .github/workflows/release.yml |
| CVSS | 5.9 (AV:N/AC:H/Au:N/C:H/I:H/A:N) |
| Status | π² Open |
All Actions are pinned to mutable semver tags (@v4, @v5, @v2). A compromised upstream action can push a new commit under the same tag, injecting malicious code into the build or release pipeline. The release workflow has contents: write, so a compromise there can backdoor release binaries distributed to all users.
Current vulnerable references:
actions/checkout@v4actions/setup-go@v5softprops/action-gh-release@v2kenji-miyake/setup-git-cliff@v2
These controls were confirmed correct during the audit and should be preserved:
| Control | Detail |
|---|---|
filippo.io/age with HKDF-SHA256 per-file keys |
Per-file key derivation limits blast radius of a single key compromise |
memguard.LockedBuffer for key material |
mlock'd + guard pages + explicit zeroing on Destroy() |
Atomic file writes (fdatasync + rename) |
Same-filesystem temp file, synced before rename β no partial writes |
flock-based state mutation serialisation |
LOCK_EX on dedicated .lock file for all writes |
| Systemd unit name regex in hooks | [a-zA-Z0-9\-_.@:]+ prevents service-name injection |
--local / --hash-only track modes |
Privacy-preserving options for sensitive files |
~/.sysfig/ hierarchy created 0700 |
Root, keys/, backups/ all restricted to owner |