Security
sync-servers moves your files with your credentials. This page says what it protects against, how, and what stays your job.
Principles
- Secure by default. SFTP is the default protocol, certificates are verified, and the unsafe choices are opt-in with a warning every time.
- Nothing moves unseen. Every synchronization is previewed. Scripts see the preview and change nothing unless they pass
--yes. - Nothing is lost silently. Deletes need an explicit option, large deletes need the count typed back, and local files that a run replaces are kept.
- Secrets never touch a file. Not the settings, not the logs, not an error message.
- A server is not trusted with your disk. Names a server returns are checked before they become local paths.
Protocols
| Protocol | Protection |
|---|---|
| SFTP | Encrypted and authenticated by SSH. Only modern key exchange, host key, cipher and MAC algorithms are offered. A server that needs older ones must set legacyAlgorithms, and every connection to it warns. |
| FTPS | TLS 1.2 or later, with the certificate chain and host name verified against the system's certificate authorities, plus a private one given with --ca-file. A certificate that fails is shown to you to decide on (see Certificates). |
| FTP | None: the password and every file travel in clear text. Refused unless the server sets allowInsecure, and every connection warns. |
| Local folder | Whatever the drive or network share provides. |
Host keys
An SSH host key proves you reached the right server. The first connection to a server shows its fingerprint, such as ssh-ed25519 SHA256:..., and asks whether to trust it. Compare it with the fingerprint your host publishes before you answer.
- Trust and remember pins the key in the server's settings. From then on, a different key stops the connection with
E_HOST_KEY_MISMATCHand shows both fingerprints. It is never accepted silently. - Trust for this connection only pins nothing.
- An unattended run never trusts an unknown key on its own. Pass the expected fingerprint with
--host-key SHA256:<fingerprint>. - When a server's key changes for a good reason, check the new fingerprint with the server's administrator, then
sync-servers server edit Production --forget-host-keyand connect again.
Certificates
An FTPS certificate proves you reached the right server. It is checked on every connection, before your user name or password is sent: its chain must lead to a certificate authority this computer trusts, or to one given with --ca-file, and it must name the host you connect to. When both hold, nothing is asked.
When the check fails, a run in a terminal shows the certificate and asks what to do, instead of only refusing it:
The certificate of 203.0.113.10:21 does not name 203.0.113.10.
Issued to *.hosting.example.net, hosting.example.net
Issued by Example Trust CA
Valid 2026-03-01 to 2027-03-31
SHA-256 3A:91:5C:...:C4
203.0.113.10 is server1.hosting.example.net, which this certificate covers.
1. Connect to server1.hosting.example.net instead (recommended)
2. Keep 203.0.113.10 and check the certificate as server1.hosting.example.net
3. Trust this certificate for 203.0.113.10
4. Cancel
- A host name the certificate covers. Shared hosting often serves many sites from one machine, and its certificate names the hosting company's machine, not your domain or its IP address. sync-servers looks the address up both ways and offers a name only when the certificate covers it and it leads back to the same machine. Connecting to that name, or checking the certificate as that name (
options.tls.servername), keeps full verification, and keeps working when the host renews its certificate. - Trust this certificate. For a self-signed or privately issued certificate, trusting it pins its SHA-256 fingerprint in the server's settings, as
options.tls.trustedCertificate. Compare the fingerprint with the one your host publishes before you trust it. Normal verification still runs first and wins when it passes. - A certificate that changed. When a pinned certificate is replaced, both fingerprints are shown and the default answer is to cancel. A renewal looks like this, and so does a machine in the middle: check the new fingerprint with the host before you trust it.
- Never trusted: a certificate that has expired, or is not valid yet, is refused with no choice to trust it. Ask the host to renew it, or check this computer's date and time.
- Unattended runs never trust a certificate on their own. The error names the certificate, a covered host name when one was found (
sync-servers server edit Production --host server1.hosting.example.net), and the option that accepts a fingerprint you checked:--trust-certificate 3A:91:5C:...:C4. It accepts that certificate for the run only. - Data connections carry your files on connections of their own. Each one must resume the TLS session of the verified control connection, or present the same certificate; any other is closed before data moves.
- FTPS never falls back to plain FTP. An
ftp://address in the Host field connects with FTPS; plain FTP stays an explicit choice. sync-servers server edit Production --forget-certificateremoves a pin, so the next connection verifies the certificate again.
Credentials
- Passwords and key passphrases are kept only in the operating system's credential store, when you choose to save them. They are never written to
sites.json, a log, a plan file or a temporary file. - A password is asked for, and sent, only after the server's host key or TLS certificate has been verified.
- A saved password is tied to the protocol, address, port and user it was saved for. If the settings change any of them, by mistake or by someone else, the saved password is not sent: you are asked again, with the reason, and an unattended run stops with
E_AUTH. Once the server accepts a password you typed, you are offered to save it for the new settings. - Every secret is registered with the logger the moment it is read and replaced with
[redacted]in every log line and error message, including FTPPASScommands and passwords inside URLs. - Private keys are read from their file when connecting and never copied.
- For scripts,
SYNC_SERVERS_PASSWORDsupplies the password. Treat it like the password itself: keep it in your CI system's secret store.
Safe deletes and overwrites
- Nothing is deleted without the delete option. A file that was never synchronized is only ever offered for deletion unchecked.
- Deleting more than 50 files, or a quarter of a side, needs the count typed back, or
--allow-mass-deletein a script. - A plan that would delete everything because one side is empty is refused, since an unmounted drive or a wrong root looks exactly like that.
- Deletes at the top of a server (
/or a drive root) are refused unless the server allows them. - Each file is checked again just before it moves; one that changed since the preview is skipped.
- Files are written to a partial copy and renamed into place only when complete.
- Local files a run deletes or overwrites are kept in the recovery folder for 14 days. Pruning the folder never touches the files of the run that just finished.
- A file spelled one way here and only another way on the server, as after renaming it by case, is held back: a server that ignores case would take its upload and the delete of the old spelling for the same file.
- When a server cannot replace a file in one step and the replacement fails after the old copy was removed, the new copy is kept on the server under its partial name rather than deleted.
- Sensitive files such as
.env, private keys and credential folders are held back unchecked, and never moved by an unattended run, also when a link in the site leads to them.
A server you do not fully trust
A compromised or malicious server can return any names it likes. Before a name becomes a local path it is checked:
- The names
.and.., and names with a slash, a backslash, a control character, or more than 255 bytes, are refused and listed as problems. - Names that are not valid on Windows, such as
CON,name.or a name with:, are refused on Windows, and so are names shaped like short 8.3 names such asGIT~1, which Windows may read as another folder. - Two names that differ only by case are both refused on a file system that cannot tell them apart.
- Links on the server are never followed, and nothing inside a linked folder on the server is transferred. A download never creates a link, and writes never go through another name for a folder, such as a short 8.3 name.
- In the local folder, a site follows only the links its
sync.symlinksallows. By default it follows none and reads or writes nothing through them; withfollow-inside-root, no read or write leaves the site's folder; withfollow, reads and writes reach only as far as your own links lead, and every such row in the preview says so. - A local file is uploaded only if the file opened is the one that was checked, so a link swapped in at the last moment is not followed.
- A download stops as soon as a server sends more than the size it listed, so a server cannot fill your disk.
- A folder listing with more than 100,000 entries, a scan of more than 1,000,000 entries, and folders nested more than 64 levels deep are refused, so a server cannot exhaust memory or keep a scan going forever.
- Control and bidirectional characters in names, server replies and file contents are shown escaped or as
?, never sent to your terminal.
What stays on your computer
- Settings, sync records, the scan cache, the recovery folder and the logs are created private to you (
0700folders and0600files on Linux and macOS). - Logs record what happened and never file contents. The newest 30 run logs are kept.
- The temporary files of a diff and of editor mode live in a private temporary folder and are removed when done.
- Nothing is sent anywhere but to your servers: no analytics, no update checks.
What was checked
Each threat below has a defence in the code and a test that proves it, run on every change in CI on Linux, macOS and Windows. The list was last walked row by row for the 1.0.0-alpha.0.6.0 release, together with two independent reviews of the whole source tree, one of the transports and the engine and one of secrets, the command line, settings and workflows. Every finding was fixed in that release.
| Threat | Defence | Proved by |
|---|---|---|
| A password read off the network | SFTP by default; FTPS with verified certificates and TLS 1.2 or later; plain FTP refused without allowInsecure and warned on each connection | A self-signed certificate is refused before the password is sent; plain FTP is refused before any connection |
| A machine in the middle on SSH | Host keys confirmed on first use and pinned; a changed key is fatal; unattended runs refuse unknown keys | A changed key is refused with both fingerprints before any password is sent; an unattended run refuses an unknown key unless the expected fingerprint matches |
| Secrets at rest | The operating system's credential store; the environment for CI; a question otherwise; no file fallback | After every scenario, every settings, state, log and local file is searched for the server password |
| Secrets in output | A redacting logger, scrubbed errors, and --json built from chosen fields | Standard output and standard error of sync, status, diff and server test with --debug are searched for the password |
| A saved password sent to the wrong server | Saved passwords are tied to the protocol, address, port and user they were saved for | A saved password is refused after a change of host, port, user, or a switch to plain FTP |
| Hostile names from a server | Every listed name is validated before it becomes a path | A sandbox server lists ../escape, CON.txt, a\b.txt, case twins and a NUL; none is written |
| Symbolic link escapes | Local links are followed only as the site's sync.symlinks allows: none by default, only inside the site's folder with follow-inside-root, and with follow only where your own links lead, each such row marked; a download never creates a link; links on the server are never followed; writes refuse folders reached by another name; cloaking and the guard see through links | Under each policy, links out of the site, broken links and links back into a folder above them are followed or become problem rows as the policy says; a Get through a link keeps the link; a link to .env is held back; short names are refused as write targets; files under a linked folder on the server become problem rows |
| Secrets uploaded by accident | The sensitive-file guard lists matches unchecked | Guard tests in every direction, over every protocol |
| Mass deletion from a wrong root | The delete option, unchecked never-synchronized deletes, the typed count, the empty-root and top-of-server refusals, and the recovery folder | An emptied local root against a full server stops before anything moves |
| Half-written files | Partial copies renamed into place only when complete; sizes checked; each file checked again just before it moves | A download that keeps dropping and an upload that keeps stalling both leave the old copy as it was, with no partial file |
| Tampered plans | Editor mode and saved plans can only choose among the planned rows; a saved plan must match a fresh scan | Edited files and saved plans that add a path, renumber a row or choose an impossible action are refused whole |
| Corrupt settings or racing writers | Schema validation, atomic writes with a backup, and a lock file | Two registries writing at once lose nothing; an invalid change leaves the file as it was |
| Exhausted memory, a full disk or a hung run | Streams only, bounded concurrency, listing, scan and depth limits, downloads capped at their listed size, and timeouts | An oversized listing and an endless folder tree are refused; a download that sends too much fails; stalled downloads and uploads time out |
| Escape sequences from a server | Control and bidirectional characters in names, server replies, logs and diffs are escaped or replaced before they reach the terminal | Previews, error messages and debug lines with escape sequences in them are checked; no source file may hold such characters either |
| A compromised dependency or workflow | Few dependencies, a lock file, no install scripts, pinned actions, least-privilege workflows, CodeQL, dependency review and Scorecard | A test reads every workflow and fails on an unpinned action, a missing permissions: {}, a kept checkout credential or an expression inside a script |
| Personal data in public files | Ignore rules, the package allowlist, and a leak scan | The package-contents check and the documentation leak scan |
Verifying a release
Every release is built from its tag by a GitHub Actions workflow that runs the full test suite first. The job that builds and tests holds no signing or publishing rights; a separate job that runs no code from the repository attests the files. The release carries the package tarball, a CycloneDX software bill of materials, a Sigstore attestation bundle and a SHA256SUMS file. To check what you downloaded:
sha256sum --check --ignore-missing SHA256SUMS
gh attestation verify isocialpractice-cmd-sync-servers-<version>.tgz --repo isocialPractice/cmd-sync-servers \
--signer-workflow isocialPractice/cmd-sync-servers/.github/workflows/release.yml
Packages on npm are published from that same workflow with npm trusted publishing, so each carries provenance that links it to the commit and the workflow run that built it. npm audit signatures checks it after installing.
What stays your job
- Checking a host key fingerprint before trusting it.
- Keeping your computer account, your credential store and your CI secrets safe; anyone who controls your user account controls your saved passwords.
- Choosing SFTP or FTPS. Plain FTP cannot be made safe.
- Reviewing the preview, especially rows held back by the guard and deletes.
Reporting a vulnerability
Report it privately through GitHub's private vulnerability reporting. Never open a public issue for a vulnerability. Include the version, the operating system and the Node.js version, the steps that reproduce it and the impact as you understand it, with placeholders such as sftp.example.com instead of real hosts or passwords.
You get an acknowledgement within 7 days and an assessment within 14. Fixes ship as a new patch version with a published advisory that credits you, unless you ask not to be named. The full policy is in SECURITY.md.