Whackermelon
projets / postfix-audit

postfix-audit

Python github ↗

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.

HTML report: header with grade, summary matrix per category, active probe results and the reconstructed attack chain
$ 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
OptionEffect
-c, --config-dirdirectory holding main.cf and master.cf (default /etc/postfix)
--livecollect system context from the current machine
-s, --sysinfocollect system context from a directory of captured command output
--probe HOST[:PORT]interrogate the running SMTP service (requires --authorized)
--authorizedasserts that probing the target is authorised
--probe-invasiveadds the SMTP smuggling test, which delivers one test message
--lang fr|enreport language (default fr)
--html PATHwrite the HTML report
--fragmentemit the report without the <html> wrapper, for embedding
--json PATHwrite the full result set
--sarif PATHwrite SARIF 2.1.0 for GitHub code scanning
--prometheus PATHwrite node_exporter textfile metrics
--baseline PATHcompare against a previous JSON export
--fleet FILE...aggregate several JSON exports into a fleet view
-q, --quietprint only the global score
--no-colorplain 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.

A finding in detail: observation, consequence, deviation from default, evidence collected by the probe, recommendation, ready to apply fix and framework mapping

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:

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.

CodeCategory
EXPONetwork exposure and attack surface
RELAYRelay control and mail routing
TLSTransport encryption
AUTHAuthentication and anti-spoofing
PROTOSMTP dialogue hardening
ABUSEAbuse and denial of service resistance
LEAKInformation disclosure
ISOLIsolation, privileges and file integrity
LOGLogging and supervision
CONFConfiguration 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:

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.

Deviation table: parameter, configured value, Postfix default, where the default came from, whether the deviation hardens or weakens, and a comment

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

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

Baseline comparison: score before and after, and each finding classified as fixed, appeared, worsened or eased

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

Fleet view: score per host and findings ranked by how many hosts carry them, with systemic findings flagged

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.

The same report rendered 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.

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.

← tous les projets