web application security

Implementing Web Application Logging with ModSecurity

Configure ModSecurity WAF with OWASP Core Rule Set (CRS) for web application logging, tune rules to reduce false positives, analyze audit logs for attack detection, and implement custom SecRules for application-specific threats. The analyst configures SecRuleEngine, SecAuditEngine, and CRS paranoia levels to balance security coverage with operational stability. Activates for requests involving WAF configuration, ModSecurity rule tuning, web application audit logging, or CRS deployment.

audit-loggingcrsmodsecurityowasprule-tuningwafweb-security
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

Overview

ModSecurity is an open-source WAF engine that works with Apache, Nginx, and IIS. The OWASP Core Rule Set (CRS) provides generic attack detection rules covering SQL injection, XSS, RCE, LFI, and other OWASP Top 10 attacks. ModSecurity logs full request/response data in audit logs for forensic analysis and generates alerts that feed into SIEM platforms.

When to Use

  • When deploying or configuring implementing web application logging with modsecurity capabilities in your environment
  • When establishing security controls aligned to compliance requirements
  • When building or improving security architecture for this domain
  • When conducting security assessments that require this implementation

Prerequisites

  • Web server (Apache 2.4+ or Nginx) with ModSecurity v3 module
  • OWASP CRS v4.x installed
  • Log aggregation infrastructure (ELK, Splunk, or Wazuh)

Steps

  1. Install ModSecurity and configure SecRuleEngine in DetectionOnly mode
  2. Deploy OWASP CRS v4 and set paranoia level (PL1-PL4)
  3. Configure SecAuditEngine for relevant-only logging
  4. Tune false positives with SecRuleRemoveById and rule exclusions
  5. Switch to blocking mode (SecRuleEngine On) after tuning period
  6. Forward audit logs to SIEM for correlation and alerting

Expected Output

ModSecurity: Warning. Pattern match "(?:union\s+select)" [file "/etc/modsecurity/crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"] [line "45"] [id "942100"] [msg "SQL Injection Attack Detected via libinjection"] [severity "CRITICAL"]
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 1

api-reference.md2.1 KB

ModSecurity WAF Logging — API Reference

Key ModSecurity Directives

Directive Description
SecRuleEngine On/Off/DetectionOnly Enable/disable rule engine
SecAuditEngine On/Off/RelevantOnly Configure audit logging scope
SecAuditLog /path/to/modsec_audit.log Audit log file path
SecAuditLogParts ABCDEFHZ Audit log sections to include
SecRequestBodyAccess On Inspect request bodies
SecResponseBodyAccess On Inspect response bodies
SecRuleRemoveById <id> Disable specific rule by ID
SecRuleUpdateTargetById <id> "!ARGS:param" Exclude parameter from rule

Audit Log Sections

Section Contents
A Audit log header (timestamp, transaction ID)
B Request headers
C Request body
E Response body
F Response headers
H Audit log trailer (rule matches, scores)
Z End of entry marker

OWASP CRS Rule ID Ranges

Range Category
911xxx Method Enforcement
920xxx Protocol Enforcement
930xxx Local File Inclusion
932xxx Remote Code Execution
941xxx Cross-Site Scripting (XSS)
942xxx SQL Injection
944xxx Java/Spring Attack
949xxx Inbound Anomaly Score Blocking

CRS Paranoia Levels

Level Description
PL1 Default — low false positives, covers common attacks
PL2 Moderate — adds more patterns, some tuning needed
PL3 High — aggressive detection, significant tuning needed
PL4 Extreme — maximum coverage, heavy tuning required

Configuration Example

SecRuleEngine DetectionOnly
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts ABCDEFHZ
SecAuditLogType Serial
SecAuditLog /var/log/modsec_audit.log

External References

Scripts 1

