Generates a native Salt state tree from an OpenSCAP / SCAP Security Guide (SSG) datastream. Designed for SUSE Linux Enterprise (SLE 15/16) and deployable through SUSE Multi-Linux Manager (Uyuni/MLM).
Given an SSG datastream and a compliance profile (default: PCI-DSS v4), scap2salt emits:
out/srv/
├── salt/
│ ├── top.sls # grain-filtered state entry point
│ └── <formula>/ # --formula-name (default pci_dss; e.g. PCI_DSS_v4_SLE16)
│ ├── init.sls # includes all active category files
│ ├── sysctl.sls # kernel parameters
│ ├── packages.sls # package install / removal
│ ├── services.sls # service enable / disable
│ ├── permissions.sls # file ownership & modes
│ ├── kernel_modules.sls # disabled modules (modprobe.d)
│ ├── sshd.sls # SSH drop-in config
│ ├── lineinfile.sls # login.defs, securetty, tmout, chrony
│ ├── pam.sls # PAM module arguments (pwquality, etc.)
│ ├── sudo.sls # sudo Defaults (sudoers.d drop-ins)
│ ├── audit.sls # auditd rules + auditd.conf settings
│ ├── dconf.sls # GNOME desktop policy
│ ├── coredump.sls # systemd core dump policy
│ ├── grub.sls # GRUB kernel command-line args
│ ├── firewall.sls # iptables loopback rules
│ ├── aide.sls # file integrity (AIDE)
│ ├── rpm.sls # GPG checks & RPM verification
│ ├── mounts.sls # filesystem mount options
│ ├── mac.sls # SELinux or AppArmor enforcement
│ ├── UNMAPPED.md # rules with a fix we don't yet parse
│ ├── NO_REMEDIATION.md # rules the SSG ships no fix for (detective-only)
│ ├── SKIPPED_NA.md # rules N/A for this MAC framework
│ ├── MAC_EQUIVALENCE.md # AppArmor ↔ SELinux control mapping
│ └── _verify/
│ ├── oscap_scan.sh # read-only compliance scan
│ ├── salt_verify.sh # state.apply test=True drift check
│ └── oscap_remediate.sh # independent oscap remediation (cross-check)
├── pillar/
│ ├── top.sls
│ └── <formula>.sls # per-category boolean toggles
└── formula_metadata/<formula>/
├── form.yml # MLM/Uyuni Web UI checkboxes
└── metadata.yml
Every state file is wrapped in a Jinja guard so any category can be disabled by setting <formula>:<category>: False in the pillar (or toggling the checkbox in the MLM Formulas tab) — where <formula> is the --formula-name (default pci_dss).
Native states only. Each SCAP rule is mapped to a first-class Salt state (sysctl.present, pkg.installed, service.running, file.managed, iptables.append, mount.mounted, etc.). Rules for which no declarative primitive exists are either emitted as guarded cmd.run states (idempotent via creates/onlyif/unless) or listed in UNMAPPED.md. Nothing is silently wrapped in a bare cmd.run.
Honest coverage. Rules the SSG ships no remediation for (detective-only checks like account uniqueness, BIOS settings, firewall-zone design) are separated into NO_REMEDIATION.md and excluded from the coverage denominator — the headline percentage reflects rules that can be remediated, not rules a handler merely hasn't been written for yet. UNMAPPED.md is strictly "has a fix we don't yet parse."
MAC framework awareness. SLE 15/Leap 15 ships AppArmor; SLE 16+ ships SELinux. SELinux-specific SCAP rules are automatically marked N/A on AppArmor targets, and mac.sls instead enforces the equivalent control intent via AppArmor. The mapping is documented in MAC_EQUIVALENCE.md.
Stdlib only. The script has no third-party dependencies and runs on any Python 3, including the Python bundled with an MLM server.
Latest full runs of the pci-dss-4 profile (PCI-DSS v4.0.1) against the upstream datastreams:
| Metric | SLE 15 (AppArmor) | SLE 16 (SELinux) |
|---|---|---|
| Rules selected by profile | 262 | 247 |
| Remediable (coverage denominator) | 238 | 230 |
| No remediation shipped (detective-only) | 18 | 16 |
| N/A for this MAC framework | 6 | 1 |
| Native coverage | 209 / 238 — 87% | 197 / 230 — 85% |
| Guarded operational states | 18 | 13 |
| Remediable rules still unmapped | 29 | 33 |
Coverage by category:
| Category | SLE 15 | SLE 16 |
|---|---|---|
| File permissions & ownership | 53 | 53 |
| Audit (auditd rules + auditd.conf) | 49 | 49 |
| Package install / removal | 22 | 17 |
| SSH server hardening | 17 | 15 |
| Config-file settings (login.defs, securetty, etc.) | 12 | 11 |
| Kernel parameters (sysctl) | 12 | 12 |
| Service enable / disable | 8 | 8 |
| GNOME desktop (dconf) policy | 7 | 7 |
| PAM module arguments (pwquality, pam_unix, pam_wheel) | 6 | 2 |
| Package signatures & verification (RPM/GPG) | 5 | 4 |
| Disabled kernel modules | 3 | 3 |
| sudo Defaults (sudoers.d drop-ins) | 3 | 3 |
| Mandatory Access Control (SELinux / AppArmor) | — | 3 |
| systemd core dump policy | 2 | 2 |
| GRUB kernel command-line arguments | 2 | 2 |
| Firewall (iptables loopback rules) | 2 | — |
| File integrity (AIDE) | 2 | 2 |
| Resource limits (security/limits.d) | 1 | 1 |
Regenerate these numbers any time with ./scap2salt.py --report-only (SLE 15) or ./scap2salt.py --target sle16 --report-only (SLE 16); each writes out/coverage-report.md.
scap2salt is intentionally faithful to ComplianceAsCode's fix scripts. The exceptions below are deliberate, because the upstream value fails its own OVAL check on the targeted platforms:
| Rule(s) | CaC value | scap2salt value | Why |
|---|---|---|---|
file_permissions_etc_shadow, file_permissions_backup_etc_shadow |
relative chmod (max 0640) |
0000 |
On SLE the OVAL requires /etc/shadow and /etc/shadow- to be 0000 (root reads them via capabilities). CaC's generic relative chmod u-xs,g-xws,o-xwrt only reaches 0640, which fails the check and would loosen the secure 0000 default. Forced to 0000 via _PERMS_ZERO_MODE in scap2salt.py. |
scap2salt translates CaC's relative symbolic chmod specs (e.g. u-xs,g-xws,o-xwrt)
into the maximum octal mode they permit (here 0640), which the matching OVAL
accepts for most files. The shadow family is the exception above. Note also that
file_permissions_unauthorized_world_writable is a filesystem-wide sweep with no
safe declarative form, so it is left in UNMAPPED.md rather than emitted.
sysctl_net_ipv4_ip_forwardon container/router hosts: the sysctl.d value (0) is re-enabled at boot by anything that needs forwarding. The formula already disables firewalld intra-zone forwarding (the usual cause) so the value persists on a plain firewalld host — but Docker/Podman/libvirt setnet.ipv4.ip_forward=1unconditionally for container/VM networking, and that cannot be turned off without breaking them. On such hosts this rule will fail by design; it reflects a real host-role conflict (a PCI-scoped endpoint shouldn't be routing), not a remediation gap.
Not a deviation, but worth knowing:
sshd_disable_root_logincan still report fail if the host carries a separate drop-in (e.g./etc/ssh/sshd_config.d/root.conf) withPermitRootLogin yes. scap2salt's00-pci-hardening.confsorts first sonois effective (sshd -Tconfirms), but the scanner flags the conflicting line. Remove or fix the offending drop-in. Also note SLE 16 ships sshd's config in/usr/etc/ssh/sshd_config; an empty/etc/ssh/sshd_configwill shadow it and disable all drop-ins.
The MLM Formulas form is built for admins to see what each toggle does and which PCI-DSS sections it covers at a glance, then drill down:
- the always-visible toggle label shows the category, its headline PCI-DSS sections, and a one-line gist — e.g. "Audit rules & auditd configuration [PCI 10.2, 10.3, 10.4, 10.5, 10.6, 10.7] — auditd rules (immutable -e 2)";
- hover (
$help) gives the fuller per-category description; - the group help links to
PCI_ACTIONS.md— a generated per-rule reference (rule id, title, severity, PCI-DSS refs, and the exact Salt action), browsable on GitHub, for the full breakdown.
Most categories default on. The accounts category is the exception — it
defaults off ($default: False) because its rules mutate existing
accounts/access and can lock people out:
- existing-password aging (
passwd -x/chage) expires current passwords; an already-expired account can't even be fixed withchageafterwards (PAM refuses) — recovery needs a direct/etc/shadowedit; sudo_require_authenticationcomments outNOPASSWD, which can sever a management agent's sudo access.
Enable it deliberately (check the box in the MLM form / set pci_dss:accounts: True) once service and admin accounts are accounted for. Enabling it also clears
accounts_maximum_age_login_defs, whose check is coupled to existing-user
max-age.
The audit baseline makes the ruleset immutable (-e 2). If audit rule fragments
change while the kernel is already immutable (i.e. on a system that has booted
with the rules locked), augenrules --load cannot apply them until the next
reboot. The audit category detects exactly this case and writes a marker to
/run/scap2salt-audit-reboot-required (tmpfs, so it clears automatically on
reboot). A guarded audit_reboot_required state then fails (shows red in the
system's States/Events in MLM/Uyuni) only while a reboot is genuinely pending,
and goes green again once the host is rebooted.
This is the practical signal available: MLM's own "Reboot required" banner is
driven by zypper needs-rebooting (kernel/core-library updates) and has no hook
a formula can set for a config-only change.
./scap2salt.py [options]| Option | Default | Description |
|---|---|---|
--target |
sle15 |
SSG product id (sle15, sle16, sle12, slmicro5, slmicro6, …) |
--profile |
pci-dss-4 |
XCCDF profile id (short or full) |
--datastream |
(auto) | Path to an existing ssg-*-ds.xml (skips download) |
--out |
./out |
Output directory |
--cache |
./.cache |
Download cache directory |
--mac |
(inferred) | Override MAC framework (selinux or apparmor) |
--report-only |
— | Classify rules and write a coverage report only — no state files |
--package |
— | Also build an MLM Salt formula RPM under out/package/ |
--pkg-version |
1.0.0 |
Version string for the formula RPM |
Quick start:
# Generate a full Salt tree for SLE 15 / PCI-DSS v4
./scap2salt.py
# Dry run: see coverage without writing states
./scap2salt.py --report-only
# SLE 16 with an already-downloaded datastream
./scap2salt.py --target sle16 --datastream /path/to/ssg-sle16-ds.xmlThe script resolves the datastream in this order:
--datastreampath (if given)- Locally installed
/usr/share/xml/scap/ssg/content/ssg-<target>-ds.xml(from thescap-security-guideRPM) - Cached download from a previous run (
.cache/ssg-<target>-ds.xml) - Download from the ComplianceAsCode GitHub release zip and extract the needed file
On an MLM/Uyuni server the RPM path is preferred; run zypper in scap-security-guide to install it.
load_benchmark() parses the datastream XML and locates the embedded <Benchmark> element. resolve_profile() walks the profile's <select> elements (respecting extends inheritance) to build the set of active rule IDs.
Each active rule is passed through an ordered list of mappers. The first mapper that recognises the rule returns a SaltState object; unrecognised rules fall through to UNMAPPED.md.
| Mapper | Recognises | Emits |
|---|---|---|
m_mac |
selinux_*, apparmor_*, grub2_enable_selinux |
selinux.mode, file.replace on /etc/default/grub, or AppArmor equivalents |
m_sysctl |
sysctl_* |
sysctl.present with a per-key drop-in under /etc/sysctl.d/ |
m_package |
package_*_installed / *_removed |
pkg.installed / pkg.removed |
m_service |
service_*_enabled / *_disabled |
service.running / service.dead |
m_file_perms |
file_permissions_*, file_owner_*, file_groupowner_* |
file.managed with replace: False (metadata only) |
m_kmod |
kernel_module_*_disabled |
file.managed writing /etc/modprobe.d/<mod>.conf with install … /bin/true + blacklist |
m_sshd |
sshd_* |
file.replace on /etc/ssh/sshd_config.d/00-pci-hardening.conf; values extracted from the rule's bash fix or a curated fallback table |
m_lineinfile |
rules that write a config line via printf '%s\n' … >> /path |
file.replace with idempotent pattern + append_if_not_found |
m_mount |
mount_option_*_nodev/nosuid/noexec |
mount.mounted with persist: True |
m_audit |
audit_rules_*, audit_*, plus rules built from the audit rules.d macro |
file.managed writing per-rule fragments to /etc/audit/rules.d/ (incl. -e 2 immutable, -F dir= watches, $var watch paths); one augenrules --load triggered on any change |
m_auditd_conf |
auditd_* |
file.replace setting key = value in /etc/audit/auditd.conf (or the audisp syslog plugin); service auditd restart triggered on any change |
m_pam |
PAM module-arg rules (cracklib pwquality, pam_unix hashing, pam_wheel) |
guarded cmd.run that adds/updates one option on the pam_*.so line, preserving siblings (idempotent via unless) |
m_sudoers |
sudo_* with a Defaults … fix |
file.managed writing a /etc/sudoers.d/99-pci-* drop-in (mode 0440) |
m_coredump |
coredump_* |
file.managed writing a [Coredump] drop-in under /etc/systemd/coredump.conf.d/ |
m_grub_audit |
grub2_audit_* |
file.replace adding the kernel arg to GRUB_CMDLINE_LINUX; grub2-mkconfig triggered on change |
m_securetty |
no_direct_root_logins, securetty_root_login_console_only |
file.managed/file.replace on /etc/securetty |
m_tmout |
accounts_tmout |
file.managed writing /etc/profile.d/autologout.sh |
m_chrony |
chronyd_specify_remote_server |
file.replace ensuring a pool/server line in /etc/chrony.conf |
m_iptables |
set_loopback_traffic, set_ipv6_loopback_traffic |
iptables.append states (idempotent, save: True) for the loopback rules |
m_chronyd_user |
chronyd_run_as_chrony_user |
file.replace setting OPTIONS="-u chrony" in /etc/sysconfig/chronyd |
m_libuser_hash |
set_password_hashing_algorithm_libuserconf |
file.replace on crypt_style in /etc/libuser.conf |
m_timer |
timer_*_enabled |
service.running on the systemd .timer |
m_limits |
disable_users_coredumps |
file.managed writing a /etc/security/limits.d/ drop-in |
m_dconf |
dconf_* |
file.managed writing a settings fragment + lock fragment to the dconf db dir; dconf update triggered on any change |
m_aide |
aide_build_database, aide_periodic_checking_systemd_timer |
guarded cmd.run (database init) and file.managed (systemd unit + timer) |
m_rpm |
ensure_gpgcheck_*, ensure_suse_gpgkey_*, rpm_verify_* |
file.replace on /etc/zypp/zypp.conf, guarded cmd.run for per-repo and per-package checks |
Values for sysctl, sshd, kmod, and lineinfile states are extracted directly from the rule's embedded bash remediation script using targeted regexes, so they stay in sync with the upstream SSG content automatically.
Deliberate exclusion:
rpm_verify_hashesis left unmapped and listed inUNMAPPED.md. Its only automated remediation is reinstalling packages, which is unsafe to run autonomously.
Guarded operational states:
m_aideandm_rpminclude inherently imperative operations (database initialisation, key import,rpm --setperms) for which no declarative Salt primitive exists. These are emitted as guardedcmd.runstates withcreates/onlyif/unlessguards so they stay idempotent andtest=True-safe. They are counted separately in the run summary and in the--report-onlycoverage report.
emit_tree() groups the SaltState objects by category and writes one .sls file per category. Additional output includes the pillar, top.sls, MLM formula metadata, verification scripts, and the coverage/unmapped/NA markdown reports.
The recommended path is the formula RPM — the way SUSE ships its own formulas — and the formula then appears in the Web UI automatically.
A mixed SLE 15 + SLE 16 fleet runs one formula per OS (different MAC framework, package names, and rule selection), assigned per system-group. Use --formula-name to give each a distinct, descriptive name — MLM/Uyuni humanises the formula directory for the Formulas list:
./scap2salt.py --target sle15 --formula-name PCI_DSS_v4_SLE15 --package # -> "PCI DSS V4 SLE15"
./scap2salt.py --target sle16 --formula-name PCI_DSS_v4_SLE16 --package # -> "PCI DSS V4 SLE16"--formula-name drives the state dir, pillar namespace, form top-key, the in-form doc link, and the RPM name. (Without it the formula is named pci_dss.) The committed example trees use these names (sle15-apparmor/salt/PCI_DSS_v4_SLE15/, sle16-selinux/salt/PCI_DSS_v4_SLE16/).
out/package/ contains the .rpm, plus the .spec, a source tarball, a build.sh (to rebuild where rpmbuild isn't present), and a README.md.
The RPM payload installs to /usr/share/salt-formulas/{metadata,states}/<formula>/, but the MLM/Uyuni Salt master only reads /srv/salt + /srv/formula_metadata, which on a containerised server are podman volumes under /var/lib/containers/storage/volumes/srv-{salt,formulametadata}/_data. The package therefore also copies the formula into those live volumes:
Containerised MLM on SL Micro (read-only / transactional root):
sudo transactional-update pkg install --allow-unsigned-rpm \
./PCI_DSS_v4_SLE16-hardening-formula-<ver>.noarch.rpm
sudo reboot # required to activate any transactional installBecause %post runs in the transactional-update chroot — where the container storage is shadowed and not writable — the deploy is done on first boot by the bundled deploy-<formula>.service oneshot (vendor-enabled via a shipped multi-user.target.wants symlink). To deploy without waiting for the reboot, run the helper directly: sudo /usr/libexec/deploy-<formula>.sh.
Containerised MLM on a writable root, or a traditional server: sudo zypper install --allow-unsigned-rpm ./<rpm> — there %post copies to the live volumes (or /usr/share/salt-formulas is in file_roots) immediately, no reboot needed.
Then, in the Web UI:
- Go to a system or system group → Formulas, tick PCI-DSS v4 Hardening, Save.
- Open the new PCI DSS V4 SLE16 (or …SLE15) sub-tab, toggle categories as desired, Save (this writes the
PCI_DSS_v4_SLE16:pillar the states read). - Apply the highstate. Use
_verify/oscap_scan.shfor an independent read-only oscap check.
In the formula model MLM generates the highstate and feeds the pillar from the form, so the RPM ships only the states + metadata (no top.sls/pillar).
For the classic "apply against a grain" model (using the SLE 16 names as the example):
- Copy
out/srv/salt/PCI_DSS_v4_SLE16/andout/srv/salt/top.slsto/srv/salt/on the MLM server, andout/srv/pillar/contents to/srv/pillar/. On a containerised server, copy straight into the podman volumes (/var/lib/containers/storage/volumes/srv-salt/_data/, …srv-pillar/_data/). - Tag in-scope clients:
salt '<minion>' grains.setval pci_scope true - Dry run:
out/srv/salt/PCI_DSS_v4_SLE16/_verify/salt_verify.sh - Apply:
salt -C 'G@pci_scope:true' state.apply PCI_DSS_v4_SLE16 - Verify:
_verify/oscap_scan.sh— produces an HTML report at/var/log/PCI_DSS_v4_SLE16-scan/report.html
The formula metadata is also written to out/srv/formula_metadata/PCI_DSS_v4_SLE16/ for manual placement under /srv/formula_metadata/ if you prefer not to use the RPM.
- PCI-DSS v3 for SLE: ComplianceAsCode only ships
pci-dss-4(PCI-DSS v4.0.1) for SUSE targets. A separate PCI-DSS v3 profile never existed for SLE in CaC (it only shipped for RHEL, and PCI SSC retired v3.2.1 in 2024). The--profileflag accepts any valid profile ID, so a RHEL datastream with a v3 profile still works. - SLE 16 / Salt: SLE 16 does not ship native Salt packages. MLM/Uyuni manages SLE 16 clients via its bundled minion — the generated states deploy unchanged.
- SLE 16 datastream availability: Confirm your ComplianceAsCode release ships
ssg-sle16-ds.xmlbefore targeting--target sle16. Use--datastream /path/to/ssg-sle16-ds.xmlonce the file is available.
- Python 3 (stdlib only — no pip install needed)
- Network access to GitHub, or
zypper in scap-security-guideon the target machine, or a pre-downloaded datastream via--datastream - Salt (for applying the generated states); oscap (for the verification scripts)