Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/tilsor/ModSecIntl_wace_lib/llms.txt

Use this file to discover all available pages before exploring further.

This guide walks you through adding WACElib to a Go module, writing a minimal YAML configuration, and executing the full InitInitTransactionAnalyzeCheckTransactionCloseTransaction lifecycle against a sample HTTP request. By the end you will have a working integration you can adapt to your WAF middleware.

Prerequisites

  • Go 1.21 or later
  • A running NATS server (default localhost:4222) — WACElib dials NATS at startup. For synchronous in-process plugins it logs a warning if the connection fails but continues operating; async and remote plugins require a live NATS connection.
  • At least one compiled model plugin (.so) and one compiled decision plugin (.so). See the model plugin interface and decision plugin interface for how to build them.
1

Add the module dependency

Fetch the WACElib module:
go get github.com/tilsor/ModSecIntl_wace_lib
Your go.mod will include an entry like:
require (
    github.com/tilsor/ModSecIntl_wace_lib v0.0.0-latest
)
2

Write a YAML configuration file

Create a wace.yaml file that declares your model plugins and decision plugin. The top-level keys map directly to configstore.ConfigFileData.
logpath: "/var/log/wace.log"
loglevel: "INFO"

modelplugins:
  - id: "headers-model"
    path: "/opt/wace/plugins/model/headers_model.so"
    plugintype: "RequestHeaders"
    weight: 1.0
    mode: sync

  - id: "body-model"
    path: "/opt/wace/plugins/model/body_model.so"
    plugintype: "RequestBody"
    weight: 1.0
    mode: sync

decisionplugins:
  - id: "weighted-decision"
    path: "/opt/wace/plugins/decision/weighted.so"
    wafweight: 0.5
    decisionbalance: 0.5
The plugintype string must exactly match one of the ModelPluginType enum values: RequestHeaders, RequestBody, AllRequest, ResponseHeaders, ResponseBody, AllResponse, or Everything. An unrecognized value causes Init to return an error.
3

Initialize WACElib at startup

Parse the YAML into a configstore.ConfigFileData struct and pass it to wace.Init together with an OpenTelemetry metric.Meter. Call Init exactly once when your process starts — calling it a second time returns an error because the internal ConfigStore is a singleton.
package main

import (
    "log"
    "os"

    wace "github.com/tilsor/ModSecIntl_wace_lib"
    "github.com/tilsor/ModSecIntl_wace_lib/configstore"

    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    "gopkg.in/yaml.v3"
)

func main() {
    // Read and parse the configuration file.
    raw, err := os.ReadFile("wace.yaml")
    if err != nil {
        log.Fatalf("failed to read config: %v", err)
    }

    var conf configstore.ConfigFileData
    if err := yaml.Unmarshal(raw, &conf); err != nil {
        log.Fatalf("failed to parse config: %v", err)
    }

    // Create an OpenTelemetry MeterProvider and Meter.
    // Replace this with your production provider (e.g. an OTLP exporter).
    provider := sdkmetric.NewMeterProvider()
    meter := provider.Meter("my-waf-service")

    // Initialize WACElib. This loads all plugin .so files and connects to NATS.
    if err := wace.Init(meter, conf); err != nil {
        log.Fatalf("wace.Init failed: %v", err)
    }

    log.Println("WACElib initialized")
    // ... start your WAF middleware
}
4

Run the transaction lifecycle for each request

For every HTTP transaction your WAF intercepts, follow the four-step per-request pattern. The example below mirrors the structure of TestCheckAttackTransaction in wacecore_test.go.
package middleware

import (
    "fmt"
    "strings"

    wace "github.com/tilsor/ModSecIntl_wace_lib"
    "github.com/tilsor/ModSecIntl_wace_lib/pluginmanager"
)

