Skip to content

doccaz/scap2salt

Repository files navigation

scap2salt

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).

What it produces

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).

Design principles

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.

Current coverage

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.

Deviations from upstream CaC

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_forward on 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 set net.ipv4.ip_forward=1 unconditionally 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_login can still report fail if the host carries a separate drop-in (e.g. /etc/ssh/sshd_config.d/root.conf) with PermitRootLogin yes. scap2salt's 00-pci-hardening.conf sorts first so no is effective (sshd -T confirms), 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_config will shadow it and disable all drop-ins.

Form transparency

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.

Opt-in (default-off) category: accounts

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 with chage afterwards (PAM refuses) — recovery needs a direct /etc/shadow edit;
  • sudo_require_authentication comments out NOPASSWD, 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.

Reboot signalling for audit rules

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.

Usage

./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.xml

Datastream acquisition

The script resolves the datastream in this order:

  1. --datastream path (if given)
  2. Locally installed /usr/share/xml/scap/ssg/content/ssg-<target>-ds.xml (from the scap-security-guide RPM)
  3. Cached download from a previous run (.cache/ssg-<target>-ds.xml)
  4. 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.

How it works

1. Parse the XCCDF benchmark

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.

2. Map rules to Salt states

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_hashes is left unmapped and listed in UNMAPPED.md. Its only automated remediation is reinstalling packages, which is unsafe to run autonomously.

Guarded operational states: m_aide and m_rpm include inherently imperative operations (database initialisation, key import, rpm --setperms) for which no declarative Salt primitive exists. These are emitted as guarded cmd.run states with creates/onlyif/unless guards so they stay idempotent and test=True-safe. They are counted separately in the run summary and in the --report-only coverage report.

3. Emit the state tree

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.

Deploying on SUSE Multi-Linux Manager

The recommended path is the formula RPM — the way SUSE ships its own formulas — and the formula then appears in the Web UI automatically.

Per-OS formula names

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/).

Recommended: formula RPM

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 install

Because %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:

  1. Go to a system or system groupFormulas, tick PCI-DSS v4 Hardening, Save.
  2. 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).
  3. Apply the highstate. Use _verify/oscap_scan.sh for 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).

Alternative: manual state tree (no RPM)

For the classic "apply against a grain" model (using the SLE 16 names as the example):

  1. Copy out/srv/salt/PCI_DSS_v4_SLE16/ and out/srv/salt/top.sls to /srv/salt/ on the MLM server, and out/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/).
  2. Tag in-scope clients: salt '<minion>' grains.setval pci_scope true
  3. Dry run: out/srv/salt/PCI_DSS_v4_SLE16/_verify/salt_verify.sh
  4. Apply: salt -C 'G@pci_scope:true' state.apply PCI_DSS_v4_SLE16
  5. 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.

Notes on targets and profiles

  • 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 --profile flag 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.xml before targeting --target sle16. Use --datastream /path/to/ssg-sle16-ds.xml once the file is available.

Requirements

  • Python 3 (stdlib only — no pip install needed)
  • Network access to GitHub, or zypper in scap-security-guide on the target machine, or a pre-downloaded datastream via --datastream
  • Salt (for applying the generated states); oscap (for the verification scripts)

About

Convert SCAP Security Guide (SSG) datastreams into native SaltStack state trees for SUSE hardening (PCI-DSS v4), deployable as SUSE Multi-Linux Manager / Uyuni formulas with form.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors