├── .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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------