Skip to content

About

SecLang Engine WAF is a ModSecurity-compatible Web Application Firewall (WAF) library for the JVM, written in Scala.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Cloud APIM - SecLang Engine

An open-source, enterprise-grade, high-performance Web Application Firewall (WAF) library for the JVM, made to protect your webapps.

CI Maven Central License Scala 2.12 | 2.13 CRS v4 Compatibility

SecLang Engine is written in Scala and implements the ModSecurity SecLang rule language, providing full compatibility with the OWASP Core Rule Set (CRS) v4.

Key Features

  • CRS Compatibility - Runs the OWASP Core Rule Set v4 out of the box, protecting your applications against SQL Injection, Cross-Site Scripting (XSS), Remote Code Execution, and other threats from the OWASP Top Ten.
  • Multi-Tenant by Design - Built from the ground up for multi-tenant environments, allowing different rule configurations per tenant with isolated execution contexts and shared rule presets.
  • JVM Native - Runs natively on the JVM with Scala 2.12 and 2.13 support. Easily integrates with existing Java/Scala applications, API gateways, and reverse proxies.
  • High Performance - Optimized for high-throughput scenarios with compiled rule programs, regex caching, and efficient variable resolution. Handles thousands of requests per second with minimal latency overhead.
  • Library-First - Designed as an embeddable library, not a standalone server. Integrate WAF capabilities directly into your application, gateway, or proxy with a simple API.
  • Extensible - Customize logging, caching, and integration points through the SecLangIntegration trait. Compose rule presets and configurations dynamically at runtime.
  • Battle-Tested - Passes 100% of the official CRS test suite, ensuring reliable protection against real-world attack patterns.

Status

This project is a work in progress.

We are currently passing 100% of the CRS test suite

Simple usage

import com.cloud.apim.seclang.model.Disposition._
import com.cloud.apim.seclang.model._
import com.cloud.apim.seclang.scaladsl.SecLang

class SecLangTest extends munit.FunSuite {
  
  test("simple seclang rules") {
    val rules = """
                  |SecRule REQUEST_HEADERS:User-Agent "@pm firefox" \
                  |    "id:00001,\
                  |    phase:1,\
                  |    block,\
                  |    t:none,t:lowercase,\
                  |    msg:'someone used firefox to access',\
                  |    logdata:'someone used firefox to access',\
                  |    tag:'test',\
                  |    ver:'0.0.0-dev',\
                  |    status:403,\
                  |    severity:'CRITICAL'"
                  |
                  |SecRule REQUEST_URI "@contains /health" \
                  |    "id:00002,\
                  |    phase:1,\
                  |    pass,\
                  |    t:none,t:lowercase,\
                  |    msg:'someone called /health',\
                  |    logdata:'someone called /health',\
                  |    tag:'test',\
                  |    ver:'0.0.0-dev'"
                  |
                  |SecRuleEngine On
                  |""".stripMargin

    val loaded = SecLang.parse(rules).fold(err => sys.error(err), identity)
    val program = SecLang.compile(loaded)
    val engine = SecLang.engine(program)

    val failing_ctx_2 = RequestContext(
      method = "GET",
      uri = "/",
      headers = Map("User-Agent" -> List("Firefox/128.0")),
      query = Map("q" -> List("test")),
      body = None
    )
    val passing_ctx_1 = RequestContext(
      method = "GET",
      uri = "/health",
      headers = Map("User-Agent" -> List("curl/8.0")),
      query = Map("q" -> List("test")),
      body = None
    )
    val passing_ctx_2 = RequestContext(
      method = "GET",
      uri = "/admin",
      headers = Map("User-Agent" -> List("chrome/8.0")),
      query = Map("q" -> List("test")),
      body = None
    )

    val failing_res_2 = engine.evaluate(failing_ctx_2, phases = List(1, 2))
    val passing_res_1 = engine.evaluate(passing_ctx_1, phases = List(1, 2))
    val passing_res_2 = engine.evaluate(passing_ctx_2, phases = List(1, 2))

    assertEquals(failing_res_2.disposition, Block(403, Some("someone used firefox to access"), Some(1)))
    assertEquals(passing_res_1.disposition, Continue)
    assertEquals(passing_res_2.disposition, Continue)
  }
}