// AnalyzeRequest runs WACElib analysis for one HTTP transaction and
// returns true if the request should be blocked.
func AnalyzeRequest(transactionID string, wafParams map[string]string) (bool, error) {

    // Step 1: Open a new transaction slot in WACElib.
    wace.InitTransaction(transactionID)

    // Step 2: Dispatch request-headers models concurrently.
    // The modelsTypeAsString argument must match the PluginType of each listed model.
    headersPayload := pluginmanager.HTTPPayload{
        URI:         "/api/v1/resource",
        Method:      "POST",
        HTTPVersion: "HTTP/1.1",
        RequestHeaders: []pluginmanager.HTTPHeader{
            {Key: "User-Agent", Value: "Mozilla/5.0"},
            {Key: "Content-Type", Value: "application/json"},
            {Key: "Host", Value: "example.com"},
        },
    }

    if err := wace.Analyze("RequestHeaders", transactionID, headersPayload, []string{"headers-model"}); err != nil {
        wace.CloseTransaction(transactionID)
        return false, fmt.Errorf("Analyze RequestHeaders: %w", err)
    }

    // Step 3: Dispatch request-body models concurrently.
    bodyPayload := pluginmanager.HTTPPayload{
        RequestBody: `{"username": "admin", "password": "' OR 1=1 --"}`,
    }

    if err := wace.Analyze("RequestBody", transactionID, bodyPayload, []string{"body-model"}); err != nil {
        wace.CloseTransaction(transactionID)
        return false, fmt.Errorf("Analyze RequestBody: %w", err)
    }

    // Step 4: Block until all dispatched models finish, then call the
    // decision plugin with WAF anomaly scores.
    block, err := wace.CheckTransaction(transactionID, "weighted-decision", wafParams)
    if err != nil {
        wace.CloseTransaction(transactionID)
        return false, fmt.Errorf("CheckTransaction: %w", err)
    }

    // Step 5: Release all per-transaction state and channels.
    wace.CloseTransaction(transactionID)

    return block, nil
}
Always call CloseTransaction — even on error paths. Omitting it leaks the transaction’s channel and result map for the lifetime of the process. Use defer wace.CloseTransaction(transactionID) immediately after InitTransaction to make this automatic.
5

Build the wafParams map from CRS scores

CheckTransaction accepts a map[string]string of WAF parameters that the decision plugin reads alongside the ML scores. In a ModSecurity or Coraza integration, these values come from the CRS anomaly scoring variables in the transaction’s TX collection:
// Parse CRS anomaly score key=value pairs into the map that
// CheckTransaction passes to the decision plugin.
wafParams := make(map[string]string)
scoreString := "COMBINED_SCORE=12,SQLI=10,XSS=2,inbound_blocking=20,inbound_threshold=5"
for _, pair := range strings.Split(scoreString, ",") {
    parts := strings.SplitN(pair, "=", 2)
    if len(parts) == 2 {
        wafParams[parts[0]] = parts[1]
    }
}

block, err := wace.CheckTransaction(transactionID, "weighted-decision", wafParams)
The exact keys your decision plugin expects depend on how that plugin is implemented. The simple decision plugin used in the test suite reads inbound_blocking and inbound_threshold, among others.

Complete minimal example

The following self-contained program combines all of the steps above. It follows the same initialization pattern as the library’s initilize test helper and the same transaction flow as TestCheckAttackTransaction in wacecore_test.go.
package main

import (
    "fmt"
    "log"
    "os"
    "strings"

    wace "github.com/tilsor/ModSecIntl_wace_lib"
    "github.com/tilsor/ModSecIntl_wace_lib/configstore"
    "github.com/tilsor/ModSecIntl_wace_lib/pluginmanager"

    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    "gopkg.in/yaml.v3"
)

func main() {
    raw, err := os.ReadFile("wace.yaml")
    if err != nil {
        log.Fatalf("read config: %v", err)
    }
    var conf configstore.ConfigFileData
    if err := yaml.Unmarshal(raw, &conf); err != nil {
        log.Fatalf("parse config: %v", err)
    }

    provider := sdkmetric.NewMeterProvider()
    meter := provider.Meter("example")

    if err := wace.Init(meter, conf); err != nil {
        log.Fatalf("wace.Init: %v", err)
    }

    // Per-request lifecycle
    txID := "TX-001"
    wace.InitTransaction(txID)
    defer wace.CloseTransaction(txID)

    payload := pluginmanager.HTTPPayload{
        URI:         "/cgi-bin/process.cgi",
        Method:      "POST",
        HTTPVersion: "HTTP/1.1",
        RequestHeaders: []pluginmanager.HTTPHeader{
            {Key: "User-Agent", Value: "Mozilla/4.0 (compatible; MSIE5.01; Windows NT)"},
            {Key: "Host", Value: "www.example.com"},
            {Key: "Content-Type", Value: "application/x-www-form-urlencoded"},
        },
        RequestBody: "licenseID=string&content=string&/paramsXML=string",
    }

    if err := wace.Analyze("RequestHeaders", txID, payload, []string{"headers-model"}); err != nil {
        log.Fatalf("Analyze: %v", err)
    }

    wafParams := make(map[string]string)
    for _, pair := range strings.Split(
        "COMBINED_SCORE=0,SQLI=0,XSS=0,inbound_blocking=20,inbound_threshold=5", ",") {
        parts := strings.SplitN(pair, "=", 2)
        if len(parts) == 2 {
            wafParams[parts[0]] = parts[1]
        }
    }

    block, err := wace.CheckTransaction(txID, "weighted-decision", wafParams)
    if err != nil {
        log.Fatalf("CheckTransaction: %v", err)
    }

    fmt.Printf("Block request: %v\n", block)
}
wace.Init initializes a process-global singleton. In tests, call configstore.Clean() with defer at the top of each test function to reset state between runs — exactly as the library’s own test suite does.

Build docs developers (and LLMs) love