Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.integrations.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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"]
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion deploy/ansible/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
9 changes: 9 additions & 0 deletions deploy/ansible/group_vars/all.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
8 changes: 8 additions & 0 deletions deploy/ansible/roles/intentgate/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 14 additions & 1 deletion deploy/ansible/roles/intentgate/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions deploy/ansible/roles/intentgate/templates/env.integrations.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

2 changes: 1 addition & 1 deletion deploy/terraform/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion deploy/terraform/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
70 changes: 65 additions & 5 deletions docker-compose.observability.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,58 @@
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:
UIG_STATE_DIR: /state
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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -48,6 +97,7 @@ services:
volumes:
- ./.intentgate-state:/state
- ./config/integrations.json:/config/integrations.json:ro
networks: [outbound]
restart: unless-stopped

notifier:
Expand All @@ -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:
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions docs/WAF.md
Original file line number Diff line number Diff line change
@@ -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/).
Loading
Loading