Getting Useful ModSecurity Logs Out of Liferay PaaS
This is LR Tools’ rewrite of a field-lessons post by David H Nebinger on Liferay.dev, following up on Liferay’s official ModSecurity documentation with what actually happened when a real client configuration turned “logs exist” into “logs are useful.”
The gap the official docs leave open
Liferay’s docs already explain how to turn ModSecurity on, supply a custom rule set, and route the audit log to the Liferay Cloud Console. What they don’t fully cover is the gap between ModSecurity is enabled and producing logs and ModSecurity is producing the logs you actually want, at a volume your logging pipeline can handle. That gap is where a real deployment can go wrong even while every individual setting looks reasonable in isolation.
Turning it on
ModSecurity is enabled per-environment in webserver/LCP.json:
{
"env": {
"LCP_WEBSERVER_MODSECURITY": "DetectionOnly"
}
}
The three supported values are Off (rules aren’t processed at all), DetectionOnly (rules run and get evaluated, but nothing is blocked), and On (rules run and are enforced). Starting in DetectionOnly, watching what would have been blocked, and only then flipping to On is the path Liferay recommends — and it’s worth actually following rather than skipping straight to enforcement. One config detail that’s easy to miss: if you supply a custom webserver/configs/[ENV]/modsec/modsecurity.conf, it replaces Liferay’s default configuration outright rather than layering on top of it, so it needs to be complete on its own.
Why the log destination matters more on PaaS
The ModSecurity default audit log path (/var/log/modsec_audit.log) is fine on a traditional server with a persistent filesystem. It’s a poor fit for PaaS, where containers get replaced, rescaled, and restarted routinely — taking any log written only to local disk with them. Liferay’s own docs already recommend the fix:
SecAuditLog /dev/stdout
Writing to stdout surfaces the audit log alongside the webserver’s normal service logs in the Cloud Console, across every container — including ones spun up by autoscaling — and makes the output downloadable and retainable instead of vanishing with the container.
Where it actually goes wrong
The troubleshooting configuration that caused real problems looked, roughly, like this:
SecAuditEngine On
SecAuditLogParts ABIJDEFHZK
SecAuditLogType Serial
SecAuditLog /dev/stdout
SecAuditLogFormat JSON
Every individual choice here has a reasonable-sounding justification — audit everything, capture verbose detail, use structured JSON, ship it to stdout. Combined, they add up to something else entirely.
SecAuditEngine controls which transactions get recorded at all: Off disables auditing, On records every single transaction, and RelevantOnly records only transactions that triggered a rule warning/error or matched a configured response status. On sounds like the safe, thorough choice until you count what “every transaction” actually includes — every image, script, and stylesheet request, plus routine API and monitoring/health-check traffic. The fix that resolved the volume problem:
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts is the more subtle culprit. Each letter in that string selects one part of the HTTP transaction to include in the audit record:
A— audit log header (always required)B— request headersC— request bodyD— reserved, unimplemented in ModSecurity 3.xE— response bodyF— final response headersG— reserved, unimplemented in ModSecurity 3.xH— audit log trailer / extra transaction detailI— unimplemented in ModSecurity 3.xJ— uploaded file infoK— unimplemented in ModSecurity 3.xZ— end-of-entry marker (always required)
E is the one that matters most. Paired with SecAuditEngine On, JSON formatting, and stdout as the destination, E means every response body — a 300KB rendered HTML page, easily — gets serialized into JSON and pushed through the container’s logging pipeline, for every logged request. SecAuditLogParts doesn’t control whether ModSecurity inspects response bodies at all — that’s a separate setting, SecResponseBodyAccess — it only controls what gets written to the log. ModSecurity 3.x’s own documented default set is ABCFHZ; the problem configuration used ABIJDEFHZK, and the fix was simply dropping the E:
SecAuditLogParts ABIJDFHZK
The general advice here: don’t copy a sample string wholesale. Decide deliberately about C (request bodies — useful, but can carry sensitive data), E (response bodies — the main volume driver), and J (uploaded file metadata), since each one trades detail for size and, in the case of C and J, potential sensitive-data exposure in your logs.
Format is a separate decision from content
SecAuditLogFormat chooses between Native (the default, human-readable) and JSON. This is independent of SecAuditLogParts — Native is fine when a person is reading the log directly and nothing downstream needs to parse it; JSON earns its keep once you’re feeding a SIEM or building automated analysis. Either way, format doesn’t fix volume: a bloated record with a full response body is still just as large whether it’s Native or JSON.
The log that isn’t the audit log
The familiar ModSecurity: Warning. Matched ...-style messages don’t come from the audit log at all — they come through Nginx’s error log. To see them in the Console, Nginx needs its own stdout routing:
error_log /dev/stdout warn;
Nginx’s log levels run debug, info, notice, warn, error, crit, alert, emerg, each including everything more severe than itself. warn is a reasonable steady-state level; drop to notice, info, or debug only while actively troubleshooting, then dial it back.
Debug logging is a temporary tool, not a setting
SecDebugLogLevel 9 is genuinely useful while diagnosing a specific issue, but it isn’t meant to run indefinitely. A sensible resting level:
SecDebugLogLevel 3
A practical starting configuration
Putting the pieces above together gives a baseline that’s sustainable rather than a firehose:
SecRuleEngine ${LCP_WEBSERVER_MODSECURITY}
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts ABIJDFHZK
SecAuditLogType Serial
SecAuditLog /dev/stdout
SecDebugLogLevel 3
paired with Nginx’s error log routed the same way:
error_log /dev/stdout warn;
From there, pick SecAuditLogFormat Native or SecAuditLogFormat JSON deliberately, based on whether a human or a downstream system is the primary consumer. The two operational choices worth repeating: use RelevantOnly, not On, and leave E out of SecAuditLogParts unless you have a specific, ongoing need for response bodies in the log.
If you also want Nginx’s normal access log centralized — handy for correlating a ModSecurity event back to the exact request that triggered it — that’s a separate, optional addition:
access_log /dev/stdout main;
Getting logs out for the long term
The Cloud Console retains logs for 30 days, not indefinitely, so anything you need to keep longer has to be exported. The Liferay CLI can pull service logs directly:
lcp log -p <project>-<environment> -s webserver
and constrain the pull to a time window:
lcp log \
-p <project>-<environment> \
-s webserver \
--since "<start_time>" \
--until "<end_time>" \
>> webserver-logs.txt
Automating a weekly export, filtering for ModSecurity-related lines, and retaining what compliance or security requirements demand is the practical pattern here — and JSON-formatted output makes that filtering considerably easier, since you’re parsing fields instead of pattern-matching raw text.
The actual lesson
There’s no single directive that fixes ModSecurity logging on PaaS — the lesson is treating the whole path (application → ModSecurity → Nginx’s error and access logs → stdout → the container runtime → platform logging infrastructure → the Console) as a pipeline you design, not a log file you point somewhere and forget. Auditing everything, including full response bodies, in JSON, sent to stdout, are all individually defensible choices — the combination is what turns “logging is on” into “logging is a problem.” Audit only what’s relevant, leave out what you don’t specifically need, keep routine verbosity sane, and export deliberately for anything that needs to outlive 30 days.
This article is LR Tools’ rewrite of the original post by David H Nebinger — read it on Liferay.dev for the author’s own framing, or see Liferay’s Web Application Firewall documentation and command-line tool reference for full setup details.
Este artículo está adaptado de: David H Nebinger, Liferay.dev