diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml deleted file mode 100644 index 7a3abf2..0000000 --- a/.github/workflows/codeql.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: "CodeQL Scanning" - -on: - push: - branches: - - "*" - pull_request: - branches: - - "*" - schedule: - - cron: '0 6 * * 6' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - language: ['go'] - - steps: - - name: Checkout respository - uses: actions/checkout@v3 - - - name: Install CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/container-image.yaml b/.github/workflows/container-image.yaml index 52e71ef..c775b61 100644 --- a/.github/workflows/container-image.yaml +++ b/.github/workflows/container-image.yaml @@ -11,15 +11,6 @@ on: pull_request: workflow_dispatch: - -env: - HAPROXY_IMAGES: > - haproxy:2.2-alpine - haproxy:2.4-alpine - haproxy:2.5-alpine - haproxy:2.6-alpine - haproxy:2.7-alpine - jobs: build: runs-on: ubuntu-latest @@ -30,14 +21,6 @@ jobs: - name: Check out code uses: actions/checkout@v3 - - name: Run e2e tests against the example - shell: bash - run: > - for image in $HAPROXY_IMAGES; do - echo "Running e2e with Haproxy image $image" - HAPROXY_IMAGE=$image docker compose -f docker-compose.e2e.yaml up --abort-on-container-exit tests - done - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 @@ -56,7 +39,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker metadata - Main + - name: Docker metadata id: meta-main uses: docker/metadata-action@v4 with: @@ -68,44 +51,14 @@ jobs: type=ref,event=branch type=ref,event=pr - - name: Image - Main + - name: Image uses: docker/build-push-action@v3 with: context: . cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64 - file: Dockerfile + file: example/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta-main.outputs.tags }} - labels: ${{ steps.meta-main.outputs.labels }} - - - - name: Docker metadata - CRS4 - id: meta-crs4 - uses: docker/metadata-action@v4 - with: - images: ghcr.io/${{ github.repository }} - flavor: | - suffix=-crs4,onlatest=true - tags: | - type=raw,value=snapshot,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=ref,event=branch - type=ref,event=pr - - - name: Image - CRS4 - uses: docker/build-push-action@v3 - with: - context: . - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64 - file: Dockerfile - push: ${{ github.event_name != 'pull_request' }} - target: coreruleset - build-args: | - CORERULESET_VERSION=v4.0.0-rc1 - tags: ${{ steps.meta-crs4.outputs.tags }} - labels: ${{ steps.meta-crs4.outputs.labels }} + labels: ${{ steps.meta-main.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 38e628c..b62f536 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,4 +1,4 @@ -name: Lint (pre-commit) +name: Lint on: pull_request: @@ -8,9 +8,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Install Go - uses: actions/setup-go@v3 + - name: Set up Go + uses: actions/setup-go@v4 with: - go-version: v1.19.x - cache: true + go-version: '1.23' - run: go run mage.go lint diff --git a/.github/workflows/package.yaml b/.github/workflows/package.yaml index 4782b83..15fa7d8 100644 --- a/.github/workflows/package.yaml +++ b/.github/workflows/package.yaml @@ -44,11 +44,8 @@ jobs: run: | sudo apt update && sudo apt -y install make pkg-config rubygems && sudo gem install fpm - # Download corazawaf/coraza/coraza.conf for distribution - curl https://raw.githubusercontent.com/corazawaf/coraza/v3.0.0/coraza.conf-recommended > coraza.conf - - name: Build binary - run: VERSION=${PACKAGE_VERSION} ARCH=${{ matrix.arch }} make + run: VERSION=${PACKAGE_VERSION} GOARCH=${{ matrix.arch }} go run mage.go build - name: Build package run: | @@ -62,11 +59,9 @@ jobs: --deb-systemd ./contrib/coraza-spoa.service \ --deb-systemd-enable \ --config-files /etc/coraza-spoa/config.yaml \ - ./coraza-spoa_${{matrix.arch}}=/usr/bin/coraza-spoa \ - ./doc/config/=/usr/share/doc/coraza-spoa/haproxy-config \ + ./coraza-spoa=/usr/bin/coraza-spoa \ ./LICENSE=/usr/share/doc/coraza-spoa/ \ - ./config.yaml.default=/etc/coraza-spoa/config.yaml \ - ./coraza.conf=/etc/coraza-spoa/coraza.conf + ./example/coraza-spoa.yaml=/etc/coraza-spoa/config.yaml ## Publish to the "testing" repo - name: Cloudsmith Push:debian/coraza-spoa-snapshots diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..b30bde5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,27 @@ +name: Test + +on: + pull_request: + push: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: setup environment + run: | + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:vbernat/haproxy-2.8 + sudo apt-get update + sudo apt-get install -y haproxy + haproxy -vv + + - name: Test + run: go run mage.go test \ No newline at end of file diff --git a/.gitignore b/.gitignore index f84664f..53175ce 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,5 @@ *.out vendor/ -# local files -config.yaml -logs/ -rules/ \ No newline at end of file +# Build output +build/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a320b69..0000000 --- a/Dockerfile +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright 2023 The OWASP Coraza contributors -# SPDX-License-Identifier: Apache-2.0 - -FROM --platform=$BUILDPLATFORM golang:1.19-alpine3.17 AS builder - -WORKDIR /build -COPY . /build - -# Download dependencies for all platforms once -RUN go mod download - -ARG TARGETOS -ARG TARGETARCH - -RUN apk add --no-cache make ca-certificates \ - && update-ca-certificates - -RUN --mount=type=cache,target=/root/.cache/go-build \ - --mount=type=cache,target=/go/pkg \ - OS=${TARGETOS} ARCH=${TARGETARCH} make - -# --- -FROM alpine:3.17 AS main - -ARG TARGETARCH - -LABEL org.opencontainers.image.authors="The OWASP Coraza contributors" \ - org.opencontainers.image.description="OWASP Coraza WAF (Haproxy SPOA)" \ - org.opencontainers.image.documentation="https://coraza.io/connectors/coraza-spoa/" \ - org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.source="https://github.com/corazawaf/coraza-spoa" \ - org.opencontainers.image.title="coraza-spoa" - -RUN apk add --no-cache tini socat ca-certificates \ - && update-ca-certificates - -# Add unprivileged user & group for the coraza-spoa -RUN addgroup --system coraza-spoa \ - && adduser --system --ingroup coraza-spoa --no-create-home --home /nonexistent --disabled-password coraza-spoa - -RUN mkdir -p /etc/coraza-spoa /var/log/coraza-spoa \ - && chown coraza-spoa:coraza-spoa /var/log/coraza-spoa - -COPY --from=builder /build/coraza-spoa_${TARGETARCH} /usr/bin/coraza-spoa -COPY --from=builder /build/config.yaml.default /etc/coraza-spoa/config.yaml -COPY --from=builder /build/docker/coraza-spoa/coraza.conf /etc/coraza-spoa/coraza.conf -COPY --from=builder /build/docker/coraza-spoa/docker-entrypoint.sh /docker-entrypoint.sh - -EXPOSE 9000 -USER coraza-spoa - -HEALTHCHECK --interval=10s --timeout=2s --retries=2 CMD "/usr/bin/socat /dev/null TCP:0.0.0.0:9000" - -ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] - -CMD ["/usr/bin/coraza-spoa", "--config", "/etc/coraza-spoa/config.yaml"] - -# --- -FROM main AS coreruleset - -ARG CORERULESET_VERSION=v4.0.0-rc1 -ARG CORERULESET_SHA256SUM=a8f0d1cac941bf2158988b92a91519f093a8bce64a260e46fa352d608c7de3e6 - -# Switch to root for crs installation -USER root - -# Download the core rule set -RUN set -xe \ - && wget -O /tmp/crs.tgz https://github.com/coreruleset/coreruleset/archive/refs/tags/${CORERULESET_VERSION}.tar.gz - -RUN echo "$CORERULESET_SHA256SUM /tmp/crs.tgz" | sha256sum -c - -RUN set -xe \ - && mkdir crs \ - && tar --strip-components 1 -C crs -xf /tmp/crs.tgz \ - && mv crs/crs-setup.conf.example /etc/coraza-spoa/crs-setup.conf \ - && mv crs/rules /etc/coraza-spoa \ - && if [[ -d crs/plugins ]] ; then mv crs/plugins /etc/coraza-spoa ; fi \ - && rm -rf crs /tmp/crs.tgz - -USER coraza-spoa diff --git a/Makefile b/Makefile deleted file mode 100644 index b3ba83d..0000000 --- a/Makefile +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2022 The Corazawaf Authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -BINARY = coraza-spoa - -VERSION ?= "dev" -REVISION ?= $(shell git rev-parse HEAD) - -ARCH ?= $(shell which go >/dev/null 2>&1 && go env GOARCH) - -ifeq ($(ARCH),) - $(error mandatory variable ARCH is empty, either set it when calling the command or make sure 'go env GOARCH' works) -endif - -OS ?= $(shell which go >/dev/null 2>&1 && go env GOOS) - -ifeq ($(OS),) - $(error mandatory variable OS is empty, either set it when calling the cammand or make sure 'go env GOOS' works) -endif - -#LDFLAGS = -ldflags "-X main.Version=${VERSION} -X main.Revision=${REVISION}" - - -default: build - -build: - GOARCH=$(ARCH) GOOS=$(OS) CGO_ENABLED=0 go build -v ${LDFLAGS} -o $(BINARY)_$(ARCH) cmd/coraza-spoa/main.go - -clean: - rm -f $(BINARY)_amd64 $(BINARY)_arm64 $(BINARY)_386 diff --git a/README.md b/README.md index 6d2c9e4..aac2d99 100644 --- a/README.md +++ b/README.md @@ -14,27 +14,23 @@ HAProxy includes a [Stream Processing Offload Engine](https://www.haproxy.com/bl ### Build -The command `make` will compile the source code and produce the executable file `coraza-spoa`. - -### Clean - -When you need to re-compile the source code, you can use the command `make clean` to clean the executable file. +The command `go run mage.go build` will compile the source code and produce the executable file `coraza-spoa`. ## Configuration ## Coraza SPOA -The example configuration file is [config.yaml.default](https://github.com/corazawaf/coraza-spoa/blob/main/config.yaml.default), you can copy it and modify the related configuration information. You can start the service by running the command: +The example configuration file is [examples/coraza-spoa.yaml](https://github.com/corazawaf/coraza-spoa/blob/main/examples/coraza-spoa.yaml), you can copy it and modify the related configuration information. You can start the service by running the command: ``` -coraza-spoa -config /etc/coraza-spoa/coraza.yaml +coraza-spoa -f /etc/coraza-spoa/coraza-spoa.yaml ``` You will also want to download & extract the [OWASP Core Ruleset]( https://github.com/coreruleset/coreruleset/releases) (version 4+ supported) to the `/etc/coraza-spoa` directory. ## HAProxy SPOE -Configure HAProxy to exchange messages with the SPOA. The example SPOE configuration file is [coraza.cfg](https://github.com/corazawaf/coraza-spoa/blob/main/doc/config/coraza.cfg), you can copy it and modify the related configuration information. Default directory to place the config is `/etc/haproxy/coraza.cfg`. +Configure HAProxy to exchange messages with the SPOA. The example SPOE configuration file is [coraza.cfg](https://github.com/corazawaf/coraza-spoa/blob/main/examples/coraza.cfg), you can copy it and modify the related configuration information. Default directory to place the config is `/etc/haproxy/coraza.cfg`. ```ini # /etc/haproxy/coraza.cfg @@ -47,7 +43,7 @@ spoe-message coraza-req event on-frontend-http-request ``` -The application name from `config.yaml` must match the `app=` name, or the `default_application` will be used. +The application name from `config.yaml` must match the `app=` name. The backend defined in `use-backend` must match a `haproxy.cfg` backend which directs requests to the SPOA daemon reachable via `127.0.0.1:9000`. @@ -70,12 +66,12 @@ backend coraza-spoa server s1 127.0.0.1:9000 ``` -A comprehensive HAProxy configuration example can be found in [docs/config/haproxy.cfg](https://github.com/corazawaf/coraza-spoa/blob/main/doc/config/coraza.cfg). +A comprehensive HAProxy configuration example can be found in [examples/haproxy.cfg](https://github.com/corazawaf/coraza-spoa/blob/main/examples/coraza.cfg). -Because, in the SPOE configuration file (coraza.cfg), we declare to use the backend [coraza-spoa](https://github.com/corazawaf/coraza-spoa/blob/88b4e54ab3ddcb58d946ed1d6389eff73745575b/doc/config/coraza.cfg#L14) to communicate with the service, so we need also to define it in the [HAProxy file](https://github.com/corazawaf/coraza-spoa/blob/dd5eb86d1e9abbdd5fe568249f36a6d85257eba7/doc/config/haproxy.cfg#L37): +Because, in the SPOE configuration file (coraza.cfg), we declare to use the backend [coraza-spoa](https://github.com/corazawaf/coraza-spoa/blob/main/examples/coraza.cfg#L14) to communicate with the service, so we need also to define it in the [HAProxy file](https://github.com/corazawaf/coraza-spoa/blob/main/examples/haproxy.cfg#L37): ## Docker -- Build the coraza-spoa image `docker-compose build` -- Run haproxy, coraza-spoa and a mock server `docker-compose up` +- Build the coraza-spoa image `docker compose build` +- Run haproxy, coraza-spoa and a mock server `docker compose up` - Perform a request which gets blocked by the WAF: `curl http://localhost:4000/\?x\=/etc/passwd` \ No newline at end of file diff --git a/cmd/coraza-spoa/main.go b/cmd/coraza-spoa/main.go deleted file mode 100644 index 2490c24..0000000 --- a/cmd/coraza-spoa/main.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright The OWASP Coraza contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "flag" - - "github.com/corazawaf/coraza-spoa/config" - "github.com/corazawaf/coraza-spoa/internal" -) - -func main() { - cfg := flag.String("config", "", "configuration file") - if cfg == nil { - panic("configuration file is not set") - } - flag.Parse() - if err := config.InitConfig(*cfg); err != nil { - panic(err) - } - spoa, err := internal.New(config.Global) - if err != nil { - panic(err) - } - if err := spoa.Start(config.Global.Bind); err != nil { - panic(err) - } -} diff --git a/config.go b/config.go new file mode 100644 index 0000000..1723b11 --- /dev/null +++ b/config.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "io" + "net/url" + "os" + "time" + + "github.com/rs/zerolog" + "gopkg.in/yaml.v3" + + "github.com/corazawaf/coraza-spoa/internal" +) + +func readConfig() (*config, error) { + open, err := os.Open(configPath) + if err != nil { + return nil, err + } + defer open.Close() + + d := yaml.NewDecoder(open) + d.KnownFields(true) + + var cfg config + if err := d.Decode(&cfg); err != nil { + return nil, err + } + + if len(cfg.Applications) == 0 { + globalLogger.Warn().Msg("no applications defined") + } + + return &cfg, nil +} + +type config struct { + Bind string `yaml:"bind"` + Log logConfig `yaml:",inline"` + Applications []struct { + Log logConfig `yaml:",inline"` + Name string `yaml:"name"` + Directives string `yaml:"directives"` + ResponseCheck bool `yaml:"response_check"` + TransactionTTLMS int `yaml:"transaction_ttl_ms"` + } `yaml:"applications"` +} + +func (c config) networkAddressFromBind() (network string, address string) { + bindUrl, err := url.Parse(c.Bind) + if err == nil { + return bindUrl.Scheme, bindUrl.Path + } + + return "tcp", c.Bind +} + +func (c config) newApplications() (map[string]*internal.Application, error) { + allApps := make(map[string]*internal.Application) + + for name, a := range c.Applications { + logger, err := a.Log.newLogger() + if err != nil { + return nil, fmt.Errorf("creating logger for application %q: %v", name, err) + } + + appConfig := internal.AppConfig{ + Logger: logger, + Directives: a.Directives, + ResponseCheck: a.ResponseCheck, + TransactionTTL: time.Duration(a.TransactionTTLMS) * time.Millisecond, + } + + application, err := appConfig.NewApplication() + if err != nil { + return nil, fmt.Errorf("initializing application %q: %v", name, err) + } + + allApps[a.Name] = application + } + + return allApps, nil +} + +type logConfig struct { + Level string `yaml:"log_level"` + File string `yaml:"log_file"` + Format string `yaml:"log_format"` +} + +func (lc logConfig) outputWriter() (io.Writer, error) { + var out io.Writer + if lc.File == "" || lc.File == "/dev/stdout" { + out = os.Stdout + } else if lc.File == "/dev/stderr" { + out = os.Stderr + } else if lc.File == "/dev/null" { + out = io.Discard + } else { + // TODO: Close the handle if not used anymore. + // Currently these are leaked as soon as we reload. + f, err := os.OpenFile(lc.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return nil, err + } + out = f + } + return out, nil +} + +func (lc logConfig) newLogger() (zerolog.Logger, error) { + out, err := lc.outputWriter() + if err != nil { + return globalLogger, err + } + + switch lc.Format { + case "console": + out = zerolog.ConsoleWriter{ + Out: out, + } + case "json": + default: + return globalLogger, fmt.Errorf("unknown log format: %v", lc.Format) + } + + if lc.Level == "" { + lc.Level = "info" + } + lvl, err := zerolog.ParseLevel(lc.Level) + if err != nil { + return globalLogger, err + } + + return zerolog.New(out).Level(lvl).With().Timestamp().Logger(), nil +} diff --git a/config.yaml.default b/config.yaml.default deleted file mode 100644 index 4a6cf88..0000000 --- a/config.yaml.default +++ /dev/null @@ -1,32 +0,0 @@ - -# The SPOA server bind address -bind: 0.0.0.0:9000 - -# Process request and response with this application if provided app name is not found. -# You can remove or comment out this config param if you don't need "default_application" functionality. -default_application: sample_app - -applications: - sample_app: - # Get the coraza.conf from https://github.com/corazawaf/coraza - # - # Download the OWASP CRS from https://github.com/coreruleset/coreruleset/releases - # and copy crs-setup.conf & the rules, plugins directories to /etc/coraza-spoa - directives: | - Include /etc/coraza-spoa/coraza.conf - Include /etc/coraza-spoa/crs-setup.conf - Include /etc/coraza-spoa/rules/*.conf - - # HAProxy configured to send requests only, that means no cache required - # NOTE: there are still some memory & caching issues, so use this with care - no_response_check: true - - # The transaction cache lifetime in milliseconds (60000ms = 60s) - transaction_ttl_ms: 60000 - # The maximum number of transactions which can be cached - transaction_active_limit: 100000 - - # The log level configuration, one of: debug/info/warn/error/panic/fatal - log_level: info - # The log file path - log_file: /dev/stdout \ No newline at end of file diff --git a/config/config.go b/config/config.go deleted file mode 100644 index d5df3f8..0000000 --- a/config/config.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright The OWASP Coraza contributors -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "fmt" - "os" - - yaml "gopkg.in/yaml.v3" -) - -// Global is used to store the configuration. -var Global *Config - -// Config is used to configure coraza-server. -type Config struct { - Bind string `yaml:"bind"` - DefaultApplication string `yaml:"default_application"` - Applications map[string]*Application `yaml:"applications"` -} - -// Application is used to manage the haproxy configuration and waf rules. -type Application struct { - LogLevel string `yaml:"log_level"` - LogFile string `yaml:"log_file"` - NoResponseCheck bool `yaml:"no_response_check"` - Directives string `yaml:"directives"` - // Deprecated: use directives instead, this will be removed in the near future. - Rules []string `yaml:"rules"` - TransactionTTLMilliseconds int `yaml:"transaction_ttl_ms"` - TransactionActiveLimit int `yaml:"transaction_active_limit"` -} - -// InitConfig initializes the configuration. -func InitConfig(file string) error { - f, err := os.Open(file) - if err != nil { - return err - } - defer f.Close() - - err = yaml.NewDecoder(f).Decode(&Global) - if err != nil { - return err - } - - // validate the configuration - err = validateConfig() - if err != nil { - return err - } - return nil -} - -func validateConfig() error { - fmt.Printf("Loading %d applications\n", len(Global.Applications)) - for _, app := range Global.Applications { - if app.LogLevel == "" { - app.LogLevel = "warn" - } - if app.TransactionTTLMilliseconds < 0 { - return fmt.Errorf("SPOA transaction ttl must be greater than 0") - } - - if app.TransactionActiveLimit < 0 { - return fmt.Errorf("SPOA transaction active limit must be greater than 0") - } - } - return nil -} diff --git a/doc/config/coraza.cfg b/doc/config/coraza.cfg deleted file mode 100644 index a588091..0000000 --- a/doc/config/coraza.cfg +++ /dev/null @@ -1,26 +0,0 @@ -# https://github.com/haproxy/haproxy/blob/master/doc/SPOE.txt -# /etc/haproxy/coraza.cfg -[coraza] -spoe-agent coraza-agent - # Process HTTP requests only (the responses are not evaluated) - messages coraza-req - # Comment the previous line and add coraza-res, to process responses also. - # NOTE: there are still some memory & caching issues, so use this with care - #messages coraza-req coraza-res - option var-prefix coraza - option set-on-error error - timeout hello 2s - timeout idle 2m - timeout processing 500ms - use-backend coraza-spoa - log global - -spoe-message coraza-req - args app=str(sample_app) id=unique-id src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body - event on-frontend-http-request - -spoe-message coraza-res - args app=str(sample_app) id=unique-id version=res.ver status=status headers=res.hdrs body=res.body - event on-http-response - - diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml deleted file mode 100644 index 5d1c5e9..0000000 --- a/docker-compose.e2e.yaml +++ /dev/null @@ -1,46 +0,0 @@ -version: "3.9" -services: - httpbin: - image: mccutchen/go-httpbin:v2.5.0 - ports: - - 8080:8080 - coraza: - build: - context: . - target: coreruleset - volumes: - - ./docker/e2e/e2e-rules.conf:/etc/coraza-spoa/rules/000-e2e-rules.conf - haproxy: - depends_on: - - coraza - - httpbin - image: ${HAPROXY_IMAGE:-haproxy:2.7-alpine} - command: - [ - "sh", - "-c", - "haproxy -f /usr/local/etc/haproxy/haproxy.cfg | tee /var/lib/haproxy/hap.log" - ] - ports: [ "4000:80" ] - links: - - "coraza:coraza" - - "httpbin:httpbin" - volumes: - - type: bind - source: ./docker/haproxy - target: /usr/local/etc/haproxy - - hap:/var/lib/haproxy - tests: - depends_on: - - haproxy - - coraza - links: - - "haproxy:haproxy" - - "httpbin:httpbin" - build: - context: docker/e2e - dockerfile: ./Dockerfile.curl - volumes: - - hap:/haproxy -volumes: - hap: diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index d3d2374..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,23 +0,0 @@ -version: "3.9" -services: - httpbin: - restart: unless-stopped - image: mccutchen/go-httpbin:v2.5.0 - ports: - - 8080:8080 - coraza: - restart: unless-stopped - build: - context: . - target: coreruleset - haproxy: - restart: unless-stopped - image: haproxy:2.7-alpine - ports: [ "4000:80", "4443:443", "4001:4001" ] - links: - - "coraza:coraza" - - "httpbin:httpbin" - volumes: - - type: bind - source: ./docker/haproxy - target: /usr/local/etc/haproxy diff --git a/docker/coraza-spoa/coraza.conf b/docker/coraza-spoa/coraza.conf deleted file mode 100644 index 08d7874..0000000 --- a/docker/coraza-spoa/coraza.conf +++ /dev/null @@ -1,252 +0,0 @@ -# -- Rule engine initialization ---------------------------------------------- - -# Enable Coraza, attaching it to every transaction. Use detection -# only to start with, because that minimises the chances of post-installation -# disruption. -# -SecRuleEngine On - - -# -- Request body handling --------------------------------------------------- - -# Allow Coraza to access request bodies. If you don't, Coraza -# won't be able to see any POST parameters, which opens a large security -# hole for attackers to exploit. -# -SecRequestBodyAccess On - -# Enable XML request body parser. -# Initiate XML Processor in case of xml content-type -# -SecRule REQUEST_HEADERS:Content-Type "(?:application(?:/soap\+|/)|text/)xml" \ - "id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML" - -# Enable JSON request body parser. -# Initiate JSON Processor in case of JSON content-type; change accordingly -# if your application does not use 'application/json' -# -SecRule REQUEST_HEADERS:Content-Type "application/json" \ - "id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" - -# Sample rule to enable JSON request body parser for more subtypes. -# Uncomment or adapt this rule if you want to engage the JSON -# Processor for "+json" subtypes -# -#SecRule REQUEST_HEADERS:Content-Type "^application/.+[+]json$" \ -# "id:'200006',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" - -# Maximum request body size we will accept for buffering. If you support -# file uploads then the value given on the first line has to be as large -# as the largest file you are willing to accept. The second value refers -# to the size of data, with files excluded. You want to keep that value as -# low as practical. -# -SecRequestBodyLimit 13107200 -SecRequestBodyNoFilesLimit 131072 - -# What to do if the request body size is above our configured limit. -# Keep in mind that this setting will automatically be set to ProcessPartial -# when SecRuleEngine is set to DetectionOnly mode in order to minimize -# disruptions when initially deploying Coraza. -# -SecRequestBodyLimitAction Reject - -# Verify that we've correctly processed the request body. -# As a rule of thumb, when failing to process a request body -# you should reject the request (when deployed in blocking mode) -# or log a high-severity alert (when deployed in detection-only mode). -# -SecRule REQBODY_ERROR "!@eq 0" \ -"id:'200002', phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.',logdata:'%{reqbody_error_msg}',severity:2" - -# By default be strict with what we accept in the multipart/form-data -# request body. If the rule below proves to be too strict for your -# environment consider changing it to detection-only. You are encouraged -# _not_ to remove it altogether. -# -SecRule MULTIPART_STRICT_ERROR "!@eq 0" \ -"id:'200003',phase:2,t:none,log,deny,status:400, \ -msg:'Multipart request body failed strict validation: \ -PE %{REQBODY_PROCESSOR_ERROR}, \ -BQ %{MULTIPART_BOUNDARY_QUOTED}, \ -BW %{MULTIPART_BOUNDARY_WHITESPACE}, \ -DB %{MULTIPART_DATA_BEFORE}, \ -DA %{MULTIPART_DATA_AFTER}, \ -HF %{MULTIPART_HEADER_FOLDING}, \ -LF %{MULTIPART_LF_LINE}, \ -SM %{MULTIPART_MISSING_SEMICOLON}, \ -IQ %{MULTIPART_INVALID_QUOTING}, \ -IP %{MULTIPART_INVALID_PART}, \ -IH %{MULTIPART_INVALID_HEADER_FOLDING}, \ -FL %{MULTIPART_FILE_LIMIT_EXCEEDED}'" - -# Did we see anything that might be a boundary? -# -# Here is a short description about the Coraza Multipart parser: the -# parser returns with value 0, if all "boundary-like" line matches with -# the boundary string which given in MIME header. In any other cases it returns -# with different value, eg. 1 or 2. -# -# The RFC 1341 descript the multipart content-type and its syntax must contains -# only three mandatory lines (above the content): -# * Content-Type: multipart/mixed; boundary=BOUNDARY_STRING -# * --BOUNDARY_STRING -# * --BOUNDARY_STRING-- -# -# First line indicates, that this is a multipart content, second shows that -# here starts a part of the multipart content, third shows the end of content. -# -# If there are any other lines, which starts with "--", then it should be -# another boundary id - or not. -# -# After 3.0.3, there are two kinds of types of boundary errors: strict and permissive. -# -# If multipart content contains the three necessary lines with correct order, but -# there are one or more lines with "--", then parser returns with value 2 (non-zero). -# -# If some of the necessary lines (usually the start or end) misses, or the order -# is wrong, then parser returns with value 1 (also a non-zero). -# -# You can choose, which one is what you need. The example below contains the -# 'strict' mode, which means if there are any lines with start of "--", then -# Coraza blocked the content. But the next, commented example contains -# the 'permissive' mode, then you check only if the necessary lines exists in -# correct order. Whit this, you can enable to upload PEM files (eg "----BEGIN.."), -# or other text files, which contains eg. HTTP headers. -# -# The difference is only the operator - in strict mode (first) the content blocked -# in case of any non-zero value. In permissive mode (second, commented) the -# content blocked only if the value is explicit 1. If it 0 or 2, the content will -# allowed. -# - -# -# See #1747 and #1924 for further information on the possible values for -# MULTIPART_UNMATCHED_BOUNDARY. -# -SecRule MULTIPART_UNMATCHED_BOUNDARY "@eq 1" \ - "id:'200004',phase:2,t:none,log,deny,msg:'Multipart parser detected a possible unmatched boundary.'" - -# Some internal errors will set flags in TX and we will need to look for these. -# All of these are prefixed with "MSC_". The following flags currently exist: -# -# COR_PCRE_LIMITS_EXCEEDED: PCRE match limits were exceeded. -# -SecRule TX:/^COR_/ "!@streq 0" \ - "id:'200005',phase:2,t:none,deny,msg:'Coraza internal error flagged: %{MATCHED_VAR_NAME}'" - - -# -- Response body handling -------------------------------------------------- - -# Allow Coraza to access response bodies. -# You should have this directive enabled in order to identify errors -# and data leakage issues. -# -# Do keep in mind that enabling this directive does increases both -# memory consumption and response latency. -# -SecResponseBodyAccess On - -# Which response MIME types do you want to inspect? You should adjust the -# configuration below to catch documents but avoid static files -# (e.g., images and archives). -# -SecResponseBodyMimeType text/plain text/html text/xml - -# Buffer response bodies of up to 512 KB in length. -SecResponseBodyLimit 524288 - -# What happens when we encounter a response body larger than the configured -# limit? By default, we process what we have and let the rest through. -# That's somewhat less secure, but does not break any legitimate pages. -# -SecResponseBodyLimitAction ProcessPartial - - -# -- Filesystem configuration ------------------------------------------------ - -# The location where Coraza stores temporary files (for example, when -# it needs to handle a file upload that is larger than the configured limit). -# -# This default setting is chosen due to all systems have /tmp available however, -# this is less than ideal. It is recommended that you specify a location that's private. -# -SecTmpDir /tmp/ - -# The location where Coraza will keep its persistent data. This default setting -# is chosen due to all systems have /tmp available however, it -# too should be updated to a place that other users can't access. -# -SecDataDir /tmp/ - - -# -- File uploads handling configuration ------------------------------------- - -# The location where Coraza stores intercepted uploaded files. This -# location must be private to Coraza. You don't want other users on -# the server to access the files, do you? -# -#SecUploadDir /opt/coraza/var/upload/ - -# By default, only keep the files that were determined to be unusual -# in some way (by an external inspection script). For this to work you -# will also need at least one file inspection rule. -# -#SecUploadKeepFiles RelevantOnly - -# Uploaded files are by default created with permissions that do not allow -# any other user to access them. You may need to relax that if you want to -# interface Coraza to an external program (e.g., an anti-virus). -# -#SecUploadFileMode 0600 - - -# -- Debug log configuration ------------------------------------------------- - -# Default debug log path -# Debug levels: -# 1: fatal -# 2: panic -# 3: error -# 4: warn -# 5: info -# 6: debug -# Most logging has not been implemented because it will be replaced with -# advanced rule profiling options -#SecDebugLog /var/log/coraza-spoa/debug.log -#SecDebugLogLevel 5 - - -# -- Audit log configuration ------------------------------------------------- - -# Log the transactions that are marked by a rule, as well as those that -# trigger a server error (determined by a 5xx or 4xx, excluding 404, -# level response status codes). -# -SecAuditEngine Off -SecAuditLogRelevantStatus "^(?:(5|4)(0|1)[0-9])$" - -# Log everything we know about a transaction. -SecAuditLogParts ABIJDEFHZ - -# Use a single file for logging. This is much easier to look at, but -# assumes that you will use the audit log only occasionally. -# -#SecAuditLog /var/log/coraza-spoa/audit.log -#SecAuditLogFormat json -#SecAuditLogType Serial - - -# -- Miscellaneous ----------------------------------------------------------- - -# Use the most commonly used application/x-www-form-urlencoded parameter -# separator. There's probably only one application somewhere that uses -# something else so don't expect to change this value. -# -SecArgumentSeparator & - -# Settle on version 0 (zero) cookies, as that is what most applications -# use. Using an incorrect cookie version may open your installation to -# evasion attacks (against the rules that examine named cookies). -# -SecCookieFormat 0 diff --git a/docker/coraza-spoa/docker-entrypoint.sh b/docker/coraza-spoa/docker-entrypoint.sh deleted file mode 100755 index 4633059..0000000 --- a/docker/coraza-spoa/docker-entrypoint.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh - -set -e - -# Allow users to run arbitrary commands within the container - -if [ $# -gt 0 ] && [ "$1" = "${1#-}" ]; then - # First char isn't `-`, probably a `docker run -ti ` - # Just exec and exit - exec "$@" - exit -fi - -exec coraza-spoa --config /etc/coraza-spoa/config.yaml diff --git a/docker/e2e/Dockerfile.curl b/docker/e2e/Dockerfile.curl deleted file mode 100644 index 26cddc5..0000000 --- a/docker/e2e/Dockerfile.curl +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2022 The OWASP Coraza contributors -# SPDX-License-Identifier: Apache-2.0 - -FROM curlimages/curl -USER root - -WORKDIR /workspace - -RUN apk add --no-cache bash - -COPY ./e2e.sh /workspace/e2e.sh - -ENV HAPROXY_HOST=haproxy:80 -ENV HTTPBIN_HOST=httpbin:8080 - -CMD ["bash", "/workspace/e2e.sh"] diff --git a/docker/e2e/e2e-rules.conf b/docker/e2e/e2e-rules.conf deleted file mode 100644 index aae10b6..0000000 --- a/docker/e2e/e2e-rules.conf +++ /dev/null @@ -1,9 +0,0 @@ -SecRuleEngine On -SecRequestBodyAccess On -SecRule REQUEST_URI "/e2e-deny" "id:101,phase:1,t:lowercase,log,deny" -SecRule REQUEST_URI "/e2e-drop" "id:102,phase:1,t:lowercase,log,drop" -SecRule REQUEST_URI "/e2e-redirect" "id:103,phase:1,t:lowercase,log,redirect:http://www.example.org/denied" -SecRule REQUEST_BODY "@rx maliciouspayload" "id:104,phase:2,t:lowercase,log,deny" -SecRule RESPONSE_STATUS "@streq 406" "id:105,phase:3,t:lowercase,log,deny" -SecRule RESPONSE_HEADERS::e2eblock "true" "id:106,phase:4,t:lowercase,log,deny" -SecRule RESPONSE_BODY "@contains responsebodycode" "id:107,phase:4,t:lowercase,log,deny" diff --git a/docker/e2e/e2e.sh b/docker/e2e/e2e.sh deleted file mode 100755 index 69372c2..0000000 --- a/docker/e2e/e2e.sh +++ /dev/null @@ -1,199 +0,0 @@ -#!/bin/bash -# Copyright 2022 The OWASP Coraza contributors -# SPDX-License-Identifier: Apache-2.0 -# -# Script derived from the original in coraza-proxy-wasm & extended for haproxy -# https://github.com/corazawaf/coraza-proxy-wasm/blob/main/e2e/e2e-example.sh - -HAPROXY_HOST=${HAPROXY_HOST:-"localhost:4000"} -HTTPBIN_HOST=${HTTPBIN_HOST:-"localhost:8080"} -HAPROXY_LOGS='/haproxy/hap.log' - -[[ "${DEBUG}" == "true" ]] && set -x - -# if env variables are in place, default values are overridden -health_url="http://${HTTPBIN_HOST}" -url_unfiltered="http://${HAPROXY_HOST}" -url_filtered_deny="${url_unfiltered}/e2e-deny" -url_filtered_drop="${url_unfiltered}/e2e-drop" -url_filtered_redirect="${url_unfiltered}/e2e-redirect" -url_filtered_resp_header="${url_unfiltered}/response-headers?e2eblock=true" -url_echo="${url_unfiltered}/anything" - -trueNegativeBodyPayload="This is a payload" -truePositiveBodyPayload="maliciouspayload" -trueNegativeBodyPayloadForResponseBody="Hello world" -truePositiveBodyPayloadForResponseBody="responsebodycode" - -# wait_for_service waits until the given URL returns a 200 status code. -# $1: The URL to send requests to. -# $2: The max number of requests to send before giving up. -function wait_for_service() { - local status_code="000" - local url=${1} - local max=${2} - while [[ "${status_code}" -ne "200" ]]; do - status_code=$(curl --write-out "%{http_code}" --silent --output /dev/null "${url}") - sleep 1 - echo -ne "[Wait] Waiting for response from ${url}. Timeout: ${max}s \r" - ((max-=1)) - if [[ "${max}" -eq 0 ]]; then - echo "[Fail] Timeout waiting for response from ${url}, make sure the server is running." - exit 1 - fi - done - echo -e "\n[Ok] Got status code ${status_code}" -} - -# check_status sends HTTP requests to the given URL and expects a given response code. -# $1: The URL to send requests to. -# $2: The expected status code. -# $3-N: The rest of the arguments will be passed to the curl command as additional arguments -# to customize the HTTP call. -function check_status() { - local url=${1} - local status=${2} - local args=("${@:3}" --write-out '%{http_code}' --silent --output /dev/null) - status_code=$(curl "${args[@]}" "${url}") - if [[ "${status_code}" -ne ${status} ]] ; then - echo "[Fail] Unexpected response with code ${status_code} from ${url}" - exit 1 - fi - echo "[Ok] Got status code ${status_code}, expected ${status}" -} - -# check_body sends the given HTTP request and checks the response body. -# $1: The URL to send requests to. -# $2: true/false indicating if an empty, or null body is expected or not. -# $3-N: The rest of the arguments will be passed to the curl command as additional arguments -# to customize the HTTP call. -function check_body() { - local url=${1} - local empty=${2} - local args=("${@:3}" --silent) - response_body=$(curl "${args[@]}" "${url}") - if [[ "${empty}" == "true" ]] && [[ -n "${response_body}" ]]; then - echo -e "[Fail] Unexpected response with a body. Body dump:\n${response_body}" - exit 1 - fi - if [[ "${empty}" != "true" ]] && [[ -z "${response_body}" ]]; then - echo -e "[Fail] Unexpected response with a body. Body dump:\n${response_body}" - exit 1 - fi - echo "[Ok] Got response with an expected body (empty=${empty})" -} - -# check_hap_logs checks HAProxy logs for the given regexp. -# $1: The regexp to check logs aginst. -function check_hap_logs() { - local regex=${1} - if [[ $(grep -q -e "$regex" "$HAPROXY_LOGS") ]]; then - echo -e "[Fail] No log lines matches pattern '$regex'" - exit 1 - fi - echo "[Ok] Got logs with an expected pattern '$regex'" -} - -step=1 -total_steps=17 - -## Testing that basic coraza phases are working - -# Testing if the server is up -echo "[${step}/${total_steps}] Testing application reachability" -wait_for_service "${health_url}" 15 - -# Testing container reachability with an unfiltered GET request -((step+=1)) -echo "[${step}/${total_steps}] (onRequestheaders) Testing true negative request" -wait_for_service "${url_echo}?arg=arg_1" 20 - -# Testing filtered request (deny) -((step+=1)) -echo "[${step}/${total_steps}] (onRequestheaders) Testing true positive custom rule - deny" -check_status "${url_filtered_deny}" 403 - -# Testing filtered request (drop) -((step+=1)) -echo "[${step}/${total_steps}] (onRequestheaders) Testing true positive custom rule - drop" -check_status "${url_filtered_drop}" 000 - -# Testing filtered request (redirect) -((step+=1)) -echo "[${step}/${total_steps}] (onRequestheaders) Testing true positive custom rule - redirect" -check_status "${url_filtered_redirect}" 302 - -# Testing body true negative -((step+=1)) -echo "[${step}/${total_steps}] (onRequestBody) Testing true negative request (body)" -check_status "${url_echo}" 200 -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data "${trueNegativeBodyPayload}" - -# Testing body detection -((step+=1)) -echo "[${step}/${total_steps}] (onRequestBody) Testing true positive request (body)" -check_status "${url_echo}" 403 -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data "${truePositiveBodyPayload}" - -# TODO - Testing response headers detection TODO -#((step+=1)) -#echo "[${step}/${total_steps}] (onResponseHeaders) Testing true positive" -#check_status "${url_filtered_resp_header}" 403 - -# TODO(M4tteoP): Update response body e2e after https://github.com/corazawaf/coraza-proxy-wasm/issues/26 -# Testing response body true negative -((step+=1)) -echo "[${step}/${total_steps}] (onResponseBody) Testing true negative" -check_body "${url_echo}" false -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data "${trueNegativeBodyPayloadForResponseBody}" - -# TODO - Testing response body detection -#((step+=1)) -#echo "[${step}/${total_steps}] (onResponseBody) Testing true positive" -#check_body "${url_echo}" true -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data "${truePositiveBodyPayloadForResponseBody}" - -## Testing extra requests examples from the readme and some CRS rules in anomaly score mode. - -# Testing XSS detection during phase 1 -((step+=1)) -echo "[${step}/${total_steps}] Testing XSS detefction at request headers" -check_status "${url_echo}?arg=" 403 - -# Testing SQLI detection during phase 2 -((step+=1)) -echo "[${step}/${total_steps}] Testing SQLi detection at request body" -check_status "${url_echo}" 403 -X POST --data "1%27%20ORDER%20BY%203--%2B" - -# Triggers a CRS scanner detection rule (913100) -((step+=1)) -echo "[${step}/${total_steps}] (onRequestBody) Testing CRS rule 913100" -check_status "${url_echo}" 403 --user-agent "Grabber/0.1 (X11; U; Linux i686; en-US; rv:1.7)" -H "Host: localhost" -H "Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5" - -# True negative GET request with an usual user-agent -((step+=1)) -echo "[${step}/${total_steps}] True negative GET request with user-agent" -check_status "${url_echo}" 200 --user-agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" - -# Find Allow action -((step+=1)) -echo "[${step}/${total_steps}] HAP log format (waf-action: allow)" -check_hap_logs "waf-action: allow" - -# Find Deny action -((step+=1)) -echo "[${step}/${total_steps}] HAP log format (waf-action: deny)" -check_hap_logs "waf-action: deny" - -# Find Drop action -((step+=1)) -echo "[${step}/${total_steps}] HAP log format (waf-action: drop)" -check_hap_logs "waf-action: drop" - -# Find Redirect action -((step+=1)) -echo "[${step}/${total_steps}] HAP log format (waf-action: redirect)" -check_hap_logs "waf-action: redirect" - -# Find no error -((step+=1)) -echo "[${step}/${total_steps}] HAP log format (spoa-error: -)" -check_hap_logs "spoa-error: -" - -echo "[Done] All tests passed" diff --git a/docker/haproxy/example.com.pem b/docker/haproxy/example.com.pem deleted file mode 100644 index f6bb046..0000000 --- a/docker/haproxy/example.com.pem +++ /dev/null @@ -1,45 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICrDCCAZQCCQDUSeGLwr83ADANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA0q -LmV4YW1wbGUuY29tMB4XDTIyMDcwMzExMTQwNFoXDTMyMDYzMDExMTQwNFowGDEW -MBQGA1UEAwwNKi5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC -AQoCggEBAKtD+lh3P8PWgRoQosndVJtU4LmQMwGW/Jvd5U43qiUt4fr+mqwBtal8 -8XSj2Aa133UUi1VHVJVf2ftVKfCZJU2EFiIkUULkawx/L8ydGnw4jzHfQrilqoIU -hGGwLi0b1SPl4EkErBrIKvBJDzwf7MAwgQ2kqZ5OqZJu7lvOYhObMxFsyS7OfP8Z -y9nELsJnA7FZr6lssc97L1W0GOA7AJYVeRndAnlKuql8EvFBdYhsDoas9B0UpRQA -F7dDnxXkmkIFdDK6nWsMB5wwZDA7kVhIbzCcV92LrgiNLuSncQ98vM2q4cZCJWA+ -7zf68s+bfhDSUBs/2eshVKZlnDnRIp0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA -UBMan7o53uHUEtLwYhNtcEUxOya8ibsb3jxbmRqUB2xdFL+BVC8Yje8RSbHIlako -9S3gAcvl08oeUpg6cobsqD4EOyM5Ebrgkt3FTWycEPDqfJeRgT51ULoapIkONMRm -Q7CiAdGAjSLxOP1E+olmkAJIxtHRe+eEI9wzgs15zHqbeRBSOebLw+cdY9ynyA5h -VqZh4wI1SIZ+nH4AGQInI9/LOrVPOSg255oeDjSwPLRkUXQzgXffNVDiecoCZQw8 -7PGNs0jJ23TefRHftheiDNUUZC+51NvH3ekXmz1A5DueElSjcI5Bf6m7pv6TYAi4 -54NyLXTtMdr5XW1fLeuTmg== ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCrQ/pYdz/D1oEa -EKLJ3VSbVOC5kDMBlvyb3eVON6olLeH6/pqsAbWpfPF0o9gGtd91FItVR1SVX9n7 -VSnwmSVNhBYiJFFC5GsMfy/MnRp8OI8x30K4paqCFIRhsC4tG9Uj5eBJBKwayCrw -SQ88H+zAMIENpKmeTqmSbu5bzmITmzMRbMkuznz/GcvZxC7CZwOxWa+pbLHPey9V -tBjgOwCWFXkZ3QJ5SrqpfBLxQXWIbA6GrPQdFKUUABe3Q58V5JpCBXQyup1rDAec -MGQwO5FYSG8wnFfdi64IjS7kp3EPfLzNquHGQiVgPu83+vLPm34Q0lAbP9nrIVSm -ZZw50SKdAgMBAAECggEBAJX3qYDLswdejp/vT0yqRYra0QlMTo2m0738mO1b6t4x -hj8NsQzLVnJ6WMhFLEX7/hb8jWF4W4WNcEgXc+kdgT+WWPc+i/WiJGbF/GoUa3u4 -3xLRqBAd6OeM5brQ1i5jv8h2Y2Ys9DNjcc4Ee5WvctV745W5yVk5El2KS6gSWWlG -P7ZJp8L96rZMhq/jf81S4FJ5cuDVsiUcyeajyV8MR2h74PTPA/sh1S+4zIG2rFnb -tRtIYg5nMmnJuEUQGVIUpfamMiO2G4OTZooyQaHw4MSkBvlmNE0iwnvdcBIKp1rV -WZwBi3urPc7evtmxAsIa4cs1xHxaMhIcI5K21RhyTTUCgYEA1TiVLI2GpYQ/uQKz -prwLtyVyzz6E/7ix1flxpRpw9nVBJBzmB+fySg29265DD/1Mo7omrRec4WASLkcc -IOogI0o+E9KpuJcTFh/lW4OhsjNb7U2CAy2vlDGD3HBBjeZywO6mDdKu6ZLhcZR/ -6zzCBEGWNEfcxe68q5DPw4gHMAsCgYEAzaB8S9EycH+EVZnGXp1diRfg1uNEgXBY -O6F6F++bjfWCCNrLSOj+rzA9J8aGFbVE+VgHgmW+IvmoX90ZA/C9i6ybVsjex1Vs -wx7MEmKC8HNLaUB8xY+EhoO/HVhUu7bpX1byM4ofyo+MJ0lFpeDmVSvgilKcKa/g -0sEuumvaWPcCgYAQggr6ohJ0qiKM3mquVAzMJzgWV47QhaovGNr1n3BzhGyAsUQW -BJ07ooi6g2lpyYCvhHAOIq/guyFtN9AztCy8zrizrDG7uuadZebKCQzx0Qwi+UdB -m73mSVceMArDQ75Kta9hjphAOUD/HDIzMkcLMVDBx3aOVIeC+Pk8+EEmNQKBgElb -O3yAwRHJLBitp0sRsNC7qAaBRWs3/QIo9TmczU2zjBREmckE9fbhqq+J70PHSzf4 -45TtXwtzLVIlukrhk31EspYfSvqpywdA8WSNqFDHkNuXmeuQ66JhVcjOPkxJULAm -gHlKiVTmKQXK6gHnXcR8xroSBowIppgJOvZei6K5AoGBAKYod7uq7V49+SeLR80L -sjeLM6lQTB5tuh0Q+J2rza0XV3xdJqq2hccHOrsYFc9GG5DLcA0uMMVodQq5CvJN -3fR1Bv6GoamjlJpESBJy4j3TrHK2ItitHtCRoIKOEPgQdqn2LxOiQ2eXpBHNrVp6 -TIZvZsA585UpztkokRhgWtkm ------END PRIVATE KEY----- diff --git a/docker/haproxy/haproxy.cfg b/docker/haproxy/haproxy.cfg deleted file mode 100644 index a654294..0000000 --- a/docker/haproxy/haproxy.cfg +++ /dev/null @@ -1,59 +0,0 @@ -# https://www.haproxy.com/documentation/hapee/latest/onepage/#home -global - log stdout format raw local0 - -defaults - log global - option httplog - timeout client 1m - timeout server 1m - timeout connect 10s - timeout http-keep-alive 2m - timeout queue 15s - timeout tunnel 4h # for websocket - -frontend stats - mode http - bind :4001 - stats enable - stats uri / - stats refresh 10s - #stats show-modules - -frontend test_frontend - mode http - bind *:80 - bind *:443 ssl crt /usr/local/etc/haproxy/example.com.pem alpn h2,http/1.1 - unique-id-format %[uuid()] - unique-id-header X-Unique-ID - log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %Th/%Ti/%TR/%Tq/%Tw/%Tc/%Tr/%Tt\ %ST\ %B\ %CC\ %CS\ %tsc\ %ac/%fc/%bc/%sc/%rc\ %sq/%bq\ %hr\ %hs\ %{+Q}r\ %ID\ spoa-error:\ %[var(txn.coraza.error)]\ waf-action:\ %[var(txn.coraza.action)]" - - filter spoe engine coraza config /usr/local/etc/haproxy/coraza.cfg - - # Currently haproxy cannot use variables to set the code or deny_status, so this needs to be manually configured here - http-request redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect } - http-response redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect } - - http-request deny deny_status 403 hdr waf-block "request" if { var(txn.coraza.action) -m str deny } - http-response deny deny_status 403 hdr waf-block "response" if { var(txn.coraza.action) -m str deny } - - http-request silent-drop if { var(txn.coraza.action) -m str drop } - http-response silent-drop if { var(txn.coraza.action) -m str drop } - - # Deny in case of an error, when processing with the Coraza SPOA - http-request deny deny_status 504 if { var(txn.coraza.error) -m int gt 0 } - http-response deny deny_status 504 if { var(txn.coraza.error) -m int gt 0 } - - # Deprecated, use action instead of fail - #http-request deny deny_status 401 hdr waf-block "request" if { var(txn.coraza.fail) -m int eq 1 } - #http-response deny deny_status 401 hdr waf-block "response" if { var(txn.coraza.fail) -m int eq 1 } - - use_backend test_backend - -backend test_backend - mode http - server s1 httpbin:8080 - -backend coraza-spoa - mode tcp - server s1 coraza:9000 diff --git a/example/Dockerfile b/example/Dockerfile new file mode 100644 index 0000000..6b1f7dc --- /dev/null +++ b/example/Dockerfile @@ -0,0 +1,26 @@ +# Copyright 2023 The OWASP Coraza contributors +# SPDX-License-Identifier: Apache-2.0 + +FROM golang:1.21 as build + +WORKDIR /go/src/app +COPY . . + +RUN go mod download +RUN go vet -v ./... + +RUN CGO_ENABLED=0 go build -o /go/bin/coraza-spoa + +FROM gcr.io/distroless/static-debian11 + +LABEL org.opencontainers.image.authors="The OWASP Coraza contributors" \ + org.opencontainers.image.description="OWASP Coraza WAF (Haproxy SPOA)" \ + org.opencontainers.image.documentation="https://coraza.io/connectors/coraza-spoa/" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.source="https://github.com/corazawaf/coraza-spoa" \ + org.opencontainers.image.title="coraza-spoa" + +COPY --from=build /go/bin/coraza-spoa / +COPY ./example/coraza-spoa.yaml /config.yaml + +CMD ["/coraza-spoa", "--config", "/config.yaml"] \ No newline at end of file diff --git a/example/coraza-spoa.yaml b/example/coraza-spoa.yaml new file mode 100644 index 0000000..106bcc9 --- /dev/null +++ b/example/coraza-spoa.yaml @@ -0,0 +1,32 @@ +# The SPOA server bind address +bind: 0.0.0.0:9000 + +# The log level configuration, one of: debug/info/warn/error/panic/fatal +log_level: info +# The log file path +log_file: /dev/stdout +# The log format, one of: console/json +log_format: console + +applications: + # name is used as key to identify the directives + - name: sample_app + # Some example rules. + # The built-in OWASP CRS rules are available in @owasp_crs/ + directives: | + Include @coraza.conf-recommended + Include @crs-setup.conf.example + Include @owasp_crs/*.conf + + # HAProxy configured to send requests only, that means no cache required + response_check: false + + # The transaction cache lifetime in milliseconds (60000ms = 60s) + transaction_ttl_ms: 60000 + + # The log level configuration, one of: debug/info/warn/error/panic/fatal + log_level: info + # The log file path + log_file: /dev/stdout + # The log format, one of: console/json + log_format: console diff --git a/example/docker-compose.yaml b/example/docker-compose.yaml new file mode 100644 index 0000000..295ba0e --- /dev/null +++ b/example/docker-compose.yaml @@ -0,0 +1,34 @@ +version: "3.9" +services: + httpbin: + image: mccutchen/go-httpbin:v2.13.4 + environment: + - MAX_BODY_SIZE=15728640 # 15 MiB + command: [ "/bin/go-httpbin", "-port", "8081" ] + ports: + - "8081:8081" + + coraza-spoa: + restart: unless-stopped + build: + context: .. + dockerfile: ./example/Dockerfile + ports: + - "9000:9000" + + haproxy: + restart: unless-stopped + image: haproxy:2.7-alpine + ports: [ "8080:80", "8443:443", "8082:8082"] + depends_on: + - httpbin + links: + - "coraza-spoa:coraza-spoa" + - "httpbin:httpbin" + volumes: + - type: bind + source: ./haproxy/ + target: /usr/local/etc/haproxy + environment: + - BACKEND_HOST=httpbin:8081 + - CORAZA_SPOA_HOST=coraza-spoa \ No newline at end of file diff --git a/docker/haproxy/coraza.cfg b/example/haproxy/coraza.cfg similarity index 61% rename from docker/haproxy/coraza.cfg rename to example/haproxy/coraza.cfg index a588091..f5ae467 100644 --- a/docker/haproxy/coraza.cfg +++ b/example/haproxy/coraza.cfg @@ -1,11 +1,10 @@ # https://github.com/haproxy/haproxy/blob/master/doc/SPOE.txt -# /etc/haproxy/coraza.cfg +# /usr/local/etc/haproxy/coraza.cfg [coraza] spoe-agent coraza-agent # Process HTTP requests only (the responses are not evaluated) messages coraza-req # Comment the previous line and add coraza-res, to process responses also. - # NOTE: there are still some memory & caching issues, so use this with care #messages coraza-req coraza-res option var-prefix coraza option set-on-error error @@ -16,11 +15,13 @@ spoe-agent coraza-agent log global spoe-message coraza-req - args app=str(sample_app) id=unique-id src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body + # Arguments are required to be in this order + args app=str(sample_app) src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body event on-frontend-http-request spoe-message coraza-res - args app=str(sample_app) id=unique-id version=res.ver status=status headers=res.hdrs body=res.body + # Arguments are required to be in this order + args app=str(sample_app) id=var(txn.e2e.id) version=res.ver status=status headers=res.hdrs body=res.body event on-http-response diff --git a/doc/config/haproxy.cfg b/example/haproxy/haproxy.cfg similarity index 56% rename from doc/config/haproxy.cfg rename to example/haproxy/haproxy.cfg index 6564bd8..2ea8108 100644 --- a/doc/config/haproxy.cfg +++ b/example/haproxy/haproxy.cfg @@ -1,4 +1,4 @@ -# https://www.haproxy.com/documentation/hapee/latest/onepage/#home +# https://docs.haproxy.org/ global log stdout format raw local0 @@ -6,20 +6,15 @@ defaults log global option httplog timeout client 1m - timeout server 1m - timeout connect 10s - timeout http-keep-alive 2m - timeout queue 15s - timeout tunnel 4h # for websocket + timeout server 1m + timeout connect 10s -frontend test +frontend default mode http bind *:80 - - unique-id-format %[uuid()] - unique-id-header X-Unique-ID - filter spoe engine coraza config /etc/haproxy/coraza.cfg - + filter spoe engine coraza config /usr/local/etc/haproxy/coraza.cfg + log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %Th/%Ti/%TR/%Tq/%Tw/%Tc/%Tr/%Tt\ %ST\ %B\ %CC\ %CS\ %tsc\ %ac/%fc/%bc/%sc/%rc\ %sq/%bq\ %hr\ %hs\ %{+Q}r\ %[var(txn.coraza.id)]\ spoa-error:\ %[var(txn.coraza.error)]\ waf-hit:\ %[var(txn.coraza.fail)]" + # Currently haproxy cannot use variables to set the code or deny_status, so this needs to be manually configured here http-request redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect } http-response redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect } @@ -31,18 +26,18 @@ frontend test http-response silent-drop if { var(txn.coraza.action) -m str drop } # Deny in case of an error, when processing with the Coraza SPOA - http-request deny deny_status 504 if { var(txn.coraza.error) -m int gt 0 } - http-response deny deny_status 504 if { var(txn.coraza.error) -m int gt 0 } + http-request deny deny_status 500 if { var(txn.coraza.error) -m int gt 0 } + http-response deny deny_status 500 if { var(txn.coraza.error) -m int gt 0 } + + use_backend httpbin_backend - use_backend test_backend +resolvers host_dns + parse-resolv-conf -backend test_backend +backend httpbin_backend mode http - http-request return status 200 content-type "text/plain" string "Welcome!\n" + server backend $BACKEND_HOST backend coraza-spoa mode tcp - balance roundrobin - timeout connect 5s # greater than hello timeout - timeout server 3m # greater than idle timeout - server s1 127.0.0.1:9000 \ No newline at end of file + server coraza_spoa coraza-spoa:9000 diff --git a/go.mod b/go.mod index 6f55b4e..264a095 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,30 @@ module github.com/corazawaf/coraza-spoa -go 1.19 +go 1.23.1 require ( - github.com/bluele/gcache v0.0.2 - github.com/corazawaf/coraza/v3 v3.0.1 - github.com/criteo/haproxy-spoe-go v1.0.6 + github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc + github.com/corazawaf/coraza/v3 v3.2.1 + github.com/dropmorepackets/haproxy-go v0.0.5 + github.com/jcchavezs/mergefs v0.0.0-20230503083351-07f27d256761 github.com/magefile/mage v1.15.0 - go.uber.org/zap v1.24.0 + github.com/mccutchen/go-httpbin/v2 v2.14.0 + github.com/pires/go-proxyproto v0.7.0 + github.com/rs/zerolog v1.33.0 gopkg.in/yaml.v3 v3.0.1 + istio.io/istio v0.0.0-20240218163812-d80ef7b19049 ) require ( - github.com/corazawaf/libinjection-go v0.1.2 // indirect - github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/tidwall/gjson v1.14.4 // indirect + github.com/corazawaf/libinjection-go v0.2.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 // indirect + github.com/tidwall/gjson v1.17.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.23.0 // indirect - golang.org/x/sys v0.18.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect rsc.io/binaryregexp v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index f044cfe..f2d5d70 100644 --- a/go.sum +++ b/go.sum @@ -1,70 +1,78 @@ -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= -github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= -github.com/corazawaf/coraza/v3 v3.0.1 h1:akPpTofIUhabGU1Zbo+YVBZK/HdcxjGy8yXkjoqVFMQ= -github.com/corazawaf/coraza/v3 v3.0.1/go.mod h1:zvldIncYMuW8xmRcOs37OWRhY3CPWBKbTngIGzR5v4Y= -github.com/corazawaf/libinjection-go v0.1.2 h1:oeiV9pc5rvJ+2oqOqXEAMJousPpGiup6f7Y3nZj5GoM= -github.com/corazawaf/libinjection-go v0.1.2/go.mod h1:OP4TM7xdJ2skyXqNX1AN1wN5nNZEmJNuWbNPOItn7aw= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/criteo/haproxy-spoe-go v1.0.6 h1:3GDQ8hm/fIkn4wxxI/pN0OoBfKon4ROzvpU5fIriYII= -github.com/criteo/haproxy-spoe-go v1.0.6/go.mod h1:o04s69MOZ7SvPthMtUt/tfn1hcorQQAS/nwzKPBlXQU= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= +github.com/corazawaf/coraza/v3 v3.2.1 h1:zBIji4ut9FtFe8lXdqFwXMAkUoDJZ7HsOlEUYWERLI8= +github.com/corazawaf/coraza/v3 v3.2.1/go.mod h1:fVndCGdUHJWl9c26VZPcORQRzUYwMPnRkC6TyTkhbUg= +github.com/corazawaf/libinjection-go v0.2.1 h1:vNJ7L6c4xkhRgYU6sIO0Tl54TmeCQv/yfxBma30Dy/Y= +github.com/corazawaf/libinjection-go v0.2.1/go.mod h1:OP4TM7xdJ2skyXqNX1AN1wN5nNZEmJNuWbNPOItn7aw= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dropmorepackets/haproxy-go v0.0.5 h1:a6aT2UrdS9MvV60ZLZnXFgi19jxRvVg/lJFQCiFYDFA= +github.com/dropmorepackets/haproxy-go v0.0.5/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/jcchavezs/mergefs v0.0.0-20230503083351-07f27d256761 h1:Wbw8wlehLICFiXFPmQjMnYkcC6qH4a0d1iNbp6md+tk= +github.com/jcchavezs/mergefs v0.0.0-20230503083351-07f27d256761/go.mod h1:BGD4X4tm4ZCbtShoISaG4Ama2L3NOq7y6cvuOxbYgzs= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= -github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= -github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 h1:lL+y4Xv20pVlCGyLzNHRC0I0rIHhIL1lTvHizoS/dU8= -github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mccutchen/go-httpbin/v2 v2.14.0 h1:9N7GUf8+JunYMFd+yHPIVYApC6KYgqtF0pHIcTGYcVQ= +github.com/mccutchen/go-httpbin/v2 v2.14.0/go.mod h1:f4DUXYlU6yH0V81O4lJIwqpmYdTXXmYwzxMnYEimFPk= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 h1:1Kw2vDBXmjop+LclnzCb/fFy+sgb3gYARwfmoUcQe6o= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= +github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= +github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94= +github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +istio.io/istio v0.0.0-20240218163812-d80ef7b19049 h1:jR4INLKnkLNgQRNMBjkAt1ctPnuTq+vQ9wlZSOtR1+o= +istio.io/istio v0.0.0-20240218163812-d80ef7b19049/go.mod h1:5ATT2TaGbT/L1SwCYvs2ArNeLxHkPKwhvT7r3TPMu6M= rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/internal/agent.go b/internal/agent.go new file mode 100644 index 0000000..8fbf59a --- /dev/null +++ b/internal/agent.go @@ -0,0 +1,96 @@ +package internal + +import ( + "context" + "errors" + "net" + "sync" + + "github.com/dropmorepackets/haproxy-go/pkg/encoding" + "github.com/dropmorepackets/haproxy-go/spop" + "github.com/rs/zerolog" +) + +type Agent struct { + Context context.Context + Applications map[string]*Application + Logger zerolog.Logger + + mtx sync.RWMutex +} + +func (a *Agent) Serve(l net.Listener) error { + agent := spop.Agent{ + Handler: a, + BaseContext: a.Context, + } + + return agent.Serve(l) +} + +func (a *Agent) ReplaceApplications(newApps map[string]*Application) { + a.mtx.Lock() + a.Applications = newApps + a.mtx.Unlock() +} + +func (a *Agent) HandleSPOE(ctx context.Context, writer *encoding.ActionWriter, message *encoding.Message) { + const ( + messageCorazaRequest = "coraza-req" + messageCorazaResponse = "coraza-res" + ) + + var messageHandler func(*Application, context.Context, *encoding.ActionWriter, *encoding.Message) error + switch name := string(message.NameBytes()); name { + case messageCorazaRequest: + messageHandler = (*Application).HandleRequest + case messageCorazaResponse: + messageHandler = (*Application).HandleResponse + default: + a.Logger.Debug().Str("message", name).Msg("unknown spoe message") + return + } + + k := encoding.AcquireKVEntry() + defer encoding.ReleaseKVEntry(k) + if !message.KV.Next(k) { + a.Logger.Panic().Msg("failed reading kv entry") + return + } + + appName := string(k.ValueBytes()) + if !k.NameEquals("app") { + // Without knowing the app, we cannot continue. We could fall back to a default application, + // but all following code would have to support that as we now already read one of the kv entries. + a.Logger.Panic().Str("expected", "app").Str("got", string(k.NameBytes())).Msg("unexpected kv entry") + return + } + + a.mtx.RLock() + app := a.Applications[appName] + a.mtx.RUnlock() + if app == nil { + // If we cannot resolve the app, we fail as this is an invalid configuration. + a.Logger.Panic().Str("app", appName).Msg("app not found") + return + } + + err := messageHandler(app, ctx, writer, message) + if err == nil { + return + } + + var interruption ErrInterrupted + if err != nil && errors.As(err, &interruption) { + _ = writer.SetInt64(encoding.VarScopeTransaction, "status", int64(interruption.Interruption.Status)) + _ = writer.SetString(encoding.VarScopeTransaction, "action", interruption.Interruption.Action) + _ = writer.SetString(encoding.VarScopeTransaction, "data", interruption.Interruption.Data) + _ = writer.SetInt64(encoding.VarScopeTransaction, "ruleid", int64(interruption.Interruption.RuleID)) + + a.Logger.Debug().Err(err).Msg("sending interruption") + return + } + + // If the error is not an ErrInterrupted, we panic to let the spop stream fail. + a.Logger.Panic().Err(err).Msg("Error handling request") +} diff --git a/internal/application.go b/internal/application.go new file mode 100644 index 0000000..3f32639 --- /dev/null +++ b/internal/application.go @@ -0,0 +1,393 @@ +package internal + +import ( + "bufio" + "bytes" + "context" + "fmt" + "math/rand" + "net/netip" + "strings" + "sync" + "time" + + coreruleset "github.com/corazawaf/coraza-coreruleset" + "github.com/corazawaf/coraza/v3" + "github.com/corazawaf/coraza/v3/types" + "github.com/dropmorepackets/haproxy-go/pkg/encoding" + "github.com/jcchavezs/mergefs" + "github.com/jcchavezs/mergefs/io" + "github.com/rs/zerolog" + "istio.io/istio/pkg/cache" +) + +type AppConfig struct { + Directives string + ResponseCheck bool + Logger zerolog.Logger + TransactionTTL time.Duration +} + +type Application struct { + waf coraza.WAF + cache cache.ExpiringCache + + AppConfig +} + +type transaction struct { + tx types.Transaction + m sync.Mutex +} + +type applicationRequest struct { + SrcIp netip.Addr + SrcPort int64 + DstIp netip.Addr + DstPort int64 + Method string + Path []byte + Query []byte + Version string + Headers []byte + Body []byte +} + +func (a *Application) HandleRequest(ctx context.Context, writer *encoding.ActionWriter, message *encoding.Message) (err error) { + k := encoding.AcquireKVEntry() + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(k) + }() + + var req applicationRequest + for message.KV.Next(k) { + switch name := string(k.NameBytes()); name { + case "src-ip": + req.SrcIp = k.ValueAddr() + case "src-port": + req.SrcPort = k.ValueInt() + case "dst-ip": + req.DstIp = k.ValueAddr() + case "dst-port": + req.DstPort = k.ValueInt() + case "method": + req.Method = string(k.ValueBytes()) + case "path": + // make a copy of the pointer and add a defer in case there is another entry + currK := k + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(currK) + }() + + req.Path = currK.ValueBytes() + + // acquire a new kv entry to continue reading other message values. + k = encoding.AcquireKVEntry() + case "query": + // make a copy of the pointer and add a defer in case there is another entry + currK := k + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(currK) + }() + + req.Query = currK.ValueBytes() + // acquire a new kv entry to continue reading other message values. + k = encoding.AcquireKVEntry() + case "version": + req.Version = string(k.ValueBytes()) + case "headers": + // make a copy of the pointer and add a defer in case there is another entry + currK := k + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(currK) + }() + + req.Headers = currK.ValueBytes() + // acquire a new kv entry to continue reading other message values. + k = encoding.AcquireKVEntry() + case "body": + // make a copy of the pointer and add a defer in case there is another entry + currK := k + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(currK) + }() + + req.Body = currK.ValueBytes() + // acquire a new kv entry to continue reading other message values. + k = encoding.AcquireKVEntry() + default: + a.Logger.Debug().Str("name", name).Msg("unknown kv entry") + } + } + + const idLength = 16 + var sb strings.Builder + sb.Grow(idLength) + for i := 0; i < idLength; i++ { + sb.WriteRune(rune('A' + rand.Intn(26))) + } + + tx := a.waf.NewTransactionWithID(sb.String()) + defer func() { + if err == nil && a.ResponseCheck { + a.cache.SetWithExpiration(tx.ID(), &transaction{tx: tx}, a.TransactionTTL) + return + } + + tx.ProcessLogging() + if err := tx.Close(); err != nil { + a.Logger.Error().Str("tx", tx.ID()).Err(err).Msg("failed to close transaction") + } + }() + + if err := writer.SetString(encoding.VarScopeTransaction, "id", tx.ID()); err != nil { + return err + } + + if tx.IsRuleEngineOff() { + a.Logger.Warn().Msg("Rule engine is Off, Coraza is not going to process any rule") + return nil + } + + tx.ProcessConnection(req.SrcIp.String(), int(req.SrcPort), req.DstIp.String(), int(req.DstPort)) + + { + url := strings.Builder{} + url.Write(req.Path) + if req.Query != nil { + url.WriteString("?") + url.Write(req.Query) + } + + tx.ProcessURI(url.String(), req.Method, "HTTP/"+req.Version) + } + + if err := readHeaders(req.Headers, tx.AddRequestHeader); err != nil { + return fmt.Errorf("reading headers: %v", err) + } + + if it := tx.ProcessRequestHeaders(); it != nil { + return ErrInterrupted{it} + } + + switch it, _, err := tx.WriteRequestBody(req.Body); { + case err != nil: + return err + case it != nil: + return ErrInterrupted{it} + } + + switch it, err := tx.ProcessRequestBody(); { + case err != nil: + return err + case it != nil: + return ErrInterrupted{it} + } + + return nil +} + +func readHeaders(headers []byte, callback func(key string, value string)) error { + s := bufio.NewScanner(bytes.NewReader(headers)) + for s.Scan() { + line := bytes.TrimSpace(s.Bytes()) + if len(line) == 0 { + continue + } + + kv := bytes.SplitN(line, []byte(":"), 2) + if len(kv) != 2 { + return fmt.Errorf("invalid header: %q", s.Text()) + } + + key, value := bytes.TrimSpace(kv[0]), bytes.TrimSpace(kv[1]) + + callback(string(key), string(value)) + } + + return nil +} + +type applicationResponse struct { + ID string + Version string + Status int64 + Headers []byte + Body []byte +} + +func (a *Application) HandleResponse(ctx context.Context, writer *encoding.ActionWriter, message *encoding.Message) (err error) { + if !a.ResponseCheck { + return fmt.Errorf("got response but response check is disabled") + } + + k := encoding.AcquireKVEntry() + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(k) + }() + + var res applicationResponse + for message.KV.Next(k) { + switch name := string(k.NameBytes()); name { + case "id": + res.ID = string(k.ValueBytes()) + case "version": + res.Version = string(k.ValueBytes()) + case "status": + res.Status = k.ValueInt() + case "headers": + // make a copy of the pointer and add a defer in case there is another entry + currK := k + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(currK) + }() + + res.Headers = currK.ValueBytes() + // acquire a new kv entry to continue reading other message values. + k = encoding.AcquireKVEntry() + case "body": + // make a copy of the pointer and add a defer in case there is another entry + currK := k + // run defer via anonymous function to not directly evaluate the arguments. + defer func() { + encoding.ReleaseKVEntry(currK) + }() + + res.Body = currK.ValueBytes() + // acquire a new kv entry to continue reading other message values. + k = encoding.AcquireKVEntry() + default: + a.Logger.Debug().Str("name", name).Msg("unknown kv entry") + } + } + + if res.ID == "" { + return fmt.Errorf("response id is empty") + } + + cv, ok := a.cache.Get(res.ID) + if !ok { + return fmt.Errorf("transaction not found: %s", res.ID) + } + a.cache.Remove(res.ID) + + t := cv.(*transaction) + if !t.m.TryLock() { + return fmt.Errorf("transaction is already being deleted: %s", res.ID) + } + tx := t.tx + + defer func() { + tx.ProcessLogging() + if err := tx.Close(); err != nil { + a.Logger.Error().Str("tx", tx.ID()).Err(err).Msg("failed to close transaction") + } + }() + + if tx.IsRuleEngineOff() { + goto exit + } + + if err := readHeaders(res.Headers, tx.AddResponseHeader); err != nil { + return fmt.Errorf("reading headers: %v", err) + } + + if it := tx.ProcessResponseHeaders(int(res.Status), "HTTP/"+res.Version); it != nil { + return ErrInterrupted{it} + } + + switch it, _, err := tx.WriteResponseBody(res.Body); { + case err != nil: + return err + case it != nil: + return ErrInterrupted{it} + } + + switch it, err := tx.ProcessResponseBody(); { + case err != nil: + return err + case it != nil: + return ErrInterrupted{it} + } + +exit: + return nil +} + +func (a AppConfig) NewApplication() (*Application, error) { + app := Application{ + AppConfig: a, + } + + config := coraza.NewWAFConfig(). + WithDirectives(a.Directives). + WithErrorCallback(app.logCallback). + WithRootFS(mergefs.Merge(coreruleset.FS, io.OSFS)) + + waf, err := coraza.NewWAF(config) + if err != nil { + return nil, err + } + app.waf = waf + + const defaultExpire = time.Second * 10 + const defaultEvictionInterval = time.Second * 1 + + app.cache = cache.NewTTLWithCallback(defaultExpire, defaultEvictionInterval, func(key, value any) { + // everytime a transaction runs into a timeout it gets closed. + t := value.(*transaction) + if !t.m.TryLock() { + // We lost a race and the transaction is already somewhere in use. + a.Logger.Info().Str("tx", t.tx.ID()).Msg("eviction called on currently used transaction") + return + } + + // Process Logging won't do anything if TX was already logged. + t.tx.ProcessLogging() + if err := t.tx.Close(); err != nil { + a.Logger.Error().Err(err).Str("tx", t.tx.ID()).Msg("error closing transaction") + } + }) + + return &app, nil +} + +func (a *Application) logCallback(mr types.MatchedRule) { + var l *zerolog.Event + + switch mr.Rule().Severity() { + case types.RuleSeverityWarning: + l = a.Logger.Warn() + case types.RuleSeverityNotice, + types.RuleSeverityInfo: + l = a.Logger.Info() + case types.RuleSeverityDebug: + l = a.Logger.Debug() + default: + l = a.Logger.Error() + } + l.Msg(mr.ErrorLog()) +} + +type ErrInterrupted struct { + Interruption *types.Interruption +} + +func (e ErrInterrupted) Error() string { + return fmt.Sprintf("interrupted with status %d and action %s", e.Interruption.Status, e.Interruption.Action) +} + +func (e ErrInterrupted) Is(target error) bool { + t, ok := target.(*ErrInterrupted) + if !ok { + return false + } + return e.Interruption == t.Interruption +} diff --git a/internal/e2e_test.go b/internal/e2e_test.go new file mode 100644 index 0000000..0efee41 --- /dev/null +++ b/internal/e2e_test.go @@ -0,0 +1,147 @@ +//go:build e2e + +package internal + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/corazawaf/coraza/v3/http/e2e" + "github.com/mccutchen/go-httpbin/v2/httpbin" + "github.com/rs/zerolog" + + "github.com/dropmorepackets/haproxy-go/pkg/testutil" +) + +func TestE2E(t *testing.T) { + t.Run("coraza e2e suite", func(t *testing.T) { + config, bin, _ := runCoraza(t) + err := e2e.Run(e2e.Config{ + NulledBody: false, + ProxiedEntrypoint: "http://127.0.0.1:" + config.FrontendPort, + HttpbinEntrypoint: bin, + }) + if err != nil { + t.Fatalf("e2e tests failed: %v", err) + } + }) + t.Run("high request rate", func(*testing.T) { + config, _, _ := runCoraza(t) + + if os.Getenv("CI") != "" { + t.Skip("CI is too slow for this test.") + } + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + req, _ := http.NewRequest("GET", "http://127.0.0.1:"+config.FrontendPort+"/get", http.NoBody) + req.Header.Set("coraza-e2e", "ok") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusOK { + t.Error(resp.Status) + } + } + }() + } + + wg.Wait() + }) +} + +func runCoraza(tb testing.TB) (testutil.HAProxyConfig, string, string) { + s := httptest.NewServer(httpbin.New()) + tb.Cleanup(s.Close) + + logger := zerolog.New(os.Stderr).With().Timestamp().Logger() + + appCfg := AppConfig{ + Directives: e2e.Directives, + ResponseCheck: false, + Logger: logger, + TransactionTTL: 10 * time.Second, + } + + application, err := appCfg.NewApplication() + if err != nil { + tb.Fatal(err) + } + + a := Agent{ + Context: context.Background(), + Applications: map[string]*Application{ + "default": application, + }, + Logger: logger, + } + + // create the listener synchronously to prevent a race + l := testutil.TCPListener(tb) + // ignore errors as the listener will be closed by t.Cleanup + go a.Serve(l) + + cfg := testutil.HAProxyConfig{ + EngineAddr: l.Addr().String(), + FrontendPort: fmt.Sprintf("%d", testutil.TCPPort(tb)), + CustomFrontendConfig: ` + # Currently haproxy cannot use variables to set the code or deny_status, so this needs to be manually configured here + http-request redirect code 302 location %[var(txn.e2e.data)] if { var(txn.e2e.action) -m str redirect } + http-response redirect code 302 location %[var(txn.e2e.data)] if { var(txn.e2e.action) -m str redirect } + + acl is_deny var(txn.e2e.action) -m str deny + acl status_424 var(txn.e2e.status) -m int 424 + + # Special check for e2e tests as they validate the config. + http-request deny deny_status 424 hdr waf-block "request" if is_deny status_424 + http-response deny deny_status 424 hdr waf-block "response" if is_deny status_424 + + http-request deny deny_status 403 hdr waf-block "request" if is_deny + http-response deny deny_status 403 hdr waf-block "response" if is_deny + + http-request silent-drop if { var(txn.e2e.action) -m str drop } + http-response silent-drop if { var(txn.e2e.action) -m str drop } + + # Deny in case of an error, when processing with the Coraza SPOA + http-request deny deny_status 504 if { var(txn.e2e.error) -m int gt 0 } + http-response deny deny_status 504 if { var(txn.e2e.error) -m int gt 0 } +`, + EngineConfig: ` +[e2e] +spoe-agent e2e +# messages coraza-req coraza-res + messages coraza-req + option var-prefix e2e + option set-on-error error + timeout hello 2s + timeout idle 2m + timeout processing 500ms + use-backend e2e-spoa + log global + +spoe-message coraza-req + args app=str(default) src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body + event on-frontend-http-request + +spoe-message coraza-res + args app=str(default) id=var(txn.e2e.id) version=res.ver status=status headers=res.hdrs body=res.body + event on-http-response +`, + BackendConfig: fmt.Sprintf(` +mode http +server httpbin %s +`, s.Listener.Addr().String()), + } + + frontendSocket := cfg.Run(tb) + + return cfg, s.URL, frontendSocket +} diff --git a/internal/message.go b/internal/message.go deleted file mode 100644 index 549375e..0000000 --- a/internal/message.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright The OWASP Coraza contributors -// SPDX-License-Identifier: Apache-2.0 - -package internal - -import ( - "fmt" - "net" - - spoe "github.com/criteo/haproxy-spoe-go" -) - -type message struct { - msg *spoe.Message - args map[string]interface{} -} - -func NewMessage(msg *spoe.Message) (*message, error) { - message := message{ - msg: msg, - args: make(map[string]interface{}, msg.Args.Count()), - } - return &message, nil -} - -func (m *message) findArg(name string) (interface{}, error) { - argVal, exist := m.args[name] - if exist { - return argVal, nil - } - - ai := m.msg.Args - for ai.Next() { - m.args[ai.Arg.Name] = ai.Arg.Value - if ai.Arg.Name == name { - return ai.Arg.Value, nil - } - } - - return nil, &ArgNotFoundError{name} -} - -func (m *message) getStringArg(name string) (string, error) { - argVal, err := m.findArg(name) - if err != nil { - return "", err - } - if argVal == nil { - return "", nil - } - val, ok := argVal.(string) - if !ok { - return "", &typeMismatchError{name, "string", argVal} - } - return val, nil -} - -func (m *message) getIntArg(name string) (int, error) { - argVal, err := m.findArg(name) - if err != nil { - return 0, err - } - if argVal == nil { - return 0, nil - } - val, ok := argVal.(int) - if !ok { - return 0, &typeMismatchError{name, "int", argVal} - } - return val, nil -} - -func (m *message) getByteArrayArg(name string) ([]byte, error) { - argVal, err := m.findArg(name) - if err != nil { - return nil, err - } - if argVal == nil { - return nil, nil - } - val, ok := argVal.([]byte) - if !ok { - return nil, &typeMismatchError{name, "[]byte", argVal} - } - return val, nil -} - -func (m *message) getIpArg(name string) (net.IP, error) { - argVal, err := m.findArg(name) - if err != nil { - return nil, err - } - if argVal == nil { - return nil, nil - } - val, ok := argVal.(net.IP) - if !ok { - return nil, &typeMismatchError{name, "net.IP", argVal} - } - return val, nil -} - -type ArgNotFoundError struct { - argName string -} - -func (e *ArgNotFoundError) Error() string { - return fmt.Sprintf("Argument '%s' not found", e.argName) -} - -type typeMismatchError struct { - key string - expectedType string - actualValue interface{} -} - -func (e *typeMismatchError) Error() string { - return fmt.Sprintf("Invalid argument for %s, %s expected, got %T", e.key, e.expectedType, e.actualValue) -} diff --git a/internal/request.go b/internal/request.go deleted file mode 100644 index d4b91fd..0000000 --- a/internal/request.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright The OWASP Coraza contributors -// SPDX-License-Identifier: Apache-2.0 - -package internal - -import ( - "fmt" - "net" - - spoe "github.com/criteo/haproxy-spoe-go" -) - -type request struct { - msg *message - app string - id string - srcIp net.IP - srcPort int - dstIp net.IP - dstPort int - method string - path string - query string - version string - headers string - body []byte -} - -func NewRequest(spoeMsg *spoe.Message) (*request, error) { - msg, err := NewMessage(spoeMsg) - if err != nil { - return nil, err - } - - request := request{} - request.msg = msg - - request.app, err = msg.getStringArg("app") - if err != nil { - return nil, err - } - - request.id, err = request.msg.getStringArg("id") - if err != nil { - return nil, err - } - - return &request, nil -} - -func (req *request) init() error { - var err error - - req.srcIp, err = req.msg.getIpArg("src-ip") - if err != nil { - return err - } - - req.srcPort, err = req.msg.getIntArg("src-port") - if err != nil { - return err - } - - req.dstIp, err = req.msg.getIpArg("dst-ip") - if err != nil { - return err - } - - req.dstPort, err = req.msg.getIntArg("dst-port") - if err != nil { - return err - } - - req.method, err = req.msg.getStringArg("method") - if err != nil { - return err - } - - req.path, err = req.msg.getStringArg("path") - if err != nil { - fmt.Println(err.Error()) - } - - req.query, err = req.msg.getStringArg("query") - if err != nil { - fmt.Println(err.Error()) - } - - req.version, err = req.msg.getStringArg("version") - if err != nil { - fmt.Println(err.Error()) - } - - req.headers, err = req.msg.getStringArg("headers") - if err != nil { - fmt.Println(err.Error()) - } - - req.body, _ = req.msg.getByteArrayArg("body") - - return nil -} diff --git a/internal/response.go b/internal/response.go deleted file mode 100644 index 79dc162..0000000 --- a/internal/response.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright The OWASP Coraza contributors -// SPDX-License-Identifier: Apache-2.0 - -package internal - -import ( - "fmt" - - spoe "github.com/criteo/haproxy-spoe-go" -) - -type response struct { - msg *message - app string - id string - version string - status int - headers string - body []byte -} - -func NewResponse(spoeMsg *spoe.Message) (*response, error) { - msg, err := NewMessage(spoeMsg) - if err != nil { - return nil, err - } - - response := response{} - response.msg = msg - - response.app, err = msg.getStringArg("app") - if err != nil { - return nil, err - } - - response.id, err = response.msg.getStringArg("id") - if err != nil { - return nil, err - } - - return &response, nil -} - -func (resp *response) init() error { - var err error - - resp.version, err = resp.msg.getStringArg("version") - if err != nil { - fmt.Println(err.Error()) - } - - resp.status, err = resp.msg.getIntArg("status") - if err != nil { - return err - } - - resp.headers, err = resp.msg.getStringArg("headers") - if err != nil { - fmt.Println(err.Error()) - } - - resp.body, _ = resp.msg.getByteArrayArg("body") - - return nil -} diff --git a/internal/spoa.go b/internal/spoa.go deleted file mode 100644 index 841758f..0000000 --- a/internal/spoa.go +++ /dev/null @@ -1,406 +0,0 @@ -// Copyright The OWASP Coraza contributors -// SPDX-License-Identifier: Apache-2.0 - -package internal - -import ( - "fmt" - "net/http" - "os" - "strings" - "time" - "io" - - "github.com/bluele/gcache" - "github.com/corazawaf/coraza-spoa/config" - "github.com/corazawaf/coraza/v3" - "github.com/corazawaf/coraza/v3/types" - spoe "github.com/criteo/haproxy-spoe-go" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -// TODO - in coraza v3 ErrorLogCallback is currently in the internal package -type ErrorLogCallback = func(rule types.MatchedRule) - -type application struct { - name string - cfg *config.Application - waf coraza.WAF - cache gcache.Cache - logger *zap.Logger -} - -// SPOA store the relevant data for starting SPOA. -type SPOA struct { - applications map[string]*application - defaultApplication string -} - -// Start starts the SPOA to detect the security risks. -func (s *SPOA) Start(bind string) error { - // s.logger.Info("Starting SPOA") - - agent := spoe.New(func(messages *spoe.MessageIterator) ([]spoe.Action, error) { - for messages.Next() { - msg := messages.Message - - switch msg.Name { - case "coraza-req": - return s.processRequest(&msg) - case "coraza-res": - return s.processResponse(&msg) - } - } - return nil, nil - }) - defer s.cleanApplications() - if err := agent.ListenAndServe(bind); err != nil { - return err - } - return nil -} - -func (s *SPOA) processInterruption(it *types.Interruption) []spoe.Action { - return []spoe.Action{ - spoe.ActionSetVar{ - Name: "status", - Scope: spoe.VarScopeTransaction, - Value: it.Status, - }, - spoe.ActionSetVar{ - Name: "action", - Scope: spoe.VarScopeTransaction, - Value: it.Action, - }, - spoe.ActionSetVar{ - Name: "data", - Scope: spoe.VarScopeTransaction, - Value: it.Data, - }, - spoe.ActionSetVar{ - Name: "ruleid", - Scope: spoe.VarScopeTransaction, - Value: it.RuleID, - }, - } -} - -func (s *SPOA) allowAction() []spoe.Action { - act := []spoe.Action{ - spoe.ActionSetVar{ - Name: "action", - Scope: spoe.VarScopeTransaction, - Value: "allow", - }, - } - return act -} - -func (s *SPOA) readHeaders(headers string) (http.Header, error) { - h := http.Header{} - hs := strings.Split(headers, "\r\n") - - for _, header := range hs { - if header == "" { - continue - } - - kv := strings.SplitN(header, ":", 2) - if len(kv) != 2 { - return nil, fmt.Errorf("invalid header: %q", header) - } - - h.Add(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])) - } - return h, nil -} - -func (s *SPOA) cleanApplications() { - for _, app := range s.applications { - if err := app.logger.Sync(); err != nil { - app.logger.Error("failed to sync logger", zap.Error(err)) - } - } -} - -func logError(logger *zap.Logger) ErrorLogCallback { - return func(mr types.MatchedRule) { - data := mr.ErrorLog() - switch mr.Rule().Severity() { - case types.RuleSeverityEmergency: - logger.Error(data) - case types.RuleSeverityAlert: - logger.Error(data) - case types.RuleSeverityCritical: - logger.Error(data) - case types.RuleSeverityError: - logger.Error(data) - case types.RuleSeverityWarning: - logger.Warn(data) - case types.RuleSeverityNotice: - logger.Info(data) - case types.RuleSeverityInfo: - logger.Info(data) - case types.RuleSeverityDebug: - logger.Debug(data) - } - } -} - -// New Create a new SPOA instance. -func New(conf *config.Config) (*SPOA, error) { - apps := make(map[string]*application) - for name, cfg := range conf.Applications { - pe := zap.NewProductionEncoderConfig() - - fileEncoder := zapcore.NewJSONEncoder(pe) - - pe.EncodeTime = zapcore.ISO8601TimeEncoder - - level, err := zapcore.ParseLevel(cfg.LogLevel) - if err != nil { - level = zap.InfoLevel - } - - var f io.Writer - switch cfg.LogFile { - case "/dev/stdout": - f = os.Stdout - case "/dev/stderr": - f = os.Stderr - default: - ff, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) - if err != nil { - return nil, err - } - f = ff - } - - core := zapcore.NewTee( - zapcore.NewCore(fileEncoder, zapcore.AddSync(f), level), - ) - - logger := zap.New(core) - - conf := coraza.NewWAFConfig(). - WithDirectives(cfg.Directives). - WithErrorCallback(logError(logger)) - - //nolint:staticcheck // https://github.com/golangci/golangci-lint/issues/741 - if len(cfg.Rules) > 0 { - // Deprecated: this will soon be removed - logger.Warn("'rules' directive in configuration is deprecated and will be removed soon, use 'directives' instead") - conf = conf.WithDirectives(strings.Join(cfg.Rules, "\n")) - } - - waf, err := coraza.NewWAF(conf) - if err != nil { - logger.Error("unable to create waf instance", zap.String("app", name), zap.Error(err)) - return nil, err - } - - app := &application{ - name: name, - cfg: cfg, - waf: waf, - logger: logger, - } - - app.cache = gcache.New(app.cfg.TransactionActiveLimit). - EvictedFunc(func(key, value interface{}) { - // everytime a transaction is timedout we clean it - tx, ok := value.(types.Transaction) - if !ok { - return - } - // Process Logging won't do anything if TX was already logged. - tx.ProcessLogging() - if err := tx.Close(); err != nil { - app.logger.Error("Failed to clean cache", zap.Error(err)) - } - }).LFU().Expiration(time.Millisecond * time.Duration(cfg.TransactionTTLMilliseconds)).Build() - - apps[name] = app - } - return &SPOA{ - applications: apps, - defaultApplication: conf.DefaultApplication, - }, nil -} - -func (s *SPOA) getApplication(appName string) (*application, error) { - var app *application - - // Looking for app by name from message - if appName != "" { - app, exist := s.applications[appName] - if exist { - return app, nil - } - } - - // Looking for app by default app name - app, exist := s.applications[s.defaultApplication] - if exist { - app.logger.Debug("application not found, using default", zap.Any("application", appName), zap.String("default", s.defaultApplication)) - return app, nil - } - - return nil, fmt.Errorf("application not found, application %s, default: %s", appName, s.defaultApplication) -} - -func (s *SPOA) processRequest(spoeMsg *spoe.Message) ([]spoe.Action, error) { - var ( - err error - req *request - app *application - tx types.Transaction - ) - - defer func() { - if tx == nil || app == nil { - return - } - if tx.IsInterrupted() { - tx.ProcessLogging() - if err := tx.Close(); err != nil { - app.logger.Error("failed to close transaction", zap.String("transaction_id", tx.ID()), zap.String("error", err.Error())) - } - } else { - if app.cfg.NoResponseCheck { - return - } - err := app.cache.SetWithExpire(tx.ID(), tx, time.Millisecond*time.Duration(app.cfg.TransactionTTLMilliseconds)) - if err != nil { - app.logger.Error(fmt.Sprintf("failed to cache transaction: %s", err.Error())) - } - } - }() - - req, err = NewRequest(spoeMsg) - if err != nil { - return nil, err - } - - app, err = s.getApplication(req.app) - if err != nil { - return nil, err - } - - tx = app.waf.NewTransactionWithID(req.id) - if tx.IsRuleEngineOff() { - app.logger.Warn("Rule engine is Off, Coraza is not going to process any rule") - return s.allowAction(), nil - } - - err = req.init() - if err != nil { - return nil, err - } - - headers, err := s.readHeaders(req.headers) - if err != nil { - return nil, err - } - for key, values := range headers { - for _, v := range values { - tx.AddRequestHeader(key, v) - } - } - - it, _, err := tx.WriteRequestBody(req.body) - if err != nil { - return nil, err - } - if it != nil { - return s.processInterruption(it), nil - } - - tx.ProcessConnection(req.srcIp.String(), req.srcPort, req.dstIp.String(), req.dstPort) - tx.ProcessURI(req.path+"?"+req.query, req.method, "HTTP/"+req.version) - - it = tx.ProcessRequestHeaders() - if it != nil { - return s.processInterruption(it), nil - } - - it, err = tx.ProcessRequestBody() - if err != nil { - return nil, err - } - if it != nil { - return s.processInterruption(it), nil - } - - return s.allowAction(), nil -} - -func (s *SPOA) processResponse(spoeMsg *spoe.Message) ([]spoe.Action, error) { - var ( - err error - resp *response - app *application - tx types.Transaction - ) - defer func() { - app.cache.Remove(resp.id) - }() - - resp, err = NewResponse(spoeMsg) - if err != nil { - return nil, err - } - - app, err = s.getApplication(resp.app) - if err != nil { - return nil, err - } - - txInterface, err := app.cache.Get(resp.id) - if err != nil { - return nil, fmt.Errorf("failed to get transaction from cache, transaction_id: %s, app: %s, error: %s", resp.id, app.name, err.Error()) - } - tx, ok := txInterface.(types.Transaction) - if !ok { - return nil, fmt.Errorf("application cache is corrupted, transaction_id: %s, app: %s", resp.id, app.name) - } - - err = resp.init() - if err != nil { - return nil, err - } - - headers, err := s.readHeaders(resp.headers) - if err != nil { - return nil, err - } - for key, values := range headers { - for _, v := range values { - tx.AddResponseHeader(key, v) - } - } - - it, _, err := tx.WriteResponseBody(resp.body) - if err != nil { - return nil, err - } - if it != nil { - return s.processInterruption(it), nil - } - - it = tx.ProcessResponseHeaders(resp.status, "HTTP/"+resp.version) - if it != nil { - return s.processInterruption(it), nil - } - - it, err = tx.ProcessResponseBody() - if err != nil { - return nil, err - } - if it != nil { - return s.processInterruption(it), nil - } - - return s.allowAction(), nil -} diff --git a/magefile.go b/magefile.go index 857119c..8a7d502 100644 --- a/magefile.go +++ b/magefile.go @@ -1,4 +1,4 @@ -// Copyright 2022 The OWASP Coraza contributors +// Copyright 2024 The OWASP Coraza contributors // SPDX-License-Identifier: Apache-2.0 //go:build mage @@ -11,25 +11,33 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "github.com/magefile/mage/mg" "github.com/magefile/mage/sh" ) -var addLicenseVersion = "v1.0.0" // https://github.com/google/addlicense -// TODO: Use recent version (for example v1.53.2) to run on Go 1.20 (https://github.com/golangci/golangci-lint/pull/3414) -var golangCILintVer = "v1.48.0" // https://github.com/golangci/golangci-lint/releases -var gosImportsVer = "v0.1.5" // https://github.com/rinchsan/gosimports/releases/tag/v0.1.5 - -var errRunGoModTidy = errors.New("go.mod/sum not formatted, commit changes") +var addLicenseVersion = "v1.1.1" // https://github.com/google/addlicense/releases +var gosImportsVer = "v0.3.7" // https://github.com/rinchsan/gosimports/releases +var golangCILintVer = "v1.54.0" // https://github.com/golangci/golangci-lint/releases var errNoGitDir = errors.New("no .git directory found") +var errUpdateGeneratedFiles = errors.New("generated files need to be updated") // Format formats code in this repository. func Format() error { + if err := sh.RunV("go", "generate", "./..."); err != nil { + return err + } + if err := sh.RunV("go", "mod", "tidy"); err != nil { return err } + + if err := sh.RunV("go", "work", "sync"); err != nil { + return err + } + // addlicense strangely logs skipped files to stderr despite not being erroneous, so use the long sh.Exec form to // discard stderr too. if _, err := sh.Exec(map[string]string{}, io.Discard, io.Discard, "go", "run", fmt.Sprintf("github.com/google/addlicense@%s", addLicenseVersion), @@ -47,18 +55,53 @@ func Format() error { ".") } +func Build() error { + if err := sh.RunV("go", "build", "-o", "build/coraza-spoa"); err != nil { + return err + } + return nil +} + // Lint verifies code quality. func Lint() error { + if err := sh.RunV("go", "generate", "./..."); err != nil { + return err + } + + if sh.Run("git", "diff", "--exit-code", "--", "'*.gen.go'") != nil { + return errUpdateGeneratedFiles + } + if err := sh.RunV("go", "run", fmt.Sprintf("github.com/golangci/golangci-lint/cmd/golangci-lint@%s", golangCILintVer), "run"); err != nil { return err } - if err := sh.RunV("go", "mod", "tidy"); err != nil { + if err := sh.RunV("go", "work", "sync"); err != nil { return err } - if sh.Run("git", "diff", "--exit-code", "go.mod", "go.sum") != nil { - return errRunGoModTidy + if err := filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() { + return nil + } + + if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { + cmd := exec.Command("go", "mod", "tidy") + cmd.Dir = path + out, err := cmd.CombinedOutput() + fmt.Printf(string(out)) + if err != nil { + return err + } + } + + return nil + }); err != nil { + return err } return nil @@ -70,9 +113,30 @@ func Test() error { return err } + // we specify the package to get streaming test output + if err := sh.RunV("go", "test", "-race", "-v", "-tags=e2e", "./internal"); err != nil { + return err + } + return nil } +// Coverage runs tests with coverage and race detector enabled. +func Coverage() error { + if err := os.MkdirAll("build", 0755); err != nil { + return err + } + if err := sh.RunV("go", "test", "-race", "-coverprofile=build/coverage.txt", "-covermode=atomic", "-coverpkg=./...", "./..."); err != nil { + return err + } + return sh.RunV("go", "tool", "cover", "-html=build/coverage.txt", "-o", "build/coverage.html") +} + +// Doc runs godoc, access at http://localhost:6060 +func Doc() error { + return sh.RunV("go", "run", "golang.org/x/tools/cmd/godoc@latest", "-http=:6060") +} + // Precommit installs a git hook to run check when committing func Precommit() error { if _, err := os.Stat(filepath.Join(".git", "hooks")); os.IsNotExist(err) { diff --git a/main.go b/main.go new file mode 100644 index 0000000..b468dc7 --- /dev/null +++ b/main.go @@ -0,0 +1,145 @@ +// Copyright The OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "flag" + "net" + "os" + "os/signal" + "runtime" + "runtime/pprof" + "syscall" + + "github.com/rs/zerolog" + + "github.com/corazawaf/coraza-spoa/internal" +) + +var configPath string +var cpuProfile string +var memProfile string +var globalLogger = zerolog.New(os.Stderr).With().Timestamp().Logger() + +func main() { + flag.StringVar(&cpuProfile, "cpuprofile", "", "write cpu profile to `file`") + flag.StringVar(&memProfile, "memprofile", "", "write memory profile to `file`") + flag.StringVar(&configPath, "config", "", "configuration file") + flag.Parse() + + if configPath == "" { + globalLogger.Fatal().Msg("Configuration file is not set") + } + + if cpuProfile != "" { + f, err := os.Create(cpuProfile) + if err != nil { + globalLogger.Fatal().Err(err).Msg("could not create CPU profile") + } + defer f.Close() + if err := pprof.StartCPUProfile(f); err != nil { + globalLogger.Fatal().Err(err).Msg("could not start CPU profile") + } + defer pprof.StopCPUProfile() + } + + cfg, err := readConfig() + if err != nil { + globalLogger.Fatal().Err(err).Msg("Failed loading config") + } + + logger, err := cfg.Log.newLogger() + if err != nil { + globalLogger.Fatal().Err(err).Msg("Failed creating global logger") + } + globalLogger = logger + + apps, err := cfg.newApplications() + if err != nil { + globalLogger.Fatal().Err(err).Msg("Failed creating applications") + } + + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + network, address := cfg.networkAddressFromBind() + l, err := (&net.ListenConfig{}).Listen(ctx, network, address) + if err != nil { + globalLogger.Fatal().Err(err).Msg("Failed opening socket") + } + + a := &internal.Agent{ + Context: ctx, + Applications: apps, + Logger: globalLogger, + } + go func() { + defer cancelFunc() + + globalLogger.Info().Msg("Starting coraza-spoa") + if err := a.Serve(l); err != nil { + globalLogger.Fatal().Err(err).Msg("listener closed") + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGINT) +outer: + for { + sig := <-sigCh + switch sig { + case syscall.SIGTERM: + globalLogger.Info().Msg("Received SIGTERM, shutting down...") + // this return will run cancel() and close the server + break outer + case syscall.SIGINT: + globalLogger.Info().Msg("Received SIGINT, shutting down...") + break outer + case syscall.SIGHUP: + globalLogger.Info().Msg("Received SIGHUP, reloading configuration...") + + newCfg, err := readConfig() + if err != nil { + globalLogger.Error().Err(err).Msg("Error loading configuration, using old configuration") + continue + } + + if cfg.Log != newCfg.Log { + newLogger, err := newCfg.Log.newLogger() + if err != nil { + globalLogger.Error().Err(err).Msg("Error creating new global logger, using old configuration") + continue + } + globalLogger = newLogger + } + + if cfg.Bind != newCfg.Bind { + globalLogger.Error().Msg("Changing bind is not supported yet, using old configuration") + continue + } + + apps, err := newCfg.newApplications() + if err != nil { + globalLogger.Error().Err(err).Msg("Error applying configuration, using old configuration") + continue + } + + a.ReplaceApplications(apps) + cfg = newCfg + } + } + + if memProfile != "" { + f, err := os.Create(memProfile) + if err != nil { + globalLogger.Fatal().Err(err).Msg("could not create memory profile") + } + defer f.Close() + runtime.GC() + if err := pprof.WriteHeapProfile(f); err != nil { + globalLogger.Fatal().Err(err).Msg("could not write memory profile") + } + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..38c2ad7 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} \ No newline at end of file