From 3847197c23bd21cd4786cfa3f3d529f3e5e9b8dc Mon Sep 17 00:00:00 2001 From: BB-AI-Arena <85533977+BB-AI-Arena@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:13:29 -0500 Subject: [PATCH] add OWASP CRS web application firewall --- .env.integrations.example | 12 ++++ .github/workflows/ci.yml | 26 +++++++ CHANGELOG.md | 10 +++ README.md | 20 +++++- deploy/ansible/README.md | 2 +- deploy/ansible/group_vars/all.example.yml | 9 +++ .../roles/intentgate/defaults/main.yml | 8 +++ .../ansible/roles/intentgate/tasks/main.yml | 15 +++- .../intentgate/templates/env.integrations.j2 | 8 +++ deploy/terraform/README.md | 2 +- deploy/terraform/outputs.tf | 2 +- docker-compose.observability.yml | 70 +++++++++++++++++-- docs/ARCHITECTURE.md | 3 + docs/WAF.md | 44 ++++++++++++ pyproject.toml | 2 +- 15 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 docs/WAF.md diff --git a/.env.integrations.example b/.env.integrations.example index ea3f481..0dcdc2a 100644 --- a/.env.integrations.example +++ b/.env.integrations.example @@ -11,6 +11,18 @@ UIG_INGEST_TOKEN=change-me GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_PASSWORD=change-me +# OWASP Core Rule Set WAF. Keep localhost binding unless another trusted edge terminates access. +UIG_BIND_ADDRESS=127.0.0.1 +UIG_WAF_PORT=8787 +PROMETHEUS_PORT=9090 +GRAFANA_PORT=3000 +UIG_WAF_SERVER_NAME=localhost +UIG_WAF_MODE=On +UIG_WAF_BLOCKING_PARANOIA=1 +UIG_WAF_DETECTION_PARANOIA=2 +UIG_WAF_INBOUND_THRESHOLD=5 +UIG_WAF_OUTBOUND_THRESHOLD=4 + # Manager escalation webhook (generic JSON, Slack, or Teams). UIG_MANAGER_ID= UIG_MANAGER_REPORT_THRESHOLD=70 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a22478..0e727f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,32 @@ jobs: - name: Validate Docker Compose run: docker compose -f docker-compose.observability.yml config --quiet + waf: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Start protected API edge + run: docker compose -f docker-compose.observability.yml up -d --build intentgate waf + - name: Verify benign traffic and backend isolation + shell: bash + run: | + curl --fail --retry 20 --retry-delay 2 --retry-all-errors http://127.0.0.1:8787/healthz + test "$(curl --silent --output /dev/null --write-out '%{http_code}' --get --data-urlencode 'user=example-user' http://127.0.0.1:8787/v1/posture)" = "200" + test "$(curl --silent --output /dev/null --write-out '%{http_code}' --header 'Authorization: Bearer local-dev-change-me' --header 'Content-Type: application/json' --data '{"source":"ci","event_id":"benign","score":1,"confidence":1,"ttl_seconds":60,"detail":"WAF verification"}' http://127.0.0.1:8787/v1/signals)" = "202" + intentgate_id="$(docker compose -f docker-compose.observability.yml ps --quiet intentgate)" + docker inspect "$intentgate_id" | jq --exit-status '.[0].NetworkSettings.Ports["8787/tcp"] == null' + - name: Verify CRS blocks an injection probe + shell: bash + run: | + test "$(curl --silent --output /dev/null --write-out '%{http_code}' --get --data-urlencode "user=' OR 1=1 --" http://127.0.0.1:8787/v1/posture)" = "403" + - name: Show WAF logs on failure + if: failure() + run: docker compose -f docker-compose.observability.yml logs waf + - name: Stop test stack + if: always() + run: docker compose -f docker-compose.observability.yml down --volumes + terraform: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b129a1..d56b056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented here. +## [0.4.0] - 2026-08-12 + +### Added + +- OWASP ModSecurity Core Rule Set WAF in front of every published Intent Gate API route +- Internal-only application network that prevents direct host access to the API backend +- Tunable blocking and detection paranoia levels, anomaly thresholds, bind address, and port +- Bounded, metadata-only WAF audit logging and a deployment smoke test +- WAF configuration support in the Ansible deployment role + ## [0.3.0] - 2026-08-12 ### Added diff --git a/README.md b/README.md index e4ef6ca..44980b9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-2ea44f.svg)](LICENSE) [![Status: POC](https://img.shields.io/badge/status-proof%20of%20concept-f59e0b)](#project-status) [![Docker](https://img.shields.io/badge/observability-Docker%20Compose-2496ED?logo=docker&logoColor=white)](docker-compose.observability.yml) +[![WAF](https://img.shields.io/badge/WAF-OWASP%20CRS-000000?logo=owasp&logoColor=white)](docs/WAF.md) [![Terraform](https://img.shields.io/badge/IaC-Terraform-844FBA?logo=terraform&logoColor=white)](deploy/terraform) [![Ansible](https://img.shields.io/badge/automation-Ansible-EE0000?logo=ansible&logoColor=white)](deploy/ansible) @@ -46,6 +47,7 @@ The ordinary-command hot path is local and deterministic. External APIs, SIEM qu - **AI-assisted code risk signals** — performs a bounded, cached code-health/provenance scan without claiming unreliable authorship detection. - **Privacy-conscious escalation** — queues redacted risk reports for an approved manager or security webhook. - **Operational visibility** — ships with Prometheus metrics and a provisioned Grafana dashboard. +- **Protected API edge** — routes every published API request through OWASP ModSecurity CRS while keeping the backend on an internal-only network. - **Standard-library core** — the gate has no mandatory runtime dependencies outside Python 3.11+. ## How it works @@ -57,6 +59,9 @@ flowchart LR C --> P["Policy engine"] AV["AV / EDR / SIEM"] --> N["Normalized signal cache"] S["Provenance scanner"] --> N + X["Security webhooks"] --> W["OWASP CRS WAF"] + W --> API["Signal API"] + API --> N N --> P P -->|"Low risk"| A["ALLOW"] P -->|"Ambiguous"| R["REVIEW"] @@ -190,10 +195,21 @@ docker compose --env-file .env.integrations -f docker-compose.observability.yml |---|---| | Grafana | `http://localhost:3000` | | Prometheus | `http://localhost:9090` | -| Intent Gate API | `http://localhost:8787` | +| WAF-protected Intent Gate API | `http://localhost:8787` | The dashboard tracks aggregate security posture, active external signals, decision counts, policy latency, source-level risk, audited commands, and pending manager reports. +## Web application firewall + +Docker deployments publish the official OWASP ModSecurity Core Rule Set Nginx proxy instead of the application container. The backend has no host port and lives on an internal-only network. Blocking is enabled by default at paranoia level 1, with additional level 2 detection telemetry, strict HTTP methods and content types, a 1 MiB body limit, disabled routine access logging, and bounded audit logs that exclude request headers and bodies. + +```bash +curl http://127.0.0.1:8787/healthz +docker compose -f docker-compose.observability.yml logs waf +``` + +Configuration is available through the ignored environment file and the Ansible role. Review [WAF Operations](docs/WAF.md) before changing thresholds, adding rule exclusions, or publishing the listener beyond loopback. + > [!WARNING] > Development credentials are intentionally simple. Change all passwords and tokens before exposing any service beyond localhost. @@ -252,7 +268,7 @@ The current suite covers policy outcomes, destructive patterns, privilege amplif ## Project status -Version **0.3.0** is a research-quality proof of concept. Important production work remains: +Version **0.4.0** is a research-quality proof of concept. Important production work remains: - Enforce at a non-bypassable execution boundary. - Replace command regexes with PowerShell, POSIX shell, and command AST parsers. diff --git a/deploy/ansible/README.md b/deploy/ansible/README.md index 41b1781..22b6c11 100644 --- a/deploy/ansible/README.md +++ b/deploy/ansible/README.md @@ -1,6 +1,6 @@ # Ansible deployment -This playbook configures a Debian-family Linux host, optionally installs Docker Engine with Compose v2, checks out an approved repository version, renders protected environment configuration, deploys the stack, and verifies its health endpoint. +This playbook configures a Debian-family Linux host, optionally installs Docker Engine with Compose v2, checks out an approved repository version, renders protected environment and WAF policy configuration, deploys the stack, and verifies the WAF health endpoint. ## Controller requirements diff --git a/deploy/ansible/group_vars/all.example.yml b/deploy/ansible/group_vars/all.example.yml index 2157732..5bb8bc1 100644 --- a/deploy/ansible/group_vars/all.example.yml +++ b/deploy/ansible/group_vars/all.example.yml @@ -9,6 +9,15 @@ intentgate_ingest_token: "replace-with-vault-secret" intentgate_grafana_admin_user: "admin" intentgate_grafana_admin_password: "replace-with-vault-secret" +intentgate_bind_address: "127.0.0.1" +intentgate_waf_port: 8787 +intentgate_waf_server_name: "localhost" +intentgate_waf_mode: "On" +intentgate_waf_blocking_paranoia: 1 +intentgate_waf_detection_paranoia: 2 +intentgate_waf_inbound_threshold: 5 +intentgate_waf_outbound_threshold: 4 + intentgate_manager_id: "security-operations" intentgate_manager_report_threshold: 70 intentgate_manager_webhook_url: "" diff --git a/deploy/ansible/roles/intentgate/defaults/main.yml b/deploy/ansible/roles/intentgate/defaults/main.yml index f690d2e..5354335 100644 --- a/deploy/ansible/roles/intentgate/defaults/main.yml +++ b/deploy/ansible/roles/intentgate/defaults/main.yml @@ -18,3 +18,11 @@ intentgate_manager_webhook_token: "" intentgate_manager_webhook_style: "generic" intentgate_grafana_admin_user: "admin" intentgate_grafana_admin_password: "" +intentgate_bind_address: "127.0.0.1" +intentgate_waf_port: 8787 +intentgate_waf_server_name: "localhost" +intentgate_waf_mode: "On" +intentgate_waf_blocking_paranoia: 1 +intentgate_waf_detection_paranoia: 2 +intentgate_waf_inbound_threshold: 5 +intentgate_waf_outbound_threshold: 4 diff --git a/deploy/ansible/roles/intentgate/tasks/main.yml b/deploy/ansible/roles/intentgate/tasks/main.yml index ffb4781..2168080 100644 --- a/deploy/ansible/roles/intentgate/tasks/main.yml +++ b/deploy/ansible/roles/intentgate/tasks/main.yml @@ -9,6 +9,19 @@ password of at least 12 characters, preferably through Ansible Vault. no_log: true +- name: Validate WAF policy settings + ansible.builtin.assert: + that: + - intentgate_waf_mode in ['On', 'DetectionOnly'] + - intentgate_waf_blocking_paranoia | int in [1, 2, 3, 4] + - intentgate_waf_detection_paranoia | int in [1, 2, 3, 4] + - intentgate_waf_detection_paranoia | int >= intentgate_waf_blocking_paranoia | int + - intentgate_waf_inbound_threshold | int > 0 + - intentgate_waf_outbound_threshold | int > 0 + - intentgate_waf_port | int > 0 + - intentgate_waf_port | int < 65536 + fail_msg: "Use a supported WAF mode, paranoia levels from 1-4, and positive thresholds and port." + - name: Validate supported automatic Docker installation platform ansible.builtin.assert: that: @@ -139,7 +152,7 @@ - name: Verify the Intent Gate health endpoint ansible.builtin.uri: - url: http://127.0.0.1:8787/healthz + url: "http://127.0.0.1:{{ intentgate_waf_port }}/healthz" method: GET status_code: 200 return_content: true diff --git a/deploy/ansible/roles/intentgate/templates/env.integrations.j2 b/deploy/ansible/roles/intentgate/templates/env.integrations.j2 index 9ce0895..9e83f51 100644 --- a/deploy/ansible/roles/intentgate/templates/env.integrations.j2 +++ b/deploy/ansible/roles/intentgate/templates/env.integrations.j2 @@ -6,4 +6,12 @@ UIG_MANAGER_WEBHOOK_TOKEN={{ intentgate_manager_webhook_token | quote }} UIG_MANAGER_WEBHOOK_STYLE={{ intentgate_manager_webhook_style | quote }} GRAFANA_ADMIN_USER={{ intentgate_grafana_admin_user | quote }} GRAFANA_ADMIN_PASSWORD={{ intentgate_grafana_admin_password | quote }} +UIG_BIND_ADDRESS={{ intentgate_bind_address | quote }} +UIG_WAF_PORT={{ intentgate_waf_port }} +UIG_WAF_SERVER_NAME={{ intentgate_waf_server_name | quote }} +UIG_WAF_MODE={{ intentgate_waf_mode | quote }} +UIG_WAF_BLOCKING_PARANOIA={{ intentgate_waf_blocking_paranoia }} +UIG_WAF_DETECTION_PARANOIA={{ intentgate_waf_detection_paranoia }} +UIG_WAF_INBOUND_THRESHOLD={{ intentgate_waf_inbound_threshold }} +UIG_WAF_OUTBOUND_THRESHOLD={{ intentgate_waf_outbound_threshold }} diff --git a/deploy/terraform/README.md b/deploy/terraform/README.md index 66f409b..38362e8 100644 --- a/deploy/terraform/README.md +++ b/deploy/terraform/README.md @@ -1,6 +1,6 @@ # Terraform deployment -This root module manages the existing Docker Compose application through the `kreuzwerker/docker` provider. It intentionally reuses `docker-compose.observability.yml` so service definitions remain canonical in one place. +This root module manages the existing Docker Compose application through the `kreuzwerker/docker` provider. It intentionally reuses `docker-compose.observability.yml` so service definitions remain canonical in one place, including the OWASP CRS WAF and internal application network. ## Requirements diff --git a/deploy/terraform/outputs.tf b/deploy/terraform/outputs.tf index 1d582e9..9fafa03 100644 --- a/deploy/terraform/outputs.tf +++ b/deploy/terraform/outputs.tf @@ -9,7 +9,7 @@ output "prometheus_url" { } output "intentgate_api_url" { - description = "Default local Intent Gate API URL." + description = "Default local WAF-protected Intent Gate API URL." value = "http://localhost:8787" } diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml index a581bac..5c5455f 100644 --- a/docker-compose.observability.yml +++ b/docker-compose.observability.yml @@ -1,4 +1,43 @@ services: + waf: + image: owasp/modsecurity-crs:4.25.0-nginx-lts + environment: + BACKEND: http://intentgate:8787 + PORT: "8080" + SERVER_NAME: ${UIG_WAF_SERVER_NAME:-localhost} + MODSEC_RULE_ENGINE: ${UIG_WAF_MODE:-On} + MODSEC_AUDIT_ENGINE: RelevantOnly + MODSEC_AUDIT_LOG: /dev/stdout + # Do not record request headers or bodies; they can contain ingest credentials or sensitive telemetry. + MODSEC_AUDIT_LOG_PARTS: AFHZ + ACCESSLOG: /dev/null + MODSEC_REQ_BODY_ACCESS: "On" + MODSEC_REQ_BODY_LIMIT: "1048576" + MODSEC_REQ_BODY_NOFILES_LIMIT: "1048576" + MODSEC_RESP_BODY_ACCESS: "Off" + BLOCKING_PARANOIA: ${UIG_WAF_BLOCKING_PARANOIA:-1} + DETECTION_PARANOIA: ${UIG_WAF_DETECTION_PARANOIA:-2} + ANOMALY_INBOUND: ${UIG_WAF_INBOUND_THRESHOLD:-5} + ANOMALY_OUTBOUND: ${UIG_WAF_OUTBOUND_THRESHOLD:-4} + ALLOWED_METHODS: GET POST + ALLOWED_REQUEST_CONTENT_TYPE: "|application/json|" + VALIDATE_UTF8_ENCODING: "1" + SERVER_TOKENS: "off" + ports: + - "${UIG_BIND_ADDRESS:-127.0.0.1}:${UIG_WAF_PORT:-8787}:8080" + depends_on: + intentgate: + condition: service_healthy + networks: [edge, application] + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: 10m + max-file: "5" + restart: unless-stopped + intentgate: build: . environment: @@ -6,8 +45,14 @@ services: UIG_INGEST_TOKEN: ${UIG_INGEST_TOKEN:-local-dev-change-me} volumes: - ./.intentgate-state:/state - ports: - - "8787:8787" + expose: ["8787"] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/healthz', timeout=2)"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + networks: [application] restart: unless-stopped prometheus: @@ -17,8 +62,11 @@ services: - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus ports: - - "9090:9090" - depends_on: [intentgate] + - "${UIG_BIND_ADDRESS:-127.0.0.1}:${PROMETHEUS_PORT:-9090}:9090" + depends_on: + intentgate: + condition: service_healthy + networks: [application, observability, management] restart: unless-stopped grafana: @@ -32,8 +80,9 @@ services: - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana-data:/var/lib/grafana ports: - - "3000:3000" + - "${UIG_BIND_ADDRESS:-127.0.0.1}:${GRAFANA_PORT:-3000}:3000" depends_on: [prometheus] + networks: [observability, management] restart: unless-stopped collector: @@ -48,6 +97,7 @@ services: volumes: - ./.intentgate-state:/state - ./config/integrations.json:/config/integrations.json:ro + networks: [outbound] restart: unless-stopped notifier: @@ -61,8 +111,18 @@ services: UIG_MANAGER_WEBHOOK_STYLE: ${UIG_MANAGER_WEBHOOK_STYLE:-generic} volumes: - ./.intentgate-state:/state + networks: [outbound] restart: unless-stopped volumes: prometheus-data: grafana-data: + +networks: + edge: + application: + internal: true + observability: + internal: true + management: + outbound: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6e6bac0..e3ab6f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,11 +18,14 @@ The command path should remain fast enough that users do not notice the gate dur | `reporting.py` | Redact and queue high-risk reports | Local append after decision | | `notifier.py` | Deliver reports to approved webhooks | Background | | `service.py` | Ingest signals and expose posture and Prometheus metrics | Background | +| OWASP CRS WAF | Inspect, constrain, and proxy all host-originated API traffic | Network edge | ## Data flow External signals receive a score, confidence, scope, and TTL. The correlation layer takes the highest effective signal and adds bounded corroboration from other sources. The gate reads only non-expired local state. +The Docker deployment publishes only the OWASP Core Rule Set WAF. The Intent Gate service is isolated on an internal application network; Prometheus reaches it there for scraping, while external webhook and API clients traverse the WAF. See [WAF Operations](WAF.md). + Audit and report files are local JSON/JSONL in `UIG_STATE_DIR`. The PowerShell helper defaults this to `.intentgate-state` in the project so the host CLI and Docker observability services share the same state. ## Latency model diff --git a/docs/WAF.md b/docs/WAF.md new file mode 100644 index 0000000..40961a7 --- /dev/null +++ b/docs/WAF.md @@ -0,0 +1,44 @@ +# WAF operations + +The Docker deployment places the official [OWASP ModSecurity Core Rule Set (CRS) container](https://github.com/coreruleset/modsecurity-crs-docker) in front of the Intent Gate HTTP API. The backend has no published host port and is reachable only on Docker's internal application network. + +## Default policy + +| Control | Default | +|---|---| +| Rule engine | Blocking (`On`) | +| CRS release | 4.25 LTS | +| Blocking paranoia | 1 | +| Detection paranoia | 2 | +| Inbound anomaly threshold | 5 | +| Allowed methods | `GET`, `POST` | +| Request content type | `application/json` | +| Maximum request body | 1 MiB | +| Response-body inspection | Disabled | +| Published interface | Loopback only | + +Relevant audit events are written as JSON to the container log. Routine access logging is disabled. Request headers and bodies are deliberately excluded because they may contain bearer credentials, commands, identity fields, or security telemetry. A suspicious request URI, query string, and rule-matched fragment can still appear in an audit event and must be handled as sensitive data. Docker log rotation is bounded to five 10 MiB files. + +## Start and verify + +```bash +docker compose --env-file .env.integrations -f docker-compose.observability.yml up -d --build +curl http://127.0.0.1:8787/healthz +docker compose -f docker-compose.observability.yml logs waf +``` + +The public API endpoint remains port `8787`; traffic now terminates at the WAF and is proxied internally. Prometheus intentionally scrapes the backend over the private application network. + +## Tune safely + +Copy `.env.integrations.example` to the ignored `.env.integrations` file. Begin new rules or higher paranoia levels in `DetectionOnly`, observe representative traffic, document false positives, and then switch back to `On`. Do not raise anomaly thresholds as a substitute for a narrow, reviewed rule exclusion. + +Webhook payloads often contain attack descriptions or command fragments that resemble real exploits. Test every enabled AV, EDR, and SIEM source against the WAF before enforcing changes. Keep exclusions scoped to the smallest route, field, and rule ID possible. + +Set `UIG_BIND_ADDRESS=0.0.0.0` only when the host firewall and an approved TLS ingress restrict access. The bundled HTTP listener is for local evaluation; production deployments should terminate TLS with managed certificates at this WAF or at a trusted upstream load balancer. + +## Limitations + +A WAF protects the HTTP surface; it does not make the command wrapper non-bypassable, authenticate read-only endpoints, prevent host-level Docker access, or replace rate limiting and identity-aware access control. Treat its logs as sensitive security records and forward them through an approved pipeline when durable retention is required. + +For rule concepts and safe tuning practices, use the [official OWASP CRS documentation](https://coreruleset.org/docs/). diff --git a/pyproject.toml b/pyproject.toml index d71f84c..08b8c7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "user-intent-gate" -version = "0.3.0" +version = "0.4.0" description = "A low-latency, context-aware command intent gate proof of concept" readme = "README.md" requires-python = ">=3.11"