postfix-audit
Audit de sécurité pour Postfix : score sur 100 par rapport aux défauts Postfix et aux guides de durcissement, sonde SMTP en option.
Security audit for Postfix. It reads a configuration directory, optionally interrogates the running service, scores the result out of 100 against Postfix’s own defaults and published hardening guidance, and explains every point it takes off.
Single file, Python 3.9 or later, no dependencies.
$ postfix_audit.py -c /etc/postfix --live --probe 127.0.0.1 --authorized
NOTE GLOBALE : 34.6/100 (E) : Configuration exposée, correction prioritaire
Majeur: 10 Mineur: 23 Remarque: 12 Point fort: 13
NOTES PAR CATÉGORIE
EXPO █████··················· 22.6/100 Exposition réseau et surface d'attaque
RELAY ██████████████████······ 74.2/100 Relais SMTP et contrôle d'acheminement
TLS █████████··············· 38.0/100 Chiffrement des flux SMTP
...
Reports render in French by default and in English with --lang en. The code,
this README and the issue tracker are in English.
Everything shown in this README comes from examples/, a synthetic host you can
run yourself in about ten seconds. See Try it.
Why this exists
Postfix hardening advice is scattered across the official README files, a dozen
blog posts of varying age, and compliance benchmarks that stop at “set this
parameter”. Four problems follow.
Defaults are invisible. postconf -n shows what somebody typed. It does not
show that mailbox_size_limit = 0, shipped by the Debian package, removes a 50 MB
ceiling Postfix applies by default, nor that a parameter absent from main.cf
still has an effective value which may be the problem. postfix-audit diffs against
the real defaults, reading them from postconf -d when the binary is reachable,
and labels each deviation as hardening, weakening or neutral, so a packaging
convention is never mistaken for a decision.
Advice ignores the server’s job. A null client that forwards cron mail to a smarthost does not need postscreen, DNS blocklists or a submission service. Flagging their absence produces noise that trains people to ignore the tool. postfix-audit infers the role from the configuration and adjusts what it expects.
A patched package is not a protected one. Distribution backports of the SMTP
smuggling fix (CVE-2023-51764) ship smtpd_forbid_bare_newline defaulting to
no. A fully updated host is still exploitable until an operator sets it. The
tool checks parameters, not version numbers.
Static analysis proves nothing on its own. So the tool can also open an SMTP session and check. Findings the probe confirms are marked verified; findings the probe contradicts are corrected, including their score.
Try it
git clone https://github.com/Whackermelon/postfix-audit
cd postfix-audit
python3 examples/demo-smtp-server.py &
./postfix_audit.py -c examples/demo-collection/etc/postfix \
-s examples/demo-collection \
--probe 127.0.0.1:2525 --authorized \
--html report.html
kill %1
examples/ holds a synthetic host: a main.cf and master.cf matching a default
Debian or Ubuntu install on an application server, plus the system context the
tool reads. Every name and address comes from the documentation ranges reserved by
RFC 2606 and RFC 5737.
Usage
# a live machine, with the active probe
sudo ./postfix_audit.py -c /etc/postfix --live --probe 127.0.0.1 --authorized \
--html report.html --json results.json
# a configuration directory extracted from a collection or a backup
./postfix_audit.py -c ./etc/postfix --sysinfo ./collection --html report.html
# CI outputs
./postfix_audit.py -c /etc/postfix --live \
--sarif results.sarif --prometheus /var/lib/node_exporter/postfix.prom
# drift since the previous audit, and a fleet roll-up
./postfix_audit.py -c /etc/postfix --live --baseline last-month.json
./postfix_audit.py --fleet results/*.json --html fleet.html
| Option | Effect |
|---|---|
-c, --config-dir | directory holding main.cf and master.cf (default /etc/postfix) |
--live | collect system context from the current machine |
-s, --sysinfo | collect system context from a directory of captured command output |
--probe HOST[:PORT] | interrogate the running SMTP service (requires --authorized) |
--authorized | asserts that probing the target is authorised |
--probe-invasive | adds the SMTP smuggling test, which delivers one test message |
--lang fr|en | report language (default fr) |
--html PATH | write the HTML report |
--fragment | emit the report without the <html> wrapper, for embedding |
--json PATH | write the full result set |
--sarif PATH | write SARIF 2.1.0 for GitHub code scanning |
--prometheus PATH | write node_exporter textfile metrics |
--baseline PATH | compare against a previous JSON export |
--fleet FILE... | aggregate several JSON exports into a fleet view |
-q, --quiet | print only the global score |
--no-color | plain console output |
Exit status is 2 when a critical finding is raised, 1 for a major one, 0
otherwise, so the tool drops straight into a pipeline.
Findings
Every finding carries the observation, the security consequence in plain terms, the deviation from the default, what was actually observed on the service, a ready to apply fix, and its mapping to ANSSI, CIS, NIST SP 800-53 and ISO/IEC 27002.

The active probe
--probe moves findings from inferred to measured. In its default mode it never
transmits a message: every transaction stops before DATA and is closed with
RSET. It records:
- the banner actually announced and the advertised ESMTP extensions;
- whether
VRFYandEXPNare accepted; - whether relaying to a third-party domain is accepted, tested at the
RCPTstage against an RFC 2606 address; - whether a valid and an unknown recipient get different answers, which is what makes recipient enumeration possible;
- which TLS versions are actually negotiable, tried one version per connection, and the cipher suite selected;
- whether the certificate validates against the system trust store, with its subject, issuer, expiry and key size.
Results feed back into the analysis. A configuration that permits TLS 1.0 but a library that refuses it downgrades the finding and raises the score; a service that accepts relaying escalates it to critical whatever the file says.
--probe-invasive adds the SMTP smuggling test. That one delivers a message to
postmaster, because there is no way to test end-of-data handling without
reaching DATA. It is opt-in for that reason.
--probe refuses to run without --authorized. Probing a mail server you do not
operate is not a neutral act.
What it checks
Ten categories, each scored separately.
| Code | Category |
|---|---|
EXPO | Network exposure and attack surface |
RELAY | Relay control and mail routing |
TLS | Transport encryption |
AUTH | Authentication and anti-spoofing |
PROTO | SMTP dialogue hardening |
ABUSE | Abuse and denial of service resistance |
LEAK | Information disclosure |
ISOL | Isolation, privileges and file integrity |
LOG | Logging and supervision |
CONF | Configuration consistency and maintenance |
Each check yields a typed finding: a vulnerability (critical, major, minor), a strength, or a remark. Strengths are reported as prominently as weaknesses, which matters when a report has to justify what already works.
A few checks worth naming, because generic benchmarks tend to miss them:
- SMTP smuggling (CVE-2023-51764), checked as a parameter rather than a version, for the reason given above.
- Directory enumeration through
RCPT TO. Whenlocal_recipient_mapskeeps its default and NSS resolves accounts through SSSD, LDAP or Winbind, recipient validation answers questions about the whole identity domain. DisablingVRFYdoes not close this. - Syntax anomalies that parse silently. An empty element in
mydestination, arelayhostwithout brackets triggering a pointless MX lookup, amydomaincontradictingmyhostname. Postfix accepts all three without warning. - Attack chain. When several findings compose into a realistic scenario, the report reconstructs it step by step, each step citing the finding that enables it. Individually minor findings usually matter for what they enable together.
Deviations from defaults
Every directive is compared against the default, with the origin of that default stated, so a value placed by the distribution package is never read as a deliberate hardening decision.

Scoring
The score is obtained points over available points, rescaled to 100. Each check carries a weight reflecting how badly its failure matters on this host; a partially satisfied check earns a fraction of its weight, so the score moves continuously rather than flipping.
A >= 90, B >= 75, C >= 60, D >= 45, E >= 30, F < 30.
The remediation plan orders findings by severity then by points recovered, and marks the fixes that change protocol behaviour and should be watched in staging first. The score is a way to prioritise work, not a certification.
Output
- Console. Score, per category bars, findings grouped by category, and the top of the remediation plan.
- HTML. A self contained report, shown throughout this README. Prints cleanly to PDF, follows the reader’s light or dark theme, no external asset beyond web fonts.
- JSON. Everything above, structured, for tracking a fleet over time.
- SARIF 2.1.0. Findings land in the GitHub Security tab with severity, help text, fix snippet and framework tags. Any SARIF consumer will read it.
- Prometheus. Textfile metrics: global score, per category score, counts by severity, number of verified findings, timestamp.
Baseline and fleet
--baseline previous.json classifies what moved since a prior run into fixed,
appeared, worsened and eased, and reports the score delta per category. It is the
difference between “we are at 46” and “we went from 32 to 46, and one new finding
appeared”.

--fleet results/*.json rolls several exports into one view: score per host,
average per category, and findings ranked by how many hosts carry them. A finding
present on every host comes from the deployment template, not from local drift,
and is fixed once at source. That distinction is invisible machine by machine.

Framework crosswalk
Findings are mapped to CIS Benchmark controls, NIST SP 800-53 Rev. 5 controls and ISO/IEC 27002:2022 controls, alongside the ANSSI guidance, so results can be folded into an existing compliance plan. The mapping appears in the HTML report, in the JSON, and as SARIF tags.
DISA STIG identifiers are deliberately absent. V- numbers change with every
release and are specific to the product targeted, so shipping a fixed set would
be wrong more often than right. The stig key exists in CROSSWALK for an
organisation tracking a given revision to populate.
Languages
--lang en renders the report in English.

Translations live in one place, FINDINGS_EN, keyed by finding id, so a
contributor can extend coverage without touching rule logic. A field with no
translation keeps its French text and the coverage rate is reported in the method
section, so a partially translated report is still accurate.
Extending
Everything lives in one file, in clearly separated sections.
POSTFIX_DEFAULTSholds the reference defaults (Postfix 3.6), used whenpostconf -dis not reachable. Extend it to cover another branch.DISTRO_TEMPLATE_DEFAULTSrecords values placed by the Debian and Ubuntu packages, which is how the tool tells a packaging convention from a deliberate hardening decision.RULE_SETSlists the rule groups. A rule is a functionrules_x(audit, profile)callingaudit.add(...). Adding a check means adding one call.CROSSWALKmaps finding ids to external framework controls.FINDINGS_ENholds the English text.
No finding hardcodes a hostname, an address or a domain: values come from the parsed configuration and the system context, so a rule written for one estate reads correctly on another.
Limits
Without --probe the analysis is purely static and proves nothing by itself: the
absence of an open relay, the real availability of smtpd_forbid_bare_newline in
the installed package, and the contents of certificates all need confirming. The
report prints the commands for each.
ANSSI references are thematic. They name the guide and the requirement, not a recommendation number. Numbers should be taken from the version of the guide that applies when the report is issued.
The tool is developed and tested on Python 3.12. Compatibility with 3.9 is verified by static analysis of the syntax and standard library calls used, not by running the test suite on that version.
Contributing
Useful contributions, roughly in order of value: defaults tables for Postfix
branches other than 3.6, checks for setups this does not cover yet (Dovecot SASL,
milter chains, virtual domain backends), STIG mappings for a tracked revision, and
extending FINDINGS_EN.
Open an issue before a large change so the scoring weights can be discussed first. A check that fires on a correct configuration costs more than a missing check.
License
MIT. See LICENSE.