Simple usage (Java)

import com.cloud.apim.seclang.javadsl.*;
import com.cloud.apim.seclang.model.CompiledProgram;

import java.util.Arrays;
import java.util.HashMap;

public class SecLangExample {

    public static void main(String[] args) {
        String rules = """
            SecRule REQUEST_HEADERS:User-Agent "@pm firefox" \\
                "id:00001,\\
                phase:1,\\
                block,\\
                t:none,t:lowercase,\\
                msg:'someone used firefox to access',\\
                logdata:'someone used firefox to access',\\
                tag:'test',\\
                ver:'0.0.0-dev',\\
                status:403,\\
                severity:'CRITICAL'"

            SecRule REQUEST_URI "@contains /health" \\
                "id:00002,\\
                phase:1,\\
                pass,\\
                t:none,t:lowercase,\\
                msg:'someone called /health',\\
                logdata:'someone called /health',\\
                tag:'test',\\
                ver:'0.0.0-dev'"

            SecRuleEngine On
            """;

        // Parse and compile rules
        SecLang.ParseResult parseResult = SecLang.parse(rules);
        if (parseResult.isError()) {
            System.err.println("Parse error: " + parseResult.getError());
            return;
        }

        CompiledProgram program = SecLang.compile(parseResult.getConfiguration());

        // Create engine (with default config, no files, default integration)
        JSecLangEngine engine = SecLang.engine(program);

        // Or with custom configuration:
        // JSecLangEngine engine = SecLang.engine(
        //     program,
        //     JSecLangEngineConfig.defaultConfig(),
        //     new HashMap<>(),
        //     JSecLangIntegration.defaultIntegration()
        // );

        // Build request contexts
        JRequestContext failingCtx = JRequestContext.builder()
            .method("GET")
            .uri("/")
            .header("User-Agent", "Firefox/128.0")
            .queryParam("q", "test")
            .build();

        JRequestContext passingCtx1 = JRequestContext.builder()
            .method("GET")
            .uri("/health")
            .header("User-Agent", "curl/8.0")
            .queryParam("q", "test")
            .build();

        JRequestContext passingCtx2 = JRequestContext.builder()
            .method("GET")
            .uri("/admin")
            .header("User-Agent", "chrome/8.0")
            .queryParam("q", "test")
            .build();

        // Evaluate requests against rules
        JEngineResult failingRes = engine.evaluate(failingCtx, Arrays.asList(1, 2));
        JEngineResult passingRes1 = engine.evaluate(passingCtx1, Arrays.asList(1, 2));
        JEngineResult passingRes2 = engine.evaluate(passingCtx2, Arrays.asList(1, 2));

        // Check results
        if (failingRes.isBlocked()) {
            System.out.println("Request blocked!");
            System.out.println("  Status: " + failingRes.getDisposition().getStatus());
            System.out.println("  Message: " + failingRes.getDisposition().getMessage().orElse("N/A"));
            System.out.println("  Rule ID: " + failingRes.getDisposition().getRuleId().orElse(0));
        }

        assert failingRes.isBlocked();
        assert failingRes.getDisposition().getStatus() == 403;
        assert passingRes1.isContinue();
        assert passingRes2.isContinue();
    }
}

Factory with presets usage

import com.cloud.apim.seclang.model.Disposition._
import com.cloud.apim.seclang.model._
import com.cloud.apim.seclang.scaladsl.SecLang

class SecLangFactoryTest extends munit.FunSuite {

