Skip to content

Commit d649e71

Browse files
committed
Merge branch 'u/mpiano/SEC-19569'
2 parents 174b113 + 4f6f5c8 commit d649e71

15 files changed

+258
-5
lines changed

example_config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ _net_filters: &net_filters
2323
# Some configuration fields supported by all probes:
2424
# filters: list of network filters (see above for schema); they act on the destination
2525
# address for tcp_connect and udp_session, local listening address for net_listen
26+
# container_labels: list of label filters to only capture events generated from processes in containers
27+
# container_poll_interval: how often to poll for running containers (in seconds)
2628
# excludeports: list of ports to be filtered out (cannot be used with includeports)
2729
# includeports: list of ports for which events will be logged (filters out all the others) (cannot be used with excludeports)
2830
# plugins: map of plugins to enable for the probe (check README for more details)
@@ -31,6 +33,8 @@ udp_session:
3133
filters: *net_filters
3234
tcp_connect:
3335
filters: *net_filters
36+
container_labels:
37+
- key=value
3438
plugins:
3539
sourceipmap:
3640
enabled: True

pidtree_bcc/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
HOTSWAP_CALLBACK_NAMESPACE = get_namespace('__change_callbacks')
2626
LOADED_CONFIG_FILES_NAMESPACE = get_namespace('__loaded_configs')
27-
HOT_SWAPPABLE_SETTINGS = ('filters', 'excludeports', 'includeports')
27+
HOT_SWAPPABLE_SETTINGS = ('filters', 'excludeports', 'includeports', 'container_labels')
2828
NON_PROBE_NAMESPACES = (DEFAULT_NAMESPACE, HOTSWAP_CALLBACK_NAMESPACE.name, LOADED_CONFIG_FILES_NAMESPACE.name)
2929

3030

pidtree_bcc/containers.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import logging
2+
import os
3+
import subprocess
4+
from functools import lru_cache
5+
from itertools import chain
6+
from typing import List
7+
from typing import Set
8+
9+
10+
@lru_cache(maxsize=1)
11+
def detect_containerizer_client() -> str:
12+
""" Detect whether the system is using Docker or Containerd.
13+
Since containerd does not have a proper python API implementation, we rely on
14+
CLI tools to query container information in both cases.
15+
This method is very opinionated towards detecting Containerd usage in Kubernetes,
16+
so in most cases it will fall back to standard Docker.
17+
18+
:return: CLI tool to query containerizer
19+
"""
20+
return 'nerdctl' if os.path.exists('/var/run/containerd/io.containerd.runtime.v2.task/k8s.io') else 'docker'
21+
22+
23+
def list_containers(filter_labels: List[str] = None) -> List[str]:
24+
""" List running containers matching filter
25+
26+
:param List[str] filter_labels: list of label values, either `<label_name>` or `<label_name>=<label_value>`
27+
:return: yields container hash IDs
28+
"""
29+
filter_args = chain.from_iterable(('--filter', f'label={label}') for label in (filter_labels or []))
30+
try:
31+
output = subprocess.check_output(
32+
(
33+
detect_containerizer_client(), 'ps',
34+
'--no-trunc', '--quiet', *filter_args,
35+
), encoding='utf8',
36+
)
37+
return output.splitlines()
38+
except Exception as e:
39+
logging.error(f'Issue listing running containers: {e}')
40+
return []
41+
42+
43+
@lru_cache(maxsize=2048)
44+
def get_container_mntns_id(sha: str) -> int:
45+
""" Get mount namespace ID for a container
46+
47+
:param str sha: container hash ID
48+
:return: mount namespace ID
49+
"""
50+
try:
51+
output = subprocess.check_output(
52+
(
53+
detect_containerizer_client(), 'inspect',
54+
'-f', r'{{.State.Pid}}', sha,
55+
), encoding='utf8',
56+
)
57+
pid = int(output.splitlines()[0])
58+
except Exception as e:
59+
logging.error(f'Issue inspecting container {sha}: {e}')
60+
return -1
61+
try:
62+
return os.stat(f'/proc/{pid}/ns/mnt').st_ino
63+
except Exception as e:
64+
logging.error(f'Issue reading mntns ID for {pid}: {e}')
65+
return -1
66+
67+
68+
def list_container_mnt_namespaces(filter_labels: List[str] = None) -> Set[int]:
69+
""" Get collection of mount namespace IDs for running containers matching label filters
70+
71+
:param List[str] filter_labels: list of label values, either `<label_name>` or `<label_name>=<label_value>`
72+
:return: set of mount namespace IDs
73+
"""
74+
return {
75+
mntns_id
76+
for mntns_id in (
77+
get_container_mntns_id(container_id)
78+
for container_id in list_containers(filter_labels)
79+
if container_id
80+
)
81+
if mntns_id > 0
82+
}

