An open-source, enterprise-grade, high-performance Web Application Firewall (WAF) library for the JVM, made to protect your webapps.
SecLang Engine is written in Scala and implements the ModSecurity SecLang rule language, providing full compatibility with the OWASP Core Rule Set (CRS) v4.
- 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
SecLangIntegrationtrait. 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.
This project is a work in progress.
We are currently passing 100% of the CRS test suite
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)
}
}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();
}
}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)
}
}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.
@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'"
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.
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.
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.
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"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>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'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 +packageThe project uses the official OWASP Core Rule Set (CRS) test suite to validate the engine implementation.
Before running the tests, you need to download the CRS test data:
./setuptest.shThis script clones the CRS repository into test-data/coreruleset/.
# 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*"The file crs-tests-status.json contains the current status of the CRS test suite, including passing/failing tests and their failure reasons.
replaceNullsparityEven7bitparityOdd7bitparityZero7bitsqlHexDecode
AUTH_TYPEFULL_REQUESTFULL_REQUEST_LENGTHHIGHEST_SEVERITYINBOUND_DATA_ERRORMODSEC_BUILDMSC_PCRE_LIMITS_EXCEEDEDMULTIPART_CRLF_LF_LINESMULTIPART_FILENAMEMULTIPART_NAMEMULTIPART_STRICT_ERRORMULTIPART_UNMATCHED_BOUNDARYOUTBOUND_DATA_ERRORREQBODY_ERRORREQBODY_ERROR_MSGRULESDBM_DELETE_ERRORSESSIONSESSIONIDURLENCODED_ERRORWEBAPPID
verifyCCverifyCPFverifySSN