├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── feature_request.yml │ └── new_plugin.yml ├── codeql.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── tests.yml ├── .gitignore ├── .luacheckrc ├── .pre-commit-config.yaml ├── .tests ├── build-push.sh ├── bw.sh ├── clamav.sh ├── clamav │ └── docker-compose.yml ├── coraza.sh ├── coraza │ └── docker-compose.yml ├── misc │ └── json2md.py ├── utils.sh ├── virustotal.sh └── virustotal │ └── docker-compose.yml ├── COMPATIBILITY.json ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── clamav ├── README.md ├── clamav.lua ├── docs │ ├── diagram.drawio │ └── diagram.svg ├── plugin.json └── ui │ └── actions.py ├── coraza ├── README.md ├── api │ ├── Dockerfile │ ├── bunkerweb.conf │ ├── coraza.conf │ ├── crs.sh │ ├── go.mod │ ├── healthcheck.sh │ └── main.go ├── confs │ └── server-http │ │ └── coraza.conf ├── coraza.lua ├── docs │ ├── diagram.drawio │ └── diagram.svg ├── plugin.json └── ui │ └── actions.py ├── discord ├── README.md ├── discord.lua ├── docs │ ├── diagram.drawio │ └── diagram.svg ├── plugin.json └── ui │ └── actions.py ├── logo.png ├── misc └── update_version.sh ├── pyproject.toml ├── slack ├── README.md ├── docs │ ├── diagram.drawio │ └── diagram.svg ├── plugin.json ├── slack.lua └── ui │ └── actions.py ├── stylua.toml ├── virustotal ├── README.md ├── docs │ ├── diagram.drawio │ └── diagram.svg ├── plugin.json ├── ui │ └── actions.py └── virustotal.lua └── webhook ├── README.md ├── docs ├── diagram.drawio └── diagram.svg ├── plugin.json ├── ui └── actions.py └── webhook.lua /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Create a report to help us reproduce and fix the bug 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | #### Before submitting a bug, please make sure the issue hasn't been already addressed by searching through [the existing and past issues](https://github.com/bunkerity/bunkerweb-plugins/issues?q=is%3Aissue+sort%3Acreated-desc+). 10 | - type: checkboxes 11 | id: plugins 12 | attributes: 13 | label: Which plugin is affected? 14 | options: 15 | - clamav 16 | - coraza 17 | - discord 18 | - slack 19 | - virustotal 20 | - webhook 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: what-happened 25 | attributes: 26 | label: What happened? 27 | description: Concise description of what you're trying to do, the expected behavior and the current bug. 28 | placeholder: Describe the bug, the expected behavior and the current behavior 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: how-to-reproduce 33 | attributes: 34 | label: How to reproduce? 35 | description: Concise description of how to reproduce the issue. 36 | placeholder: Describe how to reproduce the issue 37 | validations: 38 | required: true 39 | - type: textarea 40 | id: logs 41 | attributes: 42 | label: Relevant log output 43 | description: | 44 | Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 45 | ⚠️ DON'T FORGET TO REMOVE PRIVATE DATA LIKE IP ADDRESSES ! ⚠️ 46 | placeholder: Log output 47 | render: shell 48 | - type: input 49 | id: version 50 | attributes: 51 | label: BunkerWeb version 52 | description: What version of BunkerWeb are you running? 53 | placeholder: Version 54 | value: 1.5.2 55 | validations: 56 | required: true 57 | - type: checkboxes 58 | id: removed-private-data 59 | attributes: 60 | label: Removed private data 61 | description: | 62 | We would like to emphasize that we are not responsible for any private data that may be inadvertently included in the logs or configuration files. 63 | ⚠️ I have removed all private data from the configuration file and the logs ⚠️ 64 | options: 65 | - label: I have removed all private data from the configuration file and the logs 66 | required: true 67 | - type: checkboxes 68 | id: terms 69 | attributes: 70 | label: Code of Conduct 71 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/bunkerity/bunkerweb-plugins/blob/master/CODE_OF_CONDUCT.md) 72 | options: 73 | - label: I agree to follow this project's Code of Conduct 74 | required: true 75 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea for (an) existing plugin(s) 3 | title: "[FEATURE] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | #### Before submitting a feature request, please make sure the feature hasn't been already addressed by searching through [the existing and past feature requests](https://github.com/bunkerity/bunkerweb-plugins/issues?q=is%3Aissue+sort%3Acreated-desc+%5BFEATURE%5D+in%3Atitle). 10 | - type: checkboxes 11 | id: plugins 12 | attributes: 13 | label: Which plugin is affected? 14 | options: 15 | - clamav 16 | - coraza 17 | - discord 18 | - slack 19 | - virustotal 20 | - webhook 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: whats-needed-and-why 25 | attributes: 26 | label: What's needed and why? 27 | description: Describe the feature you would like to see in the project and why it should be implemented. 28 | validations: 29 | required: true 30 | - type: textarea 31 | id: implementations-ideas 32 | attributes: 33 | label: Implementations ideas (optional) 34 | description: How it should be used and integrated into the project ? List some posts, research papers or codes that we can use as implementation. 35 | - type: checkboxes 36 | id: terms 37 | attributes: 38 | label: Code of Conduct 39 | description: By submitting this feature request, you agree to follow our [Code of Conduct](https://github.com/bunkerity/bunkerweb-plugins/blob/master/CODE_OF_CONDUCT.md) 40 | options: 41 | - label: I agree to follow this project's Code of Conduct 42 | required: true 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_plugin.yml: -------------------------------------------------------------------------------- 1 | name: ➕ New Plugin 2 | description: Suggest an idea for a new plugin 3 | title: "[PLUGIN] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | #### Before submitting a feature request, please make sure the feature hasn't been already addressed by searching through [the existing and past feature requests](https://github.com/bunkerity/bunkerweb-plugins/issues?q=is%3Aissue+sort%3Acreated-desc+%5BPLUGIN%5D+in%3Atitle). 10 | - type: textarea 11 | id: plugin 12 | attributes: 13 | label: Plugin 14 | description: Explain the goal of the plugin and the benefit of adding it to the official BunkerWeb plugins. List any useful links or resources about the underlying technology if any. 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: settings 19 | attributes: 20 | label: Settings (optional) 21 | description: List the settings you would like to have to configure the plugin. 22 | render: JSON 23 | - type: textarea 24 | id: implementations-ideas 25 | attributes: 26 | label: Implementations ideas (optional) 27 | description: How it should be used and integrated into the project ? List some posts, research papers or codes that we can use as implementation. 28 | - type: checkboxes 29 | id: terms 30 | attributes: 31 | label: Code of Conduct 32 | description: By submitting this feature request, you agree to follow our [Code of Conduct](https://github.com/bunkerity/bunkerweb-plugins/blob/master/CODE_OF_CONDUCT.md) 33 | options: 34 | - label: I agree to follow this project's Code of Conduct 35 | required: true 36 | -------------------------------------------------------------------------------- /.github/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL config" 2 | 3 | paths: 4 | - clamav 5 | - coraza 6 | - discord 7 | - slack 8 | - virustotal 9 | - webhook 10 | paths-ignore: 11 | - coraza/api/coreruleset 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | # GHA 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | time: "09:00" 10 | timezone: "Europe/Paris" 11 | assignees: 12 | - "TheophileDiot" 13 | reviewers: 14 | - "TheophileDiot" 15 | commit-message: 16 | prefix: "deps/gha" 17 | target-branch: "dev" 18 | 19 | # Coraza 20 | - package-ecosystem: "docker" 21 | directory: "/coraza/api" 22 | schedule: 23 | interval: "daily" 24 | time: "09:00" 25 | timezone: "Europe/Paris" 26 | assignees: 27 | - "TheophileDiot" 28 | reviewers: 29 | - "TheophileDiot" 30 | commit-message: 31 | prefix: "deps/coraza/api" 32 | target-branch: "dev" 33 | - package-ecosystem: "gomod" 34 | directory: "/coraza/api" 35 | schedule: 36 | interval: "daily" 37 | time: "09:00" 38 | timezone: "Europe/Paris" 39 | assignees: 40 | - "TheophileDiot" 41 | reviewers: 42 | - "TheophileDiot" 43 | commit-message: 44 | prefix: "deps/coraza/api" 45 | target-branch: "dev" 46 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL Analysis 2 | 3 | on: 4 | schedule: 5 | # Weekly on Saturdays. 6 | - cron: "30 1 * * 6" 7 | workflow_call: 8 | 9 | jobs: 10 | code-security: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | actions: read 14 | contents: read 15 | security-events: write 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | language: ["python", "go"] 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | - name: Initialize CodeQL 24 | uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11 25 | with: 26 | languages: ${{ matrix.language }} 27 | config-file: ./.github/codeql.yml 28 | - name: Perform CodeQL Analysis 29 | uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11 30 | with: 31 | category: "/language:${{matrix.language}}" 32 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [dev, main] 6 | 7 | jobs: 8 | codeql: 9 | uses: ./.github/workflows/codeql.yml 10 | permissions: 11 | actions: read 12 | contents: read 13 | security-events: write 14 | 15 | setup: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout source code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Get BW tag 22 | run: | 23 | if [ "$GITHUB_REF" = "refs/heads/main" ] ; then 24 | echo "BW_TAG=1.6.1" >> $GITHUB_ENV 25 | else 26 | echo "BW_TAG=dev" >> $GITHUB_ENV 27 | fi 28 | 29 | - name: Login to Docker Hub 30 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 31 | with: 32 | username: ${{ secrets.DOCKER_USERNAME }} 33 | password: ${{ secrets.DOCKER_TOKEN }} 34 | 35 | - name: Pull and build BW 36 | run: ./.tests/bw.sh "${{ env.BW_TAG }}" 37 | 38 | - name: Run ClamAV tests 39 | run: ./.tests/clamav.sh 40 | 41 | - name: Run Coraza tests 42 | run: ./.tests/coraza.sh 43 | 44 | - name: Run VirusTotal tests 45 | run: ./.tests/virustotal.sh 46 | env: 47 | VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} 48 | 49 | - name: Build and push APIs 50 | if: env.BW_TAG == '1.6.1' 51 | run: ./.tests/build-push.sh "${{ env.BW_TAG }}" 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.tar.* 3 | *.zip 4 | env 5 | node_modules 6 | style.css 7 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | globals = {"ngx"} 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: (^coraza/api/coreruleset|(^LICENSE.md|.svg)$) 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: 2c9f875913ee60ca25ce70243dc24d5b6415598c # frozen: v4.6.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-yaml 11 | args: ["--allow-multiple-documents"] 12 | - id: check-case-conflict 13 | 14 | - repo: https://github.com/ambv/black 15 | rev: 3702ba224ecffbcec30af640c149f231d90aebdb # frozen: 24.4.2 16 | hooks: 17 | - id: black 18 | name: Black Python Formatter 19 | language_version: python3.9 20 | 21 | - repo: https://github.com/pre-commit/mirrors-prettier 22 | rev: ffb6a759a979008c0e6dff86e39f4745a2d9eac4 # frozen: v3.1.0 23 | hooks: 24 | - id: prettier 25 | name: Prettier Code Formatter 26 | 27 | - repo: https://github.com/JohnnyMorganz/StyLua 28 | rev: 84c370104d6a8d1eef00c80a3ebd42f7033aaaad # frozen: v0.20.0 29 | hooks: 30 | - id: stylua-github 31 | 32 | - repo: https://github.com/lunarmodules/luacheck 33 | rev: cc089e3f65acdd1ef8716cc73a3eca24a6b845e4 # frozen: v1.2.0 34 | hooks: 35 | - id: luacheck 36 | args: ["--std", "min", "--codes", "--ranges", "--no-cache"] 37 | 38 | - repo: https://github.com/pycqa/flake8 39 | rev: 1978e2b0de6efa0cb2a2b6f3f7986aa6569dd2be # frozen: 7.1.0 40 | hooks: 41 | - id: flake8 42 | name: Flake8 Python Linter 43 | args: ["--max-line-length=250", "--ignore=E266,E402,E722,W503"] 44 | 45 | - repo: https://github.com/codespell-project/codespell 46 | rev: 193cd7d27cd571f79358af09a8fb8997e54f8fff # frozen: v2.3.0 47 | hooks: 48 | - id: codespell 49 | name: Codespell Spell Checker 50 | entry: codespell --ignore-regex="(tabEl|Widgits)" --skip */ui/template.html,src/ui/static/js/utils/flatpickr.js,CHANGELOG.md 51 | language: python 52 | types: [text] 53 | 54 | - repo: https://github.com/gitleaks/gitleaks 55 | rev: 77c3c6a34b2577d71083442326c60b8fd58926ec # frozen: v8.18.4 56 | hooks: 57 | - id: gitleaks 58 | 59 | - repo: https://github.com/koalaman/shellcheck-precommit 60 | rev: 2491238703a5d3415bb2b7ff11388bf775372f29 # frozen: v0.10.0 61 | hooks: 62 | - id: shellcheck 63 | -------------------------------------------------------------------------------- /.tests/build-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . .tests/utils.sh 4 | 5 | echo "ℹ️ Build bunkerweb-coraza ..." 6 | CHANGE_DIR="./coraza/api" do_and_check_cmd docker build -t bunkerweb-coraza . 7 | 8 | echo "ℹ️ Tag bunkerweb-coraza ..." 9 | do_and_check_cmd docker image tag bunkerweb-coraza bunkerity/bunkerweb-coraza:latest 10 | do_and_check_cmd docker image tag bunkerweb-coraza "bunkerity/bunkerweb-coraza:$1" 11 | 12 | echo "ℹ️ Push bunkerweb-coraza ..." 13 | do_and_check_cmd docker image push --all-tags bunkerity/bunkerweb-coraza 14 | -------------------------------------------------------------------------------- /.tests/bw.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC1091 4 | . .tests/utils.sh 5 | 6 | echo "ℹ️ Pulling images ..." 7 | do_and_check_cmd docker pull "bunkerity/bunkerweb:$1" 8 | do_and_check_cmd docker pull "bunkerity/bunkerweb-scheduler:$1" 9 | 10 | echo "ℹ️ Tagging images ..." 11 | do_and_check_cmd docker tag "bunkerity/bunkerweb:$1" "bunkerweb:tests" 12 | do_and_check_cmd docker tag "bunkerity/bunkerweb-scheduler:$1" "bunkerweb-scheduler:tests" 13 | -------------------------------------------------------------------------------- /.tests/clamav.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC1091 4 | . .tests/utils.sh 5 | 6 | echo "ℹ️ Starting ClamAV tests ..." 7 | 8 | # Create working directory 9 | if [ -d /tmp/bunkerweb-plugins ] ; then 10 | do_and_check_cmd sudo rm -rf /tmp/bunkerweb-plugins 11 | fi 12 | do_and_check_cmd mkdir -p /tmp/bunkerweb-plugins/clamav/bw-data/plugins 13 | do_and_check_cmd cp -r ./clamav /tmp/bunkerweb-plugins/clamav/bw-data/plugins 14 | do_and_check_cmd sudo chown -R 101:101 /tmp/bunkerweb-plugins/clamav/bw-data 15 | 16 | # Copy compose 17 | do_and_check_cmd cp .tests/clamav/docker-compose.yml /tmp/bunkerweb-plugins/clamav 18 | 19 | # Edit compose 20 | do_and_check_cmd sed -i "s@bunkerity/bunkerweb:.*\$@bunkerweb:tests@g" /tmp/bunkerweb-plugins/clamav/docker-compose.yml 21 | do_and_check_cmd sed -i "s@bunkerity/bunkerweb-scheduler:.*\$@bunkerweb-scheduler:tests@g" /tmp/bunkerweb-plugins/clamav/docker-compose.yml 22 | 23 | # Download EICAR file 24 | do_and_check_cmd wget -O /tmp/bunkerweb-plugins/clamav/eicar.com https://secure.eicar.org/eicar.com 25 | 26 | # Do the tests 27 | cd /tmp/bunkerweb-plugins/clamav || exit 1 28 | echo "ℹ️ Running compose ..." 29 | do_and_check_cmd docker compose up --build -d 30 | 31 | # Wait until BW is started 32 | echo "ℹ️ Waiting for BW ..." 33 | success="ko" 34 | retry=0 35 | while [ $retry -lt 60 ] ; do 36 | ret="$(curl -s -H "Host: www.example.com" http://localhost | grep -i "hello")" 37 | # shellcheck disable=SC2181 38 | if [ $? -eq 0 ] && [ "$ret" != "" ] ; then 39 | success="ok" 40 | break 41 | fi 42 | retry=$((retry + 1)) 43 | sleep 1 44 | done 45 | 46 | # We're done 47 | if [ $retry -eq 60 ] ; then 48 | docker compose logs 49 | docker compose down -v 50 | echo "❌ Error timeout after 60s" 51 | exit 1 52 | fi 53 | if [ "$success" == "ko" ] ; then 54 | docker compose logs 55 | docker compose down -v 56 | echo "❌ Error did not receive 200 code" 57 | exit 1 58 | fi 59 | 60 | # Now check if BunkerWeb is giving a 403 61 | echo "ℹ️ Testing BW ..." 62 | success="ko" 63 | retry=0 64 | while [ $retry -lt 60 ] ; do 65 | ret="$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Host: www.example.com" -F "file=@/tmp/bunkerweb-plugins/clamav/eicar.com" http://localhost)" 66 | # shellcheck disable=SC2181 67 | if [ $? -eq 0 ] && [ "$ret" -eq 403 ] ; then 68 | success="ok" 69 | break 70 | fi 71 | retry=$((retry + 1)) 72 | sleep 1 73 | done 74 | 75 | # We're done 76 | if [ $retry -eq 60 ] ; then 77 | docker compose logs 78 | docker compose down -v 79 | echo "❌ Error timeout after 60s" 80 | exit 1 81 | fi 82 | if [ "$success" == "ko" ] ; then 83 | docker compose logs 84 | docker compose down -v 85 | echo "❌ Error did not receive 403 code" 86 | exit 1 87 | fi 88 | if [ "$1" = "verbose" ] ; then 89 | docker compose logs 90 | fi 91 | docker compose down -v 92 | 93 | echo "ℹ️ ClamAV tests done" 94 | -------------------------------------------------------------------------------- /.tests/clamav/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | bunkerweb: 5 | image: bunkerity/bunkerweb:1.6.0-rc1 6 | ports: 7 | - 80:8080/tcp 8 | - 443:8443/tcp 9 | - 443:8443/udp 10 | environment: 11 | - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24 12 | networks: 13 | - bw-universe 14 | - bw-services 15 | 16 | bw-scheduler: 17 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 18 | depends_on: 19 | - bunkerweb 20 | volumes: 21 | - ./bw-data/plugins:/data/plugins 22 | environment: 23 | - BUNKERWEB_INSTANCES=bunkerweb 24 | - SERVER_NAME=www.example.com 25 | - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24 26 | - USE_CLAMAV=yes 27 | - CLAMAV_HOST=clamav 28 | - LOG_LEVEL=info 29 | - USE_BAD_BEHAVIOR=no 30 | - USE_LIMIT_REQ=no 31 | - USE_BUNKERNET=no 32 | - USE_BLACKLIST=no 33 | - USE_MODSECURITY=no 34 | - USE_REVERSE_PROXY=yes 35 | - REVERSE_PROXY_HOST=http://hello:8080 36 | - REVERSE_PROXY_URL=/ 37 | networks: 38 | - bw-universe 39 | 40 | clamav: 41 | image: clamav/clamav:1.4 42 | volumes: 43 | - clamav-data:/var/lib/clamav 44 | networks: 45 | - bw-universe 46 | 47 | hello: 48 | image: nginxdemos/nginx-hello 49 | networks: 50 | - bw-services 51 | 52 | volumes: 53 | bw-data: 54 | clamav-data: 55 | 56 | networks: 57 | bw-universe: 58 | name: bw-universe 59 | ipam: 60 | driver: default 61 | config: 62 | - subnet: 10.20.30.0/24 63 | bw-services: 64 | name: bw-services 65 | -------------------------------------------------------------------------------- /.tests/coraza.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC1091 4 | . .tests/utils.sh 5 | 6 | echo "ℹ️ Starting Coraza tests ..." 7 | 8 | # Create working directory 9 | if [ -d /tmp/bunkerweb-plugins ] ; then 10 | do_and_check_cmd sudo rm -rf /tmp/bunkerweb-plugins 11 | fi 12 | do_and_check_cmd mkdir -p /tmp/bunkerweb-plugins/coraza/bw-data/plugins 13 | do_and_check_cmd cp -r ./coraza /tmp/bunkerweb-plugins/coraza/bw-data/plugins 14 | do_and_check_cmd sudo chown -R 101:101 /tmp/bunkerweb-plugins/coraza/bw-data 15 | do_and_check_cmd cp -r ./coraza/api /tmp/bunkerweb-plugins/coraza 16 | 17 | # Copy compose 18 | do_and_check_cmd cp .tests/coraza/docker-compose.yml /tmp/bunkerweb-plugins/coraza 19 | 20 | # Edit compose 21 | do_and_check_cmd sed -i "s@bunkerity/bunkerweb:.*\$@bunkerweb:tests@g" /tmp/bunkerweb-plugins/coraza/docker-compose.yml 22 | do_and_check_cmd sed -i "s@bunkerity/bunkerweb-scheduler:.*\$@bunkerweb-scheduler:tests@g" /tmp/bunkerweb-plugins/coraza/docker-compose.yml 23 | 24 | # Do the tests 25 | cd /tmp/bunkerweb-plugins/coraza/ || exit 1 26 | do_and_check_cmd docker compose up -d 27 | 28 | # Wait until BW is started 29 | echo "ℹ️ Waiting for BW ..." 30 | success="ko" 31 | retry=0 32 | while [ $retry -lt 60 ] ; do 33 | ret="$(curl -s -H "Host: www.example.com" http://localhost | grep -i "hello")" 34 | # shellcheck disable=SC2181 35 | if [ $? -eq 0 ] && [ "$ret" != "" ] ; then 36 | success="ok" 37 | break 38 | fi 39 | retry=$((retry + 1)) 40 | sleep 1 41 | done 42 | 43 | # We're done 44 | if [ $retry -eq 60 ] ; then 45 | docker compose logs 46 | docker compose down -v 47 | echo "❌ Error timeout after 60s" 48 | exit 1 49 | fi 50 | if [ "$success" == "ko" ] ; then 51 | docker compose logs 52 | docker compose down -v 53 | echo "❌ Error did not receive 200 code" 54 | exit 1 55 | fi 56 | 57 | # Payload in GET arg 58 | echo "ℹ️ Testing with GET payload ..." 59 | success="ko" 60 | ret="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" http://localhost/?id=/etc/passwd)" 61 | # shellcheck disable=SC2181 62 | if [ $? -eq 0 ] && [ "$ret" -eq 403 ] ; then 63 | success="ok" 64 | fi 65 | if [ "$success" == "ko" ] ; then 66 | docker compose logs 67 | docker compose down -v 68 | echo "❌ Error did not receive 403 code" 69 | exit 1 70 | fi 71 | 72 | # Payload in POST arg 73 | echo "ℹ️ Testing with POST payload ..." 74 | success="ko" 75 | ret="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" -X POST http://localhost/ -d 'id=/etc/passwd')" 76 | # shellcheck disable=SC2181 77 | if [ $? -eq 0 ] && [ "$ret" -eq 403 ] ; then 78 | success="ok" 79 | fi 80 | if [ "$success" == "ko" ] ; then 81 | docker compose logs 82 | docker compose down -v 83 | echo "❌ Error did not receive 403 code" 84 | exit 1 85 | fi 86 | 87 | # We're done 88 | if [ "$1" = "verbose" ] ; then 89 | docker compose logs 90 | fi 91 | docker compose down -v 92 | 93 | echo "ℹ️ Coraza tests done" 94 | -------------------------------------------------------------------------------- /.tests/coraza/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bunkerweb: 3 | image: bunkerity/bunkerweb:1.6.0-rc1 4 | ports: 5 | - 80:8080/tcp 6 | - 443:8443/tcp 7 | - 443:8443/udp 8 | environment: 9 | - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24 10 | networks: 11 | - bw-universe 12 | - bw-services 13 | 14 | bw-scheduler: 15 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 16 | depends_on: 17 | - bunkerweb 18 | volumes: 19 | - ./bw-data/plugins:/data/plugins 20 | environment: 21 | - BUNKERWEB_INSTANCES=bunkerweb 22 | - SERVER_NAME=www.example.com 23 | - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24 24 | - USE_CORAZA=yes 25 | - LOG_LEVEL=info 26 | - USE_BAD_BEHAVIOR=no 27 | - USE_LIMIT_REQ=no 28 | - USE_BUNKERNET=no 29 | - USE_BLACKLIST=no 30 | - USE_MODSECURITY=no 31 | - USE_REVERSE_PROXY=yes 32 | - REVERSE_PROXY_HOST=http://hello:8080 33 | - REVERSE_PROXY_URL=/ 34 | networks: 35 | - bw-universe 36 | 37 | bw-coraza: 38 | build: api 39 | networks: 40 | - bw-universe 41 | 42 | hello: 43 | image: nginxdemos/nginx-hello 44 | networks: 45 | - bw-services 46 | 47 | networks: 48 | bw-universe: 49 | name: bw-universe 50 | ipam: 51 | driver: default 52 | config: 53 | - subnet: 10.20.30.0/24 54 | bw-services: 55 | name: bw-services 56 | -------------------------------------------------------------------------------- /.tests/misc/json2md.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from io import StringIO 4 | from json import loads 5 | from glob import glob 6 | from pytablewriter import MarkdownTableWriter 7 | 8 | 9 | def print_md_table(settings) -> MarkdownTableWriter: 10 | writer = MarkdownTableWriter( 11 | headers=["Setting", "Default", "Context", "Multiple", "Description"], 12 | value_matrix=[ 13 | [ 14 | f"`{setting}`", 15 | "" if data["default"] == "" else f"`{data['default']}`", 16 | data["context"], 17 | "no" if "multiple" not in data else "yes", 18 | data["help"], 19 | ] 20 | for setting, data in settings.items() 21 | ], 22 | ) 23 | return writer 24 | 25 | 26 | def stream_support(support) -> str: 27 | md = "STREAM support " 28 | if support == "no": 29 | md += ":x:" 30 | elif support == "yes": 31 | md += ":white_check_mark:" 32 | else: 33 | md += ":warning:" 34 | return md 35 | 36 | 37 | doc = StringIO() 38 | 39 | # Print plugin settings 40 | core_settings = {} 41 | for core in glob("*/plugin.json"): 42 | with open(core, "r") as f: 43 | core_plugin = loads(f.read()) 44 | if len(core_plugin["settings"]) > 0: 45 | core_settings[core_plugin["name"]] = core_plugin 46 | 47 | for name, data in dict(sorted(core_settings.items())).items(): 48 | print(f"### {data['name']}\n", file=doc) 49 | print(f"{stream_support(data['stream'])}\n", file=doc) 50 | print(f"{data['description']}\n", file=doc) 51 | print(print_md_table(data["settings"]), file=doc) 52 | 53 | doc.seek(0) 54 | content = doc.read() 55 | doc = StringIO(content.replace("\\|", "|")) 56 | doc.seek(0) 57 | 58 | print(doc.read()) 59 | -------------------------------------------------------------------------------- /.tests/utils.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function do_and_check_cmd() { 4 | if [ "$CHANGE_DIR" != "" ] ; then 5 | cd "$CHANGE_DIR" || exit 1 6 | fi 7 | output=$("$@" 2>&1) 8 | ret="$?" 9 | if [ $ret -ne 0 ] ; then 10 | echo "❌ Error from command : $*" 11 | echo "$output" 12 | exit $ret 13 | fi 14 | #echo $output 15 | return 0 16 | } 17 | -------------------------------------------------------------------------------- /.tests/virustotal.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC1091 4 | . .tests/utils.sh 5 | 6 | echo "ℹ️ Starting VirusTotal tests ..." 7 | 8 | # Create working directory 9 | if [ -d /tmp/bunkerweb-plugins ] ; then 10 | do_and_check_cmd sudo rm -rf /tmp/bunkerweb-plugins 11 | fi 12 | do_and_check_cmd mkdir -p /tmp/bunkerweb-plugins/virustotal/bw-data/plugins 13 | do_and_check_cmd cp -r ./virustotal /tmp/bunkerweb-plugins/virustotal/bw-data/plugins 14 | do_and_check_cmd sudo chown -R 101:101 /tmp/bunkerweb-plugins/virustotal/bw-data 15 | 16 | # Copy compose 17 | do_and_check_cmd cp .tests/virustotal/docker-compose.yml /tmp/bunkerweb-plugins/virustotal 18 | 19 | # Edit compose 20 | do_and_check_cmd sed -i "s@bunkerity/bunkerweb:.*\$@bunkerweb:tests@g" /tmp/bunkerweb-plugins/virustotal/docker-compose.yml 21 | do_and_check_cmd sed -i "s@bunkerity/bunkerweb-scheduler:.*\$@bunkerweb-scheduler:tests@g" /tmp/bunkerweb-plugins/virustotal/docker-compose.yml 22 | do_and_check_cmd sed -i "s@%VTKEY%@${VIRUSTOTAL_API_KEY}@g" /tmp/bunkerweb-plugins/virustotal/docker-compose.yml 23 | 24 | # Download EICAR file 25 | do_and_check_cmd wget -O /tmp/bunkerweb-plugins/virustotal/eicar.com https://secure.eicar.org/eicar.com 26 | 27 | # Do the tests 28 | cd /tmp/bunkerweb-plugins/virustotal || exit 1 29 | do_and_check_cmd docker compose up --build -d 30 | 31 | # Wait until BW is started 32 | echo "ℹ️ Waiting for BW ..." 33 | success="ko" 34 | retry=0 35 | while [ $retry -lt 60 ] ; do 36 | ret="$(curl -s -H "Host: www.example.com" http://localhost | grep -i "hello")" 37 | # shellcheck disable=SC2181 38 | if [ $? -eq 0 ] && [ "$ret" != "" ] ; then 39 | success="ok" 40 | break 41 | fi 42 | retry=$((retry + 1)) 43 | sleep 1 44 | done 45 | 46 | # We're done 47 | if [ $retry -eq 60 ] ; then 48 | docker compose logs 49 | docker compose down -v 50 | echo "❌ Error timeout after 60s" 51 | exit 1 52 | fi 53 | if [ "$success" == "ko" ] ; then 54 | docker compose logs 55 | docker compose down -v 56 | echo "❌ Error did not receive 200 code" 57 | exit 1 58 | fi 59 | 60 | # Now check if BunkerWeb is giving a 403 61 | echo "ℹ️ Testing BW ..." 62 | success="ko" 63 | retry=0 64 | while [ $retry -lt 60 ] ; do 65 | ret="$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Host: www.example.com" -F "file=@/tmp/bunkerweb-plugins/virustotal/eicar.com" http://localhost)" 66 | # shellcheck disable=SC2181 67 | if [ $? -eq 0 ] && [ "$ret" -eq 403 ] ; then 68 | success="ok" 69 | break 70 | fi 71 | retry=$((retry + 1)) 72 | sleep 1 73 | done 74 | 75 | # We're done 76 | if [ $retry -eq 60 ] ; then 77 | docker compose logs 78 | docker compose down -v 79 | echo "❌ Error timeout after 60s" 80 | exit 1 81 | fi 82 | if [ "$success" == "ko" ] ; then 83 | docker compose logs 84 | docker compose down -v 85 | echo "❌ Error did not receive 403 code" 86 | exit 1 87 | fi 88 | if [ "$1" = "verbose" ] ; then 89 | docker compose logs 90 | fi 91 | docker compose down -v 92 | 93 | echo "ℹ️ VirusTotal tests done" 94 | -------------------------------------------------------------------------------- /.tests/virustotal/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bunkerweb: 3 | image: bunkerity/bunkerweb:1.6.0-rc1 4 | ports: 5 | - 80:8080/tcp 6 | - 443:8443/tcp 7 | - 443:8443/udp 8 | environment: 9 | - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24 10 | networks: 11 | - bw-universe 12 | - bw-services 13 | 14 | bw-scheduler: 15 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 16 | depends_on: 17 | - bunkerweb 18 | volumes: 19 | - ./bw-data/plugins:/data/plugins 20 | environment: 21 | - BUNKERWEB_INSTANCES=bunkerweb 22 | - SERVER_NAME=www.example.com 23 | - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24 24 | - USE_VIRUSTOTAL=yes 25 | - VIRUSTOTAL_API_KEY=%VTKEY% 26 | - LOG_LEVEL=info 27 | - USE_BAD_BEHAVIOR=no 28 | - USE_LIMIT_REQ=no 29 | - USE_LIMIT_CONN=no 30 | - USE_BUNKERNET=no 31 | - USE_BLACKLIST=no 32 | - USE_MODSECURITY=no 33 | - USE_REVERSE_PROXY=yes 34 | - REVERSE_PROXY_HOST=http://hello:8080 35 | - REVERSE_PROXY_URL=/ 36 | networks: 37 | - bw-universe 38 | 39 | hello: 40 | image: nginxdemos/nginx-hello 41 | networks: 42 | - bw-services 43 | 44 | volumes: 45 | bw-data: 46 | 47 | networks: 48 | bw-services: 49 | bw-universe: 50 | ipam: 51 | driver: default 52 | config: 53 | - subnet: 10.20.30.0/24 54 | -------------------------------------------------------------------------------- /COMPATIBILITY.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.3": ["1.5.0-beta"], 3 | "1.0": ["1.5.0"], 4 | "1.1": ["1.5.1"], 5 | "1.2": ["1.5.3"], 6 | "1.3": ["1.5.5"], 7 | "1.4": ["1.5.6"], 8 | "1.5": ["1.5.7", "1.5.8", "1.5.9", "1.5.10", "1.5.11", "1.5.12"], 9 | "1.6": ["1.5.7", "1.5.8", "1.5.9", "1.5.10", "1.5.11", "1.5.12"], 10 | "1.7": ["1.5.7", "1.5.8", "1.5.9", "1.5.10", "1.5.11", "1.5.12"], 11 | "1.8": [ 12 | "1.6.0-beta", 13 | "1.6.0-rc1", 14 | "1.6.0-rc2", 15 | "1.6.0-rc3", 16 | "1.6.0-rc4", 17 | "1.6.0" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to bunkerweb-plugins 2 | 3 | First off all, thanks for being here and showing your support to the project ! 4 | 5 | We accept many types of contributions whether they are technical or not. Every community feedback, work or help is, and will always be, appreciated. 6 | 7 | ## Talk about the project 8 | 9 | The first thing you can do is to talk about the project. You can share it on social media (by the way, you can can also follow us on [LinkedIn](https://www.linkedin.com/company/bunkerity/), [Twitter](https://twitter.com/bunkerity) and [GitHub](https://github.com/bunkerity)), make a blog post about it or simply tell your friends/colleagues that's an awesome project.. 10 | 11 | ## Join the community 12 | 13 | You can join the [Discord server](https://discord.com/invite/fTf46FmtyD), the [GitHub discussions](https://github.com/bunkerity/bunkerweb-plugins/discussions) and the [/r/BunkerWeb](https://www.reddit.com/r/BunkerWeb) subreddit to talk about the project and help others. 14 | 15 | ## Reporting bugs / ask for features 16 | 17 | The preferred way to report bugs and asking for features is using [issues](https://github.com/bunkerity/bunkerweb-plugins/issues). Before opening a new one, please check if a related issue is already opened using the "filters" bar. When creating a new issue please select and fill the "Bug report", "Feature request" or "New plugin" template. 18 | 19 | ## Code contribution 20 | 21 | The preferred way to contribute code is using [pull requests](https://github.com/bunkerity/bunkerweb-plugins/pulls). Before creating a pull request, please check if your code is related to an opened issue. If that's not the case, you should first create an issue so we can discuss about it. This procedure is here to avoid wasting your time in case the PR will be rejected. For minor changes (e.g. : typo, quick fix, ...), opening an issue might be facultative. **Don't forget to edit the documentations when needed !** 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | BunkerWeb logo 3 |