agent.py8.5 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""ModSecurity WAF audit log analysis and rule tuning agent."""

import json
import argparse
import re
from datetime import datetime
from collections import defaultdict


SECTION_PATTERN = re.compile(r'^--([a-f0-9]+)-([A-Z])--$')

CRS_CATEGORIES = {
    "911": "Method Enforcement",
    "913": "Scanner Detection",
    "920": "Protocol Enforcement",
    "921": "Protocol Attack",
    "930": "Local File Inclusion",
    "931": "Remote File Inclusion",
    "932": "Remote Code Execution",
    "933": "PHP Injection",
    "934": "Node.js Injection",
    "941": "XSS Attack",
    "942": "SQL Injection",
    "943": "Session Fixation",
    "944": "Java Attack",
    "949": "Inbound Blocking",
    "959": "Outbound Blocking",
}


def parse_audit_log(log_path, max_entries=5000):
    """Parse ModSecurity serial audit log format."""
    entries = []
    current = {}
    current_section = None

    with open(log_path, "r", errors="replace") as f:
        for line in f:
            match = SECTION_PATTERN.match(line.strip())
            if match:
                tx_id = match.group(1)
                section = match.group(2)
                if section == "A":
                    if current and current.get("tx_id"):
                        entries.append(current)
                        if len(entries) >= max_entries:
                            break
                    current = {"tx_id": tx_id, "sections": {}}
                current_section = section
                current["sections"][section] = ""
            elif current_section and current_section in current.get("sections", {}):
                current["sections"][current_section] += line

    if current and current.get("tx_id"):
        entries.append(current)

    parsed = []
    for entry in entries:
        record = {"tx_id": entry["tx_id"]}
        section_a = entry["sections"].get("A", "")
        if section_a:
            parts = section_a.strip().split()
            if len(parts) >= 3:
                record["timestamp"] = parts[0] if parts else ""
                record["client_ip"] = parts[1] if len(parts) > 1 else ""

        section_b = entry["sections"].get("B", "")
        if section_b:
            first_line = section_b.strip().split("\n")[0]
            req_parts = first_line.split()
            if len(req_parts) >= 2:
                record["method"] = req_parts[0]
                record["uri"] = req_parts[1]

        section_h = entry["sections"].get("H", "")
        record["rules_matched"] = []
        for rule_match in re.finditer(
            r'\[id "(\d+)"\].*?\[msg "([^"]+)"\].*?\[severity "([^"]+)"\]',
            section_h
        ):
            record["rules_matched"].append({
                "rule_id": rule_match.group(1),
                "message": rule_match.group(2),
                "severity": rule_match.group(3),
            })

        anomaly = re.search(r'Inbound Anomaly Score.*?(\d+)', section_h)
        if anomaly:
            record["anomaly_score"] = int(anomaly.group(1))

        parsed.append(record)
    return parsed


def analyze_rule_frequency(entries):
    """Analyze which rules fire most frequently for tuning."""
    rule_counts = defaultdict(int)
    rule_msgs = {}
    for entry in entries:
        for rule in entry.get("rules_matched", []):
            rid = rule["rule_id"]
            rule_counts[rid] += 1
            rule_msgs[rid] = rule["message"]

    sorted_rules = sorted(rule_counts.items(), key=lambda x: x[1], reverse=True)
    results = []
    for rid, count in sorted_rules:
        category = CRS_CATEGORIES.get(rid[:3], "Other")
        results.append({
            "rule_id": rid,
            "count": count,
            "message": rule_msgs.get(rid, ""),
            "category": category,
        })
    return results


def identify_false_positive_candidates(entries, threshold=50):
    """Identify rules that may be false positives based on frequency and pattern."""
    rule_ips = defaultdict(set)
    rule_uris = defaultdict(set)
    rule_counts = defaultdict(int)

    for entry in entries:
        for rule in entry.get("rules_matched", []):
            rid = rule["rule_id"]
            rule_counts[rid] += 1
            rule_ips[rid].add(entry.get("client_ip", ""))
            rule_uris[rid].add(entry.get("uri", ""))

    candidates = []
    for rid, count in rule_counts.items():
        if count >= threshold and len(rule_ips[rid]) > 10:
            candidates.append({
                "rule_id": rid,
                "hit_count": count,
                "unique_ips": len(rule_ips[rid]),
                "unique_uris": len(rule_uris[rid]),
                "recommendation": f"SecRuleRemoveById {rid}",
                "reason": "High frequency across many IPs — likely false positive",
            })
    return candidates


def generate_exclusion_rules(candidates):
    """Generate ModSecurity rule exclusion configuration."""
    lines = ["# Auto-generated false positive exclusions"]
    for c in candidates:
        lines.append(f"# Rule {c['rule_id']}: {c['hit_count']} hits, "
                     f"{c['unique_ips']} unique IPs")
        lines.append(f"SecRuleRemoveById {c['rule_id']}")
    return "\n".join(lines)


def analyze_attack_summary(entries):
    """Summarize detected attacks by category and severity."""
    category_counts = defaultdict(int)
    severity_counts = defaultdict(int)
    top_attackers = defaultdict(int)

    for entry in entries:
        for rule in entry.get("rules_matched", []):
            cat = CRS_CATEGORIES.get(rule["rule_id"][:3], "Other")
            category_counts[cat] += 1
            severity_counts[rule["severity"]] += 1
        if entry.get("anomaly_score", 0) >= 5:
            top_attackers[entry.get("client_ip", "")] += 1

    return {
        "by_category": dict(sorted(category_counts.items(), key=lambda x: x[1], reverse=True)),
        "by_severity": dict(severity_counts),
        "top_attackers": dict(sorted(top_attackers.items(), key=lambda x: x[1], reverse=True)[:20]),
    }


def run_audit(args):
    """Execute ModSecurity audit log analysis."""
    print(f"\n{'='*60}")
    print(f"  MODSECURITY AUDIT LOG ANALYSIS")
    print(f"  Generated: {datetime.utcnow().isoformat()} UTC")
    print(f"{'='*60}\n")

    report = {}

    entries = parse_audit_log(args.audit_log, args.max_entries)
    report["total_entries"] = len(entries)
    print(f"Parsed {len(entries)} audit log entries\n")

    attack_summary = analyze_attack_summary(entries)
    report["attack_summary"] = attack_summary
    print(f"--- ATTACK SUMMARY ---")
    for cat, count in list(attack_summary["by_category"].items())[:10]:
        print(f"  {cat}: {count}")
    print(f"\n  Severity: {attack_summary['by_severity']}")
    print(f"\n--- TOP ATTACKERS ---")
    for ip, count in list(attack_summary["top_attackers"].items())[:10]:
        print(f"  {ip}: {count} alerts")

    rule_freq = analyze_rule_frequency(entries)
    report["rule_frequency"] = rule_freq[:20]
    print(f"\n--- TOP FIRING RULES ---")
    for r in rule_freq[:15]:
        print(f"  [{r['rule_id']}] {r['count']}x — {r['message'][:60]}")

    if args.tune:
        fp_candidates = identify_false_positive_candidates(entries, args.fp_threshold)
        report["false_positive_candidates"] = fp_candidates
        print(f"\n--- FALSE POSITIVE CANDIDATES ({len(fp_candidates)}) ---")
        for c in fp_candidates[:10]:
            print(f"  Rule {c['rule_id']}: {c['hit_count']} hits, "
                  f"{c['unique_ips']} IPs — {c['reason']}")
        if fp_candidates:
            exclusions = generate_exclusion_rules(fp_candidates)
            report["exclusion_config"] = exclusions

    return report


def main():
    parser = argparse.ArgumentParser(description="ModSecurity Audit Log Agent")
    parser.add_argument("--audit-log", required=True,
                        help="Path to ModSecurity audit log file")
    parser.add_argument("--max-entries", type=int, default=5000,
                        help="Max log entries to parse (default: 5000)")
    parser.add_argument("--tune", action="store_true",
                        help="Identify false positive candidates for tuning")
    parser.add_argument("--fp-threshold", type=int, default=50,
                        help="Minimum hits for false positive candidate (default: 50)")
    parser.add_argument("--output", help="Save report to JSON file")
    args = parser.parse_args()

    report = run_audit(args)
    if args.output:
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2, default=str)
        print(f"\n[+] Report saved to {args.output}")


if __name__ == "__main__":
    main()
Keep exploring