  test("simple factory test") {
    val presets = Map(
      "no_firefox" -> SecLangPreset.withNoFiles("no_firefox", """
           |SecRule REQUEST_HEADERS:User-Agent "@pm firefox" \
           |   "id:00001,\
           |   phase:1,\
           |   block,\
           |   t:none,t:lowercase,\
           |   msg:'someone used firefox to access',\
           |   logdata:'someone used firefox to access',\
           |   tag:'test',\
           |   ver:'0.0.0-dev',\
           |   status:403,\
           |   severity:'CRITICAL'"
           |""".stripMargin),
      "health_check" -> SecLangPreset.withNoFiles("health_check", """
           |SecRule REQUEST_URI "@contains /health" \
           |   "id:00002,\
           |   phase:1,\
           |   pass,\
           |   t:none,t:lowercase,\
           |   msg:'someone called /health',\
           |   logdata:'someone called /health',\
           |   tag:'test',\
           |   ver:'0.0.0-dev'"
           |""".stripMargin)
    )
    val factory = SecLang.factory(presets)
    val rulesConfig = List(
      "@import_preset no_firefox",
      "@import_preset health_check",
      "SecRuleEngine On"
    )
    val failing_ctx_2 = RequestContext(
      method = "GET",
      uri = "/",
      headers = Map("User-Agent" -> List("Firefox/128.0")),
      query = Map("q" -> List("test")),
      body = None
    )
    val passing_ctx_1 = RequestContext(
      method = "GET",
      uri = "/health",
      headers = Map("User-Agent" -> List("curl/8.0")),
      query = Map("q" -> List("test")),
      body = None
    )
    val passing_ctx_2 = RequestContext(
      method = "GET",
      uri = "/admin",
      headers = Map("User-Agent" -> List("chrome/8.0")),
      query = Map("q" -> List("test")),
      body = None
    )

    val failing_res_2 = factory.evaluate(rulesConfig, failing_ctx_2, phases = List(1, 2))
    val passing_res_1 = factory.evaluate(rulesConfig, passing_ctx_1, phases = List(1, 2))
    val passing_res_2 = factory.evaluate(rulesConfig, passing_ctx_2, phases = List(1, 2))

    assertEquals(failing_res_2.disposition, Block(403, Some("someone used firefox to access"), Some(1)))
    assertEquals(passing_res_1.disposition, Continue)
    assertEquals(passing_res_2.disposition, Continue)
  }
}

Geolocation

The engine ships no geolocation database. @geoLookup asks the host through SecLangIntegration.geoLookup, and fills the GEO collection with the answer. SecGeoLookupDb is accepted and ignored: the database is the host's.

class MyIntegration(db: MyGeoDatabase) extends DefaultSecLangIntegration {
  // called during evaluation: answer from memory, never from the network
  override def geoLookup(address: String): Option[Map[String, String]] =
    db.find(address).map(loc => Map("COUNTRY_CODE" -> loc.isoCode, "COUNTRY_NAME" -> loc.name, "CITY" -> loc.city))
}
SecRule REMOTE_ADDR "@geoLookup" "id:1,phase:1,deny,status:403,msg:'blocked from %{GEO.COUNTRY_CODE}',chain"
    SecRule GEO:COUNTRY_CODE "@within KP IR" "t:none"

Keys follow ModSecurity (COUNTRY_CODE, COUNTRY_CODE3, COUNTRY_NAME, COUNTRY_CONTINENT, REGION, CITY, POSTAL_CODE, LATITUDE, LONGITUDE, DMA_CODE, AREA_CODE) and are read case-insensitively. When the host returns None or throws, the operator does not match and GEO keeps what it held. GEO lives for one evaluate call: a response phase rule that needs it runs @geoLookup again. From Java, override JSecLangIntegration.geoLookup.

DNS blocklists

@rbl asks the host too, through SecLangIntegration.rblLookup(address, zone). Evaluation is synchronous, so the host answers from what it already knows (a cache it fills asynchronously) and never waits on a DNS query; false means "not listed" as well as "not known yet", so an unreachable blocklist never blocks anyone. The zone goes through macro expansion, and capture puts the address in TX:0, as libmodsecurity does. SecHttpBlKey is accepted and ignored: the key belongs to the host's resolver.

SecRule REMOTE_ADDR "@rbl zen.spamhaus.org" "id:1,phase:1,deny,status:403,msg:'listed by Spamhaus'"

Request body processors

The body is read as a form (URLENCODED), as MULTIPART, as XML or as JSON, which is what fills ARGS and REQBODY_PROCESSOR. The Content-Type picks the processor: JSON covers application/json, text/json, every +json suffixed type (application/vnd.api+json, application/problem+json, ...) and the AWS types application/x-amz-json-1.0 and 1.1. ctl:requestBodyProcessor overrides it for the rules that follow, as CRS 4 does with rules 901350 to 901370.