pidtree_bcc/filtering.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66
from typing import Iterable
77
from typing import List
8+
from typing import Set
89
from typing import Union
910

1011
from pidtree_bcc.ctypes_helper import ComparableCtStructure
@@ -131,6 +132,7 @@ def load_filters_into_map(filters: List[dict], ebpf_map: Any, do_diff: bool = Fa
131132
'include_ports': [789], # optional
132133
}
133134
: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
134136
"""
135137
leftovers = set(
136138
# The map returns keys using an auto-generated type.
@@ -179,6 +181,7 @@ def load_port_filters_into_map(
179181
:param List[Union[int, str]] filters: list of ports or port ranges
180182
:param PortFilterMode mode: include or exclude
181183
:param Any ebpf_map: array in which filters are loaded
184+
:param bool do_diff: diff input with existing values, removing excess entries
182185
"""
183186
if mode not in (PortFilterMode.include, PortFilterMode.exclude):
184187
raise ValueError('Invalid global port filtering mode: {}'.format(mode))
@@ -198,3 +201,17 @@ def load_port_filters_into_map(
198201
ebpf_map[ctypes.c_int(port)] = ctypes.c_uint8(0)
199202
# 0-element of the map holds the filtering mode
200203
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):
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+
"""
213+
current_state = set((k.value for k, _ in ebpf_map.items()) if do_diff else [])
214+
for val in intset:
215+
ebpf_map[ctypes.c_int(val)] = ctypes.c_uint8(1)
216+
for val in (current_state - intset):
217+
del ebpf_map[ctypes.c_int(val)]

pidtree_bcc/probes/__init__.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os.path
55
import platform
66
import re
7+
import time
78
from datetime import datetime
89
from functools import lru_cache
910
from multiprocessing import SimpleQueue
@@ -18,7 +19,9 @@
1819
from jinja2 import FileSystemLoader
1920

2021
from pidtree_bcc.config import enumerate_probe_configs
22+
from pidtree_bcc.containers import list_container_mnt_namespaces
2123
from pidtree_bcc.filtering import load_filters_into_map
24+
from pidtree_bcc.filtering import load_intset_into_map
2225
from pidtree_bcc.filtering import load_port_filters_into_map
2326
from pidtree_bcc.filtering import NET_FILTER_MAX_PORT_RANGES
2427
from pidtree_bcc.filtering import PortFilterMode
@@ -52,6 +55,8 @@ class BPFProbe:
5255
NET_FILTER_MAP_SIZE_MAX = 4 * 1024
5356
NET_FILTER_MAP_SIZE_SCALING = 512
5457
PORT_FILTER_MAP_NAME = 'port_filter_map'
58+
MNTNS_FILTER_MAP_NAME = 'mntns_filter_map'
59+
DEFAULT_CONTAINER_POLL_INTERVAL = 30 # seconds
5560

5661
def __init__(
5762
self,
@@ -96,6 +101,12 @@ class variable defining a list of config fields.
96101
self.net_filter_mutex = Lock()
97102
if self.USES_DYNAMIC_FILTERS and config_change_queue:
98103
self.SIDECARS.append((self._poll_config_changes, (config_change_queue,)))
104+
self.container_labels_filter = template_config.get('container_labels')
105+
if self.container_labels_filter:
106+
self.SIDECARS.append((
107+
self._monitor_running_containers,
108+
(template_config.get('container_poll_interval', self.DEFAULT_CONTAINER_POLL_INTERVAL),),
109+
))
99110

100111
def build_probe_config(self, probe_config: dict, hotswap_only: bool = False) -> dict:
101112
""" Load probe configuration values
@@ -130,6 +141,7 @@ def build_probe_config(self, probe_config: dict, hotswap_only: bool = False) ->
130141
self.NET_FILTER_MAP_SIZE_MAX,
131142
round_nearest_multiple(len(self.net_filters), self.NET_FILTER_MAP_SIZE_SCALING, headroom=128),
132143
)
144+
template_config['MNTNS_FILTER_MAP_NAME'] = self.MNTNS_FILTER_MAP_NAME
133145
return template_config
134146

135147
@lru_cache(maxsize=1)
@@ -199,6 +211,17 @@ def _poll_config_changes(self, config_queue: SimpleQueue):
199211
self.build_probe_config(config_data, hotswap_only=True)
200212
self.reload_filters()
201213

