├── .activate.sh ├── .coveragerc ├── .deactivate.sh ├── .dockerignore ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .secrets.baseline ├── ABOUT.md ├── Dockerfile ├── Dockerfile.base ├── Dockerfile.bcc ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── bin └── pidtree-bcc ├── example_config.yml ├── itest ├── Dockerfile ├── Dockerfile.ubuntu ├── config_autoreload.yml ├── config_filters_autoreload.yml ├── config_generic.yml ├── config_mapping.yml ├── entrypoint_deb_package.sh ├── gen-ip-mapping.sh ├── itest_autoreload.sh ├── itest_generic.sh └── itest_sourceipmap.sh ├── packaging ├── .dockerignore ├── Dockerfile.ubuntu ├── Makefile ├── debian.sh └── debian │ ├── changelog │ ├── compat │ ├── control │ ├── pidtree-bcc.links │ └── rules ├── pidtree_bcc ├── __init__.py ├── config.py ├── containers.py ├── ctypes_helper.py ├── filtering.py ├── main.py ├── plugins │ ├── __init__.py │ ├── identityplugin.py │ ├── loginuidmap.py │ └── sourceipmap.py ├── probes │ ├── __init__.py │ ├── net_listen.j2 │ ├── net_listen.py │ ├── tcp_connect.j2 │ ├── tcp_connect.py │ ├── udp_session.j2 │ ├── udp_session.py │ └── utils.j2 ├── utils.py └── yaml_loader.py ├── requirements-bootstrap-bionic.txt ├── requirements-bootstrap.txt ├── requirements-dev-minimal.txt ├── requirements-dev.txt ├── requirements-minimal.txt ├── requirements.txt ├── run.sh ├── setup.py ├── setup.sh ├── tests ├── bpf_probe_test.py ├── config_test.py ├── conftest.py ├── containers_test.py ├── filtering_test.py ├── fixtures │ ├── child_config.yaml │ ├── hostfile │ ├── hostfile2 │ ├── parent_config.yaml │ └── remote_config.yaml ├── loginuid_plugin_test.py ├── net_listen_probe_test.py ├── plugin_test.py ├── sourceipmap_plugin_test.py ├── tcp_connect_probe_test.py ├── udp_session_probe_test.py ├── utils_test.py └── yaml_loader_test.py └── tox.ini /.activate.sh: -------------------------------------------------------------------------------- 1 | venv/bin/activate -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | . 5 | omit = 6 | .tox/* 7 | virtualenv_run/* 8 | venv/* 9 | /usr/* 10 | setup.py 11 | 12 | [report] 13 | exclude_lines = 14 | # Have to re-enable the standard pragma 15 | \#\s*pragma: no cover 16 | 17 | # Don't complain if tests don't hit defensive assertion code: 18 | ^\s*raise AssertionError\b 19 | ^\s*raise NotImplementedError\b 20 | ^\s*return NotImplemented\b 21 | ^\s*raise$ 22 | 23 | # Don't complain if non-runnable code isn't run: 24 | ^if __name__ == ['"]__main__['"]:$ 25 | -------------------------------------------------------------------------------- /.deactivate.sh: -------------------------------------------------------------------------------- 1 | deactivate 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | pidtree-bcc.fifo 2 | pidtree_bcc.egg-info 3 | itest 4 | packaging/dist 5 | venv 6 | .tox 7 | .git 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Package tests 3 | on: 4 | - push 5 | 6 | jobs: 7 | unit_test: 8 | runs-on: ubuntu-22.04 9 | strategy: 10 | matrix: 11 | python-version: ['3.8', '3.10'] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install tox 19 | run: | 20 | sudo pip3 install -r requirements-bootstrap.txt 21 | sudo pip3 install tox tox-gh-actions 22 | - name: Test with tox 23 | run: tox 24 | itests: 25 | runs-on: ubuntu-22.04 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Setup environment 29 | run: | 30 | sudo apt-get update && sudo apt-get install -y netcat-traditional 31 | sudo update-alternatives --set nc /bin/nc.traditional 32 | sudo pip3 install -r requirements-bootstrap.txt 33 | sudo pip3 install tox 34 | - name: Run all tests 35 | run: sudo make test-all 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | notes.md 2 | venv 3 | *~ 4 | .tox/* 5 | pidtree_bcc.egg-info/* 6 | *.pyc 7 | **/__pycache__/* 8 | *.swp 9 | *.#* 10 | itest/itest_output_* 11 | itest/itest_server_* 12 | itest/tmp 13 | testhosts 14 | *.deb 15 | *.gz 16 | *.xz 17 | **/debian-binary 18 | packaging/dist 19 | itest/dist 20 | .coverage 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-docstring-first 8 | - id: check-executables-have-shebangs 9 | - id: check-merge-conflict 10 | - id: check-yaml 11 | args: [--unsafe] # needing this due to custom YAML syntax 12 | - id: debug-statements 13 | - id: double-quote-string-fixer 14 | - id: name-tests-test 15 | - id: check-added-large-files 16 | - id: check-byte-order-marker 17 | - id: requirements-txt-fixer 18 | - id: fix-encoding-pragma 19 | args: ['--remove'] 20 | - repo: https://github.com/pre-commit/mirrors-autopep8 21 | rev: v1.5 22 | hooks: 23 | - id: autopep8 24 | - repo: https://gitlab.com/pycqa/flake8 25 | rev: 3.8.3 26 | hooks: 27 | - id: flake8 28 | - repo: https://github.com/asottile/reorder_python_imports 29 | rev: v2.0.0 30 | hooks: 31 | - id: reorder-python-imports 32 | args: [ 33 | '--remove-import', 'from __future__ import absolute_import', 34 | '--remove-import', 'from __future__ import unicode_literals', 35 | ] 36 | - repo: https://github.com/asottile/pyupgrade 37 | rev: v2.7.2 38 | hooks: 39 | - id: pyupgrade 40 | args: [--py3-plus] 41 | - repo: https://github.com/asottile/add-trailing-comma 42 | rev: v2.0.1 43 | hooks: 44 | - id: add-trailing-comma 45 | args: [--py35-plus] 46 | - repo: https://github.com/Yelp/detect-secrets 47 | rev: v0.14.3 48 | hooks: 49 | - id: detect-secrets 50 | args: ['--baseline', '.secrets.baseline'] 51 | -------------------------------------------------------------------------------- /.secrets.baseline: -------------------------------------------------------------------------------- 1 | { 2 | "custom_plugin_paths": [], 3 | "exclude": { 4 | "files": null, 5 | "lines": null 6 | }, 7 | "generated_at": "2020-10-02T11:00:39Z", 8 | "plugins_used": [ 9 | { 10 | "name": "AWSKeyDetector" 11 | }, 12 | { 13 | "name": "ArtifactoryDetector" 14 | }, 15 | { 16 | "base64_limit": 4.5, 17 | "name": "Base64HighEntropyString" 18 | }, 19 | { 20 | "name": "BasicAuthDetector" 21 | }, 22 | { 23 | "name": "CloudantDetector" 24 | }, 25 | { 26 | "hex_limit": 3, 27 | "name": "HexHighEntropyString" 28 | }, 29 | { 30 | "name": "IbmCloudIamDetector" 31 | }, 32 | { 33 | "name": "IbmCosHmacDetector" 34 | }, 35 | { 36 | "name": "JwtTokenDetector" 37 | }, 38 | { 39 | "keyword_exclude": null, 40 | "name": "KeywordDetector" 41 | }, 42 | { 43 | "name": "MailchimpDetector" 44 | }, 45 | { 46 | "name": "PrivateKeyDetector" 47 | }, 48 | { 49 | "name": "SlackDetector" 50 | }, 51 | { 52 | "name": "SoftlayerDetector" 53 | }, 54 | { 55 | "name": "StripeDetector" 56 | }, 57 | { 58 | "name": "TwilioKeyDetector" 59 | } 60 | ], 61 | "results": {}, 62 | "version": "0.14.3", 63 | "word_list": { 64 | "file": null, 65 | "hash": null 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ABOUT.md: -------------------------------------------------------------------------------- 1 | # About 2 | `pidtree-bcc` is a framework for creating userland-generated attestation logging 3 | of calling process trees for events that are registered in the kernel (don't 4 | worry it will all become clear!). It is built on top of 5 | [iovisor/bcc](https://github.com/iovisor/bcc), a python framework for hooking 6 | into the Linux eBPF subsystem. 7 | 8 | ## What is eBPF 9 | eBPF stands for Extended Berkely Packet Filter. As the name suggests, it is an 10 | extended version of the in-kernel virtual machine that the BSDs use for 11 | filtering packets. As the name does not suggest, Linux uses it for in-kernel 12 | tracing. 13 | 14 | The eBPF virtual machine places certain restrictions on the type of 15 | program it can run - most notably that loops are not allowed, to prevent 16 | kernel deadlocks. As such, external C functions from included libraries 17 | are not allowed, but header files can be included which is helpful for 18 | using defined data structures and macros. 19 | 20 | ## What does pidtree-bcc use this for? 21 | `pidtree-bcc` templates out C programs that are compiled at runtime by LLVM into 22 | eBPF bytecode to run in the kernel. It's primary function at the time of writing 23 | is to create kprobes (kernel probes) which will trigger the associated C 24 | function to run whenever the named syscall is dispatched. 25 | 26 | Our primary use case is the `tcp_v4_connect` syscall. When one of these fires, 27 | we trigger a C function that checks whether the target IP address is outside of 28 | the local network (i.e. is it non-RFC-1918). If it's internet routeable, a 29 | message is dispatched to the listening python process in userland, containing 30 | details about the parameters of the syscall and the PID of the calling process. 31 | The userland daemon then crawls the process tree from that PID up to PID 1 32 | (init) and logs the lot. This is especially useful for finding false-positives 33 | in IDS logging like Amazon GuardDuty. 34 | 35 | By filtering in-kernel without a heavy stack of dependency code, we can create 36 | low-resource, lightning fast filtered events and hand off to userland for any 37 | deep introspection in more lenient languages. The tradeoff here is having to do 38 | things like write bitwise subnet filters due to inclusion restrictions. 39 | 40 | It is not tamper-proof due to the userland daemon, but it is fairly reliable in 41 | theory and especially useful for finding false positives and getting a baseline 42 | for outbound traffic which should be low in an internat facing production web 43 | environment. 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG OS_RELEASE=jammy 2 | FROM pidtree-docker-base-bcc-${OS_RELEASE} 3 | 4 | # Build python environment 5 | WORKDIR /work 6 | COPY requirements.txt /work/ 7 | RUN pip3 install -r requirements.txt 8 | ADD . /work 9 | 10 | ENTRYPOINT ["/work/run.sh"] 11 | CMD ["-c", "example_config.yml"] 12 | -------------------------------------------------------------------------------- /Dockerfile.base: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE=ubuntu:jammy 2 | FROM ${BASE_IMAGE} 3 | -------------------------------------------------------------------------------- /Dockerfile.bcc: -------------------------------------------------------------------------------- 1 | ARG OS_RELEASE=jammy 2 | FROM pidtree-docker-base-${OS_RELEASE} as builder 3 | ARG BCC_TOOLS_SOURCE=source 4 | ARG BCC_VERSION=0.19.0 5 | 6 | RUN if [ $BCC_TOOLS_SOURCE = 'source' ]; then \ 7 | apt-get update \ 8 | && DEBIAN_FRONTEND=noninteractive apt-get -y install pbuilder aptitude git \ 9 | && apt-get clean; \ 10 | fi 11 | 12 | # Clone source code 13 | RUN if [ $BCC_TOOLS_SOURCE = 'source' ]; then \ 14 | git clone --single-branch --branch "v$BCC_VERSION" https://github.com/iovisor/bcc.git; \ 15 | fi 16 | WORKDIR /bcc 17 | 18 | # Fix release tagging 19 | RUN if [ $BCC_TOOLS_SOURCE = 'source' ]; then \ 20 | sed -i 's/git describe --abbrev=0/git describe --tags --abbrev=0/' scripts/git-tag.sh; \ 21 | fi 22 | 23 | # Build debian packages 24 | RUN if [ $BCC_TOOLS_SOURCE = 'source' ]; then \ 25 | /usr/lib/pbuilder/pbuilder-satisfydepends && ./scripts/build-deb.sh release; \ 26 | fi 27 | 28 | 29 | #---------------------------------------------------------------------------------------------- 30 | FROM pidtree-docker-base-${OS_RELEASE} 31 | ARG BCC_TOOLS_SOURCE=source 32 | 33 | RUN apt-get update \ 34 | && DEBIAN_FRONTEND=noninteractive apt-get -y install \ 35 | python3 \ 36 | python3-pip \ 37 | && apt-get clean 38 | 39 | # Install BCC toolchain 40 | RUN mkdir /bcc 41 | # we include a file which is always present to make the COPY succeed 42 | COPY --from=builder /etc/passwd /bcc/*.deb /bcc/ 43 | RUN if [ $BCC_TOOLS_SOURCE = 'source' ]; then apt-get -y install /bcc/libbcc_*.deb /bcc/python3-bcc*.deb; fi 44 | RUN rm -rf /bcc 45 | 46 | RUN if [ $BCC_TOOLS_SOURCE = 'upstream' ]; then \ 47 | DEBIAN_FRONTEND=noninteractive apt-get -y install python3-bpfcc \ 48 | && apt-get clean; \ 49 | fi 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Yelp 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include pidtree_bcc/probes/*.j2 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | SHELL := /bin/bash 3 | MAKEFLAGS += --warn-undefined-variables 4 | 5 | .PHONY: dev-env itest test test-all cook-image docker-run docker-run-with-fifo docker-interactive testhosts docker-run-testhosts clean clean-cache install-hooks release 6 | FIFO = $(CURDIR)/pidtree-bcc.fifo 7 | EXTRA_DOCKER_ARGS ?= 8 | DOCKER_ARGS = $(EXTRA_DOCKER_ARGS) -v /etc/passwd:/etc/passwd:ro --privileged --cap-add sys_admin --pid host 9 | DOCKER_BASE_IMAGE_TMPL ?= ubuntu:OS_RELEASE_PH 10 | HOST_OS_RELEASE = $(or $(shell cat /etc/lsb-release 2>/dev/null | grep -Po '(?<=CODENAME=)(.+)'), jammy) 11 | SUPPORTED_UBUNTU_RELEASES = bionic focal jammy noble 12 | UPSTREAM_BCC_RELEASES = jammy noble 13 | VERSION_FILE = $(PWD)/pidtree_bcc/__init__.py 14 | EDITOR ?= vi 15 | 16 | default: venv 17 | 18 | venv: requirements.txt requirements-dev.txt 19 | tox -e venv 20 | 21 | install-hooks: venv 22 | venv/bin/pre-commit install -f --install-hooks 23 | 24 | cook-image: clean-cache docker-bcc-base-$(HOST_OS_RELEASE) 25 | docker build -t pidtree-bcc --build-arg OS_RELEASE=$(HOST_OS_RELEASE) . 26 | 27 | docker-run: cook-image 28 | docker run $(DOCKER_ARGS) --rm -it pidtree-bcc -c example_config.yml 29 | 30 | docker-run-with-fifo: cook-image 31 | mkfifo pidtree-bcc.fifo || true 32 | docker run -v $(FIFO):/work/pidtree-bcc.fifo $(DOCKER_ARGS) --rm -it pidtree-bcc -c example_config.yml -f pidtree-bcc.fifo 33 | 34 | docker-interactive: cook-image 35 | # If you want to run manually inside the container, first you need to: 36 | # ./setup.sh 37 | # then you can run: 38 | # `python3 main.py -c example_config.yml` 39 | # Additionally there's a `-p` flag for printing out the templated out eBPF C code so you can debug it 40 | docker run $(DOCKER_ARGS) --rm -it --entrypoint /bin/bash pidtree-bcc 41 | 42 | testhosts: 43 | docker ps -q | xargs -n 1 docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} {{ .Name }}' | sed 's/ \// /' > $@ 44 | 45 | docker-run-testhosts: testhosts 46 | make EXTRA_DOCKER_ARGS="-v $(CURDIR)/testhosts:/etc/hosts:ro" docker-run 47 | 48 | itest: clean-cache docker-bcc-base-$(HOST_OS_RELEASE) 49 | ./itest/itest_generic.sh docker 50 | ./itest/itest_sourceipmap.sh 51 | ./itest/itest_autoreload.sh 52 | 53 | docker-base-%: Dockerfile.base 54 | $(eval dollar_star := $(subst ubuntu_,,$*)) 55 | docker build -t pidtree-docker-base-$(dollar_star) --build-arg BASE_IMAGE=$(subst OS_RELEASE_PH,$(dollar_star),$(DOCKER_BASE_IMAGE_TMPL)) -f Dockerfile.base . 56 | 57 | docker-bcc-base-%: docker-base-% 58 | $(eval dollar_star := $(subst ubuntu_,,$*)) 59 | docker build -t pidtree-docker-base-bcc-$(dollar_star) \ 60 | --build-arg OS_RELEASE=$(dollar_star) \ 61 | --build-arg BCC_TOOLS_SOURCE=$(if $(findstring $(dollar_star),$(UPSTREAM_BCC_RELEASES)),upstream,source) \ 62 | -f Dockerfile.bcc . 63 | 64 | itest_%: clean-cache docker-bcc-base-% 65 | ./itest/itest_generic.sh $* 66 | 67 | test: clean-cache 68 | tox 69 | 70 | test-all: clean-cache 71 | set -e 72 | make test 73 | make itest 74 | $(foreach release, $(SUPPORTED_UBUNTU_RELEASES), make package_ubuntu_$(release);) 75 | $(foreach release, $(SUPPORTED_UBUNTU_RELEASES), make itest_ubuntu_$(release);) 76 | 77 | package_%: docker-base-% 78 | make -C packaging package_$* 79 | 80 | clean-cache: 81 | find -name '*.pyc' -delete 82 | find -name '__pycache__' -delete 83 | 84 | clean: clean-cache 85 | rm -Rf packaging/dist itest/dist 86 | rm -Rf itest/tmp 87 | rm -Rf .tox venv 88 | 89 | release: 90 | "$(EDITOR)" $(VERSION_FILE) 91 | make -C packaging changelog VERSION_FILE=$(VERSION_FILE) 92 | version=$$(grep __version__ $(VERSION_FILE) | grep -Po "(?<=')([^']+)") 93 | git commit -am "Release $$version" 94 | git tag v$$version 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pidtree-bcc 2 | > bcc script for tracing process tree ancestry for connect syscalls 3 | 4 | [![Build Status](https://github.com/Yelp/pidtree-bcc/actions/workflows/test.yml/badge.svg)](https://github.com/Yelp/pidtree-bcc/actions) 5 | 6 | ## What 7 | `pidtree-bcc` utilizes the [bcc toolchain](https://github.com/iovisor/bcc) to 8 | create kprobes for (currently only) tcpv4 connect syscalls, and tracing the 9 | ancestry of the process that made the syscall. 10 | 11 | It also aims to have a tunable set of in-kernel filtering features in order to 12 | prevent excessive logging for things like loopback and RFC1918 `connect`s 13 | 14 | ## Why 15 | Security monitoring purposes. ML based products like Amazon's GuardDuty will 16 | tell you when hosts in your infrastructure have made "anomalous" outbound 17 | requests, but often these are as-intended but not known about by the team 18 | investigating the network traffic. Because of the transient nature of processes, 19 | often any useful context is lost by the time investigation can occur. 20 | 21 | `pidtree-bcc` is a supplementary intrusion detection system which 22 | utilizes the eBPF kernel subsystem to notify a userland daemon of all 23 | events so that they can be traced. It enables engineers to quickly 24 | identify familiar process trees (for instance, a familiar service name 25 | which corresponds to domain names associated with the destination IP 26 | address) or another engineer as the originator of request via the 27 | username associated with the process. 28 | 29 | ## Features 30 | - Full process tree attestation for outbound IPv4 TCP connections and 31 | additional process metadata 32 | - PID `pid` 33 | - Command-line for process `cmdline` 34 | - Owner of process `username` 35 | - Populated with UID when no `/etc/passwd` is mounted 36 | - Connection metadata including 37 | - Source IP `saddr` 38 | - Destination IP `daddr` 39 | - Destination port `port` 40 | - Full process tree attestation for IPv4 TCP/UDP listeners with the 41 | same process metadata as above and 42 | - Local bind address `laddr` 43 | - Listening port `port` 44 | - Network protocol `protocol` (e.g. tcp) 45 | - Configurable to also periodically provide snapshots of all listening processes 46 | - Best effort tracking of UDP sessions with configurability and output 47 | similar to the ones of TCP outbound connections. 48 | - Optional plugin system for enriching events in userland 49 | - Included `sourceipmap` plugin for mapping source address 50 | - Included `loginuidmap` plugin for adding loginuid info to process tree 51 | 52 | ## Caveats 53 | * bcc compiles your eBPF "program" to bytecode at runtime, 54 | and as such needs the appropriate kernel headers installed on the host. 55 | * The current probe implementations only support IPv4. 56 | * The userland daemon is likely susceptible to interference or denial of 57 | service, however the main aim of the project is to reduce the MTTR for 58 | "business as usual" events - that is to make so engineers spend less time 59 | chasing events that were not actually suspicious 60 | * It's possible to cause a race condition in the userland daemon in that 61 | the process or parent process that triggers the kprobe may in fact 62 | exit before the userland daemon tries to inspect it. Setting niceness 63 | values might help, but it is better to consider loopback addresses to 64 | be out-of-scope. 65 | 66 | ## Dependencies 67 | See the installation instructions for [bcc](https://github.com/iovisor/bcc). 68 | It is required for the `python3-bcc` package and its dependencies to be installed. 69 | 70 | Most notably, you need a kernel with eBPF enabled (4.4 onward) and the 71 | Linux headers for your running kernel version installed. For a 72 | quick-start, there is a Dockerfile included and a make target (`make 73 | docker-run`) to launch pidtree-bcc. Following the thread here is the 74 | best way to get a full view of the requisite state of the system for 75 | pidtree-bcc to work. 76 | 77 | ## Probes 78 | Pidtree-bcc implements a modular probe system which allows multiple eBPF programs 79 | to be compiled and run in parallel. Probe loading is handled via the top-level keys 80 | in the configuration (see [`example_config.yml`](example_config.yml) for inline documentation). 81 | 82 | Currently, this repository implements the `tcp_connect`, `net_listen` and `udp_session` probes. 83 | It is possible to extend this system with external packages via the `--extra-probe-path` 84 | command line parameter. 85 | 86 | ## Usage 87 | > CAUTION! The Makefile calls 'docker run' with `--priveleged`, 88 | > `--cap-add=SYS_ADMIN` and `--pid host` so it is your responsibility 89 | > to understand what this means and ensure that it's not going to do 90 | > anything untoward! 91 | 92 | With docker installed: 93 | ``` 94 | make docker-run 95 | ``` 96 | 97 | ... and you should see json output detailing the process tree for any process 98 | making TCP ipv4 `connect` syscalls like this one of me connecting to Freenode in weechat. 99 | ```json 100 | { 101 | "proctree": [ 102 | { 103 | "username": "oholiab", 104 | "cmdline": "weechat", 105 | "pid": 1775 106 | }, 107 | { 108 | "username": "oholiab", 109 | "cmdline": "weechat", 110 | "pid": 23769 111 | }, 112 | { 113 | "username": "oholiab", 114 | "cmdline": "-zsh", 115 | "pid": 23231 116 | }, 117 | { 118 | "username": "oholiab", 119 | "cmdline": "tmux", 120 | "pid": 1923 121 | }, 122 | { 123 | "username": "root", 124 | "cmdline": "/sbin/init", 125 | "pid": 1 126 | } 127 | ], 128 | "timestamp": "2019-11-12T14:24:57.532744Z", 129 | "pid": 1775, 130 | "daddr": "185.30.166.37", 131 | "saddr": "X.X.X.X", 132 | "error": "", 133 | "port": 6697, 134 | "probe": "tcp_connect" 135 | } 136 | ``` 137 | 138 | Notably you'll not see any for the 127/8, 169.254/16, 10/8, 192.168/16 139 | or 172.16/12 ranges because of the subnet filters I've included in the 140 | `example_config.yaml` eBPF program. This is obviously not an exhaustive 141 | list of addresses you might want to filter, so you can use the example 142 | configuration to write your own. 143 | 144 | Additionally, you can make the filters apply only to certain ports, using `except_ports` and `include_ports`. 145 | For example: 146 | 147 | ```yaml 148 | tcp_connect: 149 | filters: 150 | - subnet_name: 10 151 | network: 10.0.0.0 152 | network_mask: 255.0.0.0 153 | description: "all RFC 1918 10/8" 154 | except_ports: [80] 155 | ``` 156 | 157 | Would mean filter out all traffic from 10.0.0.0/8 except for that on port 80. If you changed except_ports 158 | to include_ports, then it would filter out only traffic to 10.0.0.0/8 on port 80. 159 | 160 | In addition, you can add a global config for filtering out all traffic except those for specific ports, 161 | using the option `includeports`. There also exists the specular global probe config `excludeports` which 162 | allows to specify a list of ports or port ranges to exclude from event capturing. These parameters are 163 | available for all currently implement probes (`tcp_connect`, `net_listen` and `udp_session`) and are mutually 164 | exclusive. If both are specified for a single probe, `includeports` will have precedence. 165 | 166 | There are times in which the configuration may get a bit too verbose, and to address that it is 167 | possible to split it into multiple files which can then be loaded using the `!include` custom 168 | YAML constructor ([example](./itest/config_autoreload.yml)). 169 | 170 | ## Plugins 171 | Plugin configuration is populated using the `plugins` key at the top level of the probe configuration: 172 | 173 | ```yaml 174 | probe_x: 175 | ... 176 | plugins: 177 | somepluginname: 178 | enabled: #True by default 179 | unload_on_init_exception: #False by default 180 | arg_1: "blah" 181 | arg_2: 182 | - some 183 | - values 184 | arg... 185 | ``` 186 | 187 | See below for a working example 188 | 189 | Plugins with no `enabled` argument set will be *enabled by default* 190 | 191 | The `unload_on_init_exception` boolean allows you to save pidtree-bcc 192 | from module misconfiguration for any given plugin configuration dict by 193 | simply setting it to `True`. Exceptions will be printed to stderr and 194 | the plugin will not be loaded. 195 | 196 | It is possible to extend this system by loading plugins from external 197 | packages via the `--extra-plugin-path` command line parameter. 198 | 199 | ### Sourceipmap 200 | This plugin adds in a key-value pair to the connection metadata 201 | (top-level) with a configurable key and a value given by mapping the IP 202 | to a name given by a merged series of /etc/hosts format hostfiles. If 203 | there is no corresponding name an empty string is returned. 204 | 205 | Configuration is re-read in to memory a minimum of every 2 seconds, so 206 | connections *can* be misattributed. 207 | 208 | To enable the `sourceipmap` plugin, simply include a `plugins` stanza in the config like so: 209 | 210 | ```yaml 211 | ... 212 | plugins: 213 | sourceipmap: 214 | enabled: True 215 | hostfiles: 216 | - "/etc/array" 217 | - "/etc/of" 218 | - "/etc/hostfiles" 219 | attribute_key: "source_host" 220 | ``` 221 | 222 | If you're looking to map source container names, you might want to try 223 | running `itests/gen-ip-mapping.sh FILENAME INTERVAL` which will populate 224 | `FILENAME` with a map of ips to docker container names ever `INTERVAL` 225 | seconds. 226 | 227 | If you then volume mount the *directory* that this file is in (the contents 228 | of the file will not update if you bind mount it in directly) to a location 229 | like `/maps`, you can then use a configuration like: 230 | 231 | ```yaml 232 | ... 233 | plugins: 234 | sourceipmap: 235 | enabled: True 236 | hostfiles: 237 | - "/maps/ipmapping.txt" 238 | attribute_key: "source_container" 239 | ``` 240 | 241 | ### LoginuidMap 242 | This plugin adds `loginuid` information (the ID and the corresponding username) 243 | to the logged process data. It can be configured to either populate info just for 244 | the top level event (i.e. the leaf process in the tree) or to iterate over all 245 | tree nodes. The info will be stored in the `loginuid` and `loginname` fields. 246 | 247 | ```yaml 248 | ... 249 | plugins: 250 | loginuidmap: 251 | enabled: True 252 | top_level: 253 | ``` 254 | 255 | ## Development caveats 256 | * Plugins must define explicitly the probes they support via the `PROBE_SUPPORT` class 257 | variable. It is possible to specify the wildcard `*` to state that a plugin is 258 | compatible with all probes, but that is to be used with care to avoid runtime issues. 259 | * Probes support the concept of "sidecar" threads. Due to the limitations of Python's 260 | threading implementation, these should only implement lightweight tasks in order to 261 | avoid "stealing" performance from the main probe process. 262 | * Most of the code is self-documenting, so if something is not clear, try to look in the 263 | docstrings. 264 | * It is possible to use private Docker base images for testing by setting the environment 265 | variable `DOCKER_BASE_IMAGE_TMPL`. This is expected to contain the substring "OS_RELEASE_PH" 266 | which will be replaced by the targeted OS release codenames. 267 | -------------------------------------------------------------------------------- /bin/pidtree-bcc: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$INSTALL_HEADERS_AT_RUNTIME" = "true" ]; then 4 | if ! dpkg -l linux-headers-$(uname -r) >/dev/null 2>&1; then 5 | while fuser /var/lib/dpkg/lock >/dev/null 2>&1; do 6 | echo "Waiting for dpkg lock to free" 7 | sleep 5 8 | done 9 | apt-get -y install linux-headers-$(uname -r) 10 | fi 11 | fi 12 | 13 | exec /opt/venvs/pidtree-bcc/bin/python3 -m pidtree_bcc.main $@ 14 | -------------------------------------------------------------------------------- /example_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _net_filters: &net_filters 3 | - subnet_name: 10 # name for the filter (must be unique) 4 | network: 10.0.0.0 # network address in dot-notation 5 | network_mask: 255.0.0.0 # network mask in dot-notation 6 | description: "all RFC 1918 10/8" # just a human readable description 7 | # except_ports: [443] # do not filter traffic for this set of ports 8 | # include_ports: [80] # only filters traffic for this set of ports 9 | - subnet_name: 17216 10 | network: 172.16.0.0 11 | network_mask: 255.240.0.0 12 | description: "all RFC 1918 172.16/12" 13 | - subnet_name: 169254 14 | network: 169.254.0.0 15 | network_mask: 255.255.0.0 16 | description: "all 169.254/16 loopback" 17 | - subnet_name: 127 18 | network: 127.0.0.0 19 | network_mask: 255.0.0.0 20 | description: "all 127/8 loopback" 21 | 22 | 23 | # Some configuration fields supported by all probes: 24 | # filters: list of network filters (see above for schema); they act on the destination 25 | # address for tcp_connect and udp_session, local listening address for net_listen 26 | # container_labels: list of label glob patterns to only capture events generated from processes in containers; 27 | # each entry is in the format `label1=patter1,label2=pattern2,...` where commas are treated like 28 | # and AND, while different entries are OR'ed to each other 29 | # excludeports: list of ports to be filtered out (cannot be used with includeports) 30 | # includeports: list of ports for which events will be logged (filters out all the others) (cannot be used with excludeports) 31 | # plugins: map of plugins to enable for the probe (check README for more details) 32 | 33 | udp_session: 34 | filters: *net_filters 35 | tcp_connect: 36 | filters: *net_filters 37 | container_labels: 38 | - key=value 39 | plugins: 40 | sourceipmap: 41 | enabled: True 42 | hostfiles: 43 | - '/etc/hosts' 44 | attribute_key: "source_host" 45 | net_listen: 46 | snapshot_periodicity: 43200 # how often the probe should output a full list of the listening processes (seconds, off by default) 47 | protocols: [tcp] # for which protocols events get logged (choices: tcp, udp) 48 | same_namespace_only: False # filter out events for network namespaces different from the one of the pidtree-bcc process (off by default) 49 | exclude_random_bind: False # filter out bind events using port 0 (affects UDP events only, off by default) 50 | filters: 51 | - subnet_name: 127 52 | network: 127.0.0.0 53 | network_mask: 255.0.0.0 54 | description: "all 127/8 loopback" 55 | excludeports: 56 | - 22222 57 | - 30000-40000 58 | -------------------------------------------------------------------------------- /itest/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pidtree-itest-base:latest 2 | RUN INSTALL_ONLY=true /work/setup.sh 3 | -------------------------------------------------------------------------------- /itest/Dockerfile.ubuntu: -------------------------------------------------------------------------------- 1 | ARG OS_RELEASE 2 | FROM pidtree-docker-base-bcc-${OS_RELEASE} 3 | 4 | ARG HOSTRELEASE 5 | # Second definition of OS_RELEASE because it gets lost after FROM statement 6 | ARG OS_RELEASE 7 | 8 | # Test package install 9 | ADD entrypoint_deb_package.sh /work/entrypoint_deb_package.sh 10 | ADD dist/ubuntu_${OS_RELEASE}/ /work/dist/ 11 | RUN /work/entrypoint_deb_package.sh setup ${HOSTRELEASE} 12 | -------------------------------------------------------------------------------- /itest/config_autoreload.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _net_filters: &net_filters !include filters.yml 3 | 4 | tcp_connect: 5 | filters: *net_filters 6 | -------------------------------------------------------------------------------- /itest/config_filters_autoreload.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - subnet_name: 0_0_0_0__2 3 | network: 0.0.0.0 4 | network_mask: 192.0.0.0 5 | description: "Non-loopback subnet section" 6 | - subnet_name: 64_0_0_0__3 7 | network: 64.0.0.0 8 | network_mask: 224.0.0.0 9 | description: "Non-loopback subnet section" 10 | - subnet_name: 96_0_0_0__4 11 | network: 96.0.0.0 12 | network_mask: 240.0.0.0 13 | description: "Non-loopback subnet section" 14 | - subnet_name: 112_0_0_0__5 15 | network: 112.0.0.0 16 | network_mask: 248.0.0.0 17 | description: "Non-loopback subnet section" 18 | - subnet_name: 120_0_0_0__6 19 | network: 120.0.0.0 20 | network_mask: 252.0.0.0 21 | description: "Non-loopback subnet section" 22 | - subnet_name: 124_0_0_0__7 23 | network: 124.0.0.0 24 | network_mask: 254.0.0.0 25 | description: "Non-loopback subnet section" 26 | - subnet_name: 126_0_0_0__8 27 | network: 126.0.0.0 28 | network_mask: 255.0.0.0 29 | description: "Non-loopback subnet section" 30 | - subnet_name: 128_0_0_0__1 31 | network: 128.0.0.0 32 | network_mask: 128.0.0.0 33 | description: "Non-loopback subnet section" 34 | - subnet_name: 127_0_0_0__16 35 | network: 127.0.0.0 36 | network_mask: 255.255.0.0 37 | description: "127.0/16 to get rid of the noise" 38 | - subnet_name: testcase 39 | network: 40 | network_mask: 255.255.255.255 41 | description: "filter for autoreload testcase" 42 | -------------------------------------------------------------------------------- /itest/config_generic.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _net_filters: &net_filters 3 | - subnet_name: 0_0_0_0__2 4 | network: 0.0.0.0 5 | network_mask: 192.0.0.0 6 | description: "Non-loopback subnet section" 7 | - subnet_name: 64_0_0_0__3 8 | network: 64.0.0.0 9 | network_mask: 224.0.0.0 10 | description: "Non-loopback subnet section" 11 | - subnet_name: 96_0_0_0__4 12 | network: 96.0.0.0 13 | network_mask: 240.0.0.0 14 | description: "Non-loopback subnet section" 15 | - subnet_name: 112_0_0_0__5 16 | network: 112.0.0.0 17 | network_mask: 248.0.0.0 18 | description: "Non-loopback subnet section" 19 | - subnet_name: 120_0_0_0__6 20 | network: 120.0.0.0 21 | network_mask: 252.0.0.0 22 | description: "Non-loopback subnet section" 23 | - subnet_name: 124_0_0_0__7 24 | network: 124.0.0.0 25 | network_mask: 254.0.0.0 26 | description: "Non-loopback subnet section" 27 | - subnet_name: 126_0_0_0__8 28 | network: 126.0.0.0 29 | network_mask: 255.0.0.0 30 | description: "Non-loopback subnet section" 31 | - subnet_name: 128_0_0_0__1 32 | network: 128.0.0.0 33 | network_mask: 128.0.0.0 34 | description: "Non-loopback subnet section" 35 | - subnet_name: 127_0_0_0__16 36 | network: 127.0.0.0 37 | network_mask: 255.255.0.0 38 | description: "127.0/16 to get rid of the noise" 39 | - subnet_name: 127_100_0_0__16 40 | network: 127.100.0.0 41 | network_mask: 255.255.0.0 42 | description: "Test case for except_ports" 43 | except_ports: [] 44 | - subnet_name: 127_101_0_0__16 45 | network: 127.101.0.0 46 | network_mask: 255.255.0.0 47 | description: "Test case for include_ports" 48 | include_ports: [] 49 | 50 | tcp_connect: 51 | filters: *net_filters 52 | excludeports: 53 | - 54 | net_listen: 55 | filters: *net_filters 56 | exclude_random_bind: True 57 | excludeports: 58 | - 59 | udp_session: 60 | filters: *net_filters 61 | includeports: 62 | - 63 | -------------------------------------------------------------------------------- /itest/config_mapping.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tcp_connect: 3 | filters: 4 | - subnet_name: 10 5 | network: 10.0.0.0 6 | network_mask : 255.0.0.0 7 | description: "all RFC 1918 10/8" 8 | - subnet_name: 17216 9 | network: 172.16.0.0 10 | network_mask : 255.240.0.0 11 | description: "all RFC 1918 172.16/12" 12 | - subnet_name: 169254 13 | network: 169.254.0.0 14 | network_mask : 255.255.0.0 15 | description: "all 169.254/16 loopback" 16 | - subnet_name: 127 17 | network: 127.0.0.0 18 | network_mask : 255.0.0.0 19 | description: "all 127/8 loopback" 20 | 21 | plugins: 22 | sourceipmap: 23 | enabled: True 24 | attribute_key: "source_container" 25 | hostfiles: 26 | - /maps/ipmapping.txt 27 | identityplugin: 28 | enabled: True 29 | -------------------------------------------------------------------------------- /itest/entrypoint_deb_package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -xe 2 | 3 | function setup { 4 | # In the event of running tests for a newer release on an older host 5 | # machine we need to forward port the kernel headers 6 | host_release=$1 7 | apt-get update 8 | apt-get -y install lsb-release 9 | source /etc/lsb-release 10 | if [[ "$host_release" != "$DISTRIB_CODENAME" && "$host_release" != "" ]]; then 11 | echo "deb http://archive.ubuntu.com/ubuntu/ ${host_release} main" >> /etc/apt/sources.list.d/forwardports.list 12 | echo "deb http://archive.ubuntu.com/ubuntu/ ${host_release}-updates main" >> /etc/apt/sources.list.d/forwardports.list 13 | echo "deb http://archive.ubuntu.com/ubuntu/ ${host_release}-security main" >> /etc/apt/sources.list.d/forwardports.list 14 | fi 15 | missing_gpg="$(apt-get update | grep 'NO_PUBKEY' | head -1)" 16 | if [[ "$missing_gpg" != '' ]]; then 17 | apt-key adv --recv-keys --keyserver keyserver.ubuntu.com $(echo "$missing_gpg" | grep -Eo '[^ ]+$') 18 | apt-get update 19 | fi 20 | apt-get -y install linux-headers-$(uname -r) 21 | if [ -f /etc/apt/sources.list.d/forwardports.list ]; then 22 | rm /etc/apt/sources.list.d/forwardports.list 23 | fi 24 | apt-get update 25 | apt-get -y install /work/dist/*.deb 26 | } 27 | function run { 28 | mount -t debugfs debugfs /sys/kernel/debug 29 | pidtree-bcc --help 30 | pidtree-bcc $@ 31 | } 32 | 33 | CMD=$1 34 | shift 35 | if [[ "$CMD" = "setup" ]]; then 36 | setup $@ 37 | elif [[ "$CMD" = "run" ]]; then 38 | run $@ 39 | else 40 | setup 41 | run 42 | fi 43 | -------------------------------------------------------------------------------- /itest/gen-ip-mapping.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MAPFILE=${1-tmp/ip_mapping.txt} 4 | INTERVAL=${2-2} 5 | 6 | mkdir -p $(dirname $MAPFILE) 7 | while true; do 8 | docker ps -q | xargs -n 1 docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} {{ .Name }}' | sed 's/ \// /' > $MAPFILE 9 | sleep $INTERVAL 10 | done 11 | -------------------------------------------------------------------------------- /itest/itest_autoreload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export TOPLEVEL=$(git rev-parse --show-toplevel) 4 | export CONTAINER_NAME=pidtree-autoreload-itest-$$ 5 | export OUTPUT_NAME=itest/tmp/itest-autoreload-$$ 6 | export DADDR='127.1.33.7' 7 | 8 | mkdir -p $TOPLEVEL/itest/tmp/autoreload 9 | 10 | function cleanup { 11 | set +e 12 | docker kill $CONTAINER_NAME 13 | rm -f $TOPLEVEL/$OUTPUT_NAME 14 | } 15 | 16 | function create_connect_event { 17 | echo "Creating connection event" 18 | nc -w 2 -l -p 41337 -s $1 & 19 | listener_pid=$! 20 | sleep 1 21 | nc -w 1 $1 41337 22 | wait $listener_pid 23 | echo "Connection event completed" 24 | } 25 | 26 | function test_output { 27 | echo "Waiting for pidtree-bcc output, looking for $DADDR" 28 | create_connect_event $DADDR & 29 | connect_pid=$! 30 | tail -n0 -f $OUTPUT_NAME | while read line; do 31 | if echo "$line" | grep "$DADDR"; then 32 | echo "Caught test traffic" 33 | pkill -x --parent $$ tail 34 | break 35 | fi 36 | done 37 | wait $connect_pid 38 | exit 0 39 | } 40 | 41 | function write_config { 42 | cp $TOPLEVEL/itest/config_autoreload.yml $TOPLEVEL/itest/tmp/autoreload/config.yml 43 | sed "s//$1/g" $TOPLEVEL/itest/config_filters_autoreload.yml > $TOPLEVEL/itest/tmp/autoreload/filters.yml 44 | } 45 | 46 | trap cleanup INT EXIT 47 | 48 | touch $TOPLEVEL/$OUTPUT_NAME 49 | 50 | if [ -f /etc/lsb-release ]; then 51 | source /etc/lsb-release 52 | else 53 | echo "WARNING: Could not source /etc/lsb-release, tentatively creating jammy docker image" 54 | DISTRIB_CODENAME=jammy 55 | fi 56 | docker build -t pidtree-itest-base --build-arg OS_RELEASE=$DISTRIB_CODENAME . 57 | docker build -t pidtree-itest itest 58 | 59 | echo "Creating background pidtree-bcc container to catch traffic" 60 | write_config $DADDR 61 | docker run --name $CONTAINER_NAME --rm -d \ 62 | --privileged --cap-add sys_admin --pid host \ 63 | -v $TOPLEVEL/itest/tmp/autoreload:/work/config \ 64 | -v $TOPLEVEL/$OUTPUT_NAME:/work/output \ 65 | pidtree-itest -c /work/config/config.yml -f /work/output -w --health-check-period 1 66 | 67 | echo "Waiting a bit to let pidtree bootstrap" 68 | sleep 15 69 | 70 | export -f test_output 71 | export -f create_connect_event 72 | 73 | timeout 10s bash -c test_output 74 | if [ $? -eq 0 ]; then 75 | echo "ERRROR: first connection even should have been filtered" 76 | exit 1 77 | fi 78 | 79 | echo "Changing configuration values and waiting for hot-swap" 80 | write_config 1.1.1.1 81 | sleep 5 82 | 83 | timeout 20s bash -c test_output 84 | if [ $? -eq 0 ]; then 85 | echo "SUCCESS!" 86 | exit 0 87 | else 88 | echo "FAILED! (timeout)" 89 | exit 1 90 | fi 91 | -------------------------------------------------------------------------------- /itest/itest_generic.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eE 2 | 3 | export OUTPUT_NAME=itest/tmp/itest_output_$$ 4 | export TEST_PORT_1=${TEST_CONNECT_PORT:-31337} 5 | export TEST_PORT_2=${TEST_LISTEN_PORT:-41337} 6 | export TEST_LISTEN_TIMEOUT=${TEST_LISTEN_TIMEOUT:-2} 7 | export DEBUG=${DEBUG:-false} 8 | export CONTAINER_NAME=pidtree-itest_$1_$$ 9 | export TOPLEVEL=$(git rev-parse --show-toplevel) 10 | 11 | # The container takes a while to bootstrap so we have to wait before we emit the test event 12 | SPIN_UP_TIME=10 13 | # We also need to timout the test if the test event *isn't* caught 14 | TIMEOUT=$(( SPIN_UP_TIME + 5 )) 15 | # Format: test_name:test_event_generator:test_flag_to_match:exit_code 16 | TEST_CASES=( 17 | "tcp_connect:create_connect_event $TEST_PORT_1:nc -w 1 127.1.33.7 $TEST_PORT_1:0" 18 | "net_listen:create_listen_event $TEST_PORT_2 127.1.33.7:nc -w $TEST_LISTEN_TIMEOUT -lnp $TEST_PORT_2:0" 19 | "udp_session:create_udp_event $TEST_PORT_1:nc -w 1 -u 127.1.33.7 $TEST_PORT_1:0" 20 | "tcp_connect_exclude:create_connect_event $TEST_PORT_2:nc -w 1 127.1.33.7 $TEST_PORT_2:124" 21 | "udp_session_exclude:create_udp_event $TEST_PORT_2:nc -w 1 -u 127.1.33.7 $TEST_PORT_2:124" 22 | "net_listen_filter:create_listen_event $TEST_PORT_2 127.0.0.1:nc -w $TEST_LISTEN_TIMEOUT -lnp $TEST_PORT_2:124" 23 | "tcp_connect_exclude_for_net:create_connect_event $TEST_PORT_1 127.100.33.7:nc -w 1 127.100.33.7 $TEST_PORT_1:0" 24 | "tcp_connect_exclude_for_net_filtered:create_connect_event $TEST_PORT_2 127.100.33.7:nc -w 1 127.100.33.7 $TEST_PORT_2:124" 25 | "tcp_connect_include_for_net:create_connect_event $TEST_PORT_1 127.101.33.7:nc -w 1 127.101.33.7 $TEST_PORT_1:124" 26 | ) 27 | 28 | function is_port_used { 29 | USED_PORTS=$(ss -4lnt | awk 'FS="[[:space:]]+" { print $4 }' | cut -d: -f2 | sort) 30 | if [ "$(echo "$USED_PORTS" | grep -E "^${1}\$")" = "$1" ]; then 31 | echo "ERROR: port $1 already in use, please reassign and try again" 32 | exit 2 33 | fi 34 | } 35 | 36 | function create_connect_event { 37 | echo "Creating test listener" 38 | nc -w $TEST_LISTEN_TIMEOUT -l -p $1 & 39 | listener_pid=$! 40 | sleep 1 41 | echo "Making test connection" 42 | nc -w 1 ${2:-127.1.33.7} $1 43 | wait $listener_pid 44 | } 45 | 46 | function create_listen_event { 47 | echo "Creating test listener" 48 | sleep 1 49 | nc -w $TEST_LISTEN_TIMEOUT -lnp $1 -s $2 2> /dev/null 50 | } 51 | 52 | function create_udp_event { 53 | echo "Creating test UDP listener" 54 | nc -u -w $TEST_LISTEN_TIMEOUT -l -p $1 & > /dev/null 55 | listener_pid=$! 56 | sleep 1 57 | echo "Making test UDP connection" 58 | echo "Hello World!" | nc -w 1 -u 127.1.33.7 $1 59 | wait $listener_pid 60 | } 61 | 62 | function cleanup { 63 | echo "CLEANUP: Caught EXIT" 64 | set +eE 65 | echo "CLEANUP: Killing container" 66 | docker kill $CONTAINER_NAME 67 | echo "CLEANUP: Removing FIFO" 68 | rm -f $TOPLEVEL/$OUTPUT_NAME $TOPLEVEL/itest/config.yml 69 | } 70 | 71 | function wait_for_tame_output { 72 | echo "Tailing output $OUTPUT_NAME to catch test traffic '$1'" 73 | tail -n0 -f $TOPLEVEL/$OUTPUT_NAME | while read line; do 74 | if echo "$line" | grep "$1"; then 75 | echo "Caught test traffic matching '$1'" 76 | pkill -x --parent $$ tail 77 | exit 0 78 | elif [ "$DEBUG" = "true" ]; then 79 | echo "DEBUG: \$line is $line" 80 | fi 81 | done 82 | } 83 | 84 | function main { 85 | trap cleanup EXIT 86 | mkdir -p $TOPLEVEL/itest/tmp 87 | is_port_used $TEST_PORT_1 88 | is_port_used $TEST_PORT_2 89 | sed "s//$TEST_PORT_1/g; s//$TEST_PORT_2/g" $TOPLEVEL/itest/config_generic.yml > $TOPLEVEL/itest/tmp/config.yml 90 | if [ "$DEBUG" = "true" ]; then set -x; fi 91 | touch $TOPLEVEL/$OUTPUT_NAME 92 | if [[ "$1" = "docker" ]]; then 93 | echo "Building itest image" 94 | # Build the base image 95 | if [ -f /etc/lsb-release ]; then 96 | source /etc/lsb-release 97 | else 98 | echo "WARNING: Could not source /etc/lsb-release, tentatively creating jammy docker image" 99 | DISTRIB_CODENAME=jammy 100 | fi 101 | docker build -t pidtree-itest-base --build-arg OS_RELEASE=$DISTRIB_CODENAME . 102 | # Run the setup.sh install steps in the image so we don't hit timeouts 103 | docker build -t pidtree-itest itest/ 104 | echo "Launching itest-container $CONTAINER_NAME" 105 | docker run --name $CONTAINER_NAME -d\ 106 | --rm --privileged --cap-add sys_admin --pid host \ 107 | -v $TOPLEVEL/itest/tmp/config.yml:/work/config.yml \ 108 | -v $TOPLEVEL/$OUTPUT_NAME:/work/outfile \ 109 | pidtree-itest -c /work/config.yml -f /work/outfile 110 | elif [[ "$1" =~ ^ubuntu_[a-z]+$ ]]; then 111 | if [ -f /etc/lsb-release ]; then 112 | source /etc/lsb-release 113 | else 114 | echo "WARNING: Could not source /etc/lsb-release - I do not know what distro we are on, you could experience weird effects as this is not supported outside of Ubuntu" >&2 115 | fi 116 | mkdir -p itest/dist/$1/ 117 | rm -f itest/dist/$1/*.deb 118 | cp $(ls -t packaging/dist/$1/*.deb | head -n 1) itest/dist/$1/ 119 | docker build -t pidtree-itest-$1 -f itest/Dockerfile.ubuntu \ 120 | --build-arg OS_RELEASE=${1/ubuntu_/} --build-arg HOSTRELEASE=$DISTRIB_CODENAME itest/ 121 | docker run --name $CONTAINER_NAME -d \ 122 | --rm --privileged --cap-add sys_admin --pid host \ 123 | -v $TOPLEVEL/itest/tmp/config.yml:/work/config.yml \ 124 | -v $TOPLEVEL/$OUTPUT_NAME:/work/outfile \ 125 | -v $TOPLEVEL/itest/dist/$1/:/work/dist \ 126 | pidtree-itest-$1 /work/entrypoint_deb_package.sh run -c /work/config.yml -f /work/outfile 127 | else 128 | echo "ERROR: '$@' is not a supported argument (see 'itest/itest_generic.sh' for options)" >&2 129 | exit 1 130 | fi 131 | echo "Sleeping $SPIN_UP_TIME seconds for pidtree-bcc to start" 132 | sleep $SPIN_UP_TIME 133 | export -f wait_for_tame_output 134 | export -f cleanup 135 | EXIT_CODE=0 136 | for test_case in "${TEST_CASES[@]}"; do 137 | test_name=$(echo "$test_case" | cut -d: -f1) 138 | test_event=$(echo "$test_case" | cut -d: -f2) 139 | test_check=$(echo "$test_case" | cut -d: -f3) 140 | test_exit=$(echo "$test_case" | cut -d: -f4) 141 | echo 142 | echo "############ $test_name ############" 143 | timeout $TIMEOUT bash -c "wait_for_tame_output '$test_check'" & 144 | WAIT_FOR_OUTPUT_PID=$! 145 | $test_event & 146 | WAIT_FOR_MOCK_EVENT=$! 147 | set +e 148 | wait $WAIT_FOR_OUTPUT_PID 149 | if [ "$?" -ne "$test_exit" ]; then 150 | echo "$test_name: FAILED!" 151 | EXIT_CODE=1 152 | else 153 | echo "$test_name: SUCCESS!" 154 | EXIT_CODE=0 155 | fi 156 | head -c $(($(echo -n $test_name | wc -c) + 26)) < /dev/zero | tr '\0' '#' 157 | echo 158 | wait $WAIT_FOR_MOCK_EVENT 159 | done 160 | echo 161 | exit $EXIT_CODE 162 | } 163 | 164 | if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 165 | main "$@" 166 | fi 167 | -------------------------------------------------------------------------------- /itest/itest_sourceipmap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | export TOPLEVEL=$(git rev-parse --show-toplevel) 4 | export CONTAINER_NAME=pidtree-mapping-itest-$$ 5 | export TAME_CONTAINER_NAME=hello-$$ 6 | export FIFONAME=itest/tmp/itest-sourceip-$$ 7 | 8 | mkdir -p $TOPLEVEL/itest/tmp 9 | 10 | $TOPLEVEL/itest/gen-ip-mapping.sh $TOPLEVEL/itest/tmp/maps/ipmapping.txt 2 & 11 | MAPGEN_PID=$! 12 | 13 | function cleanup { 14 | set +e 15 | kill $MAPGEN_PID 16 | docker kill $CONTAINER_NAME 17 | rm -f $TOPLEVEL/$FIFONAME 18 | } 19 | 20 | function test_output { 21 | echo "Waiting for pidtree-bcc output corresponding to container $TAME_CONTAINER_NAME" 22 | while read line; do 23 | echo $line | grep $TAME_CONTAINER_NAME 24 | [ $? -eq 0 ] && return 0 25 | done < $TOPLEVEL/$FIFONAME 26 | } 27 | 28 | trap cleanup INT EXIT 29 | 30 | mkfifo $TOPLEVEL/$FIFONAME 31 | 32 | if [ -f /etc/lsb-release ]; then 33 | source /etc/lsb-release 34 | else 35 | echo "WARNING: Could not source /etc/lsb-release, tentatively creating jammy docker image" 36 | DISTRIB_CODENAME=jammy 37 | fi 38 | docker build -t pidtree-itest-base --build-arg OS_RELEASE=$DISTRIB_CODENAME . 39 | docker build -t pidtree-itest itest 40 | docker pull ubuntu:latest 41 | echo "Creating background pidtree-bcc container to catch traffic" 42 | docker run --name $CONTAINER_NAME --rm -d\ 43 | --rm --privileged --cap-add sys_admin --pid host \ 44 | -v $TOPLEVEL/itest/config_mapping.yml:/work/config.yml \ 45 | -v $TOPLEVEL/itest/tmp/maps:/maps/ \ 46 | -v $TOPLEVEL/$FIFONAME:/work/output \ 47 | pidtree-itest -c /work/config.yml -f /work/output 48 | 49 | echo "Creating background container $TAME_CONTAINER_NAME to send traffic" 50 | docker run --name $TAME_CONTAINER_NAME --rm -d ubuntu:latest bash -c "sleep 15s; apt-get update" 51 | 52 | export -f test_output 53 | timeout 20s bash -c test_output 54 | 55 | if [ $? -eq 0 ]; then 56 | echo "SUCCESS!" 57 | exit 0 58 | else 59 | echo "FAILED! (timeout)" 60 | exit 1 61 | fi 62 | -------------------------------------------------------------------------------- /packaging/.dockerignore: -------------------------------------------------------------------------------- 1 | venv 2 | -------------------------------------------------------------------------------- /packaging/Dockerfile.ubuntu: -------------------------------------------------------------------------------- 1 | ARG OS_RELEASE 2 | FROM pidtree-docker-base-${OS_RELEASE} 3 | 4 | # Focal doesn't have dh-virtualenv in default repos 5 | # so we install it from the maintainer's PPA 6 | RUN if grep focal /etc/lsb-release; then \ 7 | apt-get update \ 8 | && DEBIAN_FRONTEND=noninteractive apt-get -y install software-properties-common \ 9 | && add-apt-repository ppa:jyrki-pulliainen/dh-virtualenv; \ 10 | fi 11 | 12 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y install \ 13 | python3 \ 14 | python3-pip \ 15 | dh-virtualenv \ 16 | dh-make \ 17 | build-essential \ 18 | debhelper \ 19 | devscripts \ 20 | equivs \ 21 | libyaml-dev \ 22 | && apt-get clean 23 | 24 | WORKDIR /work 25 | 26 | # we need to backpin system level six to force virtualenv to reinstall 27 | # a local copy when packaging, otherwise it won't be included in the .deb 28 | RUN if grep jammy /etc/lsb-release; then \ 29 | pip3 install --force-reinstall six==1.15.0; \ 30 | fi 31 | 32 | ADD . /work 33 | ADD packaging/debian /work/debian 34 | 35 | CMD /work/packaging/debian.sh 36 | -------------------------------------------------------------------------------- /packaging/Makefile: -------------------------------------------------------------------------------- 1 | # Set up `make` 2 | .ONESHELL: 3 | SHELL = /bin/bash 4 | MAKEFLAGS += --warn-undefined-variables 5 | 6 | # Helper variable 7 | TOPLEVEL = $(shell git rev-parse --show-toplevel) 8 | VERSION_FILE = $(TOPLEVEL)/pidtree_bcc/__init__.py 9 | 10 | # Variables 11 | VERSION = $(shell grep __version__ $(VERSION_FILE) | grep -Po "(?<=')([^']+)") 12 | PREFIX ?= usr 13 | 14 | .PHONY: changelog package_% 15 | 16 | changelog: $(VERSION_FILE) 17 | dch -v $(VERSION) 18 | 19 | package_%: 20 | make dist/$*/pidtree-bcc_$(VERSION).deb 21 | 22 | dist/%/pidtree-bcc_$(VERSION).deb: IMAGE_NAME=$(notdir $(basename $@))_build_$* 23 | dist/%/pidtree-bcc_$(VERSION).deb: debian/changelog Dockerfile.ubuntu 24 | mkdir -p dist/$* 25 | cd $(TOPLEVEL) 26 | docker build -f packaging/Dockerfile.ubuntu --build-arg OS_RELEASE=$(subst ubuntu_,,$*) -t $(IMAGE_NAME) . 27 | # even though we `cd`d above, `shell pwd` will still produce the full 28 | # path of the *packaging* directory 29 | docker run --rm -v $(shell pwd)/dist/$*:/work/dist $(IMAGE_NAME) 30 | -------------------------------------------------------------------------------- /packaging/debian.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | dpkg-buildpackage -us -uc 4 | mv ../*.deb dist 5 | chmod 777 dist/*.deb 6 | -------------------------------------------------------------------------------- /packaging/debian/changelog: -------------------------------------------------------------------------------- 1 | pidtree-bcc (2.6.2) unstable; urgency=medium 2 | 3 | * Make sure event tailing is line-buffered 4 | 5 | -- Matteo Piano Thu, 17 Apr 2025 09:29:29 -0700 6 | 7 | pidtree-bcc (2.6.1) unstable; urgency=medium 8 | 9 | * Better support containerd namespaces 10 | 11 | -- Matteo Piano Thu, 17 Apr 2025 07:16:12 -0700 12 | 13 | pidtree-bcc (2.6.0) unstable; urgency=medium 14 | 15 | * Improve handling of container tracing 16 | 17 | -- Matteo Piano Thu, 28 Nov 2024 01:49:53 -0800 18 | 19 | pidtree-bcc (2.5.0) unstable; urgency=medium 20 | 21 | * Support selecting events from specific containers 22 | 23 | -- Matteo Piano Thu, 07 Nov 2024 02:18:36 -0800 24 | 25 | pidtree-bcc (2.4.1) unstable; urgency=medium 26 | 27 | * Fix shutdown behaviour of remote config fetcher 28 | 29 | -- Matteo Piano Fri, 26 Jan 2024 03:26:04 -0800 30 | 31 | pidtree-bcc (2.4.0) unstable; urgency=medium 32 | 33 | * Add option to fetch decoupled config from remote source 34 | 35 | -- Matteo Piano Wed, 24 Jan 2024 03:43:21 -0800 36 | 37 | pidtree-bcc (2.3.1) unstable; urgency=medium 38 | 39 | * Patch BPF_PSEUDO_FUNC define for Jammy 40 | 41 | -- Matteo Piano Wed, 16 Nov 2022 04:02:32 -0800 42 | 43 | pidtree-bcc (2.3.0) unstable; urgency=medium 44 | 45 | * Allow splitting config in multiple files 46 | 47 | -- Matteo Piano Tue, 12 Jul 2022 08:10:38 -0700 48 | 49 | pidtree-bcc (2.2.0) unstable; urgency=medium 50 | 51 | * Actual support for Ubuntu Jammy 52 | 53 | -- Matteo Piano Fri, 01 Jul 2022 03:18:11 -0700 54 | 55 | pidtree-bcc (2.1.1) unstable; urgency=medium 56 | 57 | * Fix build for Ubuntu Jammy 58 | 59 | -- Matteo Piano Tue, 19 Apr 2022 08:03:08 -0700 60 | 61 | pidtree-bcc (2.1.0) unstable; urgency=medium 62 | 63 | * Dynamically set initial size of net filter maps 64 | 65 | -- Matteo Piano Fri, 11 Mar 2022 05:30:48 -0800 66 | 67 | pidtree-bcc (2.0.2) unstable; urgency=medium 68 | 69 | * Fix possible "dictionary change during iteration" bug 70 | 71 | -- Matteo Piano Fri, 11 Mar 2022 01:42:45 -0800 72 | 73 | pidtree-bcc (2.0.1) unstable; urgency=medium 74 | 75 | * Support for Ubuntu Jammy 76 | 77 | -- Matteo Piano Wed, 09 Mar 2022 04:53:39 -0800 78 | 79 | pidtree-bcc (2.0.0) unstable; urgency=medium 80 | 81 | * No changes from 2.0.0~rc0 82 | 83 | -- Matteo Piano Mon, 31 Jan 2022 01:19:07 -0800 84 | 85 | pidtree-bcc (2.0.0~rc0) unstable; urgency=medium 86 | 87 | * Restart if probe added/removed 88 | * Add mutex to filter reloads 89 | * Fix horrible classvar bug 90 | 91 | -- Matteo Piano Wed, 26 Jan 2022 05:06:33 -0800 92 | 93 | pidtree-bcc (2.0.0~alpha4) unstable; urgency=medium 94 | 95 | * Fix swapping of equilavent net-filter keys 96 | * Auto-restart when config hot-swap is not possible 97 | 98 | -- Matteo Piano Mon, 24 Jan 2022 08:53:18 -0800 99 | 100 | pidtree-bcc (2.0.0~alpha3) unstable; urgency=medium 101 | 102 | * Allow hot-swapping network filters when configuration changes 103 | 104 | -- Matteo Piano Thu, 20 Jan 2022 04:01:07 -0800 105 | 106 | pidtree-bcc (2.0.0~alpha2) unstable; urgency=medium 107 | 108 | * Switch global port filters to BPF arrays 109 | 110 | -- Matteo Piano Tue, 21 Dec 2021 03:04:36 -0800 111 | 112 | pidtree-bcc (2.0.0~alpha1) unstable; urgency=medium 113 | 114 | * Add map filtering to TCP connect and net listen probes 115 | * Fix port byte ordering in UDP probe 116 | * Add more itests for port filtering settings 117 | 118 | -- Matteo Piano Thu, 09 Dec 2021 04:28:42 -0800 119 | 120 | pidtree-bcc (2.0.0~alpha) unstable; urgency=medium 121 | 122 | * Plumbing to store network filtering into eBPF map 123 | * Migrate "UDP session" probe to dynamic net filters 124 | 125 | -- Matteo Piano Wed, 08 Dec 2021 08:52:43 -0800 126 | 127 | pidtree-bcc (1.8.0) unstable; urgency=medium 128 | 129 | * Improved system logging format 130 | * Monitor output file handle for errors 131 | * Better signal handling for sub-processes 132 | 133 | -- Matteo Piano Mon, 01 Feb 2021 04:33:01 -0800 134 | 135 | pidtree-bcc (1.7.2) unstable; urgency=low 136 | 137 | * Fix for package build on Ubuntu Xenial 138 | 139 | -- Matteo Piano Tue, 19 Jan 2021 04:31:31 -0800 140 | 141 | pidtree-bcc (1.7.1) unstable; urgency=medium 142 | 143 | * Option to filter out randomly bound ports in net_listen 144 | 145 | -- Matteo Piano Thu, 17 Dec 2020 04:15:52 -0800 146 | 147 | pidtree-bcc (1.7.0) unstable; urgency=medium 148 | 149 | * Optionally filter network listens by namespace 150 | 151 | -- Matteo Piano Tue, 15 Dec 2020 05:03:48 -0800 152 | 153 | pidtree-bcc (1.6.0) unstable; urgency=medium 154 | 155 | * Standardize ip-port filtering for net_listen probe 156 | 157 | -- Matteo Piano Mon, 14 Dec 2020 04:24:34 -0800 158 | 159 | pidtree-bcc (1.5.0) unstable; urgency=medium 160 | 161 | * Simplify UDP session tracing event key (to allow tracking sockets shared across processes) 162 | * Add option to emit lost event telemetry 163 | 164 | -- Matteo Piano Wed, 02 Dec 2020 06:58:40 -0800 165 | 166 | pidtree-bcc (1.4.1) unstable; urgency=medium 167 | 168 | * Fix port inclusion/exclusion bug in UDP session probe 169 | 170 | -- Matteo Piano Thu, 26 Nov 2020 05:48:19 -0800 171 | 172 | pidtree-bcc (1.4.0) unstable; urgency=medium 173 | 174 | * Normalize "excludeports" and "includeports" settings for all probes 175 | 176 | -- Matteo Piano Wed, 25 Nov 2020 07:27:49 -0800 177 | 178 | pidtree-bcc (1.3.2) unstable; urgency=medium 179 | 180 | * Clean exit on IO errors 181 | 182 | -- Matteo Piano Mon, 16 Nov 2020 05:31:19 -0800 183 | 184 | pidtree-bcc (1.3.1) unstable; urgency=medium 185 | 186 | * Better termination signal handling 187 | 188 | -- Matteo Piano Thu, 12 Nov 2020 07:43:04 -0800 189 | 190 | pidtree-bcc (1.3.0) unstable; urgency=medium 191 | 192 | * Added UDP session tracking probe 193 | 194 | -- Matteo Piano Thu, 05 Nov 2020 01:06:28 -0800 195 | 196 | pidtree-bcc (1.2.0) unstable; urgency=medium 197 | 198 | * Added snapshot functionality to net_listen probe 199 | * Added capability to load probes and plugins from external packages 200 | 201 | -- Matteo Piano Tue, 27 Oct 2020 10:25:50 -0700 202 | 203 | pidtree-bcc (1.1.0) unstable; urgency=medium 204 | 205 | * Added probe for network listen events 206 | 207 | -- Matteo Piano Mon, 26 Oct 2020 04:51:31 -0700 208 | 209 | pidtree-bcc (1.0.2) unstable; urgency=medium 210 | 211 | * Move probe keepalive checks to separate thread 212 | 213 | -- Matteo Piano Fri, 23 Oct 2020 09:14:55 -0700 214 | 215 | pidtree-bcc (1.0.1) unstable; urgency=medium 216 | 217 | * Fix process keepalive bug (version 1.0.0 is badly broken) 218 | 219 | -- Matteo Piano Thu, 22 Oct 2020 07:08:28 -0700 220 | 221 | pidtree-bcc (1.0.0) unstable; urgency=medium 222 | 223 | * Modular BPF probe system 224 | 225 | -- Matteo Piano Wed, 21 Oct 2020 08:41:16 -0700 226 | 227 | pidtree-bcc (0.7.2) unstable; urgency=medium 228 | 229 | * More flexible plugin loading 230 | * Added top_level option to loginuid plugin 231 | 232 | -- Matteo Piano Tue, 20 Oct 2020 09:52:07 -0700 233 | 234 | pidtree-bcc (0.7.1) unstable; urgency=medium 235 | 236 | * Added loginuid plugin 237 | 238 | -- Matteo Piano Tue, 20 Oct 2020 04:19:57 -0700 239 | 240 | pidtree-bcc (0.7.0) unstable; urgency=medium 241 | 242 | * Removed llvm and lsb-release from dependencies 243 | * Fix os.stderr to sys.stderr 244 | 245 | -- Matteo Piano Mon, 05 Oct 2020 05:08:00 -0700 246 | 247 | pidtree-bcc (0.6.3) unstable; urgency=medium 248 | 249 | * Explicitly testing support for BCC 0.12.0 250 | 251 | -- Matteo Piano Wed, 30 Sep 2020 10:00:15 -0700 252 | 253 | pidtree-bcc (0.6.2) unstable; urgency=medium 254 | 255 | * Moved installation dir to /opt/venv/pidtree-bcc 256 | * Packaged for Ubuntu Bionic 257 | 258 | -- Matthew Carroll Tue, 07 Apr 2020 03:22:17 -0700 259 | 260 | pidtree-bcc (0.6.1) unstable; urgency=medium 261 | 262 | * Minor version with nicer sigint handling and --verison support 263 | 264 | -- Matthew Carroll Wed, 01 Apr 2020 03:20:43 -0700 265 | 266 | pidtree-bcc (0.6) unstable; urgency=medium 267 | 268 | * Actual working release with an entrypoint and everything 269 | 270 | -- Matthew Carroll Tue, 31 Mar 2020 08:19:01 -0700 271 | 272 | pidtree-bcc (0.5) unstable; urgency=medium 273 | 274 | * Initial release. 275 | 276 | -- Matthew Carroll Thu, 26 Mar 2020 12:05:03 -0700 277 | -------------------------------------------------------------------------------- /packaging/debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /packaging/debian/control: -------------------------------------------------------------------------------- 1 | Source: pidtree-bcc 2 | Section: base 3 | Priority: optional 4 | Maintainer: Matt Carroll 5 | Build-Depends: dh-virtualenv 6 | 7 | Package: pidtree-bcc 8 | Architecture: amd64 9 | Depends: python3, ${pythonBCC:Depends}, ${shlibs:Depends} 10 | Description: eBPF based intrusion detection and audit logging 11 | -------------------------------------------------------------------------------- /packaging/debian/pidtree-bcc.links: -------------------------------------------------------------------------------- 1 | opt/venvs/pidtree-bcc/bin/pidtree-bcc usr/bin/pidtree-bcc 2 | -------------------------------------------------------------------------------- /packaging/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | ifeq ($(shell (. /etc/lsb-release && dpkg --compare-versions $$DISTRIB_RELEASE "ge" "22.04" && echo yes || echo no)),yes) 5 | # upstream BCC libraries are new enough on jammy (0.18 at the time of writing), and we are not building them from source anymore 6 | python_bcc = python3-bpfcc 7 | else 8 | python_bcc = python3-bcc 9 | endif 10 | 11 | ifeq ($(shell (. /etc/lsb-release && dpkg --compare-versions $$DISTRIB_RELEASE "le" "18.04" && echo yes || echo no)),yes) 12 | # bionic does not support newer pip/setuptools 13 | requirements_bootstrap = -rrequirements-bootstrap-bionic.txt 14 | else 15 | requirements_bootstrap = -rrequirements-bootstrap.txt 16 | endif 17 | 18 | # for some $reason this prevents virtualenv to spit a bunch of errors 19 | export VIRTUALENV_NO_PERIODIC_UPDATE = true 20 | 21 | export DH_VIRTUALENV_INSTALL_ROOT = /opt/venvs 22 | 23 | %: 24 | dh $@ --with python-virtualenv 25 | 26 | override_dh_gencontrol: 27 | dh_gencontrol -- -VpythonBCC:Depends="$(python_bcc)" 28 | 29 | override_dh_virtualenv: 30 | dh_virtualenv --python python3 --use-system-packages --no-test --preinstall=$(requirements_bootstrap) 31 | -------------------------------------------------------------------------------- /pidtree_bcc/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.6.2' 2 | -------------------------------------------------------------------------------- /pidtree_bcc/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import partial 3 | from multiprocessing import SimpleQueue 4 | from typing import Generator 5 | from typing import Iterable 6 | from typing import List 7 | from typing import Optional 8 | from typing import Tuple 9 | 10 | import staticconf.config 11 | import yaml 12 | from staticconf.config import ConfigNamespace 13 | from staticconf.config import ConfigurationWatcher 14 | from staticconf.config import DEFAULT as DEFAULT_NAMESPACE 15 | from staticconf.config import get_namespace 16 | from staticconf.config import get_namespaces_from_names 17 | from staticconf.loader import DictConfiguration 18 | 19 | from pidtree_bcc.utils import never_crash 20 | from pidtree_bcc.utils import self_restart 21 | from pidtree_bcc.utils import StopFlagWrapper 22 | from pidtree_bcc.yaml_loader import FileIncludeLoader 23 | 24 | 25 | HOTSWAP_CALLBACK_NAMESPACE = get_namespace('__change_callbacks') 26 | LOADED_CONFIG_FILES_NAMESPACE = get_namespace('__loaded_configs') 27 | HOT_SWAPPABLE_SETTINGS = ('filters', 'excludeports', 'includeports', 'container_labels') 28 | NON_PROBE_NAMESPACES = (DEFAULT_NAMESPACE, HOTSWAP_CALLBACK_NAMESPACE.name, LOADED_CONFIG_FILES_NAMESPACE.name) 29 | 30 | 31 | @never_crash 32 | def _forward_config_change(queue: SimpleQueue, config_data: dict): 33 | queue.put(config_data) 34 | 35 | 36 | def _non_hotswap_settings(config_data: dict) -> dict: 37 | return { 38 | k: v for k, v in config_data.items() 39 | if k not in HOT_SWAPPABLE_SETTINGS 40 | } 41 | 42 | 43 | def _get_probe_namespaces() -> Generator[ConfigNamespace, None, None]: 44 | """ Enumerate probe configuration namespaces """ 45 | # list() is used to avoid `RuntimeError: dictionary changed size during iteration` 46 | for namespace in list(get_namespaces_from_names(None, all_names=True)): 47 | if namespace.name not in NON_PROBE_NAMESPACES: 48 | yield namespace 49 | 50 | 51 | def _clear_and_restart(): 52 | """ Clear staticconf namespaces and restart """ 53 | reset_config_state() 54 | self_restart() 55 | 56 | 57 | def _drop_namespaces(names: Iterable[str]): 58 | """ Deletes configuration namespaces from staticconf 59 | 60 | :param Iterable[str] names: namespaces to drop 61 | """ 62 | for name in names: 63 | staticconf.config.configuration_namespaces.pop(name, None) 64 | 65 | 66 | def parse_config( 67 | config_file: str, 68 | watch_config: bool = False, 69 | stop_flag: Optional[StopFlagWrapper] = None, 70 | ) -> List[str]: 71 | """ Parses yaml config file (if indicated) 72 | 73 | :param str config_file: config file path 74 | :param bool watch_config: perform necessary setup to enable configuration hot swaps 75 | :return: list of all files loaded 76 | """ 77 | loader, included_files = FileIncludeLoader.get_loader_instance(stop_flag) 78 | with open(config_file) as f: 79 | config_data = yaml.load(f, Loader=loader) 80 | included_files = sorted({config_file, *included_files}) 81 | config_probe_names = {key for key in config_data if not key.startswith('_')} 82 | current_probe_names = {ns.name for ns in _get_probe_namespaces()} 83 | current_loaded_files = LOADED_CONFIG_FILES_NAMESPACE.get('files', default=None) 84 | if watch_config and ( 85 | (current_probe_names and config_probe_names != current_probe_names) 86 | or (current_loaded_files and current_loaded_files != included_files) 87 | ): 88 | # probes added or removed, triggering restart 89 | _drop_namespaces(current_probe_names - config_probe_names) 90 | _clear_and_restart() 91 | return included_files 92 | for key in config_probe_names: 93 | probe_config = config_data[key] 94 | config_namespace = get_namespace(key) 95 | current_values = config_namespace.get_config_values().copy() 96 | if key not in HOTSWAP_CALLBACK_NAMESPACE: 97 | # First time loading 98 | callback_method = partial(_forward_config_change, SimpleQueue()) if watch_config else None 99 | DictConfiguration({key: callback_method}, namespace=HOTSWAP_CALLBACK_NAMESPACE.name) 100 | elif watch_config: 101 | is_different = probe_config != current_values 102 | if is_different and _non_hotswap_settings(probe_config) != _non_hotswap_settings(current_values): 103 | # Non hot-swappable setting changed -> restart 104 | _clear_and_restart() 105 | break 106 | elif is_different: 107 | # Only hot-swappable settings changed, trigger proble filters reload 108 | HOTSWAP_CALLBACK_NAMESPACE[key](probe_config) 109 | # staticconf does clear namespaces before reloads, so we do it ourselves 110 | config_namespace.clear() 111 | DictConfiguration(probe_config, namespace=key, flatten=False) 112 | DictConfiguration({'files': included_files}, namespace=LOADED_CONFIG_FILES_NAMESPACE.name) 113 | return included_files 114 | 115 | 116 | def setup_config( 117 | config_file: str, 118 | watch_config: bool = False, 119 | min_watch_interval: int = 60, 120 | stop_flag: Optional[StopFlagWrapper] = None, 121 | ) -> Optional[ConfigurationWatcher]: 122 | """ Load and setup configuration file 123 | 124 | :param str config_file: config file path 125 | :param bool watch_config: perform necessary setup to enable configuration hot swaps 126 | :param int min_watch_interval: 127 | :return: if `watch_config` is set, the configuration watcher object, None otherwise. 128 | """ 129 | logging.getLogger('staticconf.config').setLevel(logging.WARN) 130 | config_loader = partial(parse_config, config_file, watch_config, stop_flag=stop_flag) 131 | filenames = config_loader() 132 | watcher = ConfigurationWatcher( 133 | config_loader=config_loader, 134 | filenames=filenames, 135 | min_interval=min_watch_interval, 136 | ) if watch_config else None 137 | return watcher 138 | 139 | 140 | def enumerate_probe_configs() -> Generator[Tuple[str, dict, Optional[SimpleQueue]], None, None]: 141 | """ List loaded probe configurations 142 | 143 | :return: tuple of probe name, configuration data, and optionally the queue for change notifications 144 | """ 145 | for namespace in _get_probe_namespaces(): 146 | curr_values = namespace.get_config_values().copy() 147 | change_callback = HOTSWAP_CALLBACK_NAMESPACE.get(namespace.name, default=None) 148 | change_queue = change_callback.args[0] if change_callback else None 149 | yield namespace.name, curr_values, change_queue 150 | 151 | 152 | def reset_config_state(): 153 | """ Reset all configuration namespaces """ 154 | for namespace in get_namespaces_from_names(None, all_names=True): 155 | namespace.clear() 156 | -------------------------------------------------------------------------------- /pidtree_bcc/containers.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import fnmatch 3 | import json 4 | import logging 5 | import os 6 | import shlex 7 | import subprocess 8 | import time 9 | from functools import lru_cache 10 | from itertools import chain 11 | from typing import Callable 12 | from typing import Generator 13 | from typing import Iterable 14 | from typing import List 15 | from typing import NamedTuple 16 | from typing import Set 17 | from typing import Tuple 18 | 19 | 20 | CONTAINERD_NAMESPACE_VAR = 'CONTAINERD_NAMESPACE' 21 | DEFAULT_CONTAINERD_NAMESPACE = 'k8s.io' 22 | 23 | 24 | class ContainerEventType(enum.Enum): 25 | start = 'start' 26 | stop = 'stop' 27 | 28 | 29 | class ContainerEvent(NamedTuple): 30 | event_type: ContainerEventType 31 | container_id: str 32 | 33 | def __bool__(self) -> bool: 34 | return bool(self.container_id) 35 | 36 | 37 | class MountNSInfo(NamedTuple): 38 | container_id: str 39 | container_name: str 40 | ns_id: int 41 | event_type: ContainerEventType = ContainerEventType.start 42 | 43 | 44 | @lru_cache(maxsize=1) 45 | def detect_containerizer_client() -> str: 46 | """ Detect whether the system is using Docker or Containerd. 47 | Since containerd does not have a proper python API implementation, we rely on 48 | CLI tools to query container information in both cases. 49 | This method is very opinionated towards detecting Containerd usage in Kubernetes, 50 | so in most cases it will fall back to standard Docker. 51 | 52 | :return: CLI tool to query containerizer 53 | """ 54 | containerd_namespace = os.getenv(CONTAINERD_NAMESPACE_VAR, DEFAULT_CONTAINERD_NAMESPACE) 55 | containerd_metadata_path = f'/var/run/containerd/io.containerd.runtime.v2.task/{containerd_namespace}' 56 | if os.path.exists(containerd_metadata_path): 57 | # ensure namespace env var is set for future subprocess invocations 58 | os.environ[CONTAINERD_NAMESPACE_VAR] = containerd_namespace 59 | cli_tool = 'nerdctl' 60 | else: 61 | cli_tool = 'docker' 62 | return cli_tool 63 | 64 | 65 | def list_containers(filter_labels: List[str] = None) -> List[str]: 66 | """ List running containers matching filter 67 | 68 | :param List[str] filter_labels: list of label values, either `` or `=` 69 | :return: yields container hash IDs 70 | """ 71 | filter_args = chain.from_iterable(('--filter', f'label={label}') for label in (filter_labels or [])) 72 | try: 73 | output = subprocess.check_output( 74 | ( 75 | detect_containerizer_client(), 'ps', 76 | '--no-trunc', '--quiet', *filter_args, 77 | ), encoding='utf8', 78 | ) 79 | return output.splitlines() 80 | except Exception as e: 81 | logging.error(f'Issue listing running containers: {e}') 82 | return [] 83 | 84 | 85 | def extract_container_name(inspect_data: dict) -> str: 86 | """ Extracts name from container information, falling back to labels if needed. 87 | This is needed because the "Name" field is basically always empty for containerd. 88 | 89 | :param dict inspect_data: container information 90 | :return: container name 91 | """ 92 | name = inspect_data.get('Name', '').lstrip('/') 93 | return ( 94 | name 95 | if name 96 | else inspect_data.get('Config', {}).get('Labels', {}).get('io.kubernetes.pod.name', '') 97 | ) 98 | 99 | 100 | @lru_cache(maxsize=2048) 101 | def inspect_container(sha: str) -> dict: 102 | """ Inspect container 103 | 104 | :param str sha: container hash ID 105 | :return: inspect data 106 | """ 107 | output = subprocess.check_output( 108 | (detect_containerizer_client(), 'inspect', sha), 109 | encoding='utf8', 110 | ) 111 | return json.loads(output)[0] 112 | 113 | 114 | @lru_cache(maxsize=20000) 115 | def get_container_mntns_id(sha: str, second_try: bool = False) -> int: 116 | """ Get mount namespace ID for a container 117 | 118 | :param str sha: container hash ID 119 | :return: mount namespace ID 120 | """ 121 | try: 122 | inspect_data = inspect_container(sha) 123 | main_pid = inspect_data['State']['Pid'] 124 | if main_pid == 0 and not second_try: 125 | # when detecting containers from the events stream, we may be 126 | # "too fast" and there is no process associated to the container yet 127 | time.sleep(0.5) 128 | return get_container_mntns_id(sha, second_try=True) 129 | except Exception as e: 130 | logging.error(f'Issue inspecting container {sha}: {e}') 131 | return -1 132 | try: 133 | return os.stat(f'/proc/{main_pid}/ns/mnt').st_ino 134 | except Exception as e: 135 | logging.error(f'Issue reading mntns ID for {main_pid}: {e}') 136 | return -1 137 | 138 | 139 | def filter_containers_with_label_patterns( 140 | container_ids: Iterable[str], 141 | patterns: Iterable[str], 142 | ) -> List[Tuple[str, str]]: 143 | """ Given a list of container IDs, find the ones with labels matching any of the patterns 144 | 145 | :param Iterable[str] container_ids: collection of container IDs 146 | :param Iterable[str] patterns: collection of label patterns, with entries in the format `=,...` 147 | :return: filtered list of container IDs 148 | """ 149 | result = [] 150 | unpacked_patterns = [ 151 | [pattern.split('=', 1) for pattern in pattern_set.split(',')] 152 | for pattern_set in patterns 153 | ] 154 | for container_id in container_ids: 155 | try: 156 | container_info = inspect_container(container_id) 157 | labels = container_info.get('Config', {}).get('Labels', {}) 158 | if labels and any( 159 | all( 160 | label_key in labels 161 | and fnmatch.fnmatch(labels[label_key], pattern) 162 | for label_key, pattern in pattern_set 163 | ) 164 | for pattern_set in unpacked_patterns 165 | ): 166 | result.append((container_id, extract_container_name(container_info))) 167 | except Exception as e: 168 | logging.error(f'Issue inspecting container {container_id}: {e}') 169 | return result 170 | 171 | 172 | def list_container_mnt_namespaces( 173 | patterns: Iterable[str] = None, 174 | generator: Callable[[], List[str]] = list_containers, 175 | ) -> Set[MountNSInfo]: 176 | """ Get collection of mount namespace IDs for running containers matching label filters 177 | 178 | :param Iterable[str] filter_labels: list of label values, `=,...` 179 | :param Callable[[], List[str]] generator: method to call to generate container ID list 180 | :return: set of mount namespace info 181 | """ 182 | patterns = patterns if patterns else [] 183 | return { 184 | mntns_info 185 | for mntns_info in ( 186 | MountNSInfo(container_id, name, get_container_mntns_id(container_id)) 187 | for container_id, name in filter_containers_with_label_patterns(generator(), patterns) 188 | if container_id 189 | ) 190 | if mntns_info.ns_id > 0 191 | } 192 | 193 | 194 | def monitor_container_mnt_namespaces(patterns: Iterable[str] = None) -> Generator[MountNSInfo, None, None]: 195 | """ Listens to containerizer events for new containers being created, and grab their namespace info 196 | 197 | :param Iterable[str] filter_labels: list of label values, `=,...` 198 | :return: set of mount namespace info 199 | """ 200 | for event in monitor_container_events(): 201 | if event.event_type == ContainerEventType.start: 202 | yield from list_container_mnt_namespaces(patterns, lambda: [event.container_id]) 203 | else: 204 | yield MountNSInfo(event.container_id, '', 0, event.event_type) 205 | 206 | 207 | def _tail_subprocess_json(cmd: str, shell: bool = False) -> Generator[dict, None, None]: 208 | """ Run command and tail output line by line, parsing it as JSON 209 | 210 | :param Iterable[str] cmd: command to run 211 | :return: yield dicts, stops on errors 212 | """ 213 | try: 214 | with subprocess.Popen( 215 | args=cmd if shell else shlex.split(cmd), 216 | stdout=subprocess.PIPE, 217 | encoding='utf-8', 218 | shell=shell, 219 | text=True, 220 | bufsize=1, # line buffered 221 | ) as proc: 222 | for line in proc.stdout: 223 | if not line.strip(): 224 | continue 225 | yield json.loads(line) 226 | except Exception as e: 227 | logging.error(f'Error while running {cmd}: {e}') 228 | 229 | 230 | def monitor_container_events() -> Generator[ContainerEvent, None, None]: 231 | """ Listens to containerizer events for new containers being created 232 | 233 | :return: yields container IDs 234 | """ 235 | client_cli = detect_containerizer_client() 236 | if client_cli == 'docker': 237 | use_shell = False 238 | event_filters = '--filter type=container --filter event=start --filter event=die' 239 | 240 | def event_extractor(event): return ContainerEvent( 241 | ContainerEventType.start if event.get('status', '') == 'start' else ContainerEventType.stop, 242 | event.get('id', ''), 243 | ) 244 | else: 245 | use_shell = True 246 | event_filters = "| grep --line-buffered -E '/tasks/(start|delete)'" 247 | 248 | def event_extractor(event): return ContainerEvent( 249 | ContainerEventType.start if event.get('Topic', '').endswith('start') else ContainerEventType.stop, 250 | event.get('ID', ''), 251 | ) 252 | 253 | cmd = f"{client_cli} events --format '{{{{json .}}}}' {event_filters}" 254 | while True: 255 | event_gen = _tail_subprocess_json(cmd, use_shell) 256 | for event in event_gen: 257 | res = event_extractor(event) 258 | if not res: 259 | continue 260 | yield res 261 | -------------------------------------------------------------------------------- /pidtree_bcc/ctypes_helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some utilities instrumenting ctypes declaration to make them more 3 | "object-oriented friendly", mostly to facilitate testing. 4 | """ 5 | import ctypes 6 | from functools import reduce 7 | 8 | 9 | class ComparableCtStructure(ctypes.Structure): 10 | """ Just a wrapper for ctypes structs, but comparable and printable """ 11 | 12 | def __eq__(self, other) -> bool: 13 | for field in self._fields_: 14 | if getattr(self, field[0]) != getattr(other, field[0]): 15 | return False 16 | return True 17 | 18 | def __hash__(self) -> int: 19 | return reduce(lambda a, b: a ^ b, (hash(getattr(self, field[0])) for field in self._fields_), 0) 20 | 21 | def __ne__(self, other) -> bool: 22 | return not self.__eq__(other) 23 | 24 | def __repr__(self) -> str: 25 | fields = ('{}={}'.format(field[0], getattr(self, field[0])) for field in self._fields_) 26 | return '{}({})'.format(self.__class__.__name__, ', '.join(fields)) 27 | 28 | 29 | def create_comparable_array_type(size: int, element_type: 'ctypes._CData') -> 'ctypes._CData': 30 | """ Create instrumented ctypes array type to allow comparisons (and prints nicely) 31 | 32 | :param int size: size of the array 33 | :param ctypes._CData element_type: Ctype of the array elements 34 | :return: array Ctype 35 | """ 36 | 37 | def repr_method(self) -> str: 38 | return '{}({})'.format(self.__class__.__name__, ', '.join(str(e) for e in self)) 39 | 40 | def eq_method(self, other) -> bool: 41 | if len(self) != len(other): 42 | return False 43 | for a, b in zip(self, other): 44 | if a != b: 45 | return False 46 | return True 47 | 48 | def neq_method(self, other) -> bool: 49 | return not self.__eq__(other) 50 | 51 | array_type = size * element_type 52 | array_type.__repr__ = repr_method 53 | array_type.__eq__ = eq_method 54 | array_type.__neq__ = neq_method 55 | return array_type 56 | -------------------------------------------------------------------------------- /pidtree_bcc/filtering.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import enum 3 | from collections import namedtuple 4 | from itertools import chain 5 | from typing import Any 6 | from typing import Iterable 7 | from typing import List 8 | from typing import Set 9 | from typing import Union 10 | 11 | from pidtree_bcc.ctypes_helper import ComparableCtStructure 12 | from pidtree_bcc.ctypes_helper import create_comparable_array_type 13 | from pidtree_bcc.utils import ip_to_int 14 | from pidtree_bcc.utils import netmask_to_prefixlen 15 | 16 | 17 | NET_FILTER_MAX_PORT_RANGES = 8 18 | IpPortFilter = namedtuple('IpPortFilter', ('subnet', 'netmask', 'except_ports', 'include_ports')) 19 | 20 | 21 | class NetFilter: 22 | 23 | def __init__(self, filters: List[dict]): 24 | """ Constructor 25 | 26 | :param List[dict] filters: list of IP-ports filters. Format: 27 | { 28 | 'network': '127.0.0.1', 29 | 'network_mask': '255.0.0.0', 30 | 'except_ports': [123, 456], # optional 31 | 'include_ports': [789], # optional 32 | } 33 | """ 34 | self.filters = [ 35 | IpPortFilter( 36 | ip_to_int(f['network']) & ip_to_int(f['network_mask']), 37 | ip_to_int(f['network_mask']), 38 | set(map(int, f.get('except_ports', []))), 39 | set(map(int, f.get('include_ports', []))), 40 | ) for f in filters 41 | ] 42 | 43 | def is_filtered(self, ip_address: Union[int, str], port: int) -> bool: 44 | """ Check if IP-port combination is filtered 45 | 46 | :param Union[int, str] ip_address: IP address in integer or string form 47 | :param int port: port in host byte order representation (i.e. pre htons / after ntohs) 48 | :return: True if filtered 49 | """ 50 | if isinstance(ip_address, str): 51 | ip_address = ip_to_int(ip_address) 52 | for f in self.filters: 53 | if ( 54 | f.netmask & ip_address == f.subnet 55 | and port not in f.except_ports 56 | and (not f.include_ports or port in f.include_ports) 57 | ): 58 | return True 59 | return False 60 | 61 | 62 | class CPortRange(ComparableCtStructure): 63 | _fields_ = [ 64 | ('lower', ctypes.c_uint16), 65 | ('upper', ctypes.c_uint16), 66 | ] 67 | 68 | @classmethod 69 | def from_conf_value(cls, val: Union[int, str]) -> 'CPortRange': 70 | lower, upper = ( 71 | map(int, val.split('-')) 72 | if isinstance(val, str) and '-' in val 73 | else (val, val) 74 | ) 75 | return cls(lower=lower, upper=upper) 76 | 77 | 78 | class CFilterKey(ComparableCtStructure): 79 | _fields_ = [ 80 | ('prefixlen', ctypes.c_uint32), 81 | ('data', ctypes.c_uint32), 82 | ] 83 | 84 | @classmethod 85 | def from_network_definition(cls, netmask: str, ip: str): 86 | """ Normalize data according to prefix length to avoid equivalent keys 87 | with different representations. 88 | 89 | :param str netmask: network mask 90 | :param str ip: network ip 91 | """ 92 | data = ip_to_int(ip) 93 | bitmask = ip_to_int(netmask) 94 | prefixlen = netmask_to_prefixlen(netmask) 95 | return cls(prefixlen=prefixlen, data=data & bitmask) 96 | 97 | 98 | class CFilterValue(ComparableCtStructure): 99 | range_array_t = create_comparable_array_type(NET_FILTER_MAX_PORT_RANGES, CPortRange) 100 | _fields_ = [ 101 | ('mode', ctypes.c_int), # this is actually an enum, which are ints in C 102 | ('range_size', ctypes.c_uint8), 103 | ('ranges', range_array_t), 104 | ] 105 | 106 | 107 | class PortFilterMode(enum.IntEnum): 108 | """ Reflects values used for `net_filter_mode` in utils.j2 """ 109 | all = 0 110 | exclude = 1 111 | include = 2 112 | 113 | 114 | def port_range_mapper(port_range: str) -> Iterable[int]: 115 | from_p, to_p = map(int, port_range.split('-')) 116 | return range(max(1, from_p), min(65535, to_p + 1)) 117 | 118 | 119 | def load_filters_into_map(filters: List[dict], ebpf_map: Any, do_diff: bool = False): 120 | """ Loads network filters into a eBPF map. The map is expected to be a trie 121 | with prefix as they key and net_filter_val_t as elements, according to the 122 | type definitions in the `net_filter_trie_init` macro in `utils.j2`. 123 | 124 | NOTE: modifying values in the map is not atomic, hence it may cause a brief moment 125 | of inconsistency between probe output and configuration. 126 | 127 | :param List[dict] filters: list of IP-ports filters. Format: 128 | { 129 | 'network': '127.0.0.1', 130 | 'network_mask': '255.0.0.0', 131 | 'except_ports': [123, 456], # optional 132 | 'include_ports': [789], # optional 133 | } 134 | :param Any ebpf_map: reference to eBPF table where filters should be loaded. 135 | :param bool do_diff: diff input with existing values, removing excess entries 136 | """ 137 | leftovers = set( 138 | # The map returns keys using an auto-generated type. 139 | # Casting works, but we don't want to keep map references anyway to avoid 140 | # side effect, so we might as well unpack them explicitly 141 | (CFilterKey(prefixlen=k.prefixlen, data=k.data) for k in ebpf_map) 142 | if do_diff else [], 143 | ) 144 | for entry in filters: 145 | map_key = CFilterKey.from_network_definition( 146 | netmask=entry['network_mask'], 147 | ip=entry['network'], 148 | ) 149 | if entry.get('except_ports'): 150 | mode = PortFilterMode.exclude 151 | port_ranges = list(map(CPortRange.from_conf_value, entry['except_ports'])) 152 | elif entry.get('include_ports'): 153 | mode = PortFilterMode.include 154 | port_ranges = list(map(CPortRange.from_conf_value, entry['include_ports'])) 155 | else: 156 | mode = PortFilterMode.all 157 | port_ranges = [] 158 | ebpf_map[map_key] = CFilterValue( 159 | mode=mode.value, 160 | range_size=len(port_ranges), 161 | ranges=CFilterValue.range_array_t(*port_ranges), 162 | ) 163 | leftovers.discard(map_key) 164 | for key in leftovers: 165 | del ebpf_map[key] 166 | 167 | 168 | def load_port_filters_into_map( 169 | filters: List[Union[int, str]], 170 | mode: PortFilterMode, 171 | ebpf_map: Any, 172 | do_diff: bool = False, 173 | ): 174 | """ Loads global port filters into eBPF array map. 175 | The map must be a BPF array with allocated space to fit all the possible TCP/UDP ports (2^16). 176 | 177 | NOTE: modifying values in the map is not atomic, hence it may cause a brief moment 178 | of inconsistency between probe output and configuration. For this reason, hot-swapping 179 | the filtering mode is supported but not recommended. 180 | 181 | :param List[Union[int, str]] filters: list of ports or port ranges 182 | :param PortFilterMode mode: include or exclude 183 | :param Any ebpf_map: array in which filters are loaded 184 | :param bool do_diff: diff input with existing values, removing excess entries 185 | """ 186 | if mode not in (PortFilterMode.include, PortFilterMode.exclude): 187 | raise ValueError('Invalid global port filtering mode: {}'.format(mode)) 188 | current_state = set((k.value for k, v in ebpf_map.items() if v.value > 0 and k.value > 0) if do_diff else []) 189 | portset = set( 190 | chain.from_iterable( 191 | port_range_mapper(port_or_range) 192 | if isinstance(port_or_range, str) and '-' in port_or_range 193 | else (int(port_or_range),) 194 | for port_or_range in filters 195 | ), 196 | ) 197 | leftovers = current_state - portset 198 | for port in portset: 199 | ebpf_map[ctypes.c_int(port)] = ctypes.c_uint8(1) 200 | for port in leftovers: 201 | ebpf_map[ctypes.c_int(port)] = ctypes.c_uint8(0) 202 | # 0-element of the map holds the filtering mode 203 | ebpf_map[ctypes.c_int(0)] = ctypes.c_uint8(mode.value) 204 | 205 | 206 | def load_intset_into_map(intset: Set[int], ebpf_map: Any, do_diff: bool = False, delete: bool = False): 207 | """ Loads set of int values into eBPF map 208 | 209 | :param Set[int] intset: input values 210 | :param Any ebpf_map: array in which filters are loaded 211 | :param bool do_diff: diff input with existing values, removing excess entries 212 | :param bool delete: remove values rather than adding them 213 | """ 214 | if delete: 215 | to_delete = intset 216 | else: 217 | current_state = set((k.value for k, _ in ebpf_map.items()) if do_diff else []) 218 | to_delete = current_state - intset 219 | for val in intset: 220 | ebpf_map[ctypes.c_int(val)] = ctypes.c_uint8(1) 221 | for val in to_delete: 222 | del ebpf_map[ctypes.c_int(val)] 223 | -------------------------------------------------------------------------------- /pidtree_bcc/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import select 5 | import signal 6 | import sys 7 | import time 8 | from functools import partial 9 | from multiprocessing import Process 10 | from multiprocessing import SimpleQueue 11 | from threading import Thread 12 | from typing import Any 13 | from typing import Callable 14 | from typing import List 15 | from typing import TextIO 16 | 17 | from staticconf.config import ConfigurationWatcher 18 | 19 | from pidtree_bcc import __version__ 20 | from pidtree_bcc.config import setup_config 21 | from pidtree_bcc.probes import load_probes 22 | from pidtree_bcc.utils import self_restart 23 | from pidtree_bcc.utils import smart_open 24 | from pidtree_bcc.utils import StopFlagWrapper 25 | from pidtree_bcc.yaml_loader import FileIncludeLoader 26 | 27 | 28 | EXIT_CODE = 0 29 | MAX_RESTARTS = 100 30 | HEALTH_CHECK_PERIOD_DEFAULT = 60 # seconds 31 | HANDLED_SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) 32 | 33 | 34 | class RestartSignal(BaseException): 35 | pass 36 | 37 | 38 | def parse_args() -> argparse.Namespace: 39 | """ Parses command line arguments """ 40 | program_name = 'pidtree-bcc' 41 | parser = argparse.ArgumentParser( 42 | program_name, 43 | description='eBPF tool for logging process ancestry of network events', 44 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 45 | ) 46 | parser.add_argument( 47 | '-c', '--config', type=str, 48 | help='YAML file containing probe configurations', 49 | ) 50 | parser.add_argument( 51 | '-p', '--print-and-quit', action='store_true', default=False, 52 | help='Just print the eBPF program(s) to be compiled and quit', 53 | ) 54 | parser.add_argument( 55 | '-f', '--output_file', type=str, default='-', 56 | help='File to output to (default is STDOUT, denoted by -)', 57 | ) 58 | parser.add_argument( 59 | '-w', '--watch-config', action='store_true', default=False, 60 | help=( 61 | 'Enable configuration file watch and hot-swapping for probe network filters.' 62 | 'When a non-hot-swappable setting is changed, pidtree-bcc will restart itself.' 63 | ), 64 | ) 65 | parser.add_argument( 66 | '--health-check-period', type=int, default=HEALTH_CHECK_PERIOD_DEFAULT, 67 | help='Controls how often the watchdog thread performs health and configuration checks (in seconds)', 68 | ) 69 | parser.add_argument( 70 | '--lost-event-telemetry', type=int, default=-1, metavar='NEVENTS', 71 | help=( 72 | 'If set and greater than 0, output telemetry every NEVENTS about the number ' 73 | 'of events dropped due to the kernel -> userland communication channel filling up' 74 | ), 75 | ) 76 | parser.add_argument( 77 | '--extra-probe-path', type=str, 78 | help='Extra dot-notation package path where to look for probes to load', 79 | ) 80 | parser.add_argument( 81 | '--extra-plugin-path', type=str, 82 | help='Extra dot-notation package path where to look for plugins to load', 83 | ) 84 | parser.add_argument( 85 | '-v', '--version', action='version', 86 | version='{} {}'.format(program_name, __version__), 87 | ) 88 | args = parser.parse_args() 89 | if args.config is not None and not os.path.exists(args.config): 90 | sys.stderr.write('--config file does not exist\n') 91 | return args 92 | 93 | 94 | def termination_handler(probe_workers: List[Process], signum: int, frame: Any): 95 | """ Generic termination signal handler 96 | 97 | :param List[Process] probe_workers: list of probe processes 98 | :param int signum: signal integer code 99 | :param Any frame: signal stack frame 100 | """ 101 | msg_info = ('restart', 'restarting') if signum == signal.SIGHUP else ('termination', 'exiting') 102 | logging.warning('Caught {} signal, shutting off probes and {}'.format(*msg_info)) 103 | for worker in probe_workers: 104 | worker.terminate() 105 | if signum == signal.SIGHUP: 106 | raise RestartSignal() 107 | sys.exit(EXIT_CODE) 108 | 109 | 110 | def deregister_signals(func: Callable): 111 | """ De-register signal handlers before invoking function 112 | 113 | :param Callable func: function to wrap 114 | :return: wrapped function 115 | """ 116 | def helper(*args, **kwargs): 117 | for s in HANDLED_SIGNALS: 118 | signal.signal(s, signal.SIG_DFL) 119 | return func(*args, **kwargs) 120 | return helper 121 | 122 | 123 | def health_and_config_watchdog( 124 | probe_workers: List[Process], 125 | output_fh: TextIO, 126 | stop_flag: StopFlagWrapper, 127 | config_watcher: ConfigurationWatcher = None, 128 | check_period: int = HEALTH_CHECK_PERIOD_DEFAULT, 129 | ): 130 | """ Check that probe processes are alive, output file is writable and monitor configuration changes 131 | 132 | :param List[Process] probe_workers: list of probe processes 133 | :param TextIO output_fh: Output file handle 134 | :param ConfigurationWatcher config_watcher: Watcher for monitoring configuration changes 135 | ;param int check_period: how often the checks are run (in seconds) 136 | """ 137 | global EXIT_CODE 138 | fs_poller = select.poll() 139 | fs_poller.register(output_fh, select.POLLERR) 140 | while True: 141 | time.sleep(check_period) 142 | if stop_flag.do_stop: 143 | break 144 | bad_fds = fs_poller.poll(0) 145 | if not all(worker.is_alive() for worker in probe_workers) or bad_fds: 146 | EXIT_CODE = 1 147 | msg = 'Broken output file' if bad_fds else 'Probe terminated unexpectedly' 148 | logging.error('{}, exiting'.format(msg)) 149 | os.kill(os.getpid(), signal.SIGTERM) 150 | break 151 | if config_watcher: 152 | try: 153 | config_watcher.reload_if_changed() 154 | except Exception as e: 155 | logging.warning('Issue encountered in checking config changes, restarting: {}'.format(e)) 156 | self_restart() 157 | 158 | 159 | def main(args: argparse.Namespace): 160 | global EXIT_CODE 161 | probe_workers = [] 162 | stop_wrapper = StopFlagWrapper() 163 | logging.basicConfig( 164 | stream=sys.stderr, 165 | level=logging.INFO, 166 | format='%(asctime)s - %(levelname)s - %(message)s', 167 | ) 168 | curried_handler = partial(termination_handler, probe_workers) 169 | for s in HANDLED_SIGNALS: 170 | signal.signal(s, curried_handler) 171 | config_watcher = setup_config( 172 | args.config, 173 | watch_config=args.watch_config, 174 | min_watch_interval=args.health_check_period, 175 | stop_flag=stop_wrapper, 176 | ) 177 | out = smart_open(args.output_file, mode='w') 178 | output_queue = SimpleQueue() 179 | probes = load_probes( 180 | output_queue, 181 | args.extra_probe_path, 182 | args.extra_plugin_path, 183 | args.lost_event_telemetry, 184 | ) 185 | logging.info('Loaded probes: {}'.format(', '.join(probes))) 186 | if args.print_and_quit: 187 | for probe_name, probe in probes.items(): 188 | print('----- {} -----'.format(probe_name)) 189 | print(probe.expanded_bpf_text) 190 | print('\n') 191 | sys.exit(0) 192 | for probe in probes.values(): 193 | probe_workers.append(Process(target=deregister_signals(probe.start_polling))) 194 | probe_workers[-1].start() 195 | watchdog_thread = Thread( 196 | target=health_and_config_watchdog, 197 | args=(probe_workers, out, stop_wrapper, config_watcher, args.health_check_period), 198 | daemon=True, 199 | ) 200 | watchdog_thread.start() 201 | try: 202 | while True: 203 | print(output_queue.get(), file=out) 204 | out.flush() 205 | except RestartSignal: 206 | stop_wrapper.stop() 207 | FileIncludeLoader.cleanup() 208 | raise 209 | except Exception as e: 210 | # Terminate everything if something goes wrong 211 | EXIT_CODE = 1 212 | logging.error('Encountered unexpected error: {}'.format(e)) 213 | for worker in probe_workers: 214 | worker.terminate() 215 | sys.exit(EXIT_CODE) 216 | 217 | 218 | if __name__ == '__main__': 219 | restart_attempts = 0 220 | while restart_attempts < MAX_RESTARTS: 221 | try: 222 | main(parse_args()) 223 | break 224 | except RestartSignal: 225 | restart_attempts += 1 226 | pass 227 | -------------------------------------------------------------------------------- /pidtree_bcc/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | 4 | from pidtree_bcc.utils import find_subclass 5 | 6 | 7 | class BasePlugin: 8 | 9 | # Specifies which probes are compatible with the plugin 10 | # Set to "*" to allow all probes 11 | PROBE_SUPPORT = tuple() 12 | 13 | def __init__(self, args: dict): 14 | """ Constructor 15 | 16 | :param dict args: plugin parameters 17 | """ 18 | self.validate_args(args) 19 | 20 | def process(self, event: dict) -> dict: 21 | """ Process the `event` dict, add in additional metadata and return a dict 22 | 23 | :param dict event: event dictionary 24 | :return: processed event dictionary 25 | """ 26 | raise NotImplementedError( 27 | 'Required method `process` has not been implemented by {}'.format(self.__name__), 28 | ) 29 | 30 | def validate_args(self, args: dict): 31 | """ Not required, override in inheriting class if you want to use this 32 | 33 | :param dict args: plugin parameters 34 | """ 35 | pass 36 | 37 | 38 | def load_plugins(plugin_dict: dict, calling_probe: str, extra_plugin_path: str = None) -> List[BasePlugin]: 39 | """ Load and configure plugins 40 | 41 | :param dict plugin_dict: where the keys are plugin names and the value 42 | for each key is another dict of kwargs. Each key 43 | must match a `.py` file in the plugin directory 44 | :param str calling_probe: name of the calling probe for support validation 45 | :param str extra_plugin_path: (optional) extra package path where to look for plugins 46 | :return: list of loaded plugins 47 | """ 48 | plugins = [] 49 | for plugin_name, plugin_args in plugin_dict.items(): 50 | error = None 51 | unload_on_init_exception = plugin_args.get( 52 | 'unload_on_init_exception', False, 53 | ) 54 | if not plugin_args.get('enabled', True): 55 | continue 56 | plugin_packages = [ 57 | '{}.{}'.format(p, plugin_name) 58 | for p in (__package__, extra_plugin_path) if p 59 | ] 60 | try: 61 | plugin_class = find_subclass(plugin_packages, BasePlugin) 62 | if plugin_class.PROBE_SUPPORT != '*' and calling_probe not in plugin_class.PROBE_SUPPORT: 63 | raise RuntimeError( 64 | '{} is not among supported probes for plugin {}: {}' 65 | .format(calling_probe, plugin_name, plugin_class.PROBE_SUPPORT), 66 | ) 67 | plugins.append(plugin_class(plugin_args)) 68 | except ImportError as e: 69 | error = RuntimeError( 70 | 'Could not import {}: {}' 71 | .format(plugin_packages, e), 72 | ) 73 | except StopIteration as e: 74 | error = RuntimeError( 75 | 'Could not find plugin class in module {}: {}' 76 | .format(plugin_packages, e), 77 | ) 78 | except Exception as e: 79 | error = e 80 | finally: 81 | if error: 82 | if unload_on_init_exception: 83 | logging.error(str(error)) 84 | else: 85 | raise error 86 | return plugins 87 | -------------------------------------------------------------------------------- /pidtree_bcc/plugins/identityplugin.py: -------------------------------------------------------------------------------- 1 | from pidtree_bcc.plugins import BasePlugin 2 | 3 | 4 | class Identityplugin(BasePlugin): 5 | """ Example plugin not performing any event modification """ 6 | 7 | PROBE_SUPPORT = '*' 8 | 9 | def process(self, event: dict) -> dict: 10 | return event 11 | -------------------------------------------------------------------------------- /pidtree_bcc/plugins/loginuidmap.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pwd 3 | from typing import Tuple 4 | 5 | from pidtree_bcc.plugins import BasePlugin 6 | 7 | 8 | class LoginuidMap(BasePlugin): 9 | """ Plugin for mapping PID to loginuid and username """ 10 | 11 | PROBE_SUPPORT = ('tcp_connect', 'net_listen') 12 | NO_LOGINUID = 4294967295 # unsigned -1 13 | 14 | def __init__(self, args: dict): 15 | super().__init__(args) 16 | self.process = ( 17 | self._process_tl 18 | if args.get('top_level', False) 19 | else self._process_pt 20 | ) 21 | 22 | def _process_tl(self, event: dict) -> dict: 23 | """ Adds loginuid info for the child process """ 24 | loginuid, username = self._get_loginuid(event['pid']) 25 | if loginuid is not None: 26 | event['loginuid'] = loginuid 27 | event['loginname'] = username 28 | return event 29 | 30 | def _process_pt(self, event: dict) -> dict: 31 | """ Adds loginuid info to the process tree """ 32 | for proc in event['proctree']: # proctree is sorted from leaf to root 33 | if proc['pid'] == 1: 34 | break 35 | loginuid, username = self._get_loginuid(proc['pid']) 36 | if loginuid is not None: 37 | proc['loginuid'] = loginuid 38 | proc['loginname'] = username 39 | return event 40 | 41 | @staticmethod 42 | def _get_loginuid(pid: int) -> Tuple[int, str]: 43 | """ Given a PID get loginuid and corresponding username 44 | 45 | :param int pid: process ID: 46 | :return: loginuid and username 47 | """ 48 | try: 49 | with open('/proc/{}/loginuid'.format(pid)) as f: 50 | loginuid = int(f.read().strip()) 51 | if loginuid == LoginuidMap.NO_LOGINUID: 52 | return None, None 53 | return loginuid, pwd.getpwuid(loginuid).pw_name 54 | except Exception as e: 55 | logging.error('Error fetching loginuid: {}'.format(e)) 56 | return None, None 57 | -------------------------------------------------------------------------------- /pidtree_bcc/plugins/sourceipmap.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | 4 | import staticconf 5 | 6 | from pidtree_bcc.plugins import BasePlugin 7 | 8 | 9 | def hosts_loader(filename: str) -> dict: 10 | """ Loads host mapping file 11 | 12 | :param str filename: path to file 13 | :return: mapping as dictionary 14 | """ 15 | return_dict = {} 16 | with open(filename) as mapfile: 17 | lines = [ 18 | line.strip() for line in mapfile 19 | if not line.startswith('#') and line.strip() != '' 20 | ] 21 | for line in lines: 22 | splitline = line.split() 23 | return_dict[splitline[0]] = ' '.join(splitline[1:]) 24 | return return_dict 25 | 26 | 27 | def build_configuration(filename: str, namespace: str) -> staticconf.config.ConfigurationWatcher: 28 | """ Create configuration watcher for host mapping files 29 | 30 | :param str filename: path to file 31 | :param str namespace: configuration namespace 32 | :return: configuration watcher 33 | """ 34 | config_loader = partial( 35 | staticconf.loader.build_loader(hosts_loader), 36 | filename, 37 | namespace=namespace, 38 | flatten=True, 39 | ) 40 | reloader = staticconf.config.ReloadCallbackChain(namespace) 41 | return staticconf.config.ConfigurationWatcher( 42 | config_loader, 43 | filename, 44 | min_interval=2, 45 | reloader=reloader, 46 | ) 47 | 48 | 49 | class Sourceipmap(BasePlugin): 50 | """ Plugin for mapping source ip to a name """ 51 | 52 | PROBE_SUPPORT = ('tcp_connect',) 53 | 54 | def __init__(self, args: dict): 55 | super().__init__(args) 56 | self.hosts_dict = {} 57 | self.config_watchers = [] 58 | self.attribute_key = args.get('attribute_key', 'source_host') 59 | for hostfile in args['hostfiles']: 60 | self.config_watchers.append( 61 | build_configuration(hostfile, __name__), 62 | ) 63 | for config_watcher in self.config_watchers: 64 | config_watcher.config_loader() 65 | self.config = staticconf.NamespaceReaders(__name__) 66 | 67 | def process(self, event: dict) -> dict: 68 | saddr = event.get('saddr', None) 69 | for config_watcher in self.config_watchers: 70 | config_watcher.reload_if_changed() 71 | if saddr is not None: 72 | event[self.attribute_key] = self.config.read_string(saddr, '') 73 | return event 74 | 75 | def validate_args(self, args: dict): 76 | hostfiles = args.get('hostfiles', None) 77 | if hostfiles is None: 78 | raise RuntimeError( 79 | "'hostfiles' option not supplied to sourceipmap plugin", 80 | ) 81 | elif not isinstance(hostfiles, list): 82 | raise RuntimeError( 83 | "'hostfiles' option should be a list of fully qualified file paths", 84 | ) 85 | 86 | for hostfile in hostfiles: 87 | if not os.path.isfile(hostfile): 88 | raise RuntimeError( 89 | "File `{hostfile}` passed as a 'hostfiles' entry to the sourceipmap plugin does not exist".format( 90 | hostfile=hostfile, 91 | ), 92 | ) 93 | -------------------------------------------------------------------------------- /pidtree_bcc/probes/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import logging 4 | import os.path 5 | import platform 6 | import re 7 | import time 8 | from datetime import datetime 9 | from functools import lru_cache 10 | from multiprocessing import SimpleQueue 11 | from threading import Lock 12 | from threading import Thread 13 | from typing import Any 14 | from typing import Mapping 15 | 16 | from bcc import __version__ as bccversion 17 | from bcc import BPF 18 | from jinja2 import Environment 19 | from jinja2 import FileSystemLoader 20 | 21 | from pidtree_bcc.config import enumerate_probe_configs 22 | from pidtree_bcc.containers import ContainerEventType 23 | from pidtree_bcc.containers import list_container_mnt_namespaces 24 | from pidtree_bcc.containers import monitor_container_mnt_namespaces 25 | from pidtree_bcc.filtering import load_filters_into_map 26 | from pidtree_bcc.filtering import load_intset_into_map 27 | from pidtree_bcc.filtering import load_port_filters_into_map 28 | from pidtree_bcc.filtering import NET_FILTER_MAX_PORT_RANGES 29 | from pidtree_bcc.filtering import PortFilterMode 30 | from pidtree_bcc.plugins import load_plugins 31 | from pidtree_bcc.utils import find_subclass 32 | from pidtree_bcc.utils import never_crash 33 | from pidtree_bcc.utils import round_nearest_multiple 34 | 35 | 36 | class BPFProbe: 37 | """ Base class for defining BPF probes. 38 | 39 | Takes care of loading a BPF program and polling events. 40 | The BPF program can be either define in the `BPF_TEXT` class variable or 41 | in a Jinja template file (.j2) with the same basename of the module file. 42 | In either case the program text will be processed in Jinja templating. 43 | """ 44 | 45 | # SIDECARS 46 | # List of (function, args) tuples to run in parallel with the probes as "sidecars" 47 | # No health monitoring is performed on these after launch so they are expect to be 48 | # stable or self-healing. 49 | 50 | # To be populated by `load_probes` 51 | EXTRA_PLUGIN_PATH = None 52 | 53 | # If set, it means that the probe implements network filtering with a BPF table 54 | # (not via Jinja-templated if statements) 55 | USES_DYNAMIC_FILTERS = False 56 | NET_FILTER_MAP_NAME = 'net_filter_map' 57 | NET_FILTER_MAP_SIZE_MAX = 4 * 1024 58 | NET_FILTER_MAP_SIZE_SCALING = 512 59 | PORT_FILTER_MAP_NAME = 'port_filter_map' 60 | MNTNS_FILTER_MAP_NAME = 'mntns_filter_map' 61 | CONTAINER_BASELINE_INTERVAL = 30 * 60 # seconds 62 | 63 | def __init__( 64 | self, 65 | output_queue: SimpleQueue, 66 | probe_config: dict = None, 67 | lost_event_telemetry: int = -1, 68 | config_change_queue: SimpleQueue = None, 69 | ): 70 | """ Constructor 71 | 72 | :param Queue output_queue: queue for event output 73 | :param dict probe_config: (optional) config passed as kwargs to BPF template 74 | all fields are passed to the template engine with the exception 75 | of "plugins". This behaviour can be overidden with the TEMPLATE_VARS 76 | class variable defining a list of config fields. 77 | It is possible for child class to define a CONFIG_DEFAULTS class 78 | variable containing default templating variables. 79 | :param int lost_event_telemetry: every how many messages emit the number of lost messages. 80 | Set to <= 0 to disable. 81 | :param SimpleQueue config_change_queue: queue for passing configuration changes 82 | """ 83 | self.SIDECARS = [] 84 | probe_config = probe_config if probe_config else {} 85 | self.output_queue = output_queue 86 | self.validate_config(probe_config) 87 | module_src = inspect.getsourcefile(type(self)) 88 | self.probe_name = os.path.basename(module_src).split('.')[0] 89 | self.plugins = load_plugins( 90 | probe_config.get('plugins', {}), 91 | self.probe_name, 92 | self.EXTRA_PLUGIN_PATH, 93 | ) 94 | if not hasattr(self, 'BPF_TEXT'): 95 | with open(re.sub(r'\.py$', '.j2', module_src)) as f: 96 | self.BPF_TEXT = f.read() 97 | template_config = self.build_probe_config(probe_config) 98 | jinja_env = Environment(loader=FileSystemLoader(os.path.dirname(module_src))) 99 | self.expanded_bpf_text = jinja_env.from_string(self.BPF_TEXT).render(**template_config) 100 | self.lost_event_telemetry = lost_event_telemetry 101 | self.lost_event_timer = lost_event_telemetry 102 | self.lost_event_count = 0 103 | self.net_filter_mutex = Lock() 104 | if self.USES_DYNAMIC_FILTERS and config_change_queue: 105 | self.SIDECARS.append((self._poll_config_changes, (config_change_queue,))) 106 | self.container_labels_filter = template_config.get('container_labels') 107 | if self.container_labels_filter: 108 | self.container_name_mapping = {} 109 | self.container_idns_mapping = {} 110 | self.SIDECARS.append((self._monitor_running_containers, tuple())) 111 | 112 | def build_probe_config(self, probe_config: dict, hotswap_only: bool = False) -> dict: 113 | """ Load probe configuration values 114 | 115 | :param dict probe_config: probe configuration dictionary 116 | :param bool hotswap_only: only load values which can be modified at runtime 117 | :return: updated template configuration 118 | """ 119 | template_config = ( 120 | {**self.CONFIG_DEFAULTS, **probe_config} 121 | if hasattr(self, 'CONFIG_DEFAULTS') 122 | else probe_config.copy() 123 | ) 124 | if not hotswap_only: 125 | if hasattr(self, 'TEMPLATE_VARS'): 126 | template_config = {k: template_config[k] for k in self.TEMPLATE_VARS} 127 | else: 128 | template_config.pop('plugins', None) 129 | template_config['PATCH_BUGGY_HEADERS'] = self._has_buggy_headers() 130 | if self.USES_DYNAMIC_FILTERS: 131 | self.net_filters = template_config['filters'] 132 | self.global_filters = ( 133 | (template_config['includeports'], PortFilterMode.include) 134 | if template_config.get('includeports') 135 | else (template_config.get('excludeports', []), PortFilterMode.exclude) 136 | ) 137 | if not hotswap_only: 138 | template_config['NET_FILTER_MAP_NAME'] = self.NET_FILTER_MAP_NAME 139 | template_config['PORT_FILTER_MAP_NAME'] = self.PORT_FILTER_MAP_NAME 140 | template_config['NET_FILTER_MAX_PORT_RANGES'] = NET_FILTER_MAX_PORT_RANGES 141 | template_config['NET_FILTER_MAP_SIZE'] = min( 142 | self.NET_FILTER_MAP_SIZE_MAX, 143 | round_nearest_multiple(len(self.net_filters), self.NET_FILTER_MAP_SIZE_SCALING, headroom=128), 144 | ) 145 | template_config['MNTNS_FILTER_MAP_NAME'] = self.MNTNS_FILTER_MAP_NAME 146 | return template_config 147 | 148 | @lru_cache(maxsize=1) 149 | def _has_buggy_headers(self) -> bool: 150 | """ At the time of writing, compiling eBPF on new kernels/OS is 151 | a bit buggy and we need to work around that: 152 | - https://github.com/iovisor/bcc/issues/3366 153 | - https://github.com/iovisor/bcc/issues/3993 154 | 155 | TODO: keep an eye on things, hopefully they eventually get properly patched 156 | """ 157 | kernel_version = platform.uname().release 158 | kmajor, kminor = map(int, kernel_version.split('.', 2)[:2]) 159 | _, bccminor, _ = map(int, bccversion.split('.')) 160 | return bccminor < 23 and ((kmajor == 5 and kminor >= 15) or kmajor > 5) 161 | 162 | def _process_events(self, cpu: Any, data: Any, size: Any, from_bpf: bool = True): 163 | """ BPF event callback 164 | 165 | :param Any cpu: unused arg required for callback 166 | :param Any data: BPF raw event 167 | :param Any size: unused arg required for callback 168 | :param bool from_bpf: (optional, default=True) event generated by BPF code 169 | """ 170 | event = self.bpf['events'].event(data) if from_bpf else data 171 | event = self.enrich_event(event) 172 | if not event: 173 | return 174 | self._add_event_metadata(event) 175 | for event_plugin in self.plugins: 176 | event = event_plugin.process(event) 177 | self.output_queue.put(json.dumps(event)) 178 | 179 | def _add_event_metadata(self, event: dict): 180 | """ Adds probe name and current ISO-format timestamp to event dictionary (in place) 181 | 182 | :param dict event: event dictionary 183 | """ 184 | event['timestamp'] = datetime.utcnow().isoformat() + 'Z' 185 | event['probe'] = self.probe_name 186 | 187 | def _lost_event_callback(self, lost_count: int): 188 | """ Method to be used as callback to count lost events 189 | 190 | :param int lost_count: number of events lost 191 | """ 192 | self.lost_event_count += lost_count 193 | 194 | def _poll_and_check_lost(self): 195 | """ Simple wrapper method which outputs lost event telemetry while polling """ 196 | self.bpf.perf_buffer_poll() 197 | self.lost_event_timer -= 1 198 | if self.lost_event_timer == 0: 199 | self.lost_event_timer = self.lost_event_telemetry 200 | event = {'type': 'lost_event_telemetry', 'count': self.lost_event_count} 201 | self._add_event_metadata(event) 202 | self.output_queue.put(json.dumps(event)) 203 | 204 | @never_crash 205 | def _poll_config_changes(self, config_queue: SimpleQueue): 206 | """ Polls configuration changes from the dedicated queue and reloads filters when they happen 207 | 208 | :param SimpleQueue config_change_queue: queue for passing configuration changes 209 | """ 210 | while True: 211 | config_data = config_queue.get() 212 | self.build_probe_config(config_data, hotswap_only=True) 213 | self.reload_filters() 214 | 215 | @never_crash 216 | def _monitor_running_containers(self): 217 | """ Polls running containers, filtering by label to keep mntns filtering map updated """ 218 | last_baseline = time.time() 219 | self._running_containers_baseline() 220 | logging.info(f'Done initial scanning for containers to attach to (found: {len(self.container_name_mapping)})') 221 | for mntns_info in monitor_container_mnt_namespaces(self.container_labels_filter): 222 | if (now := time.time()) - last_baseline > self.CONTAINER_BASELINE_INTERVAL: 223 | self._running_containers_baseline() 224 | last_baseline = now 225 | continue 226 | if mntns_info.event_type == ContainerEventType.stop: 227 | if (ns_id := self.container_idns_mapping.pop(mntns_info.container_id, None)): 228 | load_intset_into_map( 229 | {ns_id}, 230 | self.bpf[self.MNTNS_FILTER_MAP_NAME], 231 | delete=True, 232 | ) 233 | self.container_name_mapping.pop(ns_id, None) 234 | continue 235 | logging.info(f'Attaching to container: {mntns_info.container_name}') 236 | load_intset_into_map({mntns_info.ns_id}, self.bpf[self.MNTNS_FILTER_MAP_NAME]) 237 | self.container_name_mapping[mntns_info.ns_id] = mntns_info.container_name 238 | self.container_idns_mapping[mntns_info.container_id] = mntns_info.ns_id 239 | 240 | def _running_containers_baseline(self): 241 | """ Create baseline for monitored containers """ 242 | ns_infos = list_container_mnt_namespaces(self.container_labels_filter) 243 | load_intset_into_map( 244 | {mntns_info.ns_id for mntns_info in ns_infos}, 245 | self.bpf[self.MNTNS_FILTER_MAP_NAME], 246 | do_diff=True, 247 | ) 248 | self.container_name_mapping.update({mntns_info.ns_id: mntns_info.container_name for mntns_info in ns_infos}) 249 | self.container_idns_mapping.update({mntns_info.container_id: mntns_info.ns_id for mntns_info in ns_infos}) 250 | 251 | def reload_filters(self, is_init: bool = False): 252 | """ Load filters 253 | 254 | :param bool is_init: Indicate this is the first time loading 255 | """ 256 | with self.net_filter_mutex: 257 | logging.info('[{}] {}oading filters into BPF maps'.format(self.probe_name, 'L' if is_init else 'Rel')) 258 | load_filters_into_map(self.net_filters, self.bpf[self.NET_FILTER_MAP_NAME], not is_init) 259 | load_port_filters_into_map(*self.global_filters, self.bpf[self.PORT_FILTER_MAP_NAME], not is_init) 260 | 261 | def start_polling(self): 262 | """ Start infinite loop polling BPF events """ 263 | self.bpf = BPF( 264 | text=self.expanded_bpf_text, 265 | cflags=['-Wno-macro-redefined'] if self._has_buggy_headers() else [], 266 | ) 267 | for func, args in self.SIDECARS: 268 | Thread(target=func, args=args, daemon=True).start() 269 | if self.lost_event_telemetry > 0: 270 | extra_args = {'lost_cb': self._lost_event_callback} 271 | poll_func = self._poll_and_check_lost 272 | else: 273 | extra_args = {} 274 | poll_func = self.bpf.perf_buffer_poll 275 | if self.USES_DYNAMIC_FILTERS: 276 | self.reload_filters(is_init=True) 277 | self.bpf['events'].open_perf_buffer(self._process_events, **extra_args) 278 | while True: 279 | poll_func() 280 | 281 | def enrich_container_name(self, event: Any, formatted_event: dict) -> dict: 282 | """ Updates in place event dict with name of container related to the event """ 283 | if self.container_labels_filter: 284 | formatted_event['container_name'] = self.container_name_mapping.get(event.mntns_id, '') 285 | return formatted_event 286 | 287 | def enrich_event(self, event: Any) -> dict: 288 | """ Transform raw BPF event data into dictionary, 289 | possibly adding more interesting data to it. 290 | 291 | :param Any event: BPF event data 292 | """ 293 | raise NotImplementedError 294 | 295 | def validate_config(self, config: dict): 296 | """ Overridable method to implement config validation. 297 | Should raise exceptions on errors. 298 | 299 | :param dict config: probe configuration 300 | """ 301 | pass 302 | 303 | 304 | def load_probes( 305 | output_queue: SimpleQueue, 306 | extra_probe_path: str = None, 307 | extra_plugin_path: str = None, 308 | lost_event_telemetry: int = -1, 309 | ) -> Mapping[str, BPFProbe]: 310 | """ Find and load probe classes 311 | 312 | :param dict config: pidtree-bcc configuration 313 | :param Queue output_queue: queue for event output 314 | :param str extra_probe_path: (optional) additional package path where to look for probes 315 | :param str extra_probe_path: (optional) additional package path where to look for plugins 316 | :param int lost_event_telemetry: (optional) every how many messages emit the number of lost messages. 317 | :return: dictionary mapping probe name to its instance 318 | """ 319 | BPFProbe.EXTRA_PLUGIN_PATH = extra_plugin_path 320 | packages = [p for p in (__package__, extra_probe_path) if p] 321 | return { 322 | probe_name: find_subclass( 323 | ['{}.{}'.format(p, probe_name) for p in packages], 324 | BPFProbe, 325 | )(output_queue, probe_config, lost_event_telemetry, conf_change_queue) 326 | for probe_name, probe_config, conf_change_queue in enumerate_probe_configs() 327 | if not probe_name.startswith('_') 328 | } 329 | -------------------------------------------------------------------------------- /pidtree_bcc/probes/net_listen.j2: -------------------------------------------------------------------------------- 1 | {%- import 'utils.j2' as utils -%} 2 | {{ utils.patch_buggy_headers(PATCH_BUGGY_HEADERS) }} 3 | #include 4 | #include 5 | 6 | BPF_HASH(currsock, u32, struct sock*); 7 | BPF_PERF_OUTPUT(events); 8 | 9 | struct listen_bind_t { 10 | u32 pid; 11 | u32 laddr; 12 | u16 port; 13 | u8 protocol; 14 | {%- if container_labels %} 15 | u64 mntns_id; 16 | {% endif -%} 17 | }; 18 | 19 | {{ utils.net_filter_trie_init(NET_FILTER_MAP_NAME, PORT_FILTER_MAP_NAME, size=NET_FILTER_MAP_SIZE, max_ports=NET_FILTER_MAX_PORT_RANGES) }} 20 | 21 | {{ utils.get_proto_func() }} 22 | 23 | {% if container_labels %} 24 | {{ utils.mntns_filter_init(MNTNS_FILTER_MAP_NAME) }} 25 | {% endif %} 26 | 27 | static void net_listen_event(struct pt_regs *ctx) 28 | { 29 | u32 pid = bpf_get_current_pid_tgid(); 30 | struct sock** skp = currsock.lookup(&pid); 31 | if (skp == 0) return; 32 | int ret = PT_REGS_RC(ctx); 33 | if (ret != 0) { 34 | currsock.delete(&pid); 35 | return; 36 | } 37 | u32 laddr = 0; 38 | u16 port = 0; 39 | struct sock* sk = *skp; 40 | bpf_probe_read(&laddr, sizeof(u32), &sk->__sk_common.skc_rcv_saddr); 41 | bpf_probe_read(&port, sizeof(u16), &sk->__sk_common.skc_num); 42 | 43 | if (is_addr_port_filtered(laddr, port) || is_port_globally_filtered(port)) { 44 | currsock.delete(&pid); 45 | return; 46 | } 47 | 48 | {% if net_namespace -%} 49 | if (sk->__sk_common.skc_net.net->ns.inum != {{ net_namespace }}) { 50 | currsock.delete(&pid); 51 | return; 52 | } 53 | {%- endif %} 54 | 55 | struct listen_bind_t listen = {}; 56 | listen.pid = pid; 57 | listen.port = port; 58 | listen.laddr = laddr; 59 | listen.protocol = get_socket_protocol(sk); 60 | {% if container_labels -%} 61 | listen.mntns_id = get_mntns_id(); 62 | {% endif -%} 63 | events.perf_submit(ctx, &listen, sizeof(listen)); 64 | currsock.delete(&pid); 65 | } 66 | 67 | {% if 'udp' in protocols -%} 68 | int kprobe__inet_bind( 69 | struct pt_regs *ctx, 70 | struct socket *sock, 71 | const struct sockaddr *addr, 72 | int addrlen) 73 | { 74 | {% if container_labels -%} 75 | if (!is_mntns_included()) { 76 | return 0; 77 | } 78 | {% endif -%} 79 | {% if exclude_random_bind -%} 80 | struct sockaddr_in* inet_addr = (struct sockaddr_in*)addr; 81 | if (inet_addr->sin_port == 0) { 82 | return 0; 83 | } 84 | {% endif -%} 85 | struct sock* sk = sock->sk; 86 | u8 protocol = get_socket_protocol(sk); 87 | if (sk->__sk_common.skc_family == AF_INET && protocol == IPPROTO_UDP) { 88 | u32 pid = bpf_get_current_pid_tgid(); 89 | currsock.update(&pid, &sk); 90 | } 91 | return 0; 92 | } 93 | 94 | int kretprobe__inet_bind(struct pt_regs *ctx) 95 | { 96 | net_listen_event(ctx); 97 | return 0; 98 | } 99 | {%- endif %} 100 | 101 | {% if 'tcp' in protocols -%} 102 | int kprobe__inet_listen(struct pt_regs *ctx, struct socket *sock, int backlog) 103 | { 104 | {% if container_labels -%} 105 | if (!is_mntns_included()) { 106 | return 0; 107 | } 108 | {% endif -%} 109 | struct sock* sk = sock->sk; 110 | if (sk->__sk_common.skc_family == AF_INET) { 111 | u32 pid = bpf_get_current_pid_tgid(); 112 | currsock.update(&pid, &sk); 113 | } 114 | return 0; 115 | } 116 | 117 | int kretprobe__inet_listen(struct pt_regs *ctx) 118 | { 119 | net_listen_event(ctx); 120 | return 0; 121 | } 122 | {% endif -%} 123 | -------------------------------------------------------------------------------- /pidtree_bcc/probes/net_listen.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import socket 3 | import time 4 | import traceback 5 | from collections import namedtuple 6 | from itertools import chain 7 | from typing import Any 8 | 9 | import psutil 10 | 11 | from pidtree_bcc.filtering import NetFilter 12 | from pidtree_bcc.filtering import port_range_mapper 13 | from pidtree_bcc.probes import BPFProbe 14 | from pidtree_bcc.utils import crawl_process_tree 15 | from pidtree_bcc.utils import get_network_namespace 16 | from pidtree_bcc.utils import int_to_ip 17 | from pidtree_bcc.utils import ip_to_int 18 | from pidtree_bcc.utils import never_crash 19 | 20 | 21 | NetListenWrapper = namedtuple('NetListenWrapper', ('pid', 'laddr', 'port', 'protocol')) 22 | 23 | 24 | class NetListenProbe(BPFProbe): 25 | 26 | PROTO_MAP = { 27 | value: name.split('_')[1].lower() 28 | for name, value in inspect.getmembers(socket) 29 | if name.startswith('IPPROTO_') 30 | } 31 | CONFIG_DEFAULTS = { 32 | 'ip_to_int': ip_to_int, 33 | 'protocols': ['tcp'], 34 | 'filters': [], 35 | 'container_labels': [], 36 | 'excludeports': [], 37 | 'includeports': [], 38 | 'snapshot_periodicity': False, 39 | 'same_namespace_only': False, 40 | 'exclude_random_bind': False, 41 | } 42 | SUPPORTED_PROTOCOLS = ('udp', 'tcp') 43 | USES_DYNAMIC_FILTERS = True 44 | 45 | def build_probe_config(self, probe_config: dict, hotswap_only: bool = False) -> dict: 46 | config = super().build_probe_config(probe_config) 47 | config['net_namespace'] = get_network_namespace() if config['same_namespace_only'] else None 48 | self.net_namespace = config['net_namespace'] 49 | self.filtering = NetFilter(config['filters']) 50 | if config['includeports']: 51 | includeports = set(map(int, config['includeports'])) 52 | self.port_filter = lambda port: port in includeports 53 | else: 54 | excludeports = set( 55 | chain.from_iterable( 56 | port_range_mapper(p) if '-' in p else [int(p)] 57 | for p in map(str, config['excludeports']) 58 | ), 59 | ) 60 | self.port_filter = lambda port: port not in excludeports 61 | if not hotswap_only: 62 | self.log_tcp = 'tcp' in config['protocols'] 63 | self.log_udp = 'udp' in config['protocols'] 64 | if config['snapshot_periodicity'] and config['snapshot_periodicity'] > 0: 65 | self.SIDECARS.append(( 66 | self._snapshot_worker, 67 | (config['snapshot_periodicity'],), 68 | )) 69 | return config 70 | 71 | def validate_config(self, config: dict): 72 | """ Checks if config values are valid """ 73 | for proto in config.get('protocols', []): 74 | if proto not in self.SUPPORTED_PROTOCOLS: 75 | raise RuntimeError( 76 | '{} is not among supported protocols {}' 77 | .format(proto, self.SUPPORTED_PROTOCOLS), 78 | ) 79 | 80 | def enrich_event(self, event: Any) -> dict: 81 | """ Parses network "listen event" and adds process tree data 82 | 83 | :param Any event: BPF event 84 | :return: event dictionary with process tree 85 | """ 86 | error = '' 87 | try: 88 | proctree = crawl_process_tree(event.pid) 89 | except Exception: 90 | error = traceback.format_exc() 91 | proctree = [] 92 | return self.enrich_container_name( 93 | event, 94 | { 95 | 'pid': event.pid, 96 | 'port': event.port, 97 | 'proctree': proctree, 98 | 'laddr': int_to_ip(event.laddr), 99 | 'protocol': self.PROTO_MAP.get(event.protocol, 'unknown'), 100 | 'error': error, 101 | }, 102 | ) 103 | 104 | def _filter_net_namespace(self, pid: int) -> bool: 105 | """ Check if network namespace for process is filtered 106 | 107 | :param int pid: process ID 108 | :return: True if filtered, False if not 109 | """ 110 | if not self.net_namespace: 111 | return False 112 | net_ns = get_network_namespace(pid) 113 | return net_ns and net_ns != self.net_namespace 114 | 115 | @never_crash 116 | def _snapshot_worker(self, periodicity: int): 117 | """ Handler function for snapshot thread. 118 | 119 | :param int periodicity: how many seconds to wait between snapshots 120 | """ 121 | time.sleep(300) # sleep 5 minutes to avoid "noisy" restarts 122 | while True: 123 | socket_stats = psutil.net_connections('inet4') 124 | for conn in socket_stats: 125 | if not conn.pid: 126 | # filter out entries without associated PID 127 | continue 128 | if self.log_tcp and conn.status == 'LISTEN': 129 | protocol = socket.IPPROTO_TCP 130 | elif self.log_udp and conn.status == 'NONE' and conn.type == socket.SOCK_DGRAM: 131 | protocol = socket.IPPROTO_UDP 132 | else: 133 | protocol = None 134 | ip_addr = ip_to_int(conn.laddr.ip) 135 | if ( 136 | protocol 137 | and not self.filtering.is_filtered(ip_addr, conn.laddr.port) 138 | and self.port_filter(conn.laddr.port) 139 | and not self._filter_net_namespace(conn.pid) 140 | ): 141 | event = NetListenWrapper( 142 | conn.pid, 143 | ip_addr, 144 | conn.laddr.port, 145 | protocol, 146 | ) 147 | self._process_events(None, event, None, False) 148 | time.sleep(periodicity) 149 | -------------------------------------------------------------------------------- /pidtree_bcc/probes/tcp_connect.j2: -------------------------------------------------------------------------------- 1 | {%- import 'utils.j2' as utils -%} 2 | {{ utils.patch_buggy_headers(PATCH_BUGGY_HEADERS) }} 3 | #include 4 | #include 5 | 6 | BPF_HASH(currsock, u32, struct sock *); 7 | BPF_PERF_OUTPUT(events); 8 | 9 | struct connection_t { 10 | u32 pid; 11 | u32 daddr; 12 | u32 saddr; 13 | u16 dport; 14 | {%- if container_labels %} 15 | u64 mntns_id; 16 | {% endif -%} 17 | }; 18 | 19 | {{ utils.net_filter_trie_init(NET_FILTER_MAP_NAME, PORT_FILTER_MAP_NAME, size=NET_FILTER_MAP_SIZE, max_ports=NET_FILTER_MAX_PORT_RANGES) }} 20 | 21 | {% if container_labels %} 22 | {{ utils.mntns_filter_init(MNTNS_FILTER_MAP_NAME) }} 23 | {% endif %} 24 | 25 | int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk) 26 | { 27 | {% if container_labels -%} 28 | if (!is_mntns_included()) { 29 | return 0; 30 | } 31 | {% endif -%} 32 | u32 pid = bpf_get_current_pid_tgid(); 33 | currsock.update(&pid, &sk); 34 | return 0; 35 | }; 36 | 37 | int kretprobe__tcp_v4_connect(struct pt_regs *ctx) 38 | { 39 | int ret = PT_REGS_RC(ctx); 40 | u32 pid = bpf_get_current_pid_tgid(); 41 | 42 | struct sock **skpp; 43 | skpp = currsock.lookup(&pid); 44 | if (skpp == 0) return 0; // not there! 45 | if (ret != 0) { 46 | // failed to sync 47 | currsock.delete(&pid); 48 | return 0; 49 | } 50 | 51 | struct sock *skp = *skpp; 52 | u32 saddr = 0, daddr = 0; 53 | u16 dport = 0; 54 | bpf_probe_read(&daddr, sizeof(daddr), &skp->__sk_common.skc_daddr); 55 | bpf_probe_read(&dport, sizeof(dport), &skp->__sk_common.skc_dport); 56 | dport = ntohs(dport); 57 | 58 | if (is_addr_port_filtered(daddr, dport) || is_port_globally_filtered(dport)) { 59 | currsock.delete(&pid); 60 | return 0; 61 | } 62 | 63 | bpf_probe_read(&saddr, sizeof(saddr), &skp->__sk_common.skc_rcv_saddr); 64 | 65 | struct connection_t connection = {}; 66 | connection.pid = pid; 67 | connection.dport = dport; 68 | connection.daddr = daddr; 69 | connection.saddr = saddr; 70 | {% if container_labels -%} 71 | connection.mntns_id = get_mntns_id(); 72 | {% endif -%} 73 | 74 | events.perf_submit(ctx, &connection, sizeof(connection)); 75 | 76 | currsock.delete(&pid); 77 | 78 | return 0; 79 | } 80 | -------------------------------------------------------------------------------- /pidtree_bcc/probes/tcp_connect.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from typing import Any 3 | 4 | from pidtree_bcc.probes import BPFProbe 5 | from pidtree_bcc.utils import crawl_process_tree 6 | from pidtree_bcc.utils import int_to_ip 7 | from pidtree_bcc.utils import ip_to_int 8 | 9 | 10 | class TCPConnectProbe(BPFProbe): 11 | 12 | CONFIG_DEFAULTS = { 13 | 'ip_to_int': ip_to_int, 14 | 'filters': [], 15 | 'container_labels': [], 16 | 'includeports': [], 17 | 'excludeports': [], 18 | } 19 | USES_DYNAMIC_FILTERS = True 20 | 21 | def enrich_event(self, event: Any) -> dict: 22 | """ Parses TCP connect event and adds process tree data 23 | 24 | :param Any event: BPF event 25 | :return: event dictionary with process tree 26 | """ 27 | error = '' 28 | try: 29 | proctree = crawl_process_tree(event.pid) 30 | except Exception: 31 | error = traceback.format_exc() 32 | proctree = [] 33 | return self.enrich_container_name( 34 | event, 35 | { 36 | 'pid': event.pid, 37 | 'proctree': proctree, 38 | # We're turning a little-endian insigned long (' 4 | #include 5 | 6 | #define SESSION_START 1 7 | #define SESSION_CONTINUE 2 8 | #define SESSION_END 3 9 | 10 | struct udp_session_event { 11 | u8 type; 12 | u32 pid; 13 | u64 sock_pointer; 14 | u32 daddr; 15 | u16 dport; 16 | {%- if container_labels %} 17 | u64 mntns_id; 18 | {% endif -%} 19 | }; 20 | 21 | BPF_PERF_OUTPUT(events); 22 | BPF_HASH(tracing, u64, u8); 23 | 24 | {{ utils.net_filter_trie_init(NET_FILTER_MAP_NAME, PORT_FILTER_MAP_NAME, size=NET_FILTER_MAP_SIZE, max_ports=NET_FILTER_MAX_PORT_RANGES) }} 25 | 26 | {{ utils.get_proto_func() }} 27 | 28 | {% if container_labels %} 29 | {{ utils.mntns_filter_init(MNTNS_FILTER_MAP_NAME) }} 30 | {% endif %} 31 | 32 | // We probe only the entrypoint as looking at return codes doesn't have much value 33 | // since UDP does not do any checks for successfull communications. The only errors 34 | // which may arise from this function would be due to the kernel running out of memory, 35 | // and you have bigger problems than precisely tracing UDP connections at that point. 36 | int kprobe__udp_sendmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t size) 37 | { 38 | if(sk->__sk_common.skc_family != AF_INET) return 0; 39 | 40 | {% if container_labels -%} 41 | if (!is_mntns_included()) { 42 | return 0; 43 | } 44 | {% endif -%} 45 | 46 | // Destination info will either be embedded in the socket if `connect` 47 | // was called or specified in the message 48 | struct sockaddr_in* sin = msg->msg_name; 49 | u32 daddr = sin->sin_addr.s_addr ? sin->sin_addr.s_addr : sk->sk_daddr; 50 | u16 dport = sin->sin_port ? sin->sin_port : sk->sk_dport; 51 | dport = ntohs(dport); 52 | 53 | if (is_addr_port_filtered(daddr, dport) || is_port_globally_filtered(dport)) { 54 | return 0; 55 | } 56 | 57 | // Check if we are already tracing this session 58 | u64 sock_pointer = (u64) sk; 59 | u8 trace_flag = tracing.lookup(&sock_pointer) != 0 ? SESSION_CONTINUE : SESSION_START; 60 | 61 | u32 pid = bpf_get_current_pid_tgid(); 62 | struct udp_session_event session = {}; 63 | session.pid = pid; 64 | session.type = trace_flag; 65 | session.sock_pointer = sock_pointer; 66 | bpf_probe_read(&session.daddr, sizeof(u32), &daddr); 67 | bpf_probe_read(&session.dport, sizeof(u16), &dport); 68 | {% if container_labels -%} 69 | session.mntns_id = get_mntns_id(); 70 | {% endif -%} 71 | events.perf_submit(ctx, &session, sizeof(session)); 72 | if(trace_flag == SESSION_START) { 73 | // We don't care about the actual value in the map 74 | // any u8 var != 0 would be fine 75 | tracing.update(&sock_pointer, &trace_flag); 76 | } 77 | 78 | return 0; 79 | } 80 | 81 | // Again, we don't care about the `close` call being successfull, we treat 82 | // the invocation as the end of the session regardless 83 | int kprobe__inet_release(struct pt_regs *ctx, struct socket *sock) { 84 | u8 protocol = get_socket_protocol(sock->sk); 85 | if(protocol != IPPROTO_UDP) return 0; 86 | 87 | u64 sock_pointer = (u64) sock->sk; 88 | if(tracing.lookup(&sock_pointer) != 0) { 89 | u32 pid = bpf_get_current_pid_tgid(); 90 | struct udp_session_event session = {}; 91 | session.pid = pid; 92 | session.type = SESSION_END; 93 | session.sock_pointer = sock_pointer; 94 | {% if container_labels -%} 95 | session.mntns_id = get_mntns_id(); 96 | {% endif -%} 97 | events.perf_submit(ctx, &session, sizeof(session)); 98 | tracing.delete(&sock_pointer); 99 | } 100 | return 0; 101 | } 102 | -------------------------------------------------------------------------------- /pidtree_bcc/probes/udp_session.py: -------------------------------------------------------------------------------- 1 | import time 2 | import traceback 3 | from collections import namedtuple 4 | from threading import Lock 5 | from typing import Any 6 | from typing import Union 7 | 8 | from pidtree_bcc.probes import BPFProbe 9 | from pidtree_bcc.utils import crawl_process_tree 10 | from pidtree_bcc.utils import int_to_ip 11 | from pidtree_bcc.utils import ip_to_int 12 | from pidtree_bcc.utils import never_crash 13 | 14 | 15 | SessionEventWrapper = namedtuple('SessionEndEvent', ('type', 'sock_pointer')) 16 | 17 | 18 | class UDPSessionProbe(BPFProbe): 19 | 20 | CONFIG_DEFAULTS = { 21 | 'ip_to_int': ip_to_int, 22 | 'filters': [], 23 | 'container_labels': [], 24 | 'includeports': [], 25 | 'excludeports': [], 26 | } 27 | USES_DYNAMIC_FILTERS = True 28 | SESSION_MAX_DURATION_DEFAULT = 120 29 | SESSION_START = 1 30 | SESSION_CONTINUE = 2 31 | SESSION_END = 3 32 | 33 | def build_probe_config(self, probe_config: dict, hotswap_only: bool = False) -> dict: 34 | config = super().build_probe_config(probe_config, hotswap_only=hotswap_only) 35 | if not hotswap_only: 36 | self.session_tracking = {} 37 | self.thread_lock = Lock() 38 | self.SIDECARS.append(( 39 | self._session_expiration_worker, 40 | (config.get('session_max_duration', self.SESSION_MAX_DURATION_DEFAULT),), 41 | )) 42 | return config 43 | 44 | def enrich_event(self, event: Any) -> Union[dict, None]: 45 | """ Parses UDP session event and adds process tree data 46 | 47 | :param Any event: BPF event 48 | :return: event dictionary with process tree at session end 49 | """ 50 | with self.thread_lock: 51 | return self._enrich_event_impl(event) 52 | 53 | def _enrich_event_impl(self, event: Any) -> Union[dict, None]: 54 | """ Actual `enrich_event` implementation, separated for cleaner thread locking code """ 55 | now = time.monotonic() 56 | sock_key = event.sock_pointer 57 | if event.type == self.SESSION_START: 58 | try: 59 | error = '' 60 | proctree = crawl_process_tree(event.pid) 61 | except Exception: 62 | error = traceback.format_exc() 63 | proctree = [] 64 | self.session_tracking[sock_key] = { 65 | 'pid': event.pid, 66 | 'proctree': proctree, 67 | 'destinations': {(event.daddr, event.dport): [now, 1]}, 68 | 'error': error, 69 | 'last_update': now, 70 | } 71 | elif sock_key in self.session_tracking: 72 | if event.type == self.SESSION_CONTINUE: 73 | dest_key = (event.daddr, event.dport) 74 | session_data = self.session_tracking[sock_key] 75 | if dest_key not in session_data['destinations']: 76 | session_data['destinations'][dest_key] = [now, 1] 77 | else: 78 | session_data['destinations'][dest_key][1] += 1 79 | session_data['last_update'] = now 80 | else: 81 | session_data = self.session_tracking.pop(sock_key) 82 | session_data.pop('last_update') 83 | session_data['destinations'] = [ 84 | { 85 | 'daddr': int_to_ip(addr_port[0]), 86 | 'port': addr_port[1], 87 | 'duration': now - begin_count[0], 88 | 'msg_count': begin_count[1], 89 | } 90 | for addr_port, begin_count in session_data['destinations'].items() 91 | ] 92 | return self.enrich_container_name(event, session_data) 93 | 94 | @never_crash 95 | def _session_expiration_worker(self, session_max_duration: int): 96 | """ Handler function for session expiration thread. 97 | Removes from tracking sessions older than the specified max duration 98 | 99 | :param int session_max_duration: max session duration in seconds 100 | """ 101 | while True: 102 | time.sleep(session_max_duration) 103 | expired = [] 104 | now = time.monotonic() 105 | with self.thread_lock: 106 | for sock_pointer, session_data in self.session_tracking.items(): 107 | if now - session_data['last_update'] > session_max_duration: 108 | session_data['error'] = 'session_max_duration_exceeded' 109 | expired.append(sock_pointer) 110 | for sock_pointer in expired: 111 | end_event = SessionEventWrapper(self.SESSION_END, sock_pointer) 112 | self._process_events(None, end_event, None, False) 113 | -------------------------------------------------------------------------------- /pidtree_bcc/probes/utils.j2: -------------------------------------------------------------------------------- 1 | {% macro get_proto_func() -%} 2 | static u8 get_socket_protocol(struct sock *sk) 3 | { 4 | // I'd love to be the one to have figured this out, I'm not 5 | // https://github.com/iovisor/bcc/blob/v0.16.0/tools/tcpaccept.py#L115 6 | u8 protocol; 7 | int gso_max_segs_offset = offsetof(struct sock, sk_gso_max_segs); 8 | int sk_lingertime_offset = offsetof(struct sock, sk_lingertime); 9 | if (sk_lingertime_offset - gso_max_segs_offset == 4) { 10 | protocol = *(u8 *)((u64)&sk->sk_gso_max_segs - 3); 11 | } else { 12 | protocol = *(u8 *)((u64)&sk->sk_wmem_queued - 3); 13 | } 14 | return protocol; 15 | } 16 | {%- endmacro %} 17 | 18 | {% macro net_filter_masks(filters, ip_to_int) -%} 19 | // IPs and masks are given in integer notation with their dotted notation in the comment 20 | {% for filter in filters %} 21 | // {{ filter.get("description", filter["subnet_name"]) }} 22 | #define subnet_{{ filter["subnet_name"] }} {{ ip_to_int(filter["network"]) }} // {{ filter["network"] }} 23 | #define subnet_{{ filter["subnet_name"] }}_mask {{ ip_to_int(filter["network_mask"]) }} // {{ filter["network_mask"] }} 24 | {% endfor %} 25 | {%- endmacro %} 26 | 27 | {% macro net_filter_if_excluded(filters, daddr_var='daddr', dport_var='dport') -%} 28 | // 29 | // For each filter, drop the packet iff 30 | // - a filter's subnet matches AND 31 | // - the port is not one of the filter's excepted ports AND 32 | // - the port is one of the filter's included ports, if they exist 33 | // 34 | if (0 // for easier templating 35 | {% for filter in filters -%} 36 | || ( 37 | ( 38 | subnet_{{ filter["subnet_name"] }} 39 | & subnet_{{ filter["subnet_name"] }}_mask 40 | ) == ({{ daddr_var }} & subnet_{{ filter["subnet_name"] }}_mask) 41 | {%- if filter.get('except_ports') %} 42 | && (1 // for easier templating 43 | {% for port in filter['except_ports'] -%} 44 | && ntohs({{ port }}) != {{ dport_var }} 45 | {%- endfor %} 46 | ) 47 | {%- endif %} 48 | {%- if filter.get('include_ports') %} 49 | && (0 // for easier templating 50 | {% for port in filter['include_ports'] -%} 51 | || ntohs({{ port }}) == {{ dport_var }} 52 | {%- endfor %} 53 | ) 54 | {%- endif %} 55 | ) 56 | {% endfor %}) 57 | {%- endmacro %} 58 | 59 | {% macro include_exclude_ports(includeports, excludeports, port_var='dport') -%} 60 | {% if includeports %} 61 | if (1 62 | {%- for port in includeports %} 63 | && {{ port }} != {{ port_var }} 64 | {% endfor %}) 65 | {% else -%} 66 | if (0 67 | {%- for port in excludeports %} 68 | {% set port = port | string -%} 69 | {% if '-' in port -%} 70 | {%- set from_port, to_port = port.split('-') -%} 71 | || ({{ port_var }} >= {{ from_port }} && {{ port_var }} <= {{ to_port }}) 72 | {%- else -%} 73 | || {{ port_var }} == {{ port }} 74 | {%- endif %} 75 | {% endfor %}) 76 | {% endif -%} 77 | {%- endmacro %} 78 | 79 | {% macro net_filter_trie_init(prefix_filter_var_name, port_filter_var_name, size=512, max_ports=8) -%} 80 | struct net_filter_key_t { 81 | u32 prefixlen; 82 | u32 data; 83 | }; 84 | 85 | struct net_filter_port_range_t { 86 | u16 lower; 87 | u16 upper; 88 | }; 89 | 90 | enum net_filter_mode { all = 0, exclude = 1, include = 2 }; 91 | 92 | struct net_filter_val_t { 93 | enum net_filter_mode mode; 94 | u8 ranges_size; 95 | struct net_filter_port_range_t ranges[{{ max_ports }}]; 96 | }; 97 | 98 | BPF_LPM_TRIE({{ prefix_filter_var_name }}, struct net_filter_key_t, struct net_filter_val_t, {{ size }}); 99 | 100 | BPF_ARRAY({{ port_filter_var_name }}, u8, 65536); // element 0 stores mode flag (exclude / include) 101 | 102 | // checks if the addr-port pairing is filtered 103 | // `addr` is expected in 32 bit integer format 104 | // `port` is expected in host byte order 105 | static inline bool is_addr_port_filtered(u32 addr, u16 port) { 106 | struct net_filter_key_t filter_key = { .prefixlen = 32, .data = addr }; 107 | struct net_filter_val_t* filter_val = {{ prefix_filter_var_name }}.lookup(&filter_key); 108 | if (filter_val != 0) { 109 | struct net_filter_port_range_t curr; 110 | if (filter_val->mode == all) { 111 | return true; 112 | } 113 | for (u8 i = 0; i < {{ max_ports }}; i++) { 114 | if (i >= filter_val->ranges_size) { 115 | break; 116 | } 117 | curr = filter_val->ranges[i]; 118 | if (port >= curr.lower && port <= curr.upper) { 119 | // range match, addr-port is filtered if in "include" mode 120 | return filter_val->mode == include; 121 | } 122 | } 123 | // no port range matched, addr-port is filtered only if in "exclude" mode 124 | return filter_val->mode == exclude; 125 | } 126 | return false; 127 | } 128 | 129 | // check if port is filtered globally in the probe configuration 130 | // `port` is expected in host byte order 131 | static inline bool is_port_globally_filtered(u16 port) { 132 | int zero = 0, intport = (int)port; // required cause array keys must be ints 133 | u8* mode = {{port_filter_var_name}}.lookup(&zero); 134 | u8* match = {{port_filter_var_name}}.lookup(&intport); 135 | return ( 136 | (mode && match) // we need to check the map pointers to make the compiler happy 137 | && ((*mode == exclude && *match) || (*mode == include && !*match)) 138 | ); 139 | } 140 | {%- endmacro %} 141 | 142 | {% macro patch_buggy_headers(do_patch=False) -%} 143 | {% if do_patch -%} 144 | // This is just a work around to some issues with latest kernels: 145 | // - https://github.com/iovisor/bcc/issues/3366 146 | // - https://github.com/iovisor/bcc/issues/3993 147 | struct bpf_timer { 148 | __u64 :64; 149 | __u64 :64; 150 | }; 151 | enum { 152 | BPF_F_BROADCAST = (1ULL << 3), 153 | BPF_F_EXCLUDE_INGRESS = (1ULL << 4), 154 | }; 155 | // Missing header value in bcc <0.19.0 (Ubuntu Jammy comes with 0.18.0) 156 | #define BPF_PSEUDO_FUNC 4 157 | {%- endif %} 158 | {%- endmacro %} 159 | 160 | {% macro mntns_filter_init(mntns_filter_map_name, size=512) -%} 161 | 162 | /* Original source: https://github.com/iovisor/bcc/blob/master/tools/mountsnoop.py#L32-L52 163 | * `struct mnt_namespace` is defined in fs/mount.h, which is private 164 | * to the VFS and not installed in any kernel-devel packages. So, let's 165 | * duplicate the important part of the definition. There are actually 166 | * more members in the real struct, but we don't need them, and they're 167 | * more likely to change. 168 | */ 169 | struct mnt_namespace { 170 | // This field was removed in https://github.com/torvalds/linux/commit/1a7b8969e664d6af328f00fe6eb7aabd61a71d13 171 | #if LINUX_VERSION_CODE < KERNEL_VERSION(5, 11, 0) 172 | atomic_t count; 173 | #endif 174 | struct ns_common ns; 175 | }; 176 | 177 | BPF_HASH({{ mntns_filter_map_name }}, u64, bool, {{ size }}); 178 | 179 | static inline u64 get_mntns_id() { 180 | struct task_struct *task; 181 | task = (struct task_struct *)bpf_get_current_task(); 182 | return task->nsproxy->mnt_ns->ns.inum; 183 | } 184 | 185 | static inline bool is_mntns_included() { 186 | u64 mntns_id = get_mntns_id(); 187 | return {{ mntns_filter_map_name }}.lookup(&mntns_id) != NULL; 188 | } 189 | {%- endmacro %} 190 | -------------------------------------------------------------------------------- /pidtree_bcc/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import importlib 3 | import inspect 4 | import ipaddress 5 | import logging 6 | import os 7 | import signal 8 | import socket 9 | import struct 10 | import sys 11 | from typing import Callable 12 | from typing import List 13 | from typing import TextIO 14 | from typing import Type 15 | from typing import Union 16 | 17 | import psutil 18 | 19 | 20 | def crawl_process_tree(pid: int) -> List[dict]: 21 | """ Takes a process and returns all process ancestry until the ppid is 0 22 | 23 | :param int pid: child process ID 24 | :return: yields dicts with pid, cmdline and username navigating up the tree 25 | """ 26 | result = [] 27 | while True: 28 | if pid == 0: 29 | break 30 | proc = psutil.Process(pid) 31 | result.append( 32 | { 33 | 'pid': proc.pid, 34 | 'cmdline': ' '.join(proc.cmdline()).strip(), 35 | 'username': proc.username(), 36 | }, 37 | ) 38 | pid = proc.ppid() 39 | return result 40 | 41 | 42 | def smart_open(filename: str = None, mode: str = 'r') -> TextIO: 43 | """ File OR stdout open 44 | 45 | :param str filename: filename 46 | :param str mode: file opening mode 47 | :return: file handle object 48 | """ 49 | if filename and filename != '-': 50 | return open(filename, mode) 51 | else: 52 | return sys.stdout 53 | 54 | 55 | def find_subclass(module_path: Union[str, List[str]], base_class: Type) -> Type: 56 | """ Get child class from module 57 | 58 | :param Union[str, List[str]] module_path: module path or list of paths in dot-notation 59 | :param Type base_class: class the child class inherits from 60 | :return: imported child class 61 | :raise ImportError: module path not valid 62 | :raise StopIteration: no class found 63 | """ 64 | if isinstance(module_path, str): 65 | module_path = [module_path] 66 | errors = '' 67 | module = None 68 | for path in module_path: 69 | try: 70 | module = importlib.import_module(path) 71 | break 72 | except ImportError as e: 73 | errors += '\n' + str(e) 74 | if module is None: 75 | raise ImportError( 76 | 'Unable to load any module from {}: {}' 77 | .format(module_path, errors), 78 | ) 79 | return next( 80 | obj for _, obj in inspect.getmembers(module) 81 | if inspect.isclass(obj) 82 | and issubclass(obj, base_class) 83 | and obj != base_class 84 | ) 85 | 86 | 87 | def ip_to_int(network: str) -> int: 88 | """ Takes an IP and returns the unsigned integer encoding of the address 89 | 90 | :param str network: ip address 91 | :return: unsigned integer encoding 92 | """ 93 | return struct.unpack('=L', socket.inet_aton(network))[0] 94 | 95 | 96 | def int_to_ip(encoded_ip: int) -> str: 97 | """ Takes IP in interger representation and makes it human readable 98 | 99 | :param int encoded_ip: integer encoded IP 100 | :return: dot-notation IP 101 | """ 102 | return socket.inet_ntoa(struct.pack(' int: 106 | """ Takes an IP netmask and returns the corresponding prefix length 107 | 108 | :param str netmask: IP netmask (e.g. 255.255.0.0) 109 | :return: prefix length 110 | """ 111 | return ipaddress.ip_network('0.0.0.0/{}'.format(netmask)).prefixlen 112 | 113 | 114 | def never_crash(func: Callable) -> Callable: 115 | """ Decorator for Thread targets which ensures the thread keeps 116 | running by chatching any exception. 117 | """ 118 | @functools.wraps(func) 119 | def wrapper(*args, **kwargs): 120 | while True: 121 | try: 122 | return func(*args, **kwargs) 123 | except Exception as e: 124 | logging.error('Error executing {}: {}'.format(func.__name__, e)) 125 | return wrapper 126 | 127 | 128 | def get_network_namespace(pid: int = None) -> int: 129 | """ Get network namespace identifier 130 | 131 | :param int pid: process ID (if not provided selects calling process) 132 | :return: network namespace inum 133 | """ 134 | if not pid: 135 | pid = 'self' 136 | try: 137 | ns_link = str(os.readlink('/proc/{}/ns/net'.format(pid))) 138 | # format will be "net:[]" 139 | return int(ns_link.strip()[5:-1]) 140 | except Exception: 141 | return None 142 | 143 | 144 | def self_restart(): 145 | """ Causes pidtree-bcc to restart itself """ 146 | os.kill(os.getpid(), signal.SIGHUP) 147 | 148 | 149 | def round_nearest_multiple(value: int, factor: int, headroom: int = 0) -> int: 150 | """ Round value to nearest multiple given a factor 151 | 152 | :param int value: starting value 153 | :param int factor: factor to use in finding multiple 154 | :param int headroom: ensure this much difference between value and result 155 | :return: rounded value 156 | """ 157 | return factor * ((value + headroom) // factor + 1) 158 | 159 | 160 | class StopFlagWrapper: 161 | def __init__(self): 162 | self.do_stop = False 163 | 164 | def stop(self): 165 | self.do_stop = True 166 | -------------------------------------------------------------------------------- /pidtree_bcc/yaml_loader.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import os 4 | import re 5 | import shutil 6 | import sys 7 | import tempfile 8 | from functools import partial 9 | from threading import Condition 10 | from threading import Thread 11 | from typing import Any 12 | from typing import AnyStr 13 | from typing import Dict 14 | from typing import IO 15 | from typing import List 16 | from typing import Optional 17 | from typing import Tuple 18 | from typing import Union 19 | from urllib import request 20 | 21 | import yaml 22 | 23 | from pidtree_bcc.utils import never_crash 24 | from pidtree_bcc.utils import StopFlagWrapper 25 | 26 | 27 | class FileIncludeLoader(yaml.SafeLoader): 28 | """ Custom YAML loader which allows including data from separate files, e.g.: 29 | 30 | ``` 31 | foo: !include some/other/file 32 | ``` 33 | """ 34 | 35 | REMOTE_FETCH_INTERVAL_SECONDS = 60 * 60 36 | REMOTE_FETCH_MAX_WAIT_SECONDS = 20 37 | 38 | remote_fetcher: Optional[Thread] = None 39 | remote_fetcher_outdir: Optional[str] = None 40 | remote_fetcher_fence: Optional[Condition] = None 41 | remote_fetch_workload: Dict[str, Tuple[str, Condition]] = {} 42 | 43 | def __init__( 44 | self, 45 | stream: Union[AnyStr, IO], 46 | included_files: List[str], 47 | stop_flag: Optional[StopFlagWrapper] = None, 48 | ): 49 | """ Constructor 50 | 51 | :param Union[AnyStr, IO] stream: input data 52 | :param List[str] included_files: list reference to be filled with external files being loaded 53 | """ 54 | super().__init__(stream) 55 | self.add_constructor('!include', self.include_file) 56 | self.included_files = included_files 57 | self.stop_flag = stop_flag 58 | 59 | def include_file(self, loader: yaml.Loader, node: yaml.Node) -> Any: 60 | """ Constructs a yaml node from a separate file. 61 | 62 | :param yaml.Loader loader: YAML loader object 63 | :param yaml.Node node: parsed node 64 | :return: loaded node contents 65 | """ 66 | name = loader.construct_scalar(node) 67 | filepath = ( 68 | self.include_remote(name, self.stop_flag) 69 | if re.match(r'^https?://', name) 70 | else ( 71 | os.path.join(os.path.dirname(loader.name), name) 72 | if not os.path.isabs(name) 73 | else name 74 | ) 75 | ) 76 | try: 77 | with open(filepath) as f: 78 | self.included_files.append(filepath) 79 | next_loader = partial(FileIncludeLoader, included_files=self.included_files) 80 | return yaml.load(f, Loader=next_loader) 81 | except OSError: 82 | _, value, traceback = sys.exc_info() 83 | raise yaml.YAMLError(value).with_traceback(traceback) 84 | 85 | @classmethod 86 | def include_remote(cls, url: str, stop_flag: Optional[StopFlagWrapper] = None) -> str: 87 | """ Load remote configuration data 88 | 89 | :param str url: resource url 90 | :return: local filepath where data is stored 91 | """ 92 | if cls.remote_fetcher is None or not cls.remote_fetcher.is_alive(): 93 | logging.info('Setting up remote configuration fetcher thread') 94 | cls.remote_fetcher_fence = Condition() 95 | cls.remote_fetcher_outdir = tempfile.mkdtemp(prefix='pidtree-bcc-conf') 96 | cls.remote_fetcher = Thread( 97 | target=fetch_remote_configurations, 98 | args=( 99 | cls.REMOTE_FETCH_INTERVAL_SECONDS, 100 | cls.remote_fetcher_fence, 101 | cls.remote_fetch_workload, 102 | stop_flag, 103 | ), 104 | daemon=True, 105 | ) 106 | FileIncludeLoader.remote_fetcher.start() 107 | logging.info(f'Loading remote configuration from {url}') 108 | ready = Condition() 109 | url_sha = hashlib.sha256(url.encode()).hexdigest() 110 | output_path = os.path.join(cls.remote_fetcher_outdir, f'{url_sha}.yaml') 111 | cls.remote_fetch_workload[url] = (output_path, ready) 112 | with cls.remote_fetcher_fence: 113 | cls.remote_fetcher_fence.notify() 114 | with ready: 115 | if not ready.wait(timeout=cls.REMOTE_FETCH_MAX_WAIT_SECONDS): 116 | raise ValueError(f'Failed to load configuration at {url}') 117 | return output_path 118 | 119 | @classmethod 120 | def get_loader_instance(cls, stop_flag: Optional[StopFlagWrapper] = None) -> Tuple[partial, List[str]]: 121 | """ Get loader and callback list of included files 122 | 123 | :param StopFlagWrapper stop_flag: signal for background threads to stop 124 | :return: loader and callback list of included files 125 | """ 126 | included_files = [] 127 | return partial(cls, included_files=included_files, stop_flag=stop_flag), included_files 128 | 129 | @classmethod 130 | def cleanup(cls): 131 | """ Cleans eventual background configuration fetcher setup """ 132 | if cls.remote_fetcher_fence: 133 | with cls.remote_fetcher_fence: 134 | cls.remote_fetcher_fence.notify() 135 | shutil.rmtree(cls.remote_fetcher_outdir, ignore_errors=True) 136 | cls.remote_fetch_workload.clear() 137 | 138 | 139 | @never_crash 140 | def fetch_remote_configurations( 141 | interval: int, 142 | fence: Condition, 143 | workload: Dict[str, Tuple[str, Condition]], 144 | stop_flag: Optional[StopFlagWrapper] = None, 145 | ): 146 | """ Periodically sync to disc remote configurations 147 | 148 | :param int interval: seconds to wait between each check 149 | :param Condition fence: condition object to cause 150 | :param Dict[str, Tuple[str, Condition]] workload: set of resources to fetch (format: url -> (output_file, ready)) 151 | :param StopFlagWrapper stop_flag: signal thead to stop 152 | """ 153 | while not (stop_flag and stop_flag.do_stop): 154 | # list() prevents dict from changing during the loop 155 | for url, path_ready in list(workload.items()): 156 | output_path, ready = path_ready 157 | with ready: 158 | _fetch_remote_configuration_impl(url, output_path) 159 | ready.notify() 160 | with fence: 161 | fence.wait(timeout=interval) 162 | 163 | 164 | def _fetch_remote_configuration_impl(url: str, output_path: str): 165 | """ Downloads remote configuration to file, if changed 166 | compared to current output path. 167 | 168 | :param str url: remote config url 169 | :param str output_path: output file path 170 | """ 171 | checksum = _md5sum(output_path) if os.path.exists(output_path) else '' 172 | if checksum and '.s3.amazonaws.' in url: 173 | # special case for AWS S3, which can give us a checksum in the header 174 | req = request.Request(url=url, method='HEAD') 175 | with request.urlopen(req) as response: 176 | response_etag = response.headers.get('ETag').strip('"').lower() 177 | if response_etag == checksum: 178 | return 179 | # store data to different path and rename, so eventual replacement is atomic 180 | tmpfd, tmppath = tempfile.mkstemp() 181 | tmp = os.fdopen(tmpfd, 'wb') 182 | with request.urlopen(url) as response: 183 | shutil.copyfileobj(response, tmp) 184 | tmp.close() 185 | if _md5sum(tmppath) != checksum: 186 | os.rename(tmppath, output_path) 187 | 188 | 189 | def _md5sum(filepath: str) -> str: 190 | """ Compute MD5 checksum for file 191 | 192 | :param str filepath: path to read data from 193 | :return: hex encoded checksum string 194 | """ 195 | hash_md5 = hashlib.md5() 196 | with open(filepath, 'rb') as f: 197 | for chunk in iter(lambda: f.read(4096), b''): 198 | hash_md5.update(chunk) 199 | return hash_md5.hexdigest() 200 | -------------------------------------------------------------------------------- /requirements-bootstrap-bionic.txt: -------------------------------------------------------------------------------- 1 | pip==21.3.1 2 | setuptools==49.2.1 3 | wheel==0.37.0 4 | -------------------------------------------------------------------------------- /requirements-bootstrap.txt: -------------------------------------------------------------------------------- 1 | pip==24.1 2 | setuptools==70.1.1 3 | wheel==0.43.0 4 | -------------------------------------------------------------------------------- /requirements-dev-minimal.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | pre-commit<2.0.0 3 | pytest 4 | requirements-tools 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | aspy.yaml==1.3.0 2 | attrs==21.2.0 3 | backports.entry-points-selectable==1.1.0 4 | cfgv==3.3.1 5 | coverage==6.2 6 | distlib==0.3.4 7 | filelock==3.0.12 8 | identify==2.3.0 9 | iniconfig==1.1.1 10 | nodeenv==1.6.0 11 | packaging==20.9 12 | platformdirs==2.0.2 13 | pluggy==0.13.1 14 | pre-commit==1.21.0 15 | py==1.11.0 16 | pyparsing==2.4.7 17 | pytest==6.2.5 18 | requirements-tools==2.0.0 19 | toml==0.10.2 20 | virtualenv==20.6.0 21 | -------------------------------------------------------------------------------- /requirements-minimal.txt: -------------------------------------------------------------------------------- 1 | Jinja2 2 | psutil 3 | PyStaticConfiguration 4 | pyyaml 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Jinja2==2.11.3 2 | MarkupSafe==1.1.1 3 | psutil==5.9.0 4 | PyStaticConfiguration==0.10.5 5 | pyyaml==5.3.1 6 | six==1.16.0 7 | zipp<3.0.0; python_version<'3.6' 8 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ./setup.sh 4 | python3 -m pidtree_bcc.main $@ 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import setuptools 4 | 5 | with open('README.md') as fh: 6 | long_description = fh.read() 7 | 8 | with open('pidtree_bcc/__init__.py') as fh: 9 | version = re.search(r"__version__\s*=\s*'([^\']+)", fh.read()).group(1) 10 | 11 | setuptools.setup( 12 | name='pidtree-bcc', 13 | version=version, 14 | author='Matt Carroll', 15 | author_email='mattc@yelp.com', 16 | description='eBPF-based intrusion detection and audit logging', 17 | long_description=long_description, 18 | long_description_content_type='text/markdown', 19 | url='https://github.com/Yelp/pidtree-bcc', 20 | packages=setuptools.find_packages( 21 | exclude=('tests*', 'itest*', 'packaging*'), 22 | ), 23 | include_package_data=True, 24 | license='BSD 3-clause "New" or "Revised License"', 25 | scripts=['bin/pidtree-bcc'], 26 | classifiers=[ 27 | 'Programming Language :: Python :: 3', 28 | 'License :: OSI Approved :: BSD License', 29 | 'Operating System :: Linux', 30 | ], 31 | ) 32 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | DEBUG_PATH=/sys/kernel/debug 4 | INSTALL_ONLY=${INSTALL_ONLY:-false} 5 | 6 | apt-get update 7 | apt-get -y install linux-headers-"$(uname -r)" 8 | 9 | if [ "$INSTALL_ONLY" = "true" ]; then exit 0; fi 10 | 11 | if ! mountpoint -q $DEBUG_PATH; then 12 | mount -t debugfs debugfs $DEBUG_PATH 13 | fi 14 | -------------------------------------------------------------------------------- /tests/bpf_probe_test.py: -------------------------------------------------------------------------------- 1 | from pidtree_bcc.probes import BPFProbe 2 | 3 | 4 | class MockProbe(BPFProbe): 5 | BPF_TEXT = r'''some text 6 | {{ some_variable }} 7 | some other text''' 8 | 9 | 10 | def test_pbf_templating(): 11 | mock_probe = MockProbe(None, {'some_variable': 'some_value'}) 12 | assert mock_probe.expanded_bpf_text == '''some text 13 | some_value 14 | some other text''' 15 | MockProbe.TEMPLATE_VARS = ['some_variable'] 16 | mock_probe = MockProbe(None, {'some_variable': 'some_value', 'rand_config': 1}) 17 | assert mock_probe.expanded_bpf_text == '''some text 18 | some_value 19 | some other text''' 20 | -------------------------------------------------------------------------------- /tests/config_test.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from unittest.mock import ANY 3 | from unittest.mock import patch 4 | 5 | from pidtree_bcc import config 6 | 7 | 8 | MOCK_CONFIG = '''--- 9 | udp_session: 10 | filters: [foo] 11 | tcp_connect: 12 | filters: [bar] 13 | other: true 14 | ''' 15 | 16 | 17 | @patch('pidtree_bcc.config.self_restart') 18 | def test_configuration_loading_lifecycle(mock_restart, tmp_path: pathlib.Path): 19 | test_conf = tmp_path / 'test.yml' 20 | with test_conf.open('w') as f: 21 | f.write(MOCK_CONFIG) 22 | 23 | watcher = config.setup_config(test_conf.as_posix(), watch_config=True, min_watch_interval=0) 24 | 25 | loaded_configs = {k: (v, q) for k, v, q in config.enumerate_probe_configs()} 26 | assert loaded_configs == { 27 | 'udp_session': ({'filters': ['foo']}, ANY), 28 | 'tcp_connect': ({'filters': ['bar'], 'other': True}, ANY), 29 | } 30 | 31 | # test hot-swappable 32 | with test_conf.open('w') as f: 33 | f.write(MOCK_CONFIG.replace('foo', 'stuff')) 34 | 35 | watcher.reload_if_changed() 36 | assert loaded_configs['tcp_connect'][1].empty() 37 | assert not loaded_configs['udp_session'][1].empty() 38 | assert loaded_configs['udp_session'][1].get() == {'filters': ['stuff']} 39 | 40 | # test non-hot-swappable 41 | with test_conf.open('w') as f: 42 | f.write(MOCK_CONFIG.replace('true', 'false')) 43 | 44 | watcher.reload() # forcing reload to avoid caring about mtime 45 | mock_restart.assert_called_once_with() 46 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest.mock import MagicMock 3 | 4 | 5 | # Globally mock bcc module 6 | bcc = MagicMock() 7 | bcc.__version__ = '0.18.0' 8 | sys.modules.setdefault('bcc', bcc) 9 | -------------------------------------------------------------------------------- /tests/containers_test.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from unittest.mock import call 3 | from unittest.mock import MagicMock 4 | from unittest.mock import patch 5 | 6 | from pidtree_bcc.containers import ContainerEventType 7 | from pidtree_bcc.containers import list_container_mnt_namespaces 8 | from pidtree_bcc.containers import MountNSInfo 9 | 10 | 11 | @patch('pidtree_bcc.containers.os') 12 | @patch('pidtree_bcc.containers.subprocess') 13 | def test_list_container_mnt_namespaces(mock_subprocess, mock_os): 14 | mock_subprocess.check_output.side_effect = [ 15 | 'aaaabbbbcccc\nddddeeeeffff\naaaacccceeee\nbbbbddddffff', 16 | r'[{"State":{"Pid":123},"Name":"abc","Config":{"Labels":{"a":"b"}}}]', 17 | subprocess.CalledProcessError(returncode=1, cmd='whatever'), 18 | r'[{"State":{"Pid":456},"Name":"def","Config":{"Labels":{"a":"b"}}}]', 19 | r'[{"State":{"Pid":789},"Name":"ghi","Config":{"Labels":{"a":"b"}}}]', 20 | ] 21 | mock_os.path.exists.return_value = False # force container client detection to "docker" 22 | mock_os.stat.side_effect = [ 23 | MagicMock(st_ino=111), 24 | MagicMock(st_ino=222), 25 | IOError, 26 | ] 27 | assert list_container_mnt_namespaces(['a=b']) == { 28 | MountNSInfo('aaaabbbbcccc', 'abc', 111, ContainerEventType.start), 29 | MountNSInfo('aaaacccceeee', 'def', 222, ContainerEventType.start), 30 | } 31 | mock_subprocess.check_output.assert_has_calls([ 32 | call(('docker', 'ps', '--no-trunc', '--quiet'), encoding='utf8'), 33 | call(('docker', 'inspect', 'aaaabbbbcccc'), encoding='utf8'), 34 | call(('docker', 'inspect', 'ddddeeeeffff'), encoding='utf8'), 35 | call(('docker', 'inspect', 'aaaacccceeee'), encoding='utf8'), 36 | ]) 37 | mock_os.stat.assert_has_calls([ 38 | call('/proc/123/ns/mnt'), 39 | call('/proc/456/ns/mnt'), 40 | call('/proc/789/ns/mnt'), 41 | ]) 42 | -------------------------------------------------------------------------------- /tests/filtering_test.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | 6 | from pidtree_bcc.filtering import CFilterKey 7 | from pidtree_bcc.filtering import CFilterValue 8 | from pidtree_bcc.filtering import CPortRange 9 | from pidtree_bcc.filtering import load_filters_into_map 10 | from pidtree_bcc.filtering import load_intset_into_map 11 | from pidtree_bcc.filtering import load_port_filters_into_map 12 | from pidtree_bcc.filtering import NetFilter 13 | from pidtree_bcc.filtering import port_range_mapper 14 | from pidtree_bcc.filtering import PortFilterMode 15 | from pidtree_bcc.utils import ip_to_int 16 | 17 | 18 | @pytest.fixture 19 | def net_filtering(): 20 | return NetFilter( 21 | [ 22 | { 23 | 'network': '127.0.0.1', 24 | 'network_mask': '255.0.0.0', 25 | }, 26 | { 27 | 'network': '10.0.0.0', 28 | 'network_mask': '255.0.0.0', 29 | 'except_ports': [123], 30 | }, 31 | { 32 | 'network': '192.168.0.0', 33 | 'network_mask': '255.255.0.0', 34 | 'include_ports': [123], 35 | }, 36 | ], 37 | ) 38 | 39 | 40 | def test_filter_ip_int(net_filtering): 41 | assert net_filtering.is_filtered(ip_to_int('127.1.33.7'), 80) 42 | assert not net_filtering.is_filtered(ip_to_int('1.2.3.4'), 80) 43 | 44 | 45 | def test_filter_ip_str(net_filtering): 46 | assert net_filtering.is_filtered('127.1.33.7', 80) 47 | assert not net_filtering.is_filtered('1.2.3.4', 80) 48 | 49 | 50 | def test_filter_ip_except_port(net_filtering): 51 | assert net_filtering.is_filtered('10.1.2.3', 80) 52 | assert not net_filtering.is_filtered('10.1.2.3', 123) 53 | 54 | 55 | def test_filter_ip_include_port(net_filtering): 56 | assert net_filtering.is_filtered('192.168.0.1', 123) 57 | assert not net_filtering.is_filtered('192.168.0.1', 80) 58 | 59 | 60 | def test_load_filters_into_map(): 61 | mock_filters = [ 62 | { 63 | 'network': '127.0.0.0', 64 | 'network_mask': '255.0.0.0', 65 | }, 66 | { 67 | 'network': '10.0.0.0', 68 | 'network_mask': '255.0.0.0', 69 | 'except_ports': [123, 456], 70 | }, 71 | { 72 | 'network': '192.168.0.0', 73 | 'network_mask': '255.255.0.0', 74 | 'include_ports': ['100-200'], 75 | }, 76 | ] 77 | res_map = {} 78 | load_filters_into_map(mock_filters, res_map) 79 | assert res_map == { 80 | CFilterKey(prefixlen=8, data=127): CFilterValue(mode=0, range_size=0), 81 | CFilterKey(prefixlen=8, data=10): CFilterValue( 82 | mode=1, 83 | range_size=2, 84 | ranges=CFilterValue.range_array_t(CPortRange(123, 123), CPortRange(456, 456)), 85 | ), 86 | CFilterKey(prefixlen=16, data=43200): CFilterValue( 87 | mode=2, range_size=1, ranges=CFilterValue.range_array_t(CPortRange(100, 200)), 88 | ), 89 | } 90 | 91 | 92 | def test_load_filters_into_map_diff(): 93 | mock_filters = [ 94 | { 95 | 'network': '127.0.0.0', 96 | 'network_mask': '255.0.0.0', 97 | }, 98 | { 99 | 'network': '10.0.0.0', 100 | 'network_mask': '255.0.0.0', 101 | 'except_ports': [123, 456], 102 | }, 103 | ] 104 | res_map = {CFilterKey(prefixlen=8, data=127): 'foo', CFilterKey(prefixlen=16, data=43200): 'bar'} 105 | load_filters_into_map(mock_filters, res_map, True) 106 | assert res_map == { 107 | CFilterKey(prefixlen=8, data=127): CFilterValue(mode=0, range_size=0), 108 | CFilterKey(prefixlen=8, data=10): CFilterValue( 109 | mode=1, 110 | range_size=2, 111 | ranges=CFilterValue.range_array_t(CPortRange(123, 123), CPortRange(456, 456)), 112 | ), 113 | } 114 | 115 | 116 | @pytest.mark.parametrize( 117 | 'filter_input,mode,expected', 118 | [ 119 | ((22, 80, 443), PortFilterMode.include, {0: 2, 22: 1, 80: 1, 443: 1}), 120 | (('10-20',), PortFilterMode.exclude, {0: 1, **{i: 1 for i in range(10, 21)}}), 121 | ((1, '2-9', 10), PortFilterMode.exclude, {i: 1 for i in range(11)}), 122 | ], 123 | ) 124 | def test_load_port_filters_into_map(filter_input, mode, expected): 125 | res_map = MagicMock() 126 | load_port_filters_into_map(filter_input, mode, res_map) 127 | assert expected == { 128 | call_args[0][0].value: call_args[0][1].value 129 | for call_args in res_map.__setitem__.call_args_list 130 | } 131 | 132 | 133 | def test_load_port_filters_into_map_diff(): 134 | res_map = MagicMock() 135 | res_map.items.return_value = [(ctypes.c_int(i), ctypes.c_uint8(i % 2)) for i in range(16)] 136 | load_port_filters_into_map(range(1, 6), PortFilterMode.include, res_map, True) 137 | assert { 138 | 0: 2, 139 | **{i: 1 for i in range(1, 6)}, 140 | **{i: 0 for i in range(6, 16) if i % 2 > 0}, 141 | } == { 142 | call_args[0][0].value: call_args[0][1].value 143 | for call_args in res_map.__setitem__.call_args_list 144 | } 145 | 146 | 147 | def test_port_range_mapper(): 148 | assert list(port_range_mapper('22-80')) == list(range(22, 81)) 149 | assert list(port_range_mapper('0-10')) == list(range(1, 11)) 150 | assert list(port_range_mapper('100-100000000')) == list(range(100, 65535)) 151 | 152 | 153 | def test_lpm_trie_key(): 154 | assert ( 155 | CFilterKey.from_network_definition('255.255.0.0', '192.168.0.0') 156 | == CFilterKey.from_network_definition('255.255.0.0', '192.168.2.3') 157 | ) 158 | 159 | 160 | @pytest.mark.parametrize( 161 | 'intset,initial,expected,do_diff', 162 | ( 163 | ({1, 2, 3}, {}, {1: 1, 2: 1, 3: 1}, False), 164 | ({2, 3}, {1: 1}, {1: 1, 2: 1, 3: 1}, False), 165 | ({2, 3}, {1: 1}, {2: 1, 3: 1}, True), 166 | ), 167 | ) 168 | def test_load_intset_into_map(intset, initial, expected, do_diff): 169 | mapping = MagicMock() 170 | mapping.items.return_value = [(ctypes.c_int(k), ctypes.c_uint8(v)) for k, v in initial.items()] 171 | 172 | load_intset_into_map(intset, mapping, do_diff) 173 | assert expected == { 174 | **({} if do_diff else initial), 175 | **{ 176 | call_args[0][0].value: call_args[0][1].value 177 | for call_args in mapping.__setitem__.call_args_list 178 | }, 179 | } 180 | if do_diff: 181 | assert (set(initial) - intset) == { 182 | call_args[0][0].value 183 | for call_args in mapping.__delitem__.call_args_list 184 | } 185 | -------------------------------------------------------------------------------- /tests/fixtures/child_config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - 1 3 | - a: 2 4 | b: 3 5 | - 4 6 | -------------------------------------------------------------------------------- /tests/fixtures/hostfile: -------------------------------------------------------------------------------- 1 | 169.254.0.1 derp 2 | # 127.0.0.1 not me 3 | -------------------------------------------------------------------------------- /tests/fixtures/hostfile2: -------------------------------------------------------------------------------- 1 | 169.254.0.2 herp 2 | -------------------------------------------------------------------------------- /tests/fixtures/parent_config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | foo: !include child_config.yaml 3 | bar: 4 | fizz: buzz 5 | -------------------------------------------------------------------------------- /tests/fixtures/remote_config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | foo: !include https://raw.githubusercontent.com/Yelp/pidtree-bcc/master/tests/fixtures/child_config.yaml 3 | bar: 4 | fizz: buzz 5 | -------------------------------------------------------------------------------- /tests/loginuid_plugin_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | from unittest.mock import mock_open 3 | from unittest.mock import patch 4 | 5 | from pidtree_bcc.plugins.loginuidmap import LoginuidMap 6 | 7 | 8 | def test_loginuidmap_get_loginuid(): 9 | with patch('builtins.open', mock_open(read_data='123')) as _mock_open, \ 10 | patch('pidtree_bcc.plugins.loginuidmap.pwd') as mock_pwd: 11 | mock_pwd.getpwuid.return_value.pw_name = 'foo' 12 | loginuid_info = LoginuidMap._get_loginuid(321) 13 | mock_pwd.getpwuid.assert_called_once_with(123) 14 | _mock_open.assert_called_once_with('/proc/321/loginuid') 15 | assert loginuid_info == (123, 'foo') 16 | 17 | 18 | def test_loginuidmap_process_process_tree(): 19 | with patch.object(LoginuidMap, '_get_loginuid') as mock_get_loginuid: 20 | mock_get_loginuid.side_effect = [(1, 'foo'), (None, None), (2, 'bar')] 21 | plugin = LoginuidMap({}) 22 | event = plugin.process({'proctree': [{'pid': 100 + i} for i in range(3)]}) 23 | assert event == { 24 | 'proctree': [ 25 | {'pid': 100, 'loginuid': 1, 'loginname': 'foo'}, 26 | {'pid': 101}, 27 | {'pid': 102, 'loginuid': 2, 'loginname': 'bar'}, 28 | ], 29 | } 30 | mock_get_loginuid.assert_has_calls([call(100 + i) for i in range(3)]) 31 | 32 | 33 | def test_loginuidmap_process_top_level(): 34 | with patch.object(LoginuidMap, '_get_loginuid') as mock_get_loginuid: 35 | mock_get_loginuid.side_effect = [(1, 'foo')] 36 | plugin = LoginuidMap({'top_level': True}) 37 | event = plugin.process({'pid': 100, 'proctree': [{'pid': 100}]}) 38 | assert event == { 39 | 'pid': 100, 40 | 'loginuid': 1, 41 | 'loginname': 'foo', 42 | 'proctree': [{'pid': 100}], 43 | } 44 | mock_get_loginuid.assert_called_once_with(100) 45 | -------------------------------------------------------------------------------- /tests/net_listen_probe_test.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from unittest.mock import call 3 | from unittest.mock import MagicMock 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from pidtree_bcc.probes.net_listen import NetListenProbe 9 | from pidtree_bcc.probes.net_listen import NetListenWrapper 10 | 11 | 12 | @patch('pidtree_bcc.probes.net_listen.crawl_process_tree') 13 | def test_net_listen_enrich_event(mock_crawl): 14 | probe = NetListenProbe(None) 15 | mock_event = MagicMock( 16 | pid=123, 17 | port=1337, 18 | laddr=0, 19 | protocol=6, 20 | ) 21 | mock_crawl.return_value = [ 22 | {'pid': 123, 'cmdline': 'nc -lp 1337', 'username': 'foo'}, 23 | {'pid': 50, 'cmdline': 'bash', 'username': 'foo'}, 24 | {'pid': 1, 'cmdline': 'init', 'username': 'root'}, 25 | ] 26 | assert probe.enrich_event(mock_event) == { 27 | 'pid': 123, 28 | 'proctree': [ 29 | {'pid': 123, 'cmdline': 'nc -lp 1337', 'username': 'foo'}, 30 | {'pid': 50, 'cmdline': 'bash', 'username': 'foo'}, 31 | {'pid': 1, 'cmdline': 'init', 'username': 'root'}, 32 | ], 33 | 'laddr': '0.0.0.0', 34 | 'port': 1337, 35 | 'protocol': 'tcp', 36 | 'error': '', 37 | } 38 | mock_crawl.assert_called_once_with(123) 39 | 40 | 41 | @patch('pidtree_bcc.probes.net_listen.time') 42 | @patch('pidtree_bcc.probes.net_listen.psutil') 43 | def test_net_listen_snapshot_worker(mock_psutil, mock_time): 44 | mock_time.sleep.side_effect = [None, Exception('foobar')] # to stop inf loop 45 | mock_psutil.net_connections.return_value = [ 46 | MagicMock( 47 | pid=111, 48 | status='LISTEN', 49 | laddr=MagicMock(ip='127.0.0.1', port=1337), 50 | ), 51 | MagicMock( 52 | pid=112, 53 | status='LISTEN', 54 | laddr=MagicMock(ip='127.0.0.1', port=80), 55 | ), 56 | MagicMock( 57 | pid=113, 58 | status='NONE', 59 | laddr=MagicMock(ip='127.0.0.1', port=7331), 60 | type=socket.SOCK_DGRAM, 61 | ), 62 | MagicMock( 63 | pid=None, 64 | ), 65 | ] 66 | probe = NetListenProbe(None, {'excludeports': ['0-100'], 'protocols': ['udp', 'tcp']}) 67 | with patch.object(probe, '_process_events') as mock_process: 68 | # never_crash uses functools.wraps so we can extract the wrapped method 69 | undecorated_method = probe._snapshot_worker.__wrapped__ 70 | # assert we catch the inf loop stopping exception 71 | with pytest.raises(Exception, match='foobar'): 72 | # the undecorated method is not bound to the object, 73 | # so we need to pass `probe` as `self` 74 | undecorated_method(probe, 123) 75 | mock_process.assert_has_calls([ 76 | call(None, NetListenWrapper(111, 16777343, 1337, 6), None, False), 77 | call(None, NetListenWrapper(113, 16777343, 7331, 17), None, False), 78 | ]) 79 | mock_psutil.net_connections.assert_called_once_with('inet4') 80 | mock_time.sleep.assert_has_calls([call(300), call(123)]) 81 | -------------------------------------------------------------------------------- /tests/plugin_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pidtree_bcc.plugins import load_plugins 4 | from pidtree_bcc.plugins.identityplugin import Identityplugin 5 | 6 | 7 | def test_plugins_loads_no_plugins(): 8 | plugins = load_plugins({}, 'mock_probe') 9 | assert plugins == [] 10 | 11 | 12 | def test_plugins_loads_multiple_plugins(): 13 | plugins = load_plugins( 14 | { 15 | 'identityplugin': {}, 16 | 'sourceipmap': { 17 | 'hostfiles': ['/etc/hosts'], 18 | }, 19 | }, 'tcp_connect', 20 | ) 21 | assert len(plugins) == 2 22 | 23 | 24 | def test_plugins_loads_identity_plugin(): 25 | plugins = load_plugins({'identityplugin': {}}, 'mock_probe') 26 | assert isinstance(plugins[0], Identityplugin) 27 | 28 | 29 | def test_plugins_doesnt_load_disabled_identity_plugin(): 30 | plugins = load_plugins({'identityplugin': {'enabled': False}}, 'mock_probe') 31 | assert plugins == [] 32 | 33 | 34 | def test_plugins_exception_on_no_file(): 35 | with pytest.raises(RuntimeError) as e: 36 | load_plugins({'please_dont_make_a_plugin_called_this': {}}, 'mock_probe') 37 | assert 'No module named ' in str(e) 38 | 39 | 40 | def test_plugins_exception_with_unload(caplog): 41 | plugins = load_plugins( 42 | { 43 | 'please_dont_make_a_plugin_called_this': {'unload_on_init_exception': True}, 44 | 'identityplugin': {}, 45 | }, 'mock_probe', 46 | ) 47 | assert len(plugins) == 1 48 | assert isinstance(plugins[0], Identityplugin) 49 | assert 'Could not import [\'pidtree_bcc.plugins.please_dont_make_a_plugin_called_this\']' in caplog.text 50 | 51 | 52 | def test_plugins_load_incompatible(): 53 | with pytest.raises(RuntimeError): 54 | load_plugins({'sourceipmap': {}}, 'mock_probe') 55 | -------------------------------------------------------------------------------- /tests/sourceipmap_plugin_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from pidtree_bcc.plugins import sourceipmap 6 | 7 | 8 | def test_hosts_loader(): 9 | hostfile = os.path.dirname( 10 | os.path.realpath(__file__), 11 | ) + '/fixtures/hostfile' 12 | assert sourceipmap.hosts_loader(hostfile) == {'169.254.0.1': 'derp'} 13 | 14 | 15 | def test_multiple_hosts_loader(): 16 | hostfile1 = os.path.dirname( 17 | os.path.realpath(__file__), 18 | ) + '/fixtures/hostfile' 19 | hostfile2 = os.path.dirname( 20 | os.path.realpath(__file__), 21 | ) + '/fixtures/hostfile2' 22 | plugin = sourceipmap.Sourceipmap({'hostfiles': [hostfile1, hostfile2]}) 23 | assert plugin.process({'saddr': '169.254.0.2'})['source_host'] == 'herp' 24 | assert plugin.process({'saddr': '169.254.0.1'})['source_host'] == 'derp' 25 | 26 | 27 | def test_host_file_doesnt_exist(): 28 | hostfile = '/bananas_in_spaaaaaaaaaaace' 29 | with pytest.raises(RuntimeError) as e: 30 | sourceipmap.Sourceipmap({'hostfiles': [hostfile]}) 31 | assert ( 32 | "File `/bananas_in_spaaaaaaaaaaace` passed as a 'hostfiles'" 33 | ' entry to the sourceipmap plugin does not exist' 34 | ) in str(e) 35 | -------------------------------------------------------------------------------- /tests/tcp_connect_probe_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | from unittest.mock import patch 3 | 4 | from pidtree_bcc.probes.tcp_connect import TCPConnectProbe 5 | from pidtree_bcc.utils import ip_to_int 6 | 7 | 8 | @patch('pidtree_bcc.probes.tcp_connect.crawl_process_tree') 9 | def test_tcp_connect_enrich_event(mock_crawl): 10 | probe = TCPConnectProbe(None) 11 | mock_event = MagicMock( 12 | pid=123, 13 | dport=80, 14 | daddr=ip_to_int('1.1.1.1'), 15 | saddr=ip_to_int('127.0.0.1'), 16 | ) 17 | mock_crawl.return_value = [ 18 | {'pid': 123, 'cmdline': 'curl 1.1.1.1', 'username': 'foo'}, 19 | {'pid': 50, 'cmdline': 'bash', 'username': 'foo'}, 20 | {'pid': 1, 'cmdline': 'init', 'username': 'root'}, 21 | ] 22 | assert probe.enrich_event(mock_event) == { 23 | 'pid': 123, 24 | 'proctree': [ 25 | {'pid': 123, 'cmdline': 'curl 1.1.1.1', 'username': 'foo'}, 26 | {'pid': 50, 'cmdline': 'bash', 'username': 'foo'}, 27 | {'pid': 1, 'cmdline': 'init', 'username': 'root'}, 28 | ], 29 | 'daddr': '1.1.1.1', 30 | 'saddr': '127.0.0.1', 31 | 'port': 80, 32 | 'error': '', 33 | } 34 | mock_crawl.assert_called_once_with(123) 35 | -------------------------------------------------------------------------------- /tests/udp_session_probe_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from pidtree_bcc.probes.udp_session import SessionEventWrapper 7 | from pidtree_bcc.probes.udp_session import UDPSessionProbe 8 | 9 | 10 | @patch('pidtree_bcc.probes.udp_session.crawl_process_tree') 11 | @patch('pidtree_bcc.probes.udp_session.time') 12 | def test_udp_session_enrich_event(mock_time, mock_crawl): 13 | probe = UDPSessionProbe(None) 14 | mock_time.monotonic.side_effect = range(3) 15 | mock_crawl.return_value = [ 16 | {'pid': 123, 'cmdline': 'some_program', 'username': 'foo'}, 17 | {'pid': 50, 'cmdline': 'bash', 'username': 'foo'}, 18 | {'pid': 1, 'cmdline': 'init', 'username': 'root'}, 19 | ] 20 | assert probe.enrich_event( 21 | MagicMock(type=1, pid=123, sock_pointer=1, daddr=168430090, dport=1337), 22 | ) is None 23 | assert probe.enrich_event( 24 | MagicMock(type=2, pid=123, sock_pointer=1, daddr=16777343, dport=1337), 25 | ) is None 26 | assert probe.enrich_event( 27 | MagicMock(type=3, pid=123, sock_pointer=1), 28 | ) == { 29 | 'pid': 123, 30 | 'proctree': [ 31 | {'pid': 123, 'cmdline': 'some_program', 'username': 'foo'}, 32 | {'pid': 50, 'cmdline': 'bash', 'username': 'foo'}, 33 | {'pid': 1, 'cmdline': 'init', 'username': 'root'}, 34 | ], 35 | 'destinations': [ 36 | { 37 | 'daddr': '10.10.10.10', 38 | 'port': 1337, 39 | 'duration': 2, 40 | 'msg_count': 1, 41 | }, 42 | { 43 | 'daddr': '127.0.0.1', 44 | 'port': 1337, 45 | 'duration': 1, 46 | 'msg_count': 1, 47 | }, 48 | ], 49 | 'error': '', 50 | } 51 | mock_crawl.assert_called_once_with(123) 52 | 53 | 54 | @patch('pidtree_bcc.probes.udp_session.time') 55 | def test_udp_session_expiration_worker(mock_time): 56 | mock_time.sleep.side_effect = [None, Exception('foobar')] # to stop inf loop 57 | mock_time.monotonic.return_value = 200 58 | probe = UDPSessionProbe(None) 59 | probe.session_tracking = { 60 | 1: {'last_update': 180}, 61 | 2: {'last_update': 0}, 62 | 3: {'last_update': 190}, 63 | } 64 | with patch.object(probe, '_process_events') as mock_process: 65 | # never_crash uses functools.wraps so we can extract the wrapped method 66 | undecorated_method = probe._session_expiration_worker.__wrapped__ 67 | # assert we catch the inf loop stopping exception 68 | with pytest.raises(Exception, match='foobar'): 69 | # the undecorated method is not bound to the object, 70 | # so we need to pass `probe` as `self` 71 | undecorated_method(probe, 120) 72 | mock_process.assert_called_once_with( 73 | None, SessionEventWrapper(3, 2), None, False, 74 | ) 75 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from unittest.mock import call 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from pidtree_bcc import utils 9 | 10 | 11 | def test_crawl_process_tree(): 12 | this_pid = os.getpid() 13 | tree = utils.crawl_process_tree(this_pid) 14 | assert len(tree) >= 1 15 | assert tree[0]['pid'] == this_pid 16 | assert tree[-1]['pid'] == 1 # should be init 17 | 18 | 19 | def test_smart_open(): 20 | this_file = os.path.abspath(__file__) 21 | assert utils.smart_open() == sys.stdout 22 | assert utils.smart_open('-') == sys.stdout 23 | assert utils.smart_open(this_file).name == this_file 24 | 25 | 26 | def test_ip_to_int(): 27 | assert utils.ip_to_int('127.0.0.1') == 16777343 28 | assert utils.ip_to_int('10.10.10.10') == 168430090 29 | 30 | 31 | def test_int_to_ip(): 32 | assert utils.int_to_ip(16777343) == '127.0.0.1' 33 | assert utils.int_to_ip(168430090) == '10.10.10.10' 34 | 35 | 36 | @patch('pidtree_bcc.utils.os') 37 | def test_get_network_namespace(mock_os): 38 | mock_os.readlink.return_value = 'net:[456]' 39 | assert utils.get_network_namespace() == 456 40 | assert utils.get_network_namespace(123) == 456 41 | mock_os.readlink.assert_has_calls([ 42 | call('/proc/self/ns/net'), 43 | call('/proc/123/ns/net'), 44 | ]) 45 | mock_os.readlink.side_effect = Exception 46 | assert utils.get_network_namespace() is None 47 | 48 | 49 | def test_netmask_to_prefixlen(): 50 | assert utils.netmask_to_prefixlen('0.0.0.0') == 0 51 | assert utils.netmask_to_prefixlen('255.255.255.255') == 32 52 | assert utils.netmask_to_prefixlen('255.0.0.0') == 8 53 | with pytest.raises(ValueError): 54 | utils.netmask_to_prefixlen('1.1.1.1') 55 | 56 | 57 | def test_round_nearest_multiple(): 58 | assert utils.round_nearest_multiple(23, 10) == 30 59 | assert utils.round_nearest_multiple(27, 10, 4) == 40 60 | -------------------------------------------------------------------------------- /tests/yaml_loader_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | import yaml 5 | 6 | from pidtree_bcc.utils import StopFlagWrapper 7 | from pidtree_bcc.yaml_loader import fetch_remote_configurations 8 | from pidtree_bcc.yaml_loader import FileIncludeLoader 9 | 10 | 11 | def test_file_include_loader(): 12 | loader, included_files = FileIncludeLoader.get_loader_instance() 13 | with open('tests/fixtures/parent_config.yaml') as f: 14 | data = yaml.load(f, Loader=loader) 15 | assert data == { 16 | 'foo': [1, {'a': 2, 'b': 3}, 4], 17 | 'bar': {'fizz': 'buzz'}, 18 | } 19 | assert included_files == ['tests/fixtures/child_config.yaml'] 20 | 21 | 22 | @patch('pidtree_bcc.yaml_loader.tempfile') 23 | @patch('pidtree_bcc.yaml_loader.request') 24 | def test_file_include_remote(mock_request, mock_tempfile, tmp_path): 25 | stop_flag = StopFlagWrapper() 26 | # test could technically work with a real network request, but we mock anyway for better isolation 27 | mock_request.urlopen.return_value = open('tests/fixtures/child_config.yaml', 'rb') 28 | mock_tempfile.mkdtemp.return_value = tmp_path.absolute().as_posix() 29 | tmpout = (tmp_path / 'tmp.yaml').absolute().as_posix() 30 | mock_tempfile.mkstemp.return_value = ( 31 | os.open(tmpout, os.O_WRONLY | os.O_CREAT | os.O_EXCL), 32 | tmpout, 33 | ) 34 | # this self-referring patch ensures mocks are propagated to the fetcher thread 35 | with patch('pidtree_bcc.yaml_loader.fetch_remote_configurations', fetch_remote_configurations): 36 | loader, included_files = FileIncludeLoader.get_loader_instance(stop_flag) 37 | with open('tests/fixtures/remote_config.yaml') as f: 38 | data = yaml.load(f, Loader=loader) 39 | stop_flag.stop() 40 | FileIncludeLoader.cleanup() 41 | assert data == { 42 | 'foo': [1, {'a': 2, 'b': 3}, 4], 43 | 'bar': {'fizz': 'buzz'}, 44 | } 45 | assert included_files == [ 46 | (tmp_path / '72e7a811f0c6baf6b49f9ddd2300d252a3eba7eb370f502cb834faa018ab26b9.yaml').absolute().as_posix(), 47 | ] 48 | mock_request.urlopen.assert_called_once_with( 49 | 'https://raw.githubusercontent.com/Yelp/pidtree-bcc/master/tests/fixtures/child_config.yaml', 50 | ) 51 | assert not FileIncludeLoader.remote_fetch_workload 52 | assert not FileIncludeLoader.remote_fetcher.is_alive() 53 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py310 3 | skip_missing_interpreters = true 4 | 5 | [testenv] 6 | setenv = 7 | PIP_PREFER_BINARY=true 8 | deps = 9 | setuptools<70.0.0 10 | -rrequirements.txt 11 | -rrequirements-dev.txt 12 | commands = 13 | coverage run -m pytest --strict-markers -rxs 14 | coverage report -m 15 | 16 | [testenv:venv] 17 | basepython = /usr/bin/python3.8 18 | envdir = venv 19 | commands = 20 | # to allow system-level python3-bcc 21 | sitepackages = true 22 | 23 | [flake8] 24 | max-line-length = 120 25 | 26 | [gh-actions] 27 | python = 28 | 3.8: py38 29 | 3.10: py310 30 | --------------------------------------------------------------------------------