4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 |

13 | 14 | This repository contains "official" plugins for the [BunkerWeb solution](https://github.com/bunkerity/bunkerweb). If you don't already know BunkerWeb, you should first read the [documentation](https://docs.bunkerweb.io/?utm_campaign=self&utm_source=github). 15 | 16 | # Prerequisites 17 | 18 | The installation of external plugins is covered in the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the documentation. 19 | 20 | # Plugins 21 | 22 | Each plugin is located in a subdirectory of this repository. A README file located in each subdirectory contains documentation about the plugin. Here is the list : 23 | 24 | - [ClamAV](https://github.com/bunkerity/bunkerweb-plugins/tree/main/clamav) 25 | - [Coraza](https://github.com/bunkerity/bunkerweb-plugins/tree/main/coraza) 26 | - [Discord](https://github.com/bunkerity/bunkerweb-plugins/tree/main/discord) 27 | - [Slack](https://github.com/bunkerity/bunkerweb-plugins/tree/main/slack) 28 | - [VirusTotal](https://github.com/bunkerity/bunkerweb-plugins/tree/main/virustotal) 29 | - [WebHook](https://github.com/bunkerity/bunkerweb-plugins/tree/main/webhook) 30 | 31 | # Support 32 | 33 | ## Professional 34 | 35 | We offer professional services related to BunkerWeb like : 36 | 37 | - Consulting 38 | - Support 39 | - Custom development 40 | - Partnership 41 | 42 | Please contact us at contact \[@\] bunkerity.com if you are interested. 43 | 44 | ## Community 45 | 46 | To get free community support you can use the following media : 47 | 48 | - The #help channel of BunkerWeb in the [Discord server](https://bunkerity.discord.com/?utm_campaign=self&utm_source=github) 49 | - The help category of [GitHub discussions](https://github.com/bunkerity/bunkerweb-plugins/discussions) 50 | - The [/r/BunkerWeb](https://www.reddit.com/r/BunkerWeb) subreddit 51 | - The [Server Fault](https://serverfault.com/) and [Super User](https://superuser.com/) forums 52 | 53 | Please don't use [GitHub issues](https://github.com/bunkerity/bunkerweb-plugins/issues) to ask for help, use it only for bug reports and feature requests. 54 | 55 | # License 56 | 57 | This project is licensed under the terms of the [GNU Affero General Public License (AGPL) version 3](https://github.com/bunkerity/bunkerweb-plugins/tree/main/LICENSE.md). 58 | 59 | # Contribute 60 | 61 | If you would like to contribute to the plugins you can read the [contributing guidelines](https://github.com/bunkerity/bunkerweb-plugins/tree/main/CONTRIBUTING.md) to get started. 62 | 63 | # Security policy 64 | 65 | We take security bugs as serious issues and encourage responsible disclosure, see our [security policy](https://github.com/bunkerity/bunkerweb-plugins/tree/main/SECURITY.md) for more information. 66 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | Even though this project is focused on security, it is still prone to possible vulnerabilities. We consider every security bug as a serious issue and will try our best to address it. 4 | 5 | ## Responsible disclosure 6 | 7 | If you have found a security bug, please send us an email at security \[@\] bunkerity.com with technical details so we can resolve it as soon as possible. 8 | 9 | Here is a non-exhaustive list of issues we consider as high risk : 10 | 11 | - Vulnerability in the code 12 | - Bypass of a security feature 13 | - Vulnerability in a third-party dependency 14 | - Risk in the supply chain 15 | 16 | ## Bounty 17 | 18 | To encourage responsible disclosure, we may reward you with a bounty at the sole discretion of the maintainers. 19 | -------------------------------------------------------------------------------- /clamav/README.md: -------------------------------------------------------------------------------- 1 | # ClamAV plugin 2 | 3 |

4 | BunkerWeb ClamAV diagram 5 |

6 | 7 | This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically check if any uploaded file is detected by the ClamAV antivirus engine and deny the request if that's the case. 8 | 9 | # Table of contents 10 | 11 | - [ClamAV plugin](#clamav-plugin) 12 | - [Table of contents](#table-of-contents) 13 | - [Prerequisites](#prerequisites) 14 | - [Setup](#setup) 15 | - [Docker](#docker) 16 | - [Swarm](#swarm) 17 | - [Kubernetes](#kubernetes) 18 | - [Settings](#settings) 19 | - [TODO](#todo) 20 | 21 | # Prerequisites 22 | 23 | Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first. 24 | 25 | # Setup 26 | 27 | See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration. 28 | 29 | ## Docker 30 | 31 | ```yaml 32 | services: 33 | 34 | bunkerweb: 35 | image: bunkerity/bunkerweb:1.6.0-rc1 36 | ... 37 | networks: 38 | - bw-plugins 39 | ... 40 | 41 | bw-scheduler: 42 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 43 | ... 44 | environment: 45 | - USE_CLAMAV=yes 46 | - CLAMAV_HOST=clamav 47 | ... 48 | 49 | clamav: 50 | image: clamav/clamav:1.4 51 | volumes: 52 | - ./clamav-data:/var/lib/clamav 53 | networks: 54 | - bw-plugins 55 | 56 | networks: 57 | # BunkerWeb networks 58 | ... 59 | bw-plugins: 60 | name: bw-plugins 61 | ``` 62 | 63 | ## Swarm 64 | 65 | ```yaml 66 | services: 67 | 68 | bunkerweb: 69 | image: bunkerity/bunkerweb:1.6.0-rc1 70 | ... 71 | networks: 72 | - bw-plugins 73 | ... 74 | 75 | bw-scheduler: 76 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 77 | ... 78 | environment: 79 | - USE_CLAMAV=yes 80 | - CLAMAV_HOST=clamav 81 | ... 82 | 83 | clamav: 84 | image: clamav/clamav:1.4 85 | networks: 86 | - bw-plugins 87 | 88 | networks: 89 | # BunkerWeb networks 90 | ... 91 | bw-plugins: 92 | driver: overlay 93 | attachable: true 94 | name: bw-plugins 95 | ... 96 | ``` 97 | 98 | ## Kubernetes 99 | 100 | First you will need to deploy the dependencies : 101 | 102 | ```yaml 103 | apiVersion: apps/v1 104 | kind: Deployment 105 | metadata: 106 | name: bunkerweb-clamav 107 | spec: 108 | replicas: 1 109 | selector: 110 | matchLabels: 111 | app: bunkerweb-clamav 112 | template: 113 | metadata: 114 | labels: 115 | app: bunkerweb-clamav 116 | spec: 117 | containers: 118 | - name: bunkerweb-clamav 119 | image: clamav/clamav:1.4 120 | --- 121 | apiVersion: v1 122 | kind: Service 123 | metadata: 124 | name: svc-bunkerweb-clamav 125 | spec: 126 | selector: 127 | app: bunkerweb-clamav 128 | ports: 129 | - protocol: TCP 130 | port: 3310 131 | targetPort: 3310 132 | ``` 133 | 134 | Then you can configure the plugin : 135 | 136 | ```yaml 137 | apiVersion: networking.k8s.io/v1 138 | kind: Ingress 139 | metadata: 140 | name: ingress 141 | annotations: 142 | bunkerweb.io/USE_CLAMAV: "yes" 143 | bunkerweb.io/CLAMAV_HOST: "svc-bunkerweb-clamav.default.svc.cluster.local" 144 | ``` 145 | 146 | # Settings 147 | 148 | | Setting | Default | Context | Multiple | Description | 149 | | ---------------- | -------- | --------- | -------- | ------------------------------------------------------- | 150 | | `USE_CLAMAV` | `no` | multisite | no | Activate automatic scan of uploaded files with ClamAV. | 151 | | `CLAMAV_HOST` | `clamav` | global | no | ClamAV hostname or IP address. | 152 | | `CLAMAV_PORT` | `3310` | global | no | ClamAV port. | 153 | | `CLAMAV_TIMEOUT` | `1000` | global | no | Network timeout (in ms) when communicating with ClamAV. | 154 | 155 | # TODO 156 | 157 | - Test and document clustered mode 158 | - Custom ClamAV configuration 159 | - Document Linux integration 160 | -------------------------------------------------------------------------------- /clamav/clamav.lua: -------------------------------------------------------------------------------- 1 | local class = require("middleclass") 2 | local plugin = require("bunkerweb.plugin") 3 | local sha512 = require("resty.sha512") 4 | local str = require("resty.string") 5 | local upload = require("resty.upload") 6 | local utils = require("bunkerweb.utils") 7 | 8 | local clamav = class("clamav", plugin) 9 | 10 | local ngx = ngx 11 | local NOTICE = ngx.NOTICE 12 | local ERR = ngx.ERR 13 | local socket = ngx.socket 14 | local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR 15 | local HTTP_OK = ngx.HTTP_OK 16 | local to_hex = str.to_hex 17 | local has_variable = utils.has_variable 18 | local get_deny_status = utils.get_deny_status 19 | local tonumber = tonumber 20 | local floor = math.floor 21 | 22 | local stream_size = function(size) 23 | return ("%c%c%c%c") 24 | :format( 25 | size % 0x100, 26 | floor(size / 0x100) % 0x100, 27 | floor(size / 0x10000) % 0x100, 28 | floor(size / 0x1000000) % 0x100 29 | ) 30 | :reverse() 31 | end 32 | 33 | local read_all = function(form) 34 | while true do 35 | local typ = form:read() 36 | if not typ then 37 | return 38 | end 39 | if typ == "eof" then 40 | return 41 | end 42 | end 43 | end 44 | 45 | function clamav:initialize(ctx) 46 | -- Call parent initialize 47 | plugin.initialize(self, "clamav", ctx) 48 | end 49 | 50 | function clamav:init_worker() 51 | -- Check if worker is needed 52 | local init_needed, err = has_variable("USE_CLAMAV", "yes") 53 | if init_needed == nil then 54 | return self:ret(false, "can't check USE_CLAMAV variable : " .. err) 55 | end 56 | if not init_needed or self.is_loading then 57 | return self:ret(true, "init_worker not needed") 58 | end 59 | -- Send PING to ClamAV 60 | local ok, data = self:command("PING") 61 | if not ok then 62 | return self:ret(false, "connectivity with ClamAV failed : " .. data) 63 | end 64 | if data ~= "PONG" then 65 | return self:ret(false, "wrong data received from ClamAV : " .. data) 66 | end 67 | self.logger:log( 68 | NOTICE, 69 | "connectivity with " 70 | .. self.variables["CLAMAV_HOST"] 71 | .. ":" 72 | .. self.variables["CLAMAV_PORT"] 73 | .. " is successful" 74 | ) 75 | return self:ret(true, "success") 76 | end 77 | 78 | function clamav:access() 79 | -- Check if ClamAV is activated 80 | if self.variables["USE_CLAMAV"] ~= "yes" then 81 | return self:ret(true, "ClamAV plugin not enabled") 82 | end 83 | 84 | -- Check if we have downloads 85 | if 86 | not self.ctx.bw.http_content_type 87 | or ( 88 | not self.ctx.bw.http_content_type:match("boundary") 89 | or not self.ctx.bw.http_content_type:match("multipart/form%-data") 90 | ) 91 | then 92 | return self:ret(true, "no file upload detected") 93 | end 94 | 95 | -- Check files 96 | local ok, detected, checksum = self:scan() 97 | if not ok then 98 | return self:ret(false, "error while scanning file(s) : " .. detected) 99 | end 100 | if detected then 101 | return self:ret( 102 | true, 103 | "file with checksum " .. checksum .. "is detected : " .. detected, 104 | get_deny_status(), 105 | nil, 106 | { 107 | id = "detected", 108 | checksum = checksum, 109 | signature = detected, 110 | } 111 | ) 112 | end 113 | return self:ret(true, "no file detected") 114 | end 115 | 116 | function clamav:command(cmd) 117 | -- Get socket 118 | local clamav_socket, err = self:socket() 119 | if not clamav_socket then 120 | return false, err 121 | end 122 | -- Send command 123 | local bytes 124 | bytes, err = clamav_socket:send("n" .. cmd .. "\n") 125 | if not bytes then 126 | clamav_socket:close() 127 | return false, err 128 | end 129 | -- Receive response 130 | local data 131 | data, err = clamav_socket:receive("*l") 132 | if not data then 133 | clamav_socket:close() 134 | return false, err 135 | end 136 | clamav_socket:close() 137 | return true, data 138 | end 139 | 140 | function clamav:socket() 141 | -- Init socket 142 | local tcp_socket = socket.tcp() 143 | tcp_socket:settimeout(tonumber(self.variables["CLAMAV_TIMEOUT"])) 144 | local ok, err = tcp_socket:connect(self.variables["CLAMAV_HOST"], tonumber(self.variables["CLAMAV_PORT"])) 145 | if not ok then 146 | return false, err 147 | end 148 | return tcp_socket 149 | end 150 | 151 | function clamav:scan() 152 | -- Loop on files 153 | local form = upload:new(4096, 512, true) 154 | if not form then 155 | return false, "failed to create upload form" 156 | end 157 | local sha = sha512:new() 158 | local scan_socket = nil 159 | while true do 160 | -- Read part 161 | local typ, res, err = form:read() 162 | if not typ then 163 | if scan_socket then 164 | scan_socket:close() 165 | end 166 | return false, "form:read() failed : " .. err 167 | end 168 | 169 | local bytes 170 | 171 | -- Header case : check if we have a filename 172 | if typ == "header" then 173 | local found = false 174 | for _, header in ipairs(res) do 175 | if header:find('^.*filename="(.*)".*$') then 176 | found = true 177 | break 178 | end 179 | end 180 | if found then 181 | if scan_socket then 182 | scan_socket:close() 183 | end 184 | scan_socket, err = self:socket() 185 | if not scan_socket then 186 | read_all(form) 187 | return false, "socket failed : " .. err 188 | end 189 | bytes, err = scan_socket:send("nINSTREAM\n") 190 | if not bytes then 191 | scan_socket:close() 192 | read_all(form) 193 | return false, "socket:send() failed : " .. err 194 | end 195 | end 196 | -- Body case : update checksum and send to clamav 197 | elseif typ == "body" and scan_socket then 198 | sha:update(res) 199 | bytes, err = scan_socket:send(stream_size(#res) .. res) 200 | if not bytes then 201 | scan_socket:close() 202 | read_all(form) 203 | return false, "socket:send() failed : " .. err 204 | end 205 | -- Part end case : get final checksum and clamav result 206 | elseif typ == "part_end" and scan_socket then 207 | local checksum = to_hex(sha:final()) 208 | sha:reset() 209 | -- Check if file is in cache 210 | local ok, cached = self:is_in_cache(checksum) 211 | if not ok then 212 | self.logger:log( 213 | ngx.ERR, 214 | "can't check if file with checksum " .. checksum .. " is in cache : " .. cached 215 | ) 216 | elseif cached then 217 | scan_socket:close() 218 | scan_socket = nil 219 | if cached ~= "clean" then 220 | read_all(form) 221 | return true, cached, checksum 222 | end 223 | else 224 | -- End the INSTREAM 225 | bytes, err = scan_socket:send(stream_size(0)) 226 | if not bytes then 227 | scan_socket:close() 228 | read_all(form) 229 | return false, "socket:send() failed : " .. err 230 | end 231 | -- Read result 232 | local data 233 | data, err = scan_socket:receive("*l") 234 | if not data then 235 | scan_socket:close() 236 | read_all(form) 237 | return false, err 238 | end 239 | scan_socket:close() 240 | scan_socket = nil 241 | if data:match("^.*INSTREAM size limit exceeded.*$") then 242 | self.logger:log( 243 | ERR, 244 | "can't scan file with checksum " 245 | .. checksum 246 | .. " because size exceeded StreamMaxLength in clamd.conf" 247 | ) 248 | else 249 | -- luacheck: ignore iend 250 | local istart, iend 251 | istart, iend, data = data:find("^stream: (.*) FOUND$") 252 | local detected = "clean" 253 | if istart then 254 | detected = data 255 | end 256 | ok, err = self:add_to_cache(checksum, detected) 257 | if not ok then 258 | self.logger:log(ERR, "can't cache result : " .. err) 259 | end 260 | if detected ~= "clean" then 261 | read_all(form) 262 | return true, detected, checksum 263 | end 264 | end 265 | end 266 | -- End of body case : no file detected 267 | elseif typ == "eof" then 268 | if scan_socket then 269 | scan_socket:close() 270 | end 271 | return true 272 | end 273 | end 274 | -- luacheck: ignore 511 275 | return false, "malformed content" 276 | end 277 | 278 | function clamav:is_in_cache(checksum) 279 | local ok, data = self.cachestore:get("plugin_clamav_" .. checksum) 280 | if not ok then 281 | return false, data 282 | end 283 | return true, data 284 | end 285 | 286 | function clamav:add_to_cache(checksum, value) 287 | local ok, err = self.cachestore:set("plugin_clamav_" .. checksum, value, 86400) 288 | if not ok then 289 | return false, err 290 | end 291 | return true 292 | end 293 | 294 | function clamav:api() 295 | if self.ctx.bw.uri == "/clamav/ping" and self.ctx.bw.request_method == "POST" then 296 | -- Check clamav connection 297 | local check, err = has_variable("USE_CLAMAV", "yes") 298 | if check == nil then 299 | return self:ret(true, "error while checking variable USE_CLAMAV (" .. err .. ")") 300 | end 301 | if not check then 302 | return self:ret(true, "Clamav plugin not enabled") 303 | end 304 | 305 | -- Send PING to ClamAV 306 | local ok, data = self:command("PING") 307 | if not ok then 308 | return self:ret(true, "connectivity with ClamAV failed : " .. data, HTTP_INTERNAL_SERVER_ERROR) 309 | end 310 | if data ~= "PONG" then 311 | return self:ret(true, "wrong data received from ClamAV : " .. data, HTTP_INTERNAL_SERVER_ERROR) 312 | end 313 | return self:ret(true, "connectivity with ClamAV is successful", HTTP_OK) 314 | end 315 | return self:ret(false, "success") 316 | end 317 | 318 | return clamav 319 | -------------------------------------------------------------------------------- /clamav/docs/diagram.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /clamav/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "clamav", 3 | "name": "ClamAV", 4 | "description": "Automatic scan of uploaded files with ClamAV antivirus engine.", 5 | "version": "1.9", 6 | "stream": "no", 7 | "settings": { 8 | "USE_CLAMAV": { 9 | "context": "multisite", 10 | "default": "no", 11 | "help": "Activate automatic scan of uploaded files with ClamAV.", 12 | "id": "use-clamav", 13 | "label": "Use ClamAV", 14 | "regex": "^(yes|no)$", 15 | "type": "check" 16 | }, 17 | "CLAMAV_HOST": { 18 | "context": "global", 19 | "default": "clamav", 20 | "help": "ClamAV hostname or IP address.", 21 | "id": "clamav-host", 22 | "label": "ClamAV host", 23 | "regex": "^.*$", 24 | "type": "text" 25 | }, 26 | "CLAMAV_PORT": { 27 | "context": "global", 28 | "default": "3310", 29 | "help": "ClamAV port.", 30 | "id": "clamav-port", 31 | "label": "ClamAV port", 32 | "regex": "^.*$", 33 | "type": "text" 34 | }, 35 | "CLAMAV_TIMEOUT": { 36 | "context": "global", 37 | "default": "1000", 38 | "help": "Network timeout (in ms) when communicating with ClamAV.", 39 | "id": "clamav-timeout", 40 | "label": "Network timeout", 41 | "regex": "^.*$", 42 | "type": "text" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /clamav/ui/actions.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from traceback import format_exc 3 | 4 | 5 | def pre_render(**kwargs): 6 | logger = getLogger("UI") 7 | ret = { 8 | "ping_status": { 9 | "title": "CLAMAV STATUS", 10 | "value": "error", 11 | "col-size": "col-12 col-md-6", 12 | "card-classes": "h-100", 13 | }, 14 | } 15 | try: 16 | ping_data = kwargs["bw_instances_utils"].get_ping("clamav") 17 | ret["ping_status"]["value"] = ping_data["status"] 18 | except BaseException as e: 19 | logger.debug(format_exc()) 20 | logger.error(f"Failed to get clamav ping: {e}") 21 | ret["error"] = str(e) 22 | 23 | if "error" in ret: 24 | return ret 25 | 26 | return ret 27 | 28 | 29 | def clamav(**kwargs): 30 | pass 31 | -------------------------------------------------------------------------------- /coraza/README.md: -------------------------------------------------------------------------------- 1 | # Coraza plugin 2 | 3 |

4 | BunkerWeb Coraza diagram 5 |

6 | 7 | This [Plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) will act as a Library of rule that aim to detect and deny malicious requests 8 | 9 | # Table of contents 10 | 11 | - [Coraza plugin](#coraza-plugin) 12 | - [Table of contents](#table-of-contents) 13 | - [Setup](#setup) 14 | - [Docker/Swarm](#dockerswarm) 15 | - [Settings](#settings) 16 | - [TODO](#todo) 17 | 18 | # Setup 19 | 20 | See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration. 21 | 22 | ## Docker/Swarm 23 | 24 | ```yaml 25 | services: 26 | 27 | bunkerweb: 28 | image: bunkerity/bunkerweb:1.6.0-rc1 29 | ... 30 | networks: 31 | - bw-plugins 32 | ... 33 | 34 | bw-scheduler: 35 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 36 | ... 37 | environment: 38 | HTTP2: "no" # The Coraza plugin doesn't support HTTP2 yet 39 | USE_MODSECURITY: "no" # We don't need ModSecurity anymore 40 | USE_CORAZA: "yes" 41 | CORAZA_API: "http://bw-coraza:8080" # This is the address of the coraza container in the same network 42 | 43 | ... 44 | 45 | bw-coraza: 46 | image: bunkerity/bunkerweb-coraza:1.6.0-rc1 47 | networks: 48 | - bw-plugins 49 | 50 | networks: 51 | # BunkerWeb networks 52 | ... 53 | bw-plugins: 54 | name: bw-plugins 55 | ``` 56 | 57 | # Settings 58 | 59 | | Setting | Default | Context | Multiple | Description | 60 | | ------------ | ----------------------- | --------- | -------- | --------------------------- | 61 | | `USE_CORAZA` | `no` | multisite | no | Activate Coraza library | 62 | | `CORAZA_API` | `http://bw-coraza:8080` | global | no | hostname of the CORAZA API. | 63 | 64 | # TODO 65 | 66 | - Don't use API container 67 | - More documentation 68 | -------------------------------------------------------------------------------- /coraza/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine@sha256:04ec5618ca64098b8325e064aa1de2d3efbbd022a3ac5554d49d5ece99d41ad5 AS builder 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY go.mod main.go ./ 6 | RUN go get -d ./... 7 | RUN go mod download && go mod verify 8 | RUN go build -v -tags=coraza.rule.multiphase_evaluation -o /usr/local/bin/bw-coraza 9 | 10 | COPY --chmod=644 crs.sh . 11 | RUN apk add bash git && \ 12 | bash crs.sh Download 13 | 14 | FROM golang:1.23-alpine@sha256:04ec5618ca64098b8325e064aa1de2d3efbbd022a3ac5554d49d5ece99d41ad5 15 | 16 | COPY --from=builder --chown=0:0 /usr/local/bin/bw-coraza /usr/local/bin/bw-coraza 17 | 18 | RUN apk add --no-cache bash curl && \ 19 | addgroup -g 1000 coraza && \ 20 | adduser -h /usr/share/coraza -g coraza -s /bin/bash -G coraza -D -u 1000 coraza && \ 21 | mkdir -p /var/log/coraza /var/run/coraza /rules-before /rules-after && \ 22 | chown root:coraza /var/log/coraza /var/run/coraza /rules-before /rules-after && \ 23 | chmod 770 /var/log/coraza /var/run/coraza /rules-before /rules-after && \ 24 | ln -s /proc/1/fd/1 /var/log/coraza/coraza.log 25 | 26 | WORKDIR /usr/share/coraza 27 | 28 | COPY --from=builder --chown=0:1000 /usr/src/app/coreruleset ./coreruleset 29 | COPY --chown=0:1000 coraza.conf bunkerweb*.conf ./ 30 | COPY --chown=0:1000 --chmod=750 healthcheck.sh ./ 31 | 32 | # Fix CVEs 33 | RUN apk add --no-cache "libcrypto3>=3.3.1-r1" "libssl3>=3.3.1-r1" # CVE-2024-5535 34 | 35 | VOLUME /rules-before /rules-after 36 | 37 | EXPOSE 8080 38 | 39 | USER coraza:coraza 40 | 41 | HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=6 CMD /usr/share/coraza/healthcheck.sh 42 | 43 | CMD ["bw-coraza"] 44 | -------------------------------------------------------------------------------- /coraza/api/bunkerweb.conf: -------------------------------------------------------------------------------- 1 | SecAction \ 2 | "id:900120,\ 3 | phase:1,\ 4 | pass,\ 5 | t:none,\ 6 | nolog,\ 7 | setvar:tx.early_blocking=1" 8 | -------------------------------------------------------------------------------- /coraza/api/coraza.conf: -------------------------------------------------------------------------------- 1 | # -- Rule engine initialization ---------------------------------------------- 2 | 3 | # Enable Coraza, attaching it to every transaction. Use detection 4 | # only to start with, because that minimises the chances of post-installation 5 | # disruption. 6 | # 7 | SecRuleEngine On 8 | 9 | 10 | # -- Request body handling --------------------------------------------------- 11 | 12 | # Allow Coraza to access request bodies. If you don't, Coraza 13 | # won't be able to see any POST parameters, which opens a large security 14 | # hole for attackers to exploit. 15 | # 16 | SecRequestBodyAccess On 17 | 18 | # Enable XML request body parser. 19 | # Initiate XML Processor in case of xml content-type 20 | # 21 | SecRule REQUEST_HEADERS:Content-Type "^(?:application(?:/soap\+|/)|text/)xml" \ 22 | "id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML" 23 | 24 | # Enable JSON request body parser. 25 | # Initiate JSON Processor in case of JSON content-type; change accordingly 26 | # if your application does not use 'application/json' 27 | # 28 | SecRule REQUEST_HEADERS:Content-Type "^application/json" \ 29 | "id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" 30 | 31 | # Sample rule to enable JSON request body parser for more subtypes. 32 | # Uncomment or adapt this rule if you want to engage the JSON 33 | # Processor for "+json" subtypes 34 | # 35 | #SecRule REQUEST_HEADERS:Content-Type "^application/[a-z0-9.-]+[+]json" \ 36 | # "id:'200006',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" 37 | 38 | # Maximum request body size we will accept for buffering. If you support 39 | # file uploads then the value given on the first line has to be as large 40 | # as the largest file you are willing to accept. The second value refers 41 | # to the size of data, with files excluded. You want to keep that value as 42 | # low as practical. 43 | # 44 | SecRequestBodyLimit 13107200 45 | 46 | SecRequestBodyInMemoryLimit 131072 47 | 48 | SecRequestBodyNoFilesLimit 131072 49 | 50 | # What to do if the request body size is above our configured limit. 51 | # Keep in mind that this setting will automatically be set to ProcessPartial 52 | # when SecRuleEngine is set to DetectionOnly mode in order to minimize 53 | # disruptions when initially deploying Coraza. 54 | # 55 | SecRequestBodyLimitAction Reject 56 | 57 | # Verify that we've correctly processed the request body. 58 | # As a rule of thumb, when failing to process a request body 59 | # you should reject the request (when deployed in blocking mode) 60 | # or log a high-severity alert (when deployed in detection-only mode). 61 | # 62 | SecRule REQBODY_ERROR "!@eq 0" \ 63 | "id:'200002', phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.',logdata:'%{reqbody_error_msg}',severity:2" 64 | 65 | # By default be strict with what we accept in the multipart/form-data 66 | # request body. If the rule below proves to be too strict for your 67 | # environment consider changing it to detection-only. You are encouraged 68 | # _not_ to remove it altogether. 69 | # 70 | SecRule MULTIPART_STRICT_ERROR "!@eq 0" \ 71 | "id:'200003',phase:2,t:none,log,deny,status:400, \ 72 | msg:'Multipart request body failed strict validation: \ 73 | PE %{REQBODY_PROCESSOR_ERROR}, \ 74 | BQ %{MULTIPART_BOUNDARY_QUOTED}, \ 75 | BW %{MULTIPART_BOUNDARY_WHITESPACE}, \ 76 | DB %{MULTIPART_DATA_BEFORE}, \ 77 | DA %{MULTIPART_DATA_AFTER}, \ 78 | HF %{MULTIPART_HEADER_FOLDING}, \ 79 | LF %{MULTIPART_LF_LINE}, \ 80 | SM %{MULTIPART_MISSING_SEMICOLON}, \ 81 | IQ %{MULTIPART_INVALID_QUOTING}, \ 82 | IP %{MULTIPART_INVALID_PART}, \ 83 | IH %{MULTIPART_INVALID_HEADER_FOLDING}, \ 84 | FL %{MULTIPART_FILE_LIMIT_EXCEEDED}'" 85 | 86 | # Did we see anything that might be a boundary? 87 | # 88 | # Here is a short description about the Coraza Multipart parser: the 89 | # parser returns with value 0, if all "boundary-like" line matches with 90 | # the boundary string which given in MIME header. In any other cases it returns 91 | # with different value, eg. 1 or 2. 92 | # 93 | # The RFC 1341 descript the multipart content-type and its syntax must contains 94 | # only three mandatory lines (above the content): 95 | # * Content-Type: multipart/mixed; boundary=BOUNDARY_STRING 96 | # * --BOUNDARY_STRING 97 | # * --BOUNDARY_STRING-- 98 | # 99 | # First line indicates, that this is a multipart content, second shows that 100 | # here starts a part of the multipart content, third shows the end of content. 101 | # 102 | # If there are any other lines, which starts with "--", then it should be 103 | # another boundary id - or not. 104 | # 105 | # After 3.0.3, there are two kinds of types of boundary errors: strict and permissive. 106 | # 107 | # If multipart content contains the three necessary lines with correct order, but 108 | # there are one or more lines with "--", then parser returns with value 2 (non-zero). 109 | # 110 | # If some of the necessary lines (usually the start or end) misses, or the order 111 | # is wrong, then parser returns with value 1 (also a non-zero). 112 | # 113 | # You can choose, which one is what you need. The example below contains the 114 | # 'strict' mode, which means if there are any lines with start of "--", then 115 | # Coraza blocked the content. But the next, commented example contains 116 | # the 'permissive' mode, then you check only if the necessary lines exists in 117 | # correct order. With this, you can enable to upload PEM files (eg "----BEGIN.."), 118 | # or other text files, which contains eg. HTTP headers. 119 | # 120 | # The difference is only the operator - in strict mode (first) the content blocked 121 | # in case of any non-zero value. In permissive mode (second, commented) the 122 | # content blocked only if the value is explicit 1. If it 0 or 2, the content will 123 | # allowed. 124 | # 125 | 126 | # 127 | # See #1747 and #1924 for further information on the possible values for 128 | # MULTIPART_UNMATCHED_BOUNDARY. 129 | # 130 | SecRule MULTIPART_UNMATCHED_BOUNDARY "@eq 1" \ 131 | "id:'200004',phase:2,t:none,log,deny,msg:'Multipart parser detected a possible unmatched boundary.'" 132 | 133 | # Some internal errors will set flags in TX and we will need to look for these. 134 | # All of these are prefixed with "MSC_". The following flags currently exist: 135 | # 136 | # COR_PCRE_LIMITS_EXCEEDED: PCRE match limits were exceeded. 137 | # 138 | SecRule TX:/^COR_/ "!@streq 0" \ 139 | "id:'200005',phase:2,t:none,deny,msg:'Coraza internal error flagged: %{MATCHED_VAR_NAME}'" 140 | 141 | 142 | # -- Response body handling -------------------------------------------------- 143 | 144 | # Allow Coraza to access response bodies. 145 | # You should have this directive enabled in order to identify errors 146 | # and data leakage issues. 147 | # 148 | # Do keep in mind that enabling this directive does increases both 149 | # memory consumption and response latency. 150 | # 151 | SecResponseBodyAccess On 152 | 153 | # Which response MIME types do you want to inspect? You should adjust the 154 | # configuration below to catch documents but avoid static files 155 | # (e.g., images and archives). 156 | # 157 | SecResponseBodyMimeType text/plain text/html text/xml 158 | 159 | # Buffer response bodies of up to 512 KB in length. 160 | SecResponseBodyLimit 524288 161 | 162 | # What happens when we encounter a response body larger than the configured 163 | # limit? By default, we process what we have and let the rest through. 164 | # That's somewhat less secure, but does not break any legitimate pages. 165 | # 166 | SecResponseBodyLimitAction ProcessPartial 167 | 168 | 169 | # -- Filesystem configuration ------------------------------------------------ 170 | 171 | # The location where Coraza will keep its persistent data. This default setting 172 | # is chosen due to all systems have /tmp available however, it 173 | # too should be updated to a place that other users can't access. 174 | # 175 | SecDataDir /tmp/ 176 | 177 | 178 | # -- File uploads handling configuration ------------------------------------- 179 | 180 | # The location where Coraza stores intercepted uploaded files. This 181 | # location must be private to Coraza. You don't want other users on 182 | # the server to access the files, do you? 183 | # 184 | #SecUploadDir /opt/coraza/var/upload/ 185 | 186 | # By default, only keep the files that were determined to be unusual 187 | # in some way (by an external inspection script). For this to work you 188 | # will also need at least one file inspection rule. 189 | # 190 | #SecUploadKeepFiles RelevantOnly 191 | 192 | # Uploaded files are by default created with permissions that do not allow 193 | # any other user to access them. You may need to relax that if you want to 194 | # interface Coraza to an external program (e.g., an anti-virus). 195 | # 196 | #SecUploadFileMode 0600 197 | 198 | 199 | # -- Debug log configuration ------------------------------------------------- 200 | 201 | # Default debug log path 202 | # Debug levels: 203 | # 0: No logging (least verbose) 204 | # 1: Error 205 | # 2: Warn 206 | # 3: Info 207 | # 4-8: Debug 208 | # 9: Trace (most verbose) 209 | # Most logging has not been implemented because it will be replaced with 210 | # advanced rule profiling options 211 | #SecDebugLog /opt/coraza/var/log/debug.log 212 | #SecDebugLogLevel 3 213 | 214 | # -- Audit log configuration ------------------------------------------------- 215 | 216 | # Log the transactions that are marked by a rule, as well as those that 217 | # trigger a server error (determined by a 5xx or 4xx, excluding 404, 218 | # level response status codes). 219 | # 220 | SecAuditEngine RelevantOnly 221 | SecAuditLogRelevantStatus "^(?:(5|4)(0|1)[0-9])$" 222 | 223 | # Log everything we know about a transaction. 224 | SecAuditLogParts ABIJDEFHZ 225 | 226 | # Use a single file for logging. This is much easier to look at, but 227 | # assumes that you will use the audit log only occasionally. 228 | # 229 | SecAuditLogType Serial 230 | SecAuditLog /var/log/coraza/coraza.log 231 | 232 | # -- Miscellaneous ----------------------------------------------------------- 233 | 234 | # Use the most commonly used application/x-www-form-urlencoded parameter 235 | # separator. There's probably only one application somewhere that uses 236 | # something else so don't expect to change this value. 237 | # 238 | SecArgumentSeparator & 239 | 240 | # Settle on version 0 (zero) cookies, as that is what most applications 241 | # use. Using an incorrect cookie version may open your installation to 242 | # evasion attacks (against the rules that examine named cookies). 243 | # 244 | SecCookieFormat 0 245 | -------------------------------------------------------------------------------- /coraza/api/crs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function git_secure_clone() { 4 | repo="$1" 5 | commit="$2" 6 | folder="$(echo "$repo" | sed -E "s@https://github.com/.*/(.*)\.git@\1@")" 7 | if [ ! -d "${folder}" ] ; then 8 | output="$(git clone "$repo" "${folder}" 2>&1)" 9 | # shellcheck disable=SC2181 10 | if [ $? -ne 0 ] ; then 11 | echo "❌ Error cloning $1" 12 | echo "$output" 13 | exit 1 14 | fi 15 | old_dir="$(pwd)" 16 | cd "${folder}" || exit 1 17 | output="$(git checkout "${commit}^{commit}" 2>&1)" 18 | # shellcheck disable=SC2181 19 | if [ $? -ne 0 ] ; then 20 | echo "❌ Commit hash $commit is absent from repository $repo" 21 | echo "$output" 22 | exit 1 23 | fi 24 | cd "$old_dir" || exit 1 25 | output="$(rm -rf "${folder}/.git")" 26 | # shellcheck disable=SC2181 27 | if [ $? -ne 0 ] ; then 28 | echo "❌ Can't delete .git from repository $repo" 29 | echo "$output" 30 | exit 1 31 | fi 32 | else 33 | echo "⚠️ Skipping clone of $repo because target directory is already present" 34 | fi 35 | } 36 | 37 | function do_and_check_cmd() { 38 | if [ "$CHANGE_DIR" != "" ] ; then 39 | cd "$CHANGE_DIR" || exit 1 40 | fi 41 | output=$("$@" 2>&1) 42 | ret="$?" 43 | # shellcheck disable=SC2181 44 | if [ $ret -ne 0 ] ; then 45 | echo "❌ Error from command : $*" 46 | echo "$output" 47 | exit $ret 48 | fi 49 | #echo $output 50 | return 0 51 | } 52 | 53 | function remove_coreruleset(){ 54 | dir="coreruleset" 55 | if [ -d "$dir" ] ; then 56 | output="$(rm -rf $dir)" 57 | echo "$output" 58 | exit 1 59 | else 60 | echo "⚠️ Skipping remove of $dir because target directory do not exist" 61 | fi 62 | } 63 | 64 | # CRS v4 65 | echo "ℹ️ Download CRS or Remove CRS" 66 | if [[ "$1" == "Remove" ]]; then 67 | remove_coreruleset 68 | elif [[ "$1" == "Download" ]]; then 69 | git_secure_clone "https://github.com/coreruleset/coreruleset.git" "23196d6a8b3ee2b668bbc26750954501342bfee4" # v4.10.0 70 | else 71 | echo "❌ Error wrong argument : $1 try Remove or Download" 72 | fi 73 | -------------------------------------------------------------------------------- /coraza/api/go.mod: -------------------------------------------------------------------------------- 1 | module bw-coraza 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/corazawaf/coraza/v3 v3.3.2 7 | github.com/gorilla/handlers v1.5.2 8 | github.com/gorilla/mux v1.8.1 9 | ) 10 | 11 | require ( 12 | github.com/corazawaf/libinjection-go v0.2.2 // indirect 13 | github.com/felixge/httpsnoop v1.0.4 // indirect 14 | github.com/magefile/mage v1.15.0 // indirect 15 | github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 // indirect 16 | github.com/tidwall/gjson v1.18.0 // indirect 17 | github.com/tidwall/match v1.1.1 // indirect 18 | github.com/tidwall/pretty v1.2.1 // indirect 19 | golang.org/x/net v0.34.0 // indirect 20 | golang.org/x/sync v0.10.0 // indirect 21 | rsc.io/binaryregexp v0.2.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /coraza/api/healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -f /var/run/coraza/coraza.pid ] ; then 4 | exit 1 5 | fi 6 | 7 | check="$(curl -s -H "Host: healthcheck.bw-coraza.io" http://127.0.0.1:8080/ping 2>&1)" 8 | # shellcheck disable=SC2181 9 | if [ $? -ne 0 ] || [ "$check" != '{"pong":"ok"}' ] ; then 10 | exit 1 11 | fi 12 | 13 | exit 0 14 | -------------------------------------------------------------------------------- /coraza/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "strconv" 13 | "time" 14 | "github.com/corazawaf/coraza/v3" 15 | "github.com/corazawaf/coraza/v3/types" 16 | "github.com/gorilla/mux" 17 | "github.com/gorilla/handlers" 18 | ) 19 | 20 | var ( 21 | InfoLogger *log.Logger 22 | WarningLogger *log.Logger 23 | ErrorLogger *log.Logger 24 | ) 25 | 26 | type Pong struct { 27 | Pong string `json:"pong"` 28 | } 29 | 30 | type Resp struct { 31 | Deny bool `json:"deny"` 32 | Msg string `json:"msg"` 33 | } 34 | 35 | var waf coraza.WAF 36 | 37 | func processInterruption(w http.ResponseWriter, tx types.Transaction, it *types.Interruption) { 38 | action := it.Action 39 | ruleid := it.RuleID 40 | rules := tx.MatchedRules() 41 | txid := tx.ID() 42 | 43 | for _, rule := range rules { 44 | if rule.Message() != "" { 45 | WarningLogger.Printf(rule.AuditLog()) 46 | } 47 | } 48 | 49 | switch action { 50 | case "block", "deny", "drop", "redirect", "reject": 51 | WarningLogger.Printf("[%s] %s action from rule ID %d", txid, action, ruleid) 52 | data := Resp{ 53 | Deny: true, 54 | Msg: fmt.Sprintf("%s action from rule ID %d", action, ruleid), 55 | } 56 | json.NewEncoder(w).Encode(data) 57 | return 58 | case "allow": 59 | InfoLogger.Printf("[%s] %s action from rule ID %d", txid, action, ruleid) 60 | data := Resp{ 61 | Deny: false, 62 | Msg: fmt.Sprintf("allow action from rule ID %d", ruleid), 63 | } 64 | json.NewEncoder(w).Encode(data) 65 | return 66 | } 67 | ErrorLogger.Printf("[%s] Unknown %s action from rule ID %d", txid, action, ruleid) 68 | } 69 | 70 | func handlePing(w http.ResponseWriter, req *http.Request) { 71 | InfoLogger.Printf("Ping received") 72 | data := Pong{ 73 | Pong: "ok", 74 | } 75 | json.NewEncoder(w).Encode(data) 76 | } 77 | 78 | func handleRequest(w http.ResponseWriter, req *http.Request) { 79 | InfoLogger.Printf("Request received") 80 | version := req.Header.Get("X-Coraza-Version") 81 | method := req.Header.Get("X-Coraza-Method") 82 | ip := req.Header.Get("X-Coraza-Ip") 83 | txid := req.Header.Get("X-Coraza-Id") 84 | uri := req.Header.Get("X-Coraza-Uri") 85 | 86 | InfoLogger.Printf("[%s] Processing request with ip=%s, uri=%s, method=%s and version=%s", txid, ip, uri, method, version) 87 | tx := waf.NewTransactionWithID(txid) 88 | defer func() { 89 | tx.ProcessLogging() 90 | if err := tx.Close(); err != nil { 91 | ErrorLogger.Printf("[%s] Failed to close transaction : %s", txid, err.Error()) 92 | } 93 | }() 94 | if tx.IsRuleEngineOff() { 95 | InfoLogger.Printf("[%s] Rule engine is set to off", txid) 96 | data := Resp{ 97 | Deny: false, 98 | Msg: "rule engine is set to off", 99 | } 100 | json.NewEncoder(w).Encode(data) 101 | return 102 | } 103 | 104 | InfoLogger.Printf("[%s] Processing phase 1", txid) 105 | 106 | tx.ProcessConnection(ip, 42000, "", 0) 107 | tx.ProcessURI(uri, method, version) 108 | for name, values := range req.Header { 109 | if strings.HasPrefix(name, "X-Coraza-Header-") { 110 | for _, value := range values { 111 | tx.AddRequestHeader(strings.Replace(name, "X-Coraza-Header-", "", 1), value) 112 | } 113 | } 114 | } 115 | if it := tx.ProcessRequestHeaders(); it != nil { 116 | processInterruption(w, tx, it) 117 | return 118 | } 119 | InfoLogger.Printf("[%s] Processing phase 2", txid) 120 | var bodyreason = "" 121 | if !tx.IsRequestBodyAccessible() { 122 | bodyreason = "RequestBodyAccess disabled" 123 | } 124 | 125 | if req.Body == nil || req.Body == http.NoBody { 126 | bodyreason = "no body" 127 | } 128 | if bodyreason == "" { 129 | InfoLogger.Printf("[%s] Reading body", txid) 130 | 131 | bodyBytes, err := ioutil.ReadAll(req.Body) 132 | if err != nil { 133 | ErrorLogger.Printf("[%s] Error while reading body : %s", txid, err) 134 | w.WriteHeader(http.StatusInternalServerError) 135 | return 136 | } 137 | 138 | bodyString := string(bodyBytes) 139 | 140 | it, _, err := tx.ReadRequestBodyFrom(strings.NewReader(bodyString)) 141 | if it != nil { 142 | processInterruption(w, tx, it) 143 | return 144 | } 145 | 146 | if it != nil { 147 | processInterruption(w, tx, it) 148 | return 149 | } 150 | 151 | if err != nil { 152 | ErrorLogger.Printf("[%s] Failed to append request body : %s", txid, err.Error()) 153 | w.WriteHeader(http.StatusInternalServerError) 154 | return 155 | } 156 | 157 | rbr, err := tx.RequestBodyReader() 158 | if err != nil { 159 | ErrorLogger.Printf("Failed to get the request body: %s", err.Error()) 160 | w.WriteHeader(http.StatusInternalServerError) 161 | return 162 | } 163 | body := io.MultiReader(rbr, req.Body) 164 | if rwt, ok := body.(io.WriterTo); ok { 165 | req.Body = struct { 166 | io.Reader 167 | io.WriterTo 168 | io.Closer 169 | }{body, rwt, req.Body} 170 | } 171 | } else { 172 | InfoLogger.Printf("[%s] Not reading body (%s)", txid, bodyreason) 173 | } 174 | it, err := tx.ProcessRequestBody() 175 | if it != nil { 176 | processInterruption(w, tx, it) 177 | return 178 | } 179 | if err != nil { 180 | ErrorLogger.Printf("[%s] Failed to process request body : %s", txid, err.Error()) 181 | w.WriteHeader(http.StatusInternalServerError) 182 | return 183 | } 184 | InfoLogger.Printf("[%s] Request processed without action", txid) 185 | data := Resp{ 186 | Deny: false, 187 | Msg: "pass", 188 | } 189 | json.NewEncoder(w).Encode(data) 190 | } 191 | 192 | func loggingMiddleware(next http.Handler) http.Handler { 193 | return handlers.LoggingHandler(os.Stdout, next) 194 | } 195 | 196 | func main() { 197 | InfoLogger = log.New(os.Stdout, "INFO: ", log.LstdFlags) 198 | WarningLogger = log.New(os.Stdout, "WARNING: ", log.LstdFlags) 199 | ErrorLogger = log.New(os.Stdout, "ERROR: ", log.LstdFlags) 200 | var err error 201 | waf, err = coraza.NewWAF( 202 | coraza.NewWAFConfig(). 203 | WithDirectivesFromFile("coraza.conf"). 204 | WithDirectivesFromFile("bunkerweb.conf"). 205 | WithDirectivesFromFile("/rules-before/*.conf"). 206 | WithDirectivesFromFile("coreruleset/crs-setup.conf.example"). 207 | WithDirectivesFromFile("coreruleset/rules/*.conf"). 208 | WithDirectivesFromFile("/rules-after/*.conf")) 209 | if err != nil { 210 | ErrorLogger.Printf("Error while initializing Coraza : %s", err.Error()) 211 | os.Exit(1) 212 | } 213 | r := mux.NewRouter() 214 | r.HandleFunc("/ping", handlePing) 215 | r.HandleFunc("/request", handleRequest) 216 | r.Use(loggingMiddleware) 217 | r.NotFoundHandler = r.NewRoute().HandlerFunc(http.NotFound).GetHandler() 218 | 219 | // Write the .pid file 220 | pid := os.Getpid() 221 | pidStr := strconv.Itoa(pid) 222 | pidFilePath := "/var/run/coraza/coraza.pid" 223 | err = os.WriteFile(pidFilePath, []byte(pidStr), 0644) 224 | if err != nil { 225 | log.Fatalf("Failed to write PID file: %s", err) 226 | } 227 | 228 | // Schedule removal of the .pid file upon exit 229 | defer func() { 230 | if removeErr := os.Remove(pidFilePath); removeErr != nil { 231 | log.Printf("Failed to remove PID file: %s", removeErr) 232 | } 233 | }() 234 | 235 | InfoLogger.Printf("Coraza API is ready to handle requests") 236 | srv := &http.Server{ 237 | Handler: r, 238 | Addr: "0.0.0.0:8080", 239 | WriteTimeout: 15 * time.Second, 240 | ReadTimeout: 15 * time.Second, 241 | } 242 | srv.ListenAndServe() 243 | } 244 | -------------------------------------------------------------------------------- /coraza/confs/server-http/coraza.conf: -------------------------------------------------------------------------------- 1 | location = /bw/coraza { 2 | internal; 3 | set $coraza_api "{{ CORAZA_API }}/request"; 4 | proxy_pass $coraza_api; 5 | } 6 | -------------------------------------------------------------------------------- /coraza/coraza.lua: -------------------------------------------------------------------------------- 1 | local cjson = require("cjson") 2 | local class = require("middleclass") 3 | local http = require("resty.http") 4 | local plugin = require("bunkerweb.plugin") 5 | local utils = require("bunkerweb.utils") 6 | 7 | local coraza = class("coraza", plugin) 8 | 9 | local ngx = ngx 10 | local ngx_req = ngx.req 11 | local ERR = ngx.ERR 12 | local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR 13 | local HTTP_OK = ngx.HTTP_OK 14 | local http_new = http.new 15 | local has_variable = utils.has_variable 16 | local get_deny_status = utils.get_deny_status 17 | local rand = utils.rand 18 | local tostring = tostring 19 | local decode = cjson.decode 20 | local open = io.open 21 | local coroutine_create = coroutine.create 22 | local coroutine_yield = coroutine.yield 23 | local coroutine_resume = coroutine.resume 24 | 25 | function coraza:initialize(ctx) 26 | -- Call parent initialize 27 | plugin.initialize(self, "coraza", ctx) 28 | end 29 | 30 | function coraza:init_worker() 31 | -- Check if needed 32 | if not self:is_needed() then 33 | return self:ret(true, "coraza not activated") 34 | end 35 | -- Send ping request 36 | local ok, data = self:ping() 37 | if not ok then 38 | return self:ret(false, "error while sending ping request to " .. self.variables["CORAZA_API"] .. " : " .. data) 39 | end 40 | return self:ret(true, "ping request to " .. self.variables["CORAZA_API"] .. " is successful") 41 | end 42 | 43 | function coraza:access() 44 | -- Check if needed 45 | if not self:is_needed() then 46 | return self:ret(true, "coraza not activated") 47 | end 48 | -- Process phases 1 (headers) and 2 (body) 49 | 50 | local ok, deny, data = self:process_request() 51 | if not ok then 52 | return self:ret(false, "error while processing request : " .. deny) 53 | end 54 | if deny then 55 | return self:ret(true, "coraza denied request : " .. data, get_deny_status(), nil, { id = "raw", data = data }) 56 | end 57 | 58 | return self:ret(true, "coraza accepted request") 59 | end 60 | 61 | function coraza:ping() 62 | -- Get http object 63 | local httpc, err = http_new() 64 | if not httpc then 65 | return false, err 66 | end 67 | httpc:set_timeout(1000) 68 | -- Send ping 69 | local res 70 | res, err = httpc:request_uri(self.variables["CORAZA_API"] .. "/ping", { keepalive = false }) 71 | if not res then 72 | return false, err 73 | end 74 | -- Check status 75 | if res.status ~= 200 then 76 | err = "received status " .. tostring(res.status) .. " from Coraza API" 77 | local ok, data = pcall(decode, res.body) 78 | if ok then 79 | err = err .. " with data " .. data 80 | end 81 | return false, err 82 | end 83 | -- Get pong 84 | local ok, data = pcall(decode, res.body) 85 | if not ok then 86 | return false, data 87 | end 88 | if data.pong == nil then 89 | return false, "malformed json response" 90 | end 91 | return true 92 | end 93 | 94 | function coraza:process_request() 95 | -- Instantiate lua-resty-http obj 96 | local httpc, err = http_new() 97 | if not httpc then 98 | return false, err 99 | end 100 | -- Variables to pass to coraza 101 | local data = { 102 | ["X-Coraza-Version"] = self.ctx.bw.http_version, 103 | ["X-Coraza-Method"] = self.ctx.bw.request_method, 104 | ["X-Coraza-Ip"] = self.ctx.bw.remote_addr, 105 | ["X-Coraza-Id"] = rand(16), 106 | ["X-Coraza-Uri"] = self.ctx.bw.request_uri, 107 | } 108 | -- Compute headers 109 | local headers 110 | headers, err = ngx_req.get_headers() 111 | if err == "truncated" then 112 | return true, true, "too many headers" 113 | end 114 | for header, value in pairs(headers) do 115 | data["X-Coraza-Header-" .. header] = value 116 | end 117 | -- Body setup 118 | ngx_req.read_body() 119 | local body = ngx_req.get_body_data() 120 | if not body then 121 | local file = ngx_req.get_body_file() 122 | if file then 123 | local handle 124 | -- luacheck: ignore err 125 | handle, err = open(file) 126 | if handle then 127 | data["Content-Length"] = tostring(handle:seek("end")) 128 | handle:close() 129 | end 130 | local fbody = function() 131 | handle, err = open(file) 132 | if not handle then 133 | return nil, err 134 | end 135 | local cbody = function() 136 | while true do 137 | local chunk = handle:read(8192) 138 | if not chunk then 139 | break 140 | end 141 | coroutine_yield(chunk) 142 | end 143 | handle:close() 144 | end 145 | local co = coroutine_create(cbody) 146 | return function(...) 147 | local ok, ret = coroutine_resume(co, ...) 148 | if ok then 149 | return ret 150 | end 151 | return nil, ret 152 | end 153 | end 154 | body = fbody() 155 | end 156 | end 157 | local res, err = httpc:request_uri(self.variables["CORAZA_API"] .. "/request", { 158 | method = "POST", 159 | headers = data, 160 | body = body, 161 | }) 162 | if not res then 163 | return false, err 164 | end 165 | -- Check status 166 | if res.status ~= 200 then 167 | local err = "received status " .. tostring(res.status) .. " from Coraza API" 168 | local ok 169 | ok, data = pcall(decode, res.body) 170 | if ok then 171 | err = err .. " with data " .. data 172 | end 173 | return false, err 174 | end 175 | -- Get result 176 | local ok 177 | ok, data = pcall(decode, res.body) 178 | if not ok then 179 | return false, data 180 | end 181 | if data.deny == nil or not data.msg then 182 | return false, "malformed json response" 183 | end 184 | return true, data.deny, data.msg 185 | end 186 | 187 | function coraza:is_needed() 188 | -- Loading case 189 | if self.is_loading then 190 | return false 191 | end 192 | -- Request phases (no default) 193 | if self.is_request and (self.ctx.bw.server_name ~= "_") then 194 | return self.variables["USE_CORAZA"] == "yes" and not ngx_req.is_internal() 195 | end 196 | -- Other cases : at least one service uses it 197 | local is_needed, err = has_variable("USE_CORAZA", "yes") 198 | if is_needed == nil then 199 | self.logger:log(ERR, "can't check USE_CORAZA variable : " .. err) 200 | end 201 | return is_needed 202 | end 203 | 204 | function coraza:api() 205 | if self.ctx.bw.uri == "/coraza/ping" and self.ctx.bw.request_method == "POST" then 206 | -- Check coraza connection 207 | local check, err = has_variable("USE_CORAZA", "yes") 208 | if check == nil then 209 | return self:ret(true, "error while checking variable USE_CORAZA (" .. err .. ")") 210 | end 211 | if not check then 212 | return self:ret(true, "Coraza plugin not enabled") 213 | end 214 | 215 | -- Send ping request 216 | local ok, data = self:ping() 217 | if not ok then 218 | return self:ret( 219 | true, 220 | "error while sending ping request to " .. self.variables["CORAZA_API"] .. " : " .. data, 221 | HTTP_INTERNAL_SERVER_ERROR 222 | ) 223 | end 224 | return self:ret(true, "ping request is successful", HTTP_OK) 225 | end 226 | return self:ret(false, "success") 227 | end 228 | 229 | return coraza 230 | -------------------------------------------------------------------------------- /coraza/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "coraza", 3 | "name": "Coraza", 4 | "description": "Use Coraza as a library to inspect client request.", 5 | "version": "1.9", 6 | "stream": "no", 7 | "settings": { 8 | "USE_CORAZA": { 9 | "context": "multisite", 10 | "default": "no", 11 | "help": "Activate Coraza library", 12 | "id": "use coraza library", 13 | "label": "Use coraza", 14 | "regex": "^(no|yes)$", 15 | "type": "check" 16 | }, 17 | "CORAZA_API": { 18 | "context": "global", 19 | "default": "http://bw-coraza:8080", 20 | "help": "hostname of the CORAZA API.", 21 | "id": "coraza-api", 22 | "label": "Coraza Api", 23 | "regex": "^.*$", 24 | "type": "text" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /coraza/ui/actions.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from traceback import format_exc 3 | 4 | 5 | def pre_render(**kwargs): 6 | logger = getLogger("UI") 7 | ret = { 8 | "ping_status": { 9 | "title": "CORAZA STATUS", 10 | "value": "error", 11 | "col-size": "col-12 col-md-6", 12 | "card-classes": "h-100", 13 | }, 14 | } 15 | try: 16 | ping_data = kwargs["bw_instances_utils"].get_ping("coraza") 17 | ret["ping_status"]["value"] = ping_data["status"] 18 | except BaseException as e: 19 | logger.debug(format_exc()) 20 | logger.error(f"Failed to get coraza ping: {e}") 21 | ret["error"] = str(e) 22 | 23 | if "error" in ret: 24 | return ret 25 | 26 | return ret 27 | 28 | 29 | def coraza(**kwargs): 30 | pass 31 | -------------------------------------------------------------------------------- /discord/README.md: -------------------------------------------------------------------------------- 1 | # Discord plugin 2 | 3 |

4 | BunkerWeb Discord diagram 5 |

6 | 7 | This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send you attack notifications on a Discord channel of your choice using a webhook. 8 | 9 | # Table of contents 10 | 11 | - [Discord plugin](#discord-plugin) 12 | - [Table of contents](#table-of-contents) 13 | - [Prerequisites](#prerequisites) 14 | - [Setup](#setup) 15 | - [Docker](#docker) 16 | - [Swarm](#swarm) 17 | - [Kubernetes](#kubernetes) 18 | - [Settings](#settings) 19 | - [TODO](#todo) 20 | 21 | # Prerequisites 22 | 23 | Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first. 24 | 25 | You will need to setup a Discord webhook URL, you will find more information [here](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). 26 | 27 | # Setup 28 | 29 | See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration. 30 | 31 | There is no additional services to setup besides the plugin itself. 32 | 33 | ## Docker 34 | 35 | ```yaml 36 | services: 37 | 38 | bw-scheduler: 39 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 40 | ... 41 | environment: 42 | - USE_DISCORD=yes 43 | - DISCORD_WEBHOOK_URL=https://discordapp.com/api/webhooks/... 44 | ... 45 | ``` 46 | 47 | ## Swarm 48 | 49 | ```yaml 50 | services: 51 | 52 | bw-scheduler: 53 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 54 | ... 55 | environment: 56 | - USE_DISCORD=yes 57 | - DISCORD_WEBHOOK_URL=https://discordapp.com/api/webhooks/... 58 | ... 59 | networks: 60 | - bw-plugins 61 | ... 62 | 63 | networks: 64 | bw-plugins: 65 | driver: overlay 66 | attachable: true 67 | name: bw-plugins 68 | ... 69 | ``` 70 | 71 | ## Kubernetes 72 | 73 | ```yaml 74 | apiVersion: networking.k8s.io/v1 75 | kind: Ingress 76 | metadata: 77 | name: ingress 78 | annotations: 79 | bunkerweb.io/USE_DISCORD: "yes" 80 | bunkerweb.io/DISCORD_WEBHOOK_URL: "https://discordapp.com/api/webhooks/..." 81 | ``` 82 | 83 | # Settings 84 | 85 | | Setting | Default | Context | Multiple | Description | 86 | | -------------------------- | ----------------------------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------- | 87 | | `USE_DISCORD` | `no` | multisite | no | Enable sending alerts to a Discord channel. | 88 | | `DISCORD_WEBHOOK_URL` | `https://discordapp.com/api/webhooks/...` | global | no | Address of the Discord Webhook. | 89 | | | 90 | | `DISCORD_RETRY_IF_LIMITED` | `no` | global | no | Retry to send the request if Discord API is rate limiting us (may consume a lot of resources). | 91 | 92 | # TODO 93 | 94 | - Add more info in notification : 95 | - Date 96 | - Country of IP 97 | - ASN of IP 98 | - ... 99 | - Add settings to control what details to send : 100 | - Anonymize IP 101 | - Add body 102 | - Add headers 103 | -------------------------------------------------------------------------------- /discord/discord.lua: -------------------------------------------------------------------------------- 1 | local cjson = require("cjson") 2 | local class = require("middleclass") 3 | local http = require("resty.http") 4 | local plugin = require("bunkerweb.plugin") 5 | local utils = require("bunkerweb.utils") 6 | 7 | local discord = class("discord", plugin) 8 | 9 | local ngx = ngx 10 | local ngx_req = ngx.req 11 | local ERR = ngx.ERR 12 | local WARN = ngx.WARN 13 | local INFO = ngx.INFO 14 | local ngx_timer = ngx.timer 15 | local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR 16 | local HTTP_TOO_MANY_REQUESTS = ngx.HTTP_TOO_MANY_REQUESTS 17 | local HTTP_OK = ngx.HTTP_OK 18 | local http_new = http.new 19 | local has_variable = utils.has_variable 20 | local get_variable = utils.get_variable 21 | local get_reason = utils.get_reason 22 | local tostring = tostring 23 | local len = string.len 24 | local sub = string.sub 25 | local format = string.format 26 | local encode = cjson.encode 27 | local floor = math.floor 28 | local date = os.date 29 | 30 | function discord:initialize(ctx) 31 | -- Call parent initialize 32 | plugin.initialize(self, "discord", ctx) 33 | end 34 | 35 | function discord:log(bypass_use_discord) 36 | -- Check if discord is enabled 37 | if not bypass_use_discord then 38 | if self.variables["USE_DISCORD"] ~= "yes" then 39 | return self:ret(true, "discord plugin not enabled") 40 | end 41 | end 42 | -- Check if request is denied 43 | local reason, reason_data = get_reason(self.ctx) 44 | if reason == nil then 45 | return self:ret(true, "request not denied") 46 | end 47 | -- Compute data 48 | local timestamp = ngx_req.start_time() 49 | local formattedTimestamp = date("!%Y-%m-%dT%H:%M:%S", timestamp) 50 | local milliseconds = floor((timestamp - floor(timestamp)) * 1000) 51 | local formatField = function(inputString) 52 | if len(inputString) <= 1021 then 53 | return inputString 54 | else 55 | return sub(inputString, 1, 1021) .. "..." 56 | end 57 | end 58 | 59 | local data = { 60 | username = "BunkerWeb", 61 | embeds = { 62 | { 63 | title = "Denied request for IP " .. self.ctx.bw.remote_addr, 64 | timestamp = formattedTimestamp .. "." .. format("%03d", milliseconds) .. "Z", 65 | color = 0x125678, 66 | provider = { 67 | name = "BunkerWeb", 68 | url = "https://github.com/bunkerity/bunkerweb", 69 | }, 70 | author = { 71 | name = "BunkerWeb's Discord plugin", 72 | url = "https://github.com/bunkerity/bunkerweb", 73 | icon_url = "https://raw.githubusercontent.com/bunkerity/bunkerweb-plugins/main/logo.png", 74 | }, 75 | fields = { 76 | { 77 | name = "Request data", 78 | value = formatField(ngx.var.request), 79 | inline = false, 80 | }, 81 | { 82 | name = "Reason", 83 | value = formatField(reason), 84 | inline = false, 85 | }, 86 | { 87 | name = "Reason data", 88 | value = formatField(encode(reason_data or {})), 89 | inline = false, 90 | }, 91 | }, 92 | }, 93 | }, 94 | } 95 | local headers, err = ngx_req.get_headers() 96 | if not headers then 97 | data.embeds[1].description = "**error while getting headers : " .. err .. "**" 98 | else 99 | local count = 0 100 | for _ in pairs(headers) do 101 | count = count + 1 102 | end 103 | if count > 23 then 104 | data.embeds[1].description = "Headers :\n```" 105 | for header, value in pairs(headers) do 106 | data.embeds[1].description = data.embeds[1].description .. header .. ": " .. value .. "\n" 107 | end 108 | data.embeds[1].description = data.embeds[1].description .. "```" 109 | else 110 | for header, value in pairs(headers) do 111 | table.insert(data.embeds[1].fields, { 112 | name = header, 113 | value = formatField(value), 114 | inline = true, 115 | }) 116 | end 117 | end 118 | end 119 | -- Send request 120 | local hdr 121 | hdr, err = ngx_timer.at(0, self.send, self, data) 122 | if not hdr then 123 | return self:ret(true, "can't create report timer : " .. err) 124 | end 125 | return self:ret(true, "scheduled timer") 126 | end 127 | 128 | -- luacheck: ignore 212 129 | function discord.send(premature, self, data) 130 | local httpc, err = http_new() 131 | if not httpc then 132 | self.logger:log(ERR, "can't instantiate http object : " .. err) 133 | end 134 | local res, err_http = httpc:request_uri(self.variables["DISCORD_WEBHOOK_URL"], { 135 | method = "POST", 136 | headers = { 137 | ["Content-Type"] = "application/json", 138 | }, 139 | body = encode(data), 140 | }) 141 | httpc:close() 142 | if not res then 143 | self.logger:log(ERR, "error while sending request : " .. err_http) 144 | end 145 | if self.variables["DISCORD_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then 146 | self.logger:log(WARN, "Discord API is rate-limiting us, retrying in " .. res.headers["Retry-After"] .. "s") 147 | local hdr 148 | hdr, err = ngx_timer.at(res.headers["Retry-After"], self.send, self, data) 149 | if not hdr then 150 | self.logger:log(ERR, "can't create report timer : " .. err) 151 | return 152 | end 153 | return 154 | end 155 | if res.status < 200 or res.status > 299 then 156 | self.logger:log(ERR, "request returned status " .. tostring(res.status)) 157 | return 158 | end 159 | self.logger:log(INFO, "request sent to webhook") 160 | end 161 | 162 | function discord:log_default() 163 | -- Check if discord is activated 164 | local check, err = has_variable("USE_DISCORD", "yes") 165 | if check == nil then 166 | return self:ret(false, "error while checking variable USE_DISCORD (" .. err .. ")") 167 | end 168 | if not check then 169 | return self:ret(true, "Discord plugin not enabled") 170 | end 171 | -- Check if default server is disabled 172 | check, err = get_variable("DISABLE_DEFAULT_SERVER", false) 173 | if check == nil then 174 | return self:ret(false, "error while getting variable DISABLE_DEFAULT_SERVER (" .. err .. ")") 175 | end 176 | if check ~= "yes" then 177 | return self:ret(true, "default server not disabled") 178 | end 179 | -- Call log method 180 | return self:log(true) 181 | end 182 | 183 | function discord:api() 184 | if self.ctx.bw.uri == "/discord/ping" and self.ctx.bw.request_method == "POST" then 185 | -- Check discord connection 186 | local check, err = has_variable("USE_DISCORD", "yes") 187 | if check == nil then 188 | return self:ret(true, "error while checking variable USE_DISCORD (" .. err .. ")") 189 | end 190 | if not check then 191 | return self:ret(true, "Discord plugin not enabled") 192 | end 193 | 194 | -- Send test data to discord webhook 195 | local data = { 196 | username = "BunkerWeb", 197 | embeds = { 198 | { 199 | title = "Test message", 200 | description = "This is a test message sent by BunkerWeb's Discord plugin", 201 | color = 0x125678, 202 | provider = { 203 | name = "BunkerWeb", 204 | url = "https://github.com/bunkerity/bunkerweb", 205 | }, 206 | author = { 207 | name = "BunkerWeb's Discord plugin", 208 | url = "https://github.com/bunkerity/bunkerweb", 209 | icon_url = "https://raw.githubusercontent.com/bunkerity/bunkerweb-plugins/main/logo.png", 210 | }, 211 | }, 212 | }, 213 | } 214 | -- Send request 215 | local httpc 216 | httpc, err = http_new() 217 | if not httpc then 218 | self.logger:log(ERR, "can't instantiate http object : " .. err) 219 | end 220 | local res, err_http = httpc:request_uri(self.variables["DISCORD_WEBHOOK_URL"], { 221 | method = "POST", 222 | headers = { 223 | ["Content-Type"] = "application/json", 224 | }, 225 | body = encode(data), 226 | }) 227 | httpc:close() 228 | if not res then 229 | return self:ret(true, "error while sending request : " .. err_http, HTTP_INTERNAL_SERVER_ERROR) 230 | end 231 | if self.variables["DISCORD_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then 232 | return self:ret( 233 | true, 234 | "Discord API is rate-limiting us, retry in " .. res.headers["Retry-After"] .. "s", 235 | HTTP_TOO_MANY_REQUESTS 236 | ) 237 | end 238 | if res.status < 200 or res.status > 299 then 239 | return self:ret(true, "request returned status " .. tostring(res.status), HTTP_INTERNAL_SERVER_ERROR) 240 | end 241 | return self:ret(true, "request sent to webhook", HTTP_OK) 242 | end 243 | return self:ret(false, "success") 244 | end 245 | 246 | return discord 247 | -------------------------------------------------------------------------------- /discord/docs/diagram.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /discord/docs/diagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
INCOMING TRAFFIC
INCOMING TRAFFIC
INTERNAL TRAFFIC
INTERNAL TRAFFIC
ATTACK
NOTIFICATIONS
ATTACK...
DISCORD
DISCORD
Text is not SVG - cannot display
-------------------------------------------------------------------------------- /discord/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "discord", 3 | "name": "Discord", 4 | "description": "Send alerts to a Discord channel (using webhooks).", 5 | "version": "1.9", 6 | "stream": "yes", 7 | "settings": { 8 | "USE_DISCORD": { 9 | "context": "multisite", 10 | "default": "no", 11 | "help": "Enable sending alerts to a Discord channel.", 12 | "id": "use-discord", 13 | "label": "Use Discord", 14 | "regex": "^(yes|no)$", 15 | "type": "check" 16 | }, 17 | "DISCORD_WEBHOOK_URL": { 18 | "context": "global", 19 | "default": "https://discordapp.com/api/webhooks/...", 20 | "help": "Address of the Discord Webhook.", 21 | "id": "discord-webhook-url", 22 | "label": "Discord webhook URL", 23 | "regex": "^.*$", 24 | "type": "password" 25 | }, 26 | "DISCORD_RETRY_IF_LIMITED": { 27 | "context": "global", 28 | "default": "no", 29 | "help": "Retry to send the request if Discord API is rate limiting us (may consume a lot of resources).", 30 | "id": "discord-retry-if-limited", 31 | "label": "Retry if limited by Discord", 32 | "regex": "^(yes|no)$", 33 | "type": "check" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /discord/ui/actions.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from traceback import format_exc 3 | 4 | 5 | def pre_render(**kwargs): 6 | logger = getLogger("UI") 7 | ret = { 8 | "ping_status": { 9 | "title": "DISCORD STATUS", 10 | "value": "error", 11 | "col-size": "col-12 col-md-6", 12 | "card-classes": "h-100", 13 | }, 14 | } 15 | try: 16 | ping_data = kwargs["bw_instances_utils"].get_ping("discord") 17 | ret["ping_status"]["value"] = ping_data["status"] 18 | except BaseException as e: 19 | logger.debug(format_exc()) 20 | logger.error(f"Failed to get discord ping: {e}") 21 | ret["error"] = str(e) 22 | 23 | if "error" in ret: 24 | return ret 25 | 26 | return ret 27 | 28 | 29 | def discord(**kwargs): 30 | pass 31 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bunkerity/bunkerweb-plugins/63de9b9bf8242ea955ad3a362920c2cc04aae082/logo.png -------------------------------------------------------------------------------- /misc/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | dir=$(pwd) 9 | 10 | # If the dir ends with "misc", go up one level 11 | if [[ $dir == *"misc" ]]; then 12 | dir=$(dirname "$dir") 13 | fi 14 | 15 | echo "Updating version of plugins to \"$1\"" 16 | 17 | find . -type f -name "plugin.json" -exec sed -i 's@"version": "[0-9].*"@"version": "'"$1"'"@' {} \; 18 | sed -i 's@"bunkerweb_plugins\-[0-9].*\-blue"@"bunkerweb_plugins-'"$1"'-blue@' README.md 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "BunkerWeb Plugins" 3 | description = "External plugins for BunkerWeb" 4 | authors = [ 5 | { name = "Bunkerity", email = "contact@bunkerity.com" } 6 | ] 7 | 8 | [tool.black] 9 | line-length = 160 10 | include = '\.pyi?$' 11 | exclude = ''' 12 | /( 13 | | \.git 14 | | env 15 | )/ 16 | ''' 17 | -------------------------------------------------------------------------------- /slack/README.md: -------------------------------------------------------------------------------- 1 | # Slack plugin 2 | 3 |

4 | BunkerWeb Slack diagram 5 |

6 | 7 | This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send you attack notifications on a Slack channel of your choice using a webhook. 8 | 9 | # Table of contents 10 | 11 | - [Slack plugin](#slack-plugin) 12 | - [Table of contents](#table-of-contents) 13 | - [Prerequisites](#prerequisites) 14 | - [Setup](#setup) 15 | - [Docker](#docker) 16 | - [Swarm](#swarm) 17 | - [Kubernetes](#kubernetes) 18 | - [Settings](#settings) 19 | - [TODO](#todo) 20 | 21 | # Prerequisites 22 | 23 | Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first. 24 | 25 | You will need to setup a Slack webhook URL, you will find more information [here](https://api.slack.com/messaging/webhooks). 26 | 27 | # Setup 28 | 29 | See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration. 30 | 31 | There is no additional services to setup besides the plugin itself. 32 | 33 | ## Docker 34 | 35 | ```yaml 36 | services: 37 | 38 | bw-scheduler: 39 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 40 | ... 41 | environment: 42 | - USE_SLACK=yes 43 | - SLACK_WEBHOOK_URL=https://api.slack.com/messaging/webhooks/... 44 | ... 45 | ``` 46 | 47 | ## Swarm 48 | 49 | ```yaml 50 | services: 51 | 52 | bw-scheduler: 53 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 54 | ... 55 | environment: 56 | - USE_SLACK=yes 57 | - SLACK_WEBHOOK_URL=https://api.slack.com/messaging/webhooks/... 58 | ... 59 | ``` 60 | 61 | ## Kubernetes 62 | 63 | ```yaml 64 | apiVersion: networking.k8s.io/v1 65 | kind: Ingress 66 | metadata: 67 | name: ingress 68 | annotations: 69 | bunkerweb.io/USE_SLACK: "yes" 70 | bunkerweb.io/SLACK_WEBHOOK_URL: "https://api.slack.com/messaging/webhooks/..." 71 | ``` 72 | 73 | # Settings 74 | 75 | | Setting | Default | Context | Multiple | Description | 76 | | ------------------------ | -------------------------------------- | --------- | -------- | -------------------------------------------------------------------------------------------- | 77 | | `USE_SLACK` | `no` | multisite | no | Enable sending alerts to a Slack channel. | 78 | | `SLACK_WEBHOOK_URL` | `https://hooks.slack.com/services/...` | global | no | Address of the Slack Webhook. | 79 | | | 80 | | `SLACK_RETRY_IF_LIMITED` | `no` | global | no | Retry to send the request if Slack API is rate limiting us (may consume a lot of resources). | 81 | 82 | # TODO 83 | 84 | - Add more info in notification : 85 | - Date 86 | - Country of IP 87 | - ASN of IP 88 | - ... 89 | - Add settings to control what details to send : 90 | - Anonymize IP 91 | - Add body 92 | - Add headers 93 | -------------------------------------------------------------------------------- /slack/docs/diagram.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /slack/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "slack", 3 | "name": "Slack", 4 | "description": "Send alerts to a Slack channel (using webhooks).", 5 | "version": "1.9", 6 | "stream": "partial", 7 | "settings": { 8 | "USE_SLACK": { 9 | "context": "multisite", 10 | "default": "no", 11 | "help": "Enable sending alerts to a Slack channel.", 12 | "id": "use-slack", 13 | "label": "Use Slack", 14 | "regex": "^(yes|no)$", 15 | "type": "check" 16 | }, 17 | "SLACK_WEBHOOK_URL": { 18 | "context": "global", 19 | "default": "https://hooks.slack.com/services/...", 20 | "help": "Address of the Slack Webhook.", 21 | "id": "slack-webhook-url", 22 | "label": "Slack webhook URL", 23 | "regex": "^.*$", 24 | "type": "password" 25 | }, 26 | "SLACK_RETRY_IF_LIMITED": { 27 | "context": "global", 28 | "default": "no", 29 | "help": "Retry to send the request if Slack API is rate limiting us (may consume a lot of resources).", 30 | "id": "slack-retry-if-limited", 31 | "label": "Retry if limited by Slack", 32 | "regex": "^(yes|no)$", 33 | "type": "check" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /slack/slack.lua: -------------------------------------------------------------------------------- 1 | local cjson = require("cjson") 2 | local class = require("middleclass") 3 | local http = require("resty.http") 4 | local plugin = require("bunkerweb.plugin") 5 | local utils = require("bunkerweb.utils") 6 | 7 | local slack = class("slack", plugin) 8 | 9 | local ngx = ngx 10 | local ngx_req = ngx.req 11 | local ERR = ngx.ERR 12 | local WARN = ngx.WARN 13 | local INFO = ngx.INFO 14 | local ngx_timer = ngx.timer 15 | local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR 16 | local HTTP_TOO_MANY_REQUESTS = ngx.HTTP_TOO_MANY_REQUESTS 17 | local HTTP_OK = ngx.HTTP_OK 18 | local http_new = http.new 19 | local has_variable = utils.has_variable 20 | local get_variable = utils.get_variable 21 | local get_reason = utils.get_reason 22 | local tostring = tostring 23 | local encode = cjson.encode 24 | 25 | function slack:initialize(ctx) 26 | -- Call parent initialize 27 | plugin.initialize(self, "slack", ctx) 28 | end 29 | 30 | function slack:log(bypass_use_slack) 31 | -- Check if slack is enabled 32 | if not bypass_use_slack then 33 | if self.variables["USE_SLACK"] ~= "yes" then 34 | return self:ret(true, "slack plugin not enabled") 35 | end 36 | end 37 | -- Check if request is denied 38 | local reason, reason_data = get_reason(self.ctx) 39 | if reason == nil then 40 | return self:ret(true, "request not denied") 41 | end 42 | -- Compute data 43 | local data = {} 44 | data.text = "```Denied request for IP " 45 | .. self.ctx.bw.remote_addr 46 | .. " (reason = " 47 | .. reason 48 | .. " / reason data = " 49 | .. encode(reason_data or {}) 50 | .. ").\n\nRequest data :\n\n" 51 | .. ngx.var.request 52 | .. "\n" 53 | local headers, err = ngx_req.get_headers() 54 | if not headers then 55 | data.text = data.text .. "error while getting headers : " .. err 56 | else 57 | for header, value in pairs(headers) do 58 | data.text = data.text .. header .. ": " .. value .. "\n" 59 | end 60 | end 61 | data.text = data.text .. "```" 62 | -- Send request 63 | local hdr 64 | hdr, err = ngx_timer.at(0, self.send, self, data) 65 | if not hdr then 66 | return self:ret(true, "can't create report timer : " .. err) 67 | end 68 | return self:ret(true, "scheduled timer") 69 | end 70 | 71 | -- luacheck: ignore 212 72 | function slack.send(premature, self, data) 73 | local httpc, err = http_new() 74 | if not httpc then 75 | self.logger:log(ERR, "can't instantiate http object : " .. err) 76 | end 77 | local res, err_http = httpc:request_uri(self.variables["SLACK_WEBHOOK_URL"], { 78 | method = "POST", 79 | headers = { 80 | ["Content-Type"] = "application/json", 81 | }, 82 | body = encode(data), 83 | }) 84 | httpc:close() 85 | if not res then 86 | self.logger:log(ERR, "error while sending request : " .. err_http) 87 | end 88 | if self.variables["SLACK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then 89 | self.logger:log(WARN, "slack API is rate-limiting us, retrying in " .. res.headers["Retry-After"] .. "s") 90 | local hdr 91 | hdr, err = ngx_timer.at(res.headers["Retry-After"], self.send, self, data) 92 | if not hdr then 93 | self.logger:log(ERR, "can't create report timer : " .. err) 94 | return 95 | end 96 | return 97 | end 98 | if res.status < 200 or res.status > 299 then 99 | self.logger:log(ERR, "request returned status " .. tostring(res.status)) 100 | return 101 | end 102 | self.logger:log(INFO, "request sent to webhook") 103 | end 104 | 105 | function slack:log_default() 106 | -- Check if slack is activated 107 | local check, err = has_variable("USE_SLACK", "yes") 108 | if check == nil then 109 | return self:ret(false, "error while checking variable USE_slack (" .. err .. ")") 110 | end 111 | if not check then 112 | return self:ret(true, "slack plugin not enabled") 113 | end 114 | -- Check if default server is disabled 115 | check, err = get_variable("DISABLE_DEFAULT_SERVER", false) 116 | if check == nil then 117 | return self:ret(false, "error while getting variable DISABLE_DEFAULT_SERVER (" .. err .. ")") 118 | end 119 | if check ~= "yes" then 120 | return self:ret(true, "default server not disabled") 121 | end 122 | -- Call log method 123 | return self:log(true) 124 | end 125 | 126 | function slack:api() 127 | if self.ctx.bw.uri == "/slack/ping" and self.ctx.bw.request_method == "POST" then 128 | -- Check slack connection 129 | local check, err = has_variable("USE_SLACK", "yes") 130 | if check == nil then 131 | return self:ret(true, "error while checking variable USE_SLACK (" .. err .. ")") 132 | end 133 | if not check then 134 | return self:ret(true, "Slack plugin not enabled") 135 | end 136 | 137 | -- Send test data to slack webhook 138 | local data = { 139 | text = "```Test message from bunkerweb```", 140 | } 141 | -- Send request 142 | local httpc 143 | httpc, err = http_new() 144 | if not httpc then 145 | self.logger:log(ERR, "can't instantiate http object : " .. err) 146 | end 147 | local res, err_http = httpc:request_uri(self.variables["SLACK_WEBHOOK_URL"], { 148 | method = "POST", 149 | headers = { 150 | ["Content-Type"] = "application/json", 151 | }, 152 | body = encode(data), 153 | }) 154 | httpc:close() 155 | if not res then 156 | self.logger:log(ERR, "error while sending request : " .. err_http) 157 | end 158 | if self.variables["SLACK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then 159 | return self:ret( 160 | true, 161 | "slack API is rate-limiting us, retry in " .. res.headers["Retry-After"] .. "s", 162 | HTTP_TOO_MANY_REQUESTS 163 | ) 164 | end 165 | if res.status < 200 or res.status > 299 then 166 | return self:ret(true, "request returned status " .. tostring(res.status), HTTP_INTERNAL_SERVER_ERROR) 167 | end 168 | return self:ret(true, "request sent to webhook", HTTP_OK) 169 | end 170 | return self:ret(false, "success") 171 | end 172 | 173 | return slack 174 | -------------------------------------------------------------------------------- /slack/ui/actions.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from traceback import format_exc 3 | 4 | 5 | def pre_render(**kwargs): 6 | logger = getLogger("UI") 7 | ret = { 8 | "ping_status": { 9 | "title": "SLACK STATUS", 10 | "value": "error", 11 | "col-size": "col-12 col-md-6", 12 | "card-classes": "h-100", 13 | }, 14 | } 15 | try: 16 | ping_data = kwargs["bw_instances_utils"].get_ping("slack") 17 | ret["ping_status"]["value"] = ping_data["status"] 18 | except BaseException as e: 19 | logger.debug(format_exc()) 20 | logger.error(f"Failed to get slack ping: {e}") 21 | ret["error"] = str(e) 22 | 23 | if "error" in ret: 24 | return ret 25 | 26 | return ret 27 | 28 | 29 | def slack(**kwargs): 30 | pass 31 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | [sort_requires] 2 | enabled = true 3 | -------------------------------------------------------------------------------- /virustotal/README.md: -------------------------------------------------------------------------------- 1 | # VirusTotal plugin 2 | 3 |

4 | BunkerWeb VirusTotal diagram 5 |

6 | 7 | This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically check if any uploaded file is already analyzed on VirusTotal and deny the request if the file is detected by some antivirus engine(s). 8 | 9 | At the moment, submission of new file is not supported, it only checks if files already exist in VT and get the scan result if that's the case. 10 | 11 | # Table of contents 12 | 13 | - [VirusTotal plugin](#virustotal-plugin) 14 | - [Table of contents](#table-of-contents) 15 | - [Prerequisites](#prerequisites) 16 | - [Setup](#setup) 17 | - [Docker](#docker) 18 | - [Swarm](#swarm) 19 | - [Kubernetes](#kubernetes) 20 | - [Settings](#settings) 21 | 22 | # Prerequisites 23 | 24 | Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first. 25 | 26 | You will need a VirusTotal API key to contact their API (see [here](https://support.virustotal.com/hc/en-us/articles/115002088769-Please-give-me-an-API-key)). The free API key is also working but you should check the terms of service and limits as described [here](https://support.virustotal.com/hc/en-us/articles/115002119845-What-is-the-difference-between-the-public-API-and-the-private-API-). 27 | 28 | # Setup 29 | 30 | See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration. 31 | 32 | ## Docker 33 | 34 | ```yaml 35 | services: 36 | 37 | bw-scheduler: 38 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 39 | ... 40 | environment: 41 | - USE_VIRUSTOTAL=yes 42 | - VIRUSTOTAL_API_KEY=mykey 43 | ... 44 | ``` 45 | 46 | ## Swarm 47 | 48 | ```yaml 49 | services: 50 | 51 | bw-scheduler: 52 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 53 | ... 54 | environment: 55 | - USE_VIRUSTOTAL=yes 56 | - VIRUSTOTAL_API_KEY=mykey 57 | ... 58 | networks: 59 | - bw-plugins 60 | ... 61 | 62 | ... 63 | ``` 64 | 65 | ## Kubernetes 66 | 67 | ```yaml 68 | apiVersion: networking.k8s.io/v1 69 | kind: Ingress 70 | metadata: 71 | name: ingress 72 | annotations: 73 | bunkerweb.io/USE_VIRUSTOTAL: "yes" 74 | bunkerweb.io/VIRUSTOTAL_API_KEY: "mykey" 75 | ``` 76 | 77 | # Settings 78 | 79 | | Setting | Default | Context | Multiple | Description | 80 | | ---------------------------- | ------- | --------- | -------- | -------------------------------------------------------------------------------- | 81 | | `USE_VIRUSTOTAL` | `no` | multisite | no | Activate VirusTotal integration. | 82 | | `VIRUSTOTAL_API_KEY` | | global | no | Key to authenticate with VirusTotal API. | 83 | | `VIRUSTOTAL_SCAN_FILE` | `yes` | multisite | no | Activate automatic scan of uploaded files with VirusTotal (only existing files). | 84 | | `VIRUSTOTAL_SCAN_IP` | `yes` | multisite | no | Activate automatic scan of uploaded ips with VirusTotal. | 85 | | `VIRUSTOTAL_IP_SUSPICIOUS` | `5` | global | no | Minimum number of suspicious reports before considering IP as bad. | 86 | | `VIRUSTOTAL_IP_MALICIOUS` | `3` | global | no | Minimum number of malicious reports before considering IP as bad. | 87 | | `VIRUSTOTAL_FILE_SUSPICIOUS` | `5` | global | no | Minimum number of suspicious reports before considering file as bad. | 88 | | `VIRUSTOTAL_FILE_MALICIOUS` | `3` | global | no | Minimum number of malicious reports before considering file as bad. | 89 | -------------------------------------------------------------------------------- /virustotal/docs/diagram.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /virustotal/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "virustotal", 3 | "name": "VirusTotal", 4 | "description": "Automatic scan of uploaded files and ips optionally with the VirusTotal API.", 5 | "version": "1.9", 6 | "stream": "partial", 7 | "settings": { 8 | "USE_VIRUSTOTAL": { 9 | "context": "multisite", 10 | "default": "no", 11 | "help": "Activate VirusTotal integration.", 12 | "id": "use-virustotal", 13 | "label": "Use VirusTotal", 14 | "regex": "^(yes|no)$", 15 | "type": "check" 16 | }, 17 | "VIRUSTOTAL_API_KEY": { 18 | "context": "global", 19 | "default": "", 20 | "help": "Key to authenticate with VirusTotal API.", 21 | "id": "virustotal-api-key", 22 | "label": "API key", 23 | "regex": "^.*$", 24 | "type": "password" 25 | }, 26 | "VIRUSTOTAL_SCAN_FILE": { 27 | "context": "multisite", 28 | "default": "yes", 29 | "help": "Activate automatic scan of uploaded files with VirusTotal (only existing files).", 30 | "id": "virustotal-scan-file", 31 | "label": "Scan files", 32 | "regex": "^(yes|no)$", 33 | "type": "check" 34 | }, 35 | "VIRUSTOTAL_SCAN_IP": { 36 | "context": "multisite", 37 | "default": "yes", 38 | "help": "Activate automatic scan of uploaded ips with VirusTotal.", 39 | "id": "virustotal-scan-ip", 40 | "label": "Scan IP addresses", 41 | "regex": "^(yes|no)$", 42 | "type": "check" 43 | }, 44 | "VIRUSTOTAL_IP_SUSPICIOUS": { 45 | "context": "global", 46 | "default": "5", 47 | "help": "Minimum number of suspicious reports before considering IP as bad.", 48 | "id": "virustotal-ip-suspicious", 49 | "label": "Suspicious IP number", 50 | "regex": "^.*$", 51 | "type": "text" 52 | }, 53 | "VIRUSTOTAL_IP_MALICIOUS": { 54 | "context": "global", 55 | "default": "3", 56 | "help": "Minimum number of malicious reports before considering IP as bad.", 57 | "id": "virustotal-ip-malicious", 58 | "label": "Malicious IP number", 59 | "regex": "^.*$", 60 | "type": "text" 61 | }, 62 | "VIRUSTOTAL_FILE_SUSPICIOUS": { 63 | "context": "global", 64 | "default": "5", 65 | "help": "Minimum number of suspicious reports before considering file as bad.", 66 | "id": "virustotal-file-suspicious", 67 | "label": "Suspicious file number", 68 | "regex": "^.*$", 69 | "type": "text" 70 | }, 71 | "VIRUSTOTAL_FILE_MALICIOUS": { 72 | "context": "global", 73 | "default": "3", 74 | "help": "Minimum number of malicious reports before considering file as bad.", 75 | "id": "virustotal-file-malicious", 76 | "label": "Malicious file number", 77 | "regex": "^.*$", 78 | "type": "text" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /virustotal/ui/actions.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from traceback import format_exc 3 | 4 | 5 | def pre_render(**kwargs): 6 | logger = getLogger("UI") 7 | ret = { 8 | "ping_status": { 9 | "title": "VIRUSTOTAL STATUS", 10 | "value": "error", 11 | "col-size": "col-12 col-md-6", 12 | "card-classes": "h-100", 13 | }, 14 | } 15 | try: 16 | ping_data = kwargs["bw_instances_utils"].get_ping("virustotal") 17 | ret["ping_status"]["value"] = ping_data["status"] 18 | except BaseException as e: 19 | logger.debug(format_exc()) 20 | logger.error(f"Failed to get virustotal ping: {e}") 21 | ret["error"] = str(e) 22 | 23 | if "error" in ret: 24 | return ret 25 | 26 | return ret 27 | 28 | 29 | def virustotal(**kwargs): 30 | pass 31 | -------------------------------------------------------------------------------- /virustotal/virustotal.lua: -------------------------------------------------------------------------------- 1 | local cjson = require("cjson") 2 | local class = require("middleclass") 3 | local http = require("resty.http") 4 | local plugin = require("bunkerweb.plugin") 5 | local sha256 = require("resty.sha256") 6 | local str = require("resty.string") 7 | local upload = require("resty.upload") 8 | local utils = require("bunkerweb.utils") 9 | 10 | local virustotal = class("virustotal", plugin) 11 | 12 | local ngx = ngx 13 | local ERR = ngx.ERR 14 | local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR 15 | local HTTP_OK = ngx.HTTP_OK 16 | local to_hex = str.to_hex 17 | local http_new = http.new 18 | local has_variable = utils.has_variable 19 | local get_deny_status = utils.get_deny_status 20 | local tostring = tostring 21 | local decode = cjson.decode 22 | local encode = cjson.encode 23 | 24 | local read_all = function(form) 25 | while true do 26 | local typ = form:read() 27 | if not typ then 28 | return 29 | end 30 | if typ == "eof" then 31 | return 32 | end 33 | end 34 | end 35 | 36 | function virustotal:initialize(ctx) 37 | -- Call parent initialize 38 | plugin.initialize(self, "virustotal", ctx) 39 | end 40 | 41 | -- Todo : find a "ping" endpoint on VT API 42 | -- function virustotal:init_worker() 43 | -- end 44 | 45 | function virustotal:access() 46 | -- Check if enabled 47 | if 48 | self.variables["USE_VIRUSTOTAL"] ~= "yes" 49 | or (self.variables["VIRUSTOTAL_SCAN_IP"] ~= "yes" and self.variables["VIRUSTOTAL_SCAN_FILE"] ~= "yes") 50 | then 51 | return self:ret(true, "virustotal plugin not enabled") 52 | end 53 | 54 | -- IP check 55 | if self.variables["VIRUSTOTAL_SCAN_IP"] == "yes" and self.ctx.bw.ip_is_global then 56 | local ok, report = self:check_ip() 57 | if not ok then 58 | return self:ret(false, "error while checking if IP is malicious : " .. report) 59 | end 60 | if report and report ~= "clean" then 61 | return self:ret( 62 | true, 63 | "IP " .. self.ctx.bw.remote_addr .. " is malicious : " .. report, 64 | get_deny_status(), 65 | nil, 66 | { 67 | id = "ip", 68 | report = report, 69 | } 70 | ) 71 | end 72 | end 73 | 74 | -- File check 75 | if self.variables["VIRUSTOTAL_SCAN_FILE"] == "yes" then 76 | -- Check if we have downloads 77 | if 78 | not self.ctx.bw.http_content_type 79 | or ( 80 | not self.ctx.bw.http_content_type:match("boundary") 81 | or not self.ctx.bw.http_content_type:match("multipart/form%-data") 82 | ) 83 | then 84 | return self:ret(true, "no file upload detected") 85 | end 86 | -- Perform the check 87 | local ok, detected, checksum = self:check_file() 88 | if not ok then 89 | return self:ret(false, "error while checking if file is malicious : " .. detected) 90 | end 91 | -- Malicious case 92 | if detected and detected ~= "clean" then 93 | return self:ret( 94 | true, 95 | "file with checksum " .. checksum .. "is detected : " .. detected, 96 | get_deny_status(), 97 | nil, 98 | { 99 | id = "file", 100 | checksum = checksum, 101 | detected = detected, 102 | } 103 | ) 104 | end 105 | end 106 | return self:ret(true, "no ip/file detected") 107 | end 108 | 109 | function virustotal:check_ip() 110 | -- Check cache 111 | local ok, report = self:is_in_cache("ip_" .. self.ctx.bw.remote_addr) 112 | if not ok then 113 | return false, report 114 | end 115 | if report then 116 | return true, report 117 | end 118 | -- Ask VT API 119 | local found, response 120 | ok, found, response = self:request("/ip_addresses/" .. self.ctx.bw.remote_addr) 121 | if not ok then 122 | return false, response 123 | end 124 | local result = "clean" 125 | if found then 126 | result = self:get_result(response, "IP") 127 | end 128 | -- Add to cache 129 | local err 130 | ok, err = self:add_to_cache("ip_" .. self.ctx.bw.remote_addr, result) 131 | if not ok then 132 | return false, err 133 | end 134 | return true, result 135 | end 136 | 137 | function virustotal:check_file() 138 | -- Loop on files 139 | local form, err = upload:new(4096, 512, true) 140 | if not form then 141 | return false, err 142 | end 143 | local sha = sha256:new() 144 | local processing = nil 145 | while true do 146 | -- Read part 147 | local typ, res 148 | typ, res, err = form:read() 149 | if not typ then 150 | return false, "form:read() failed : " .. err 151 | end 152 | -- Header case : check if we have a filename 153 | if typ == "header" then 154 | local found = false 155 | for _, header in ipairs(res) do 156 | if header:find('^.*filename="(.*)".*$') then 157 | found = true 158 | break 159 | end 160 | end 161 | if found then 162 | processing = true 163 | end 164 | -- Body case : update checksum 165 | elseif typ == "body" and processing then 166 | sha:update(res) 167 | -- Part end case : get final checksum and clamav result 168 | elseif typ == "part_end" and processing then 169 | processing = nil 170 | -- Compute checksum 171 | local checksum = to_hex(sha:final()) 172 | sha:reset() 173 | -- Check if file is in cache 174 | local ok, cached = self:is_in_cache("file_" .. checksum) 175 | if not ok then 176 | self.logger:log(ERR, "can't check if file with checksum " .. checksum .. " is in cache : " .. cached) 177 | elseif cached then 178 | if cached ~= "clean" then 179 | read_all(form) 180 | return true, cached, checksum 181 | end 182 | else 183 | -- Check if file is already present on VT 184 | local found, response 185 | ok, found, response = self:request("/files/" .. checksum) 186 | if not ok then 187 | read_all(form) 188 | return false, found 189 | end 190 | local result = "clean" 191 | if found then 192 | result = self:get_result(response, "FILE") 193 | end 194 | -- Add to cache 195 | ok, err = self:add_to_cache("file_" .. checksum, result) 196 | if not ok then 197 | read_all(form) 198 | return false, err 199 | end 200 | -- Stop here if one file is detected 201 | if result ~= "clean" then 202 | read_all(form) 203 | return true, result, checksum 204 | end 205 | end 206 | -- End of body case : no file detected 207 | elseif typ == "eof" then 208 | return true 209 | end 210 | end 211 | -- luacheck: ignore 511 212 | return false, "malformed content" 213 | end 214 | 215 | function virustotal:get_result(response, type) 216 | local result = "clean" 217 | if 218 | response["suspicious"] > tonumber(self.variables["VIRUSTOTAL_" .. type .. "_SUSPICIOUS"]) 219 | or response["malicious"] > tonumber(self.variables["VIRUSTOTAL_" .. type .. "_MALICIOUS"]) 220 | then 221 | result = tostring(response["suspicious"]) 222 | .. " suspicious and " 223 | .. tostring(response["malicious"]) 224 | .. " malicious" 225 | end 226 | return result 227 | end 228 | 229 | function virustotal:is_in_cache(key) 230 | local ok, data = self.cachestore:get("plugin_virustotal_" .. key) 231 | if not ok then 232 | return false, data 233 | end 234 | return true, data 235 | end 236 | 237 | function virustotal:add_to_cache(key, value) 238 | local ok, err = self.cachestore:set("plugin_virustotal_" .. key, value, 86400) 239 | if not ok then 240 | return false, err 241 | end 242 | return true 243 | end 244 | 245 | function virustotal:request(url) 246 | -- Get object 247 | local httpc, err = http_new() 248 | if not httpc then 249 | return false, err 250 | end 251 | -- Send request 252 | local res 253 | res, err = httpc:request_uri("https://www.virustotal.com/api/v3" .. url, { 254 | headers = { 255 | ["x-apikey"] = self.variables["VIRUSTOTAL_API_KEY"], 256 | }, 257 | }) 258 | if not res then 259 | return false, err 260 | end 261 | -- Check status 262 | if res.status == 404 then 263 | return true, false 264 | end 265 | if res.status ~= 200 then 266 | err = "received status " .. tostring(res.status) .. " from VT API" 267 | local ok, data = pcall(decode, res.body) 268 | if ok then 269 | err = err .. " with data " .. data 270 | end 271 | return false, err 272 | end 273 | -- Get result 274 | local ok, data = pcall(decode, res.body) 275 | if not ok then 276 | return false, data 277 | end 278 | if not data.data or not data.data.attributes or not data.data.attributes.last_analysis_stats then 279 | return false, "malformed json response" 280 | end 281 | return true, true, data.data.attributes.last_analysis_stats 282 | end 283 | 284 | function virustotal:api() 285 | if self.ctx.bw.uri == "/virustotal/ping" and self.ctx.bw.request_method == "POST" then 286 | -- Check virustotal connection 287 | local check, err = has_variable("USE_VIRUSTOTAL", "yes") 288 | if check == nil then 289 | return self:ret(true, "error while checking variable USE_VIRUSTOTAL (" .. err .. ")") 290 | end 291 | if not check then 292 | return self:ret(true, "Virustotal plugin not enabled") 293 | end 294 | 295 | -- Send test data to virustotal virustotal 296 | local ok, found, response = 297 | self:request("/files/275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f") -- sha256 of eicar test file 298 | if not ok then 299 | return self:ret(true, "error while sending test data to virustotal : " .. found, HTTP_INTERNAL_SERVER_ERROR) 300 | end 301 | if not found then 302 | return self:ret( 303 | true, 304 | "error while sending test data to virustotal : file not found on virustotal but it should be", 305 | HTTP_INTERNAL_SERVER_ERROR 306 | ) 307 | end 308 | return self:ret(true, "test data sent to virustotal, response: " .. encode(response), HTTP_OK) 309 | end 310 | return self:ret(false, "success") 311 | end 312 | 313 | return virustotal 314 | -------------------------------------------------------------------------------- /webhook/README.md: -------------------------------------------------------------------------------- 1 | # WebHook plugin 2 | 3 |

4 | BunkerWeb WebHook diagram 5 |

6 | 7 | This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send you attack notifications on a custom HTTP endpoint of your choice using a webhook. 8 | 9 | # Table of contents 10 | 11 | - [WebHook plugin](#webhook-plugin) 12 | - [Table of contents](#table-of-contents) 13 | - [Prerequisites](#prerequisites) 14 | - [Setup](#setup) 15 | - [Docker](#docker) 16 | - [Swarm](#swarm) 17 | - [Kubernetes](#kubernetes) 18 | - [Settings](#settings) 19 | - [TODO](#todo) 20 | 21 | # Prerequisites 22 | 23 | Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first. 24 | 25 | # Setup 26 | 27 | See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration. 28 | 29 | There is no additional services to setup besides the plugin itself. 30 | 31 | ## Docker 32 | 33 | ```yaml 34 | services: 35 | 36 | bw-scheduler: 37 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 38 | ... 39 | environment: 40 | - USE_WEBHOOK=yes 41 | - WEBHOOK_URL=https://api.example.com/bw 42 | ... 43 | ``` 44 | 45 | ## Swarm 46 | 47 | ```yaml 48 | services: 49 | 50 | bw-scheduler: 51 | image: bunkerity/bunkerweb-scheduler:1.6.0-rc1 52 | .. 53 | environment: 54 | - USE_WEBHOOK=yes 55 | - WEBHOOK_URL=https://api.example.com/bw 56 | ... 57 | ``` 58 | 59 | ## Kubernetes 60 | 61 | ```yaml 62 | apiVersion: networking.k8s.io/v1 63 | kind: Ingress 64 | metadata: 65 | name: ingress 66 | annotations: 67 | bunkerweb.io/USE_WEBHOOK: "yes" 68 | bunkerweb.io/WEBHOOK_URL: "https://api.example.com/bw" 69 | ``` 70 | 71 | # Settings 72 | 73 | | Setting | Default | Context | Multiple | Description | 74 | | -------------------------- | ---------------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------- | 75 | | `USE_WEBHOOK` | `no` | multisite | no | Enable sending alerts to a custom webhook. | 76 | | `WEBHOOK_URL` | `https://api.example.com/bw` | global | no | Address of the webhook. | 77 | | `WEBHOOK_RETRY_IF_LIMITED` | `no` | global | no | Retry to send the request if the remote server is rate limiting us (may consume a lot of resources). | 78 | 79 | # TODO 80 | 81 | - Add more info in notification : 82 | - Date 83 | - Country of IP 84 | - ASN of IP 85 | - ... 86 | - Add settings to control what details to send : 87 | - Anonymize IP 88 | - Add body 89 | - Add headers 90 | -------------------------------------------------------------------------------- /webhook/docs/diagram.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /webhook/docs/diagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
INCOMING TRAFFIC
INCOMING TRAFFIC
INTERNAL TRAFFIC
INTERNAL TRAFFIC
ATTACK
NOTIFICATIONS
ATTACK...
HTTP ENDPOINT

HTTP END...
Text is not SVG - cannot display
-------------------------------------------------------------------------------- /webhook/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "webhook", 3 | "name": "WebHook", 4 | "description": "Send alerts to a custom webhook.", 5 | "version": "1.9", 6 | "stream": "yes", 7 | "settings": { 8 | "USE_WEBHOOK": { 9 | "context": "multisite", 10 | "default": "no", 11 | "help": "Enable sending alerts to a custom webhook.", 12 | "id": "use-webhook", 13 | "label": "Use webhook", 14 | "regex": "^(yes|no)$", 15 | "type": "check" 16 | }, 17 | "WEBHOOK_URL": { 18 | "context": "global", 19 | "default": "https://api.example.com/bw", 20 | "help": "Address of the webhook.", 21 | "id": "webhook-url", 22 | "label": "Webhook URL", 23 | "regex": "^.*$", 24 | "type": "text" 25 | }, 26 | "WEBHOOK_RETRY_IF_LIMITED": { 27 | "context": "global", 28 | "default": "no", 29 | "help": "Retry to send the request if the remote server is rate limiting us (may consume a lot of resources).", 30 | "id": "webhook-retry-if-limited", 31 | "label": "Retry if limited by webhook", 32 | "regex": "^(yes|no)$", 33 | "type": "check" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /webhook/ui/actions.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from traceback import format_exc 3 | 4 | 5 | def pre_render(**kwargs): 6 | logger = getLogger("UI") 7 | ret = { 8 | "ping_status": { 9 | "title": "WEBHOOK STATUS", 10 | "value": "error", 11 | "col-size": "col-12 col-md-6", 12 | "card-classes": "h-100", 13 | }, 14 | } 15 | try: 16 | ping_data = kwargs["bw_instances_utils"].get_ping("webhook") 17 | ret["ping_status"]["value"] = ping_data["status"] 18 | except BaseException as e: 19 | logger.debug(format_exc()) 20 | logger.error(f"Failed to get webhook ping: {e}") 21 | ret["error"] = str(e) 22 | 23 | if "error" in ret: 24 | return ret 25 | 26 | return ret 27 | 28 | 29 | def webhook(**kwargs): 30 | pass 31 | -------------------------------------------------------------------------------- /webhook/webhook.lua: -------------------------------------------------------------------------------- 1 | local cjson = require("cjson") 2 | local class = require("middleclass") 3 | local http = require("resty.http") 4 | local plugin = require("bunkerweb.plugin") 5 | local utils = require("bunkerweb.utils") 6 | 7 | local webhook = class("webhook", plugin) 8 | 9 | local ngx = ngx 10 | local ngx_req = ngx.req 11 | local ERR = ngx.ERR 12 | local WARN = ngx.WARN 13 | local INFO = ngx.INFO 14 | local ngx_timer = ngx.timer 15 | local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR 16 | local HTTP_TOO_MANY_REQUESTS = ngx.HTTP_TOO_MANY_REQUESTS 17 | local HTTP_OK = ngx.HTTP_OK 18 | local http_new = http.new 19 | local has_variable = utils.has_variable 20 | local get_variable = utils.get_variable 21 | local get_reason = utils.get_reason 22 | local tostring = tostring 23 | local encode = cjson.encode 24 | 25 | function webhook:initialize(ctx) 26 | -- Call parent initialize 27 | plugin.initialize(self, "webhook", ctx) 28 | end 29 | 30 | function webhook:log(bypass_use_webhook) 31 | -- Check if webhook is enabled 32 | if not bypass_use_webhook then 33 | if self.variables["USE_WEBHOOK"] ~= "yes" then 34 | return self:ret(true, "webhook plugin not enabled") 35 | end 36 | end 37 | -- Check if request is denied 38 | local reason, reason_data = get_reason(self.ctx) 39 | if reason == nil then 40 | return self:ret(true, "request not denied") 41 | end 42 | -- Compute data 43 | local data = {} 44 | data.content = "```Denied request for IP " 45 | .. self.ctx.bw.remote_addr 46 | .. " (reason = " 47 | .. reason 48 | .. " / reason data = " 49 | .. encode(reason_data or {}) 50 | .. ").\n\nRequest data :\n\n" 51 | .. ngx.var.request 52 | .. "\n" 53 | local headers, err = ngx_req.get_headers() 54 | if not headers then 55 | data.content = data.content .. "error while getting headers : " .. err 56 | else 57 | for header, value in pairs(headers) do 58 | data.content = data.content .. header .. ": " .. value .. "\n" 59 | end 60 | end 61 | data.content = data.content .. "```" 62 | -- Send request 63 | local hdr 64 | hdr, err = ngx_timer.at(0, self.send, self, data) 65 | if not hdr then 66 | return self:ret(true, "can't create report timer : " .. err) 67 | end 68 | return self:ret(true, "scheduled timer") 69 | end 70 | 71 | -- luacheck: ignore 212 72 | function webhook.send(premature, self, data) 73 | local httpc, err = http_new() 74 | if not httpc then 75 | self.logger:log(ERR, "can't instantiate http object : " .. err) 76 | end 77 | local res, err_http = httpc:request_uri(self.variables["WEBHOOK_URL"], { 78 | method = "POST", 79 | headers = { 80 | ["Content-Type"] = "application/json", 81 | }, 82 | body = encode(data), 83 | }) 84 | httpc:close() 85 | if not res then 86 | self.logger:log(ERR, "error while sending request : " .. err_http) 87 | end 88 | if self.variables["WEBHOOK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then 89 | self.logger:log(WARN, "HTTP endpoint is rate-limiting us, retrying in " .. res.headers["Retry-After"] .. "s") 90 | local hdr 91 | hdr, err = ngx_timer.at(res.headers["Retry-After"], self.send, self, data) 92 | if not hdr then 93 | self.logger:log(ERR, "can't create report timer : " .. err) 94 | return 95 | end 96 | return 97 | end 98 | if res.status < 200 or res.status > 299 then 99 | self.logger:log(ERR, "request returned status " .. tostring(res.status)) 100 | return 101 | end 102 | self.logger:log(INFO, "request sent to webhook") 103 | end 104 | 105 | function webhook:log_default() 106 | -- Check if webhook is activated 107 | local check, err = has_variable("USE_WEBHOOK", "yes") 108 | if check == nil then 109 | return self:ret(false, "error while checking variable USE_WEBHOOK (" .. err .. ")") 110 | end 111 | if not check then 112 | return self:ret(true, "webhook plugin not enabled") 113 | end 114 | -- Check if default server is disabled 115 | check, err = get_variable("DISABLE_DEFAULT_SERVER", false) 116 | if check == nil then 117 | return self:ret(false, "error while getting variable DISABLE_DEFAULT_SERVER (" .. err .. ")") 118 | end 119 | if check ~= "yes" then 120 | return self:ret(true, "default server not disabled") 121 | end 122 | -- Call log method 123 | return self:log(true) 124 | end 125 | 126 | function webhook:api() 127 | if self.ctx.bw.uri == "/webhook/ping" and self.ctx.bw.request_method == "POST" then 128 | -- Check webhook connection 129 | local check, err = has_variable("USE_WEBHOOK", "yes") 130 | if check == nil then 131 | return self:ret(true, "error while checking variable USE_WEBHOOK (" .. err .. ")") 132 | end 133 | if not check then 134 | return self:ret(true, "Webhook plugin not enabled") 135 | end 136 | 137 | -- Send test data to webhook webhook 138 | local data = { 139 | content = "```Test message from bunkerweb```", 140 | } 141 | -- Send request 142 | local httpc 143 | httpc, err = http_new() 144 | if not httpc then 145 | self.logger:log(ERR, "can't instantiate http object : " .. err) 146 | end 147 | local res, err_http = httpc:request_uri(self.variables["WEBHOOK_URL"], { 148 | method = "POST", 149 | headers = { 150 | ["Content-Type"] = "application/json", 151 | }, 152 | body = encode(data), 153 | }) 154 | httpc:close() 155 | if not res then 156 | self.logger:log(ERR, "error while sending request : " .. err_http) 157 | end 158 | if self.variables["WEBHOOK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then 159 | return self:ret( 160 | true, 161 | "webhook API is rate-limiting us, retry in " .. res.headers["Retry-After"] .. "s", 162 | HTTP_TOO_MANY_REQUESTS 163 | ) 164 | end 165 | if res.status < 200 or res.status > 299 then 166 | return self:ret(true, "request returned status " .. tostring(res.status), HTTP_INTERNAL_SERVER_ERROR) 167 | end 168 | return self:ret(true, "request sent to webhook", HTTP_OK) 169 | end 170 | return self:ret(false, "success") 171 | end 172 | 173 | return webhook 174 | --------------------------------------------------------------------------------