214+
@never_crash
215+
def _monitor_running_containers(self, poll_interval: int):
216+
""" Polls running containers, filtering by label to keep mntns filtering map updated """
217+
monitored_mntns = set()
218+
while True:
219+
mntns = list_container_mnt_namespaces(self.container_labels_filter)
220+
if mntns != monitored_mntns:
221+
monitored_mntns = mntns
222+
load_intset_into_map(mntns, self.bpf[self.MNTNS_FILTER_MAP_NAME], True)
223+
time.sleep(poll_interval)
224+
202225
def reload_filters(self, is_init: bool = False):
203226
""" Load filters
204227
@@ -211,12 +234,12 @@ def reload_filters(self, is_init: bool = False):
211234

212235
def start_polling(self):
213236
""" Start infinite loop polling BPF events """
214-
for func, args in self.SIDECARS:
215-
Thread(target=func, args=args, daemon=True).start()
216237
self.bpf = BPF(
217238
text=self.expanded_bpf_text,
218239
cflags=['-Wno-macro-redefined'] if self._has_buggy_headers() else [],
219240
)
241+
for func, args in self.SIDECARS:
242+
Thread(target=func, args=args, daemon=True).start()
220243
if self.lost_event_telemetry > 0:
221244
extra_args = {'lost_cb': self._lost_event_callback}
222245
poll_func = self._poll_and_check_lost

pidtree_bcc/probes/net_listen.j2

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ struct listen_bind_t {
1717

1818
{{ utils.get_proto_func() }}
1919

20+
{% if container_labels %}
21+
{{ utils.mntns_filter_init(MNTNS_FILTER_MAP_NAME) }}
22+
{% endif %}
23+
2024
static void net_listen_event(struct pt_regs *ctx)
2125
{
2226
u32 pid = bpf_get_current_pid_tgid();
@@ -61,6 +65,11 @@ int kprobe__inet_bind(
6165
const struct sockaddr *addr,
6266
int addrlen)
6367
{
68+
{% if container_labels -%}
69+
if (!is_mntns_included()) {
70+
return 0;
71+
}
72+
{% endif -%}
6473
{% if exclude_random_bind -%}
6574
struct sockaddr_in* inet_addr = (struct sockaddr_in*)addr;
6675
if (inet_addr->sin_port == 0) {
@@ -86,6 +95,11 @@ int kretprobe__inet_bind(struct pt_regs *ctx)
8695
{% if 'tcp' in protocols -%}
8796
int kprobe__inet_listen(struct pt_regs *ctx, struct socket *sock, int backlog)
8897
{
98+
{% if container_labels -%}
99+
if (!is_mntns_included()) {
100+
return 0;
101+
}
102+
{% endif -%}
89103
struct sock* sk = sock->sk;
90104
if (sk->__sk_common.skc_family == AF_INET) {
91105
u32 pid = bpf_get_current_pid_tgid();

pidtree_bcc/probes/net_listen.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class NetListenProbe(BPFProbe):
3232
'ip_to_int': ip_to_int,
3333
'protocols': ['tcp'],
3434
'filters': [],
35+
'container_labels': [],
3536
'excludeports': [],
3637
'includeports': [],
3738
'snapshot_periodicity': False,

pidtree_bcc/probes/tcp_connect.j2

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,17 @@ struct connection_t {
1515

1616
{{ 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) }}
1717

18+
{% if container_labels %}
19+
{{ utils.mntns_filter_init(MNTNS_FILTER_MAP_NAME) }}
20+
{% endif %}
21+
1822
int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk)
1923
{
24+
{% if container_labels -%}
25+
if (!is_mntns_included()) {
26+
return 0;
27+
}
28+
{% endif -%}
2029
u32 pid = bpf_get_current_pid_tgid();
2130
currsock.update(&pid, &sk);
2231
return 0;

pidtree_bcc/probes/tcp_connect.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class TCPConnectProbe(BPFProbe):
1212
CONFIG_DEFAULTS = {
1313
'ip_to_int': ip_to_int,
1414
'filters': [],
15+
'container_labels': [],
1516
'includeports': [],
1617
'excludeports': [],
1718
}

pidtree_bcc/probes/udp_session.j2

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ BPF_HASH(tracing, u64, u8);
2222

2323
{{ utils.get_proto_func() }}
2424

25+
{% if container_labels %}
26+
{{ utils.mntns_filter_init(MNTNS_FILTER_MAP_NAME) }}
27+
{% endif %}
28+
2529
// We probe only the entrypoint as looking at return codes doesn't have much value
2630
// since UDP does not do any checks for successfull communications. The only errors
2731
// which may arise from this function would be due to the kernel running out of memory,
@@ -30,6 +34,12 @@ int kprobe__udp_sendmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg
3034
{
3135
if(sk->__sk_common.skc_family != AF_INET) return 0;
3236

37+
{% if container_labels -%}
38+
if (!is_mntns_included()) {
39+
return 0;
40+
}
41+
{% endif -%}
42+
3343
// Destination info will either be embedded in the socket if `connect`
3444
// was called or specified in the message
3545
struct sockaddr_in* sin = msg->msg_name;

0 commit comments

Comments
 (0)