Logging

The engine logs through its integration rather than to stdout. What it accepts and ignores (an unsupported ctl:auditEngine, a directive or a statement it does not implement) goes to SecLangIntegration.logDebug, the output of debugRules to logInfo. Rules loaded through a factory report to the factory's integration; SecLang.parse and SecLang.compile called directly stay silent. DefaultSecLangIntegration writes those logs to stdout, NoLogSecLangIntegration drops them.

Installation

Add the following dependency to your build.sbt:

libraryDependencies += "com.cloud-apim" %% "seclang-engine" % "1.5.0"

The library is cross-compiled for Scala 2.12 and Scala 2.13, so %% picks the right artifact (seclang-engine_2.12 or seclang-engine_2.13) for your project.

Using Snapshots

To use snapshot versions, add the Sonatype snapshots repository:

resolvers += "Sonatype OSS Snapshots" at "https://s01.oss.sonatype.org/content/repositories/snapshots"

libraryDependencies += "com.cloud-apim" %% "seclang-engine" % "1.0.0-SNAPSHOT"

Maven

For Maven projects, add to your pom.xml:

<dependency>
  <groupId>com.cloud-apim</groupId>
  <artifactId>seclang-engine_2.12</artifactId>
  <version>1.5.0</version>
</dependency>

or, for Scala 2.13:

<dependency>
  <groupId>com.cloud-apim</groupId>
  <artifactId>seclang-engine_2.13</artifactId>
  <version>1.5.0</version>
</dependency>

Gradle

For Gradle projects:

implementation 'com.cloud-apim:seclang-engine_2.12:1.5.0'
// or, for Scala 2.13
implementation 'com.cloud-apim:seclang-engine_2.13:1.5.0'

Build

To build the project from source:

# Clone the repository
git clone https://github.com/cloud-apim/seclang-engine.git
cd seclang-engine

# Compile (default scala version, 2.12)
sbt compile

# Compile for every supported scala version (2.12 and 2.13)
sbt +compile

# Package
sbt package

# Package for every supported scala version
sbt +package

Testing

The project uses the official OWASP Core Rule Set (CRS) test suite to validate the engine implementation.

Setup

Before running the tests, you need to download the CRS test data:

./setuptest.sh

This script clones the CRS repository into test-data/coreruleset/.

Running tests

# Run all tests
sbt test

# Run all tests for every supported scala version (2.12 and 2.13)
sbt +test

# Run a specific test suite
sbt "testOnly *SecLangBasicTest"
sbt "testOnly *SecLangCRSTest"

# Run a specific test
sbt "testOnly *SecLangBasicTest -- *simple*"

Test status

The file crs-tests-status.json contains the current status of the CRS test suite, including passing/failing tests and their failure reasons.

Missing stuff

Missing transformations

  • replaceNulls
  • parityEven7bit
  • parityOdd7bit
  • parityZero7bit
  • sqlHexDecode

Missing variables

  • AUTH_TYPE
  • FULL_REQUEST
  • FULL_REQUEST_LENGTH
  • HIGHEST_SEVERITY
  • INBOUND_DATA_ERROR
  • MODSEC_BUILD
  • MSC_PCRE_LIMITS_EXCEEDED
  • MULTIPART_CRLF_LF_LINES
  • MULTIPART_FILENAME
  • MULTIPART_NAME
  • MULTIPART_STRICT_ERROR
  • MULTIPART_UNMATCHED_BOUNDARY
  • OUTBOUND_DATA_ERROR
  • REQBODY_ERROR
  • REQBODY_ERROR_MSG
  • RULE
  • SDBM_DELETE_ERROR
  • SESSION
  • SESSIONID
  • URLENCODED_ERROR
  • WEBAPPID

Missing operators

  • verifyCC
  • verifyCPF
  • verifySSN

About

SecLang Engine WAF is a ModSecurity-compatible Web Application Firewall (WAF) library for the JVM, written in Scala.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages