Penetration test readiness
How an outside penetration tester will attack Gardience, what stops each attack, and which automated test proves it on every release. Testers work from public methods: the OWASP Web Security Testing Guide (WSTG) for web applications, the OWASP Application Security Verification Standard (ASVS) level 2 as the checklist behind it, the Penetration Testing Execution Standard (PTES) for the overall flow, and NIST SP 800-115 in government work. For a Salt-based product they also run the published Salt exploits. This page follows that order.
Everything in the "Proof" column runs in the release check against a fresh install of the exact bundle being released (tools/release-check.sh); the release cannot be signed if any of it fails. You can run the same tests against an install you own: VIEWER=user:pass tools/pentest/run.sh NAME HTTPS_PORT ADMIN PASSWORD.
This is our own testing. It is not an independent third-party penetration test or an attestation.
1. Reconnaissance and exposure (PTES intelligence gathering, WSTG-INFO)
| What a tester tries | What stops it | Proof |
|---|---|---|
| Port scan for every service | Only the console (443, 80 redirect) and the two Salt agent ports are published. salt-api, the Gardience service and the playbook runner are internal only | nmap in run.sh (top 1,000 ports plus the internal ones) |
| Version banners to look up known CVEs | server_tokens off; no X-Powered-By; Salt's web server error pages (which name CherryPy and its version) are replaced at the front door and in the sign-in | wstg.py: 27 paths, headers and bodies |
Forgotten files: .git, .env, backups, API docs, server-status, metrics | Everything not explicitly routed returns 404; the API description is not served | wstg.py, nuclei (14,000+ templates) |
| Stack traces and debug pages | Errors return short JSON; Salt master errors are never passed through | wstg.py broken-body checks on every route |
2. Configuration and transport (WSTG-CONF, WSTG-CRYP)
| What a tester tries | What stops it | Proof |
|---|---|---|
| Weak TLS, old protocols, known TLS attacks | TLS 1.2 and 1.3 only, AEAD ciphers, forward secrecy, no session tickets | testssl.sh |
| Missing security headers, clickjacking | HSTS (1 year), nosniff, frame-ancestors 'none' and X-Frame-Options DENY, no-referrer, strict permissions policy, cross-origin isolation, on every response including errors | wstg.py header checks, ZAP baseline |
| Inline script injection | Content-Security-Policy with scripts only from the server or a per-response nonce | wstg.py, ZAP |
| TRACE, open CORS | TRACE refused; Salt's CORS headers are stripped, so no other site may read any response | wstg.py (OPTIONS and GET with a foreign Origin on four paths) |
| Sensitive data cached by browsers or proxies | Cache-Control: no-store on every console and API response | wstg.py |
3. Identity and authentication (WSTG-IDNT, WSTG-ATHN, ASVS V2)
| What a tester tries | What stops it | Proof |
|---|---|---|
| Default or guessable accounts | No built-in passwords; the installer generates them | wstg.py (8 common pairs) |
| Username enumeration from different errors | Wrong password and unknown user get the same answer | wstg.py |
| Brute force from one address | 10 sign-ins a minute per address at the front door | wstg.py |
| Password-only sign-in for privileged accounts | One-time codes from an authenticator app for local and directory accounts, required per role (gardience mfa require admin); single sign-on users get their identity provider's MFA | release gate (code, replay, required-but-missing), unit tests (RFC 6238 vectors) |
| Brute force spread across many addresses | 10 failed sign-ins in 15 minutes pause that account until the window passes (a throttle, so an attacker cannot lock someone out for good) | wstg.py, unit test |
| Other Salt authentication back ends (auto, sharedsecret, rest) | Only file, LDAP, PAM and Gardience SSO reach the master | wstg.py |
| Signing in as a service identity | Service names are reserved; the break-glass account exists only while root on the server has it open | wstg.py, release gate |
| Injection in the sign-in body (NoSQL operators, SQL, extra Salt fields) | Only well-formed username, password and eauth strings are forwarded | wstg.py |
| SSO: forged, replayed or stolen responses, signature wrapping, XML attacks | Signed assertions required, unsolicited responses refused, one-time state bound to the browser, hardened XML parser | sso-attack.js in the lab suites |
4. Sessions (WSTG-SESS, ASVS V3)
| What a tester tries | What stops it | Proof |
|---|---|---|
| Stealing the session cookie | The cookie is Secure, HttpOnly and SameSite=Strict | wstg.py, unit test |
| Cross-site request forgery: another site makes the signed-in browser send commands | Any change sent to Salt's API must carry the console's token header, which a browser never adds to a request another site makes; the Gardience API accepts only the header | wstg.py (form, text and multipart posts with a foreign Origin) |
| Token in the URL, made-up tokens | Refused | wstg.py |
| Reusing a token after sign-out | Dead everywhere at once (Gardience and Salt's API) | wstg.py, release gate |
| Long-lived sessions | 8-hour token; directory and SSO sessions end after 1 hour by default; removing a user ends their sessions and API keys at once | release gate, unit tests |
5. Access control (WSTG-ATHZ, ASVS V4)
| What a tester tries | What stops it | Proof |
|---|---|---|
| Calling every API route without signing in | 401 or 404 on every one of the ~100 routes; a console page without a session is only an empty loading shell; public on purpose: health checks, the CLI download and SSO entry points | wstg.py (enumerated from the running app) |
| A viewer calling every change route | Refused on every one | wstg.py with a viewer account |
| An admin's console session running raw Salt (cmd.run, file writes, key changes, orchestration, ssh client) | Admins are governed: changes go through Gardience (approvals, tickets, audit). Raw Salt rights exist only on the time-limited break-glass account | wstg.py (9 calls), release gate |
| Self-approving a change | Two-person approval; the launcher cannot approve | release gate |
| A low-privilege user flooding the fleet with jobs (denial of service) | Refreshes a viewer may start (fleet scan, inventory, drift check) run one at a time, however often they are called | wstg.py viewer checks |
| Path traversal and IDs in URLs | Refused cleanly on every route with a path parameter | wstg.py (5 encodings, every such route) |
6. Input handling (WSTG-INPV, ASVS V5)
| What a tester tries | What stops it | Proof |
|---|---|---|
| Server-side request forgery through admin-set addresses (webhook, ticket check) | Loopback, link-local, cloud metadata, multicast and the stack's own services are refused at save and at call time; the call goes to the address that was checked (no DNS rebinding); redirects are not followed. Office addresses (an on-premises Jira) still work | wstg.py (8 targets x 2 settings), unit tests |
| Stored XSS from a compromised machine (it controls its own name and facts) | Every page escapes machine data; the CSP stops any script that slipped through | ui/xss.sh: plants markup in a live agent's facts, then checks 24 console pages in Chrome |
| Reflected XSS, open redirects | Not reflected; no redirect leaves the server | wstg.py |
| Wrong-shaped or broken JSON, deeply nested bodies | Every body must be a JSON object, checked once for all routes: a clean 4xx, never a server error | wstg.py (6 bodies on every change route) |
| Oversized bodies | 413 at the front door above 20 MB | wstg.py |
Formula injection in exported reports (a machine named =HYPERLINK(...)) | Every exported text cell starting with = + - @ is prefixed with an apostrophe | unit test |
| Command injection through runbook inputs | Each input is typed, checked against its pattern and passed to the Salt function as one whole argument; no shell string is built from it. The one runbook that runs a shell command ("Run a command") does so on purpose and needs command-execution rights | ZAP active scan, unit tests |
7. Salt-specific attacks
| What a tester tries | What stops it | Proof |
|---|---|---|
CVE-2020-11651 (Metasploit saltstack_salt_unauth_rce, saltstack_salt_root_key): read the root key or publish jobs without a key | Salt 3008.3; refused | salt-cves.py (sends the exploit's requests to the live master) |
| CVE-2020-11652: wheel and runner calls without a key | Refused | salt-cves.py |
| CVE-2021-25281/25282/25283 (salt-api wheel_async, ssh client) | salt-api is not published; /run and /token are not routed; the ssh client is disabled | release gate, nmap |
| A compromised agent forging job events or master events | Hardened master refuses agent-sent events on reserved tags and stamps the sender | forge/run.sh in the lab suites |
| A compromised agent claiming a group to get its secrets | Self-reported group claims wait for admin approval | release gate |
8. Business logic and audit (WSTG-BUSL, ASVS V7)
| What a tester tries | What stops it | Proof |
|---|---|---|
| Editing or truncating the audit log | Hash-chained (HMAC) log, verified on demand and after restore | release gate |
| Using break-glass without a trace | Every opening and closing is audited and announced, even if it was open for seconds; its sign-in and every command are audited | release gate |
| Remote code execution in the web service, used to grant itself or an attacker more rights | The web service mounts its configuration read-only; users, Salt permissions and break-glass are written only by a container with no network and by root's gardience command | release gate (service cannot write master.d, keeper has no network) |
| A compromised web service turned against the fleet | The automation identity runs only the functions Gardience calls (no ".*"); arbitrary command execution is a root-controlled switch | unit tests; release gate (no ".*", commands off refuses Run a command) |
| An AI agent or leaked API key abusing the API or MCP | Per-key rate limit, keys narrowed to runbooks and node groups, approval by default, hourly cap on unapproved runs, approve only with an admin-made key and never one's own, every read and write audited, 20-call batch cap | unit tests; wstg.py agent checks; release gate agent flow |
| An operator or approver calling admin-only routes | All 25 admin-only change routes refuse both roles (403) | wstg.py with operator and approver accounts |
| Reading secrets through Salt's configuration read | Every secret (LDAP bind password and any other credential) is blanked in what the call returns | Salt image build check (harden.py) |
| Stolen backups | Encrypted by default (AES-256) with the server's backup key or your passphrase file; keyed integrity code | release gate (encrypted by default; a new server cannot restore it without the key) |
| Tampered evidence for an auditor | Evidence packs signed with the install's Ed25519 key; a verifier ships in the bundle | release gate |
| Tampered or replayed upgrade bundles | Release-key signatures; a bundle cannot replace the key it is checked against | release gate |
What a tester may still report
- Session token in browser storage. The console keeps its token in session and local storage so a sign-in works across tabs. A script on the page could read it, which is why scripts are locked to the server and a per-response nonce. Typical rating: low or informational.
- Inline styles allowed by the CSP. Needed by the console; scripts stay locked. Low.
- Self-signed certificate on a fresh install. Replace it with your own (
--tls-cert,--tls-key). - Salt agent ports open to whatever networks you allow. Agents need them; restrict them to agent networks with your firewall or security group.
- No idle timeout shorter than the 8-hour token for local accounts (directory and SSO sessions: 1 hour). Set
session_hourslower if your policy needs it.