From 64788d364b3da3ac1d2afe4f65f34fb0c0769bba Mon Sep 17 00:00:00 2001 From: Boris Ilyushonak <57406418+biscout42@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:32:12 +0200 Subject: [PATCH 1/5] Update CHANGES Co-authored-by: Christos Kalkanis --- CHANGES | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGES b/CHANGES index 2278733..8fd80fd 100644 --- a/CHANGES +++ b/CHANGES @@ -207,11 +207,15 @@ Changes from 0.8 to 0.9 and friends are unaffected. Every other eBPF map except the ring buffer is frozen after load (BPF_MAP_FREEZE, also 5.2+), so no process can change quark's internal state through bpf(2). - o The rule DSL learned the event.scope field. Its values are container - and host. A container event comes from a process whose cgroup names - a container, a host event from a process whose cgroup does not. A - process with an unknown cgroup matches neither scope. The kprobe - backend does not report the cgroup of new processes, so on kprobe - only processes seen at start-up can match a scope. Only the last - cgroup component is parsed, nested cgroups match host. The nova + o The rule DSL learned the event.scope field, which selects events by + where the originating process runs: "container" matches processes + running inside a container, "host" matches processes running directly + on the host. A process is inside a container when the last component + of its cgroup path is a systemd scope unit created by a container + runtime, like docker-.scope. Any other cgroup path is treated as + host, including nested cgroups inside a container and the flat layout + of the cgroupfs driver. Events without a process, or from a process + whose cgroup is unknown, match neither value. The KPROBE backend only + learns the cgroup of processes that existed when quark started, so events + from processes created afterwards never match either value. The NOVA backend rejects the field with ENOTSUP. From 07bf653230b7c61ac8518324fbe572e9222068f4 Mon Sep 17 00:00:00 2001 From: Boris Ilyushonak <57406418+biscout42@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:32:23 +0200 Subject: [PATCH 2/5] Update quark-mon.8 Co-authored-by: Christos Kalkanis --- quark-mon.8 | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/quark-mon.8 b/quark-mon.8 index ecdd151..bfd1216 100644 --- a/quark-mon.8 +++ b/quark-mon.8 @@ -126,19 +126,27 @@ use .Em - for stdin. The +.Dq event.scope +field matches events by where the originating process runs. .Dq event.scope container -field matches events from a process whose cgroup names a container. -The +matches events from processes running inside a container and .Dq event.scope host -field matches events from a process whose cgroup does not name a container. -An event without a process, or from a process with an unknown cgroup, -matches neither scope. -The kprobe backend does not report the cgroup of new processes, so on -kprobe only processes that existed at start-up can match a scope. -Only a cgroup whose last component is a container scope, like -.Pa docker-.scope , -names a container. -Nested cgroups and the cgroupfs driver layout match the host scope. +matches events from processes running directly on the host. +A process is considered to be inside a container when the last component +of its cgroup path is a systemd scope unit created by a container runtime, +like +.Pa docker-.scope . +Any other cgroup path is treated as host. +This includes a nested cgroup inside a container, like +.Pa docker-.scope/init.scope , +and the flat layout of the cgroupfs driver, like +.Pa /docker/ . +Events without a process, and events from a process whose cgroup is +unknown, match neither value. +The kprobe backend only learns the cgroup of processes that existed when +.Nm +started, so events from processes created afterwards never match either +value. This field is not supported by the NOVA backend. .It Fl T Enable ptrace event tracing. From 318c3b2d03b77372ee130482f6b35948e2e3434e Mon Sep 17 00:00:00 2001 From: Boris Ilyushonak Date: Thu, 17 Sep 2026 12:21:23 +0200 Subject: [PATCH 3/5] Add pod.name and container.image.name rules pod.name matches the Kubernetes pod name of the process container and accepts the single * wildcard used by process.exe and file.path, as in pod.name nginx-*. container.image.name matches the image name of the process container exactly, the image name being the last path component of the image without registry and tag. Both fields walk the container link established by link_container_data, which already runs before the ruleset match. A process without a linked container, a container without a pod, or missing metadata matches neither. The nova backend rejects both with ENOTSUP like event.scope. The wildcard split in quark_rule_match_field is moved into wild_init so the three wildcard fields and the exact-match field share it. Co-Authored-By: Claude Fable 5.1 --- CHANGES | 9 +++ nova.h | 2 + nova_queue.c | 10 +++ parse.y | 9 +++ quark-mon.8 | 23 ++++++ quark-test.c | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++ quark.c | 98 ++++++++++++++++------- 7 files changed, 339 insertions(+), 30 deletions(-) diff --git a/CHANGES b/CHANGES index 8fd80fd..e8423da 100644 --- a/CHANGES +++ b/CHANGES @@ -219,3 +219,12 @@ Changes from 0.8 to 0.9 learns the cgroup of processes that existed when quark started, so events from processes created afterwards never match either value. The NOVA backend rejects the field with ENOTSUP. + o The rule DSL learned the pod.name and container.image.name fields. + pod.name matches the Kubernetes pod name of the process container + and accepts a single * wildcard, as in pod.name nginx-*. + container.image.name matches the image name of the process + container exactly, the image name being the last path component + without registry and tag, so agnhost for + registry.k8s.io/e2e-test-images/agnhost:2.39. Both need metadata + from quark-kube-talker, a process without a known container matches + neither. The nova backend rejects both fields with ENOTSUP. diff --git a/nova.h b/nova.h index d04f269..9bc0443 100644 --- a/nova.h +++ b/nova.h @@ -36,6 +36,8 @@ #define QUARK_RF_POISON (1ULL << 8) #define QUARK_RF_FILE_EXEC_CHANGE (1ULL << 9) #define QUARK_RF_EVENT_SCOPE (1ULL << 10) +#define QUARK_RF_POD_NAME (1ULL << 11) +#define QUARK_RF_CONTAINER_IMAGE_NAME (1ULL << 12) enum quark_rule_scope { QUARK_RULE_SCOPE_INVALID, diff --git a/nova_queue.c b/nova_queue.c index ebc31fe..3dd8700 100644 --- a/nova_queue.c +++ b/nova_queue.c @@ -173,6 +173,16 @@ nova_rule_from_quark(struct nova_queue *nqq, qwarn("event.scope is not supported in nova backend"); return (-1); break; + case QUARK_RF_POD_NAME: + errno = ENOTSUP; + qwarn("pod.name is not supported in nova backend"); + return (-1); + break; + case QUARK_RF_CONTAINER_IMAGE_NAME: + errno = ENOTSUP; + qwarn("container.image.name is not supported in nova backend"); + return (-1); + break; default: errno = EINVAL; qwarn("bad field->code %llu", field->code); diff --git a/parse.y b/parse.y index 5564afe..b8c09ca 100644 --- a/parse.y +++ b/parse.y @@ -56,6 +56,7 @@ int quark_lex(YYSTYPE *, struct quark_parser_ctx *); %token PASS DROP POISON ON ANY STRING %token PROCESS_PID PROCESS_PPID PROCESS_UID PROCESS_GID PROCESS_SID %token PROCESS_EXE FILE_PATH FILE_EXEC_CHANGE EVENT_SCOPE +%token POD_NAME CONTAINER_IMAGE_NAME %% grammar: /* empty */ @@ -131,6 +132,12 @@ matchfield: PROCESS_PID num_u32 { $$.rf.id = QUARK_RULE_SCOPE_HOST; else ABORT("bad event scope: %s", $2.str); + } | POD_NAME STRING { + $$.rf.code = QUARK_RF_POD_NAME; + $$.rf.wild.pre = (char *)$2.str; + } | CONTAINER_IMAGE_NAME STRING { + $$.rf.code = QUARK_RF_CONTAINER_IMAGE_NAME; + $$.rf.wild.pre = (char *)$2.str; } | POISON num_u64 { $$.rf.code = QUARK_RF_POISON; $$.rf.poison_tag = $2.num_u64; @@ -206,6 +213,8 @@ static struct keyword { { "file.path", FILE_PATH }, { "file.exec_change", FILE_EXEC_CHANGE }, { "event.scope", EVENT_SCOPE }, + { "pod.name", POD_NAME }, + { "container.image.name", CONTAINER_IMAGE_NAME }, }; /* diff --git a/quark-mon.8 b/quark-mon.8 index bfd1216..ccf2e83 100644 --- a/quark-mon.8 +++ b/quark-mon.8 @@ -148,6 +148,29 @@ The kprobe backend only learns the cgroup of processes that existed when started, so events from processes created afterwards never match either value. This field is not supported by the NOVA backend. +The +.Dq pod.name +field matches events from a process in a container that belongs to the +named Kubernetes pod. +A single +.Sq * +wildcard is accepted, as in +.Dq pod.name nginx-* . +The +.Dq container.image.name +field matches events from a process in a container whose image name is +exactly the given string. +The image name is the last path component of the image without the +registry and tag, so +.Pa registry.k8s.io/e2e-test-images/agnhost:2.39 +has the image name +.Dq agnhost . +No wildcard is accepted. +Both fields require Kubernetes metadata from quark-kube-talker, see +.Fl K . +An event without a process, or from a process whose container is unknown, +matches neither field. +These fields are not supported by the NOVA backend. .It Fl T Enable ptrace event tracing. .It Fl t diff --git a/quark-test.c b/quark-test.c index e7a77de..5fe310e 100644 --- a/quark-test.c +++ b/quark-test.c @@ -3340,6 +3340,11 @@ t_rule_parser(const struct test *t, struct quark_queue_attr *qa) "pass on file.path /foo/* file.exec_change\n", "pass on event.scope container\n", "drop on event.scope host\n", + "pass on pod.name nginx-*\n", + "drop on pod.name *-sidecar\n", + "drop on pod.name \"my pod\"\n", + "drop on container.image.name nginx\n", + "pass on container.image.name agnhost event.scope container\n", "drop on process.exe /foo/bar\n", "drop on process.exe /foo/*\n", "drop on process.exe */bar\n", @@ -3372,6 +3377,11 @@ t_rule_parser(const struct test *t, struct quark_queue_attr *qa) "drop on file.name \"foo\n", "drop on event.scope\n", "drop on event.scope guest\n", + "drop on pod.name\n", + "drop on pod.name a*b*\n", + "drop on container.image.name\n", + "drop on container.image.name nginx*\n", + "drop on container.image.name *\n", NULL }; @@ -3492,6 +3502,212 @@ t_rule_scope(const struct test *t, struct quark_queue_attr *qa) } +/* + * Set up a bare queue with a process linked to a container in a pod. + * The pod name and image name come from the caller. + */ +static struct quark_process * +rule_kube_process(struct quark_queue *qq, u32 pid, const char *cid, + const char *pod_uid, const char *pod_name, const char *image_name) +{ + struct quark_pod *pod; + struct quark_container *container; + struct quark_process *qp; + char *cgroup, *container_id; + + /* The cgroup docker-.scope parses into docker:// */ + assert(asprintf(&container_id, "docker://%s", cid) != -1); + if (pod_uid != NULL) { + pod = quark_pod_get(qq, pod_uid); + assert(pod != NULL); + if (pod_name != NULL && pod->name == NULL) { + pod->name = strdup(pod_name); + assert(pod->name != NULL); + } + } + container = quark_container_get(qq, container_id, pod_uid); + assert(container != NULL); + free(container_id); + if (image_name != NULL) { + container->image_name = strdup(image_name); + assert(container->image_name != NULL); + } + + assert(asprintf(&cgroup, "/system.slice/docker-%s.scope", cid) != -1); + qp = test_process_event(qq, pid, cgroup); + free(cgroup); + link_container_data(qq, qp); + assert(qp->container == container); + + return (qp); +} + +static int +t_rule_pod_name(const struct test *t, struct quark_queue_attr *qa) +{ + struct quark_queue qq; + struct quark_event qev; + struct quark_process bare; + struct quark_process *qp; + struct quark_rule *rule; + struct quark_ruleset ruleset; + + quark_queue_init_bare(&qq); + bzero(&qev, sizeof(qev)); + bzero(&bare, sizeof(bare)); + + ruleset_from_string(&ruleset, + "pass on pod.name nginx-*\n" + "drop on pod.name *-sidecar\n" + "poison 7 on pod.name exact\n"); + + /* No process, no match. */ + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Process without a container, no match. */ + qev.process = &bare; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Prefix wildcard. */ + qp = rule_kube_process(&qq, 100, "aaa", "pod-a", + "nginx-7c5b8d-x9k2p", NULL); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 0 && + rule->action == QUARK_RA_PASS); + + /* Prefix alone is not a match, pod name must be longer. */ + qp = rule_kube_process(&qq, 101, "bbb", "pod-b", "nginx", NULL); + qev.process = qp; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Suffix wildcard. */ + qp = rule_kube_process(&qq, 102, "ccc", "pod-c", + "istio-sidecar", NULL); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 1 && + rule->action == QUARK_RA_DROP); + + /* Exact, via poison. */ + qp = rule_kube_process(&qq, 103, "ddd", "pod-d", "exact", NULL); + qev.process = qp; + assert(qp->poison_tag == 0); + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + assert(qp->poison_tag == 7); + qp = rule_kube_process(&qq, 104, "eee", "pod-e", "exactly", NULL); + qev.process = qp; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + assert(qp->poison_tag == 0); + + /* Pod without a name, no match. */ + qp = rule_kube_process(&qq, 105, "fff", "pod-f", NULL, NULL); + qev.process = qp; + assert(qp->container->pod->name == NULL); + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Container without a pod, no match. */ + qp = rule_kube_process(&qq, 106, "ggg", NULL, NULL, NULL); + qev.process = qp; + assert(qp->container->pod == NULL); + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + quark_ruleset_clear(&ruleset); + quark_queue_close(&qq); + + return (0); +} + +static int +t_rule_container_image_name(const struct test *t, struct quark_queue_attr *qa) +{ + struct quark_queue qq; + struct quark_event qev; + struct quark_process bare; + struct quark_process *qp; + struct quark_rule *rule; + struct quark_rule_field rf; + struct quark_ruleset ruleset; + + quark_queue_init_bare(&qq); + bzero(&qev, sizeof(qev)); + bzero(&bare, sizeof(bare)); + + ruleset_from_string(&ruleset, + "drop on container.image.name nginx\n" + "pass on container.image.name agnhost pod.name e2e-*\n"); + + /* No process, no match. */ + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Process without a container, no match. */ + qev.process = &bare; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Exact match. */ + qp = rule_kube_process(&qq, 100, "aaa", "pod-a", "web", "nginx"); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 0 && + rule->action == QUARK_RA_DROP); + + /* Prefix and suffix are not a match, exact only. */ + qp = rule_kube_process(&qq, 101, "bbb", "pod-b", "web", + "nginx-unprivileged"); + qev.process = qp; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + qp = rule_kube_process(&qq, 102, "ccc", "pod-c", "web", + "my-nginx"); + qev.process = qp; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Container without an image name, no match. */ + qp = rule_kube_process(&qq, 103, "ddd", "pod-d", "web", NULL); + qev.process = qp; + assert(qp->container->image_name == NULL); + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Container without a pod still matches on the image name. */ + qp = rule_kube_process(&qq, 104, "eee", NULL, NULL, "nginx"); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 0); + + /* Combined with pod.name, both must match. */ + qp = rule_kube_process(&qq, 105, "fff", "pod-f", "e2e-test", + "agnhost"); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 1 && + rule->action == QUARK_RA_PASS); + qp = rule_kube_process(&qq, 106, "ggg", "pod-g", "prod-test", + "agnhost"); + qev.process = qp; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + quark_ruleset_clear(&ruleset); + + /* A wildcard is rejected by the field API. */ + quark_ruleset_init(&ruleset); + rule = quark_ruleset_append_rule(&ruleset, QUARK_RA_PASS, 0); + assert(rule != NULL); + bzero(&rf, sizeof(rf)); + rf.code = QUARK_RF_CONTAINER_IMAGE_NAME; + rf.wild.pre = (char *)"nginx*"; + errno = 0; + assert(quark_rule_match_field(rule, rf) == -1); + assert(errno == EINVAL); + rf.wild.pre = (char *)""; + errno = 0; + assert(quark_rule_match_field(rule, rf) == -1); + assert(errno == EINVAL); + quark_ruleset_clear(&ruleset); + + quark_queue_close(&qq); + + return (0); +} + static int t_trusted_pid(const struct test *t, struct quark_queue_attr *qa) { @@ -4270,6 +4486,8 @@ struct test all_tests[] = { T_EBPF(t_rule_id), T_EBPF(t_rule_parser), T(t_rule_scope), + T(t_rule_pod_name), + T(t_rule_container_image_name), T_EBPF(t_trusted_pid), T_EBPF(t_trusted_map_rdonly), T_EBPF(t_map_freeze), diff --git a/quark.c b/quark.c index e9dffeb..039b890 100644 --- a/quark.c +++ b/quark.c @@ -5347,6 +5347,8 @@ quark_ruleset_clear(struct quark_ruleset *ruleset) switch (rule->fields[j].code) { case QUARK_RF_FILEPATH: /* FALLTHROUGH */ case QUARK_RF_EXE: /* FALLTHROUGH */ + case QUARK_RF_POD_NAME: /* FALLTHROUGH */ + case QUARK_RF_CONTAINER_IMAGE_NAME: free(rule->fields[j].wild.pre); break; default: @@ -5461,6 +5463,17 @@ quark_rule_field_match(struct quark_rule *rule, struct quark_rule_field *field, (qev->file->change_mask & QUARK_FILE_CH_PERMS)) && (qev->file->mode & (S_IXUSR | S_IXGRP | S_IXOTH))); break; + case QUARK_RF_POD_NAME: + if (qp != NULL && qp->container != NULL && + qp->container->pod != NULL && + qp->container->pod->name != NULL) + return (path_match(field, qp->container->pod->name)); + break; + case QUARK_RF_CONTAINER_IMAGE_NAME: + if (qp != NULL && qp->container != NULL && + qp->container->image_name != NULL) + return (path_match(field, qp->container->image_name)); + break; case QUARK_RF_EVENT_SCOPE: if (qp == NULL || qp->cgroup == NULL) break; @@ -5552,12 +5565,56 @@ quark_ruleset_append_rule(struct quark_ruleset *ruleset, int action, u64 poison_ return (rule); } +/* + * Initialize a wildcard from the user string in w->pre, which is copied. + * As in foo*bar: pre = foo, post = bar. Only one * is allowed, none if + * allow_star is 0. On error w->pre is left untouched and nothing is + * allocated. Returns -1 with errno set. + */ +static int +wild_init(struct quark_wild *w, int allow_star) +{ + char *copy, *star; + size_t len; + + w->post = NULL; + w->pre_len = w->post_len = 0; + + if (w->pre == NULL || (len = strlen(w->pre)) == 0 || len >= PATH_MAX) + return (errno = EINVAL, -1); + star = strchr(w->pre, '*'); + if (star != NULL) { + if (!allow_star || strchr(star + 1, '*') != NULL) + return (errno = EINVAL, -1); + } + if ((copy = strdup(w->pre)) == NULL) + return (-1); + /* Rebase star into the copy */ + if (star != NULL) + star = copy + (star - w->pre); + w->pre = copy; + w->post = w->pre + len; + if (star != NULL) { + *star = 0; + w->post = star + 1; + } + /* Don't move this up, as the block above might shorten "pre" */ + w->pre_len = strlen(w->pre); + w->post_len = strlen(w->post); + if (star == NULL) + w->pre_len++; /* Include NUL in the comparison */ + if (w->post_len > 0) + w->post_len++; /* Include NUL in the comparison */ + + return (0); +} + int quark_rule_match_field(struct quark_rule *rule, struct quark_rule_field rf) { struct quark_rule_field *new_fields; size_t new_n_fields; - char *path, *star; + char *path; path = NULL; @@ -5576,36 +5633,17 @@ quark_rule_match_field(struct quark_rule *rule, struct quark_rule_field rf) goto inval; break; case QUARK_RF_EXE: /* FALLTHROUGH */ - case QUARK_RF_FILEPATH: - rf.wild.post = NULL; - rf.wild.pre_len = rf.wild.post_len = 0; - - if (rf.wild.pre == NULL || strlen(rf.wild.pre) == 0 || - strlen(rf.wild.pre) >= PATH_MAX) - goto inval; - /* Save path in case we error out and need to free */ - path = rf.wild.pre = strdup(rf.wild.pre); - if (path == NULL) + case QUARK_RF_FILEPATH: /* FALLTHROUGH */ + case QUARK_RF_POD_NAME: + if (wild_init(&rf.wild, 1) == -1) goto bad; - /* - * Split rf.wild.pre and rf.wild.post - * as in foo*bar: pre = foo, post = bar - */ - rf.wild.post = rf.wild.pre + strlen(rf.wild.pre); - if ((star = strchr(rf.wild.pre, '*')) != NULL) { - *star = 0; - /* Only one * is allowed */ - rf.wild.post = star + 1; - if (strchr(rf.wild.post, '*') != NULL) - goto bad; - } - /* Don't move this up, as the block above might shorten "pre" */ - rf.wild.pre_len = strlen(rf.wild.pre); - rf.wild.post_len = strlen(rf.wild.post); - if (star == NULL) - rf.wild.pre_len++; /* Include NUL in the comparison */ - if (rf.wild.post_len > 0) - rf.wild.post_len++; /* Include NUL in the comparison */ + path = rf.wild.pre; + break; + case QUARK_RF_CONTAINER_IMAGE_NAME: + /* Exact match only, no wildcard */ + if (wild_init(&rf.wild, 0) == -1) + goto bad; + path = rf.wild.pre; break; case QUARK_RF_FILE_EXEC_CHANGE: break; From d12bfd435c9ef43adc292935b68604bf76d295a0 Mon Sep 17 00:00:00 2001 From: Boris Ilyushonak Date: Thu, 17 Sep 2026 12:29:28 +0200 Subject: [PATCH 4/5] Use strcmp for the container.image.name match Co-Authored-By: Claude Fable 5.1 --- quark.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quark.c b/quark.c index 039b890..975ec59 100644 --- a/quark.c +++ b/quark.c @@ -5470,9 +5470,11 @@ quark_rule_field_match(struct quark_rule *rule, struct quark_rule_field *field, return (path_match(field, qp->container->pod->name)); break; case QUARK_RF_CONTAINER_IMAGE_NAME: + /* Exact match, wild_init() rejected any wildcard at load time */ if (qp != NULL && qp->container != NULL && qp->container->image_name != NULL) - return (path_match(field, qp->container->image_name)); + return (!strcmp(field->wild.pre, + qp->container->image_name)); break; case QUARK_RF_EVENT_SCOPE: if (qp == NULL || qp->cgroup == NULL) From a47e4130674a2ebfb38917a994f8794703775799 Mon Sep 17 00:00:00 2001 From: Boris Ilyushonak Date: Thu, 17 Sep 2026 12:52:49 +0200 Subject: [PATCH 5/5] Match container.image against the full image reference Rename the rule field to container.image. The field matches the image string that the runtime reports, with registry and tag. The field accepts one * wildcard, like the other string fields. Remove the strcmp match and the allow_star flag. Use path_match for all string fields. Create the test container through quark_container_create. Do not set image_name and do not link the process by hand in the test helper. Co-Authored-By: Claude Fable 5.1 --- CHANGES | 18 ++++----- nova.h | 2 +- nova_queue.c | 4 +- parse.y | 8 ++-- quark-mon.8 | 27 ++++++++----- quark-test.c | 111 ++++++++++++++++++++++++++++++++++----------------- quark.c | 35 ++++++---------- 7 files changed, 119 insertions(+), 86 deletions(-) diff --git a/CHANGES b/CHANGES index e8423da..0433876 100644 --- a/CHANGES +++ b/CHANGES @@ -219,12 +219,12 @@ Changes from 0.8 to 0.9 learns the cgroup of processes that existed when quark started, so events from processes created afterwards never match either value. The NOVA backend rejects the field with ENOTSUP. - o The rule DSL learned the pod.name and container.image.name fields. - pod.name matches the Kubernetes pod name of the process container - and accepts a single * wildcard, as in pod.name nginx-*. - container.image.name matches the image name of the process - container exactly, the image name being the last path component - without registry and tag, so agnhost for - registry.k8s.io/e2e-test-images/agnhost:2.39. Both need metadata - from quark-kube-talker, a process without a known container matches - neither. The nova backend rejects both fields with ENOTSUP. + o The rule DSL learned the pod.name and container.image fields. + pod.name matches the Kubernetes pod name of the process container. + container.image matches the full image reference of the process + container as reported by the runtime, registry and tag included, so + docker.io/library/nginx:1.25. Both accept a single * wildcard, as in + pod.name nginx-* or container.image quay.io/*. Both need container + metadata, from quark-kube-talker or the container API, a process + without a known container matches neither. The nova backend rejects + both fields with ENOTSUP. diff --git a/nova.h b/nova.h index 9bc0443..e58bb93 100644 --- a/nova.h +++ b/nova.h @@ -37,7 +37,7 @@ #define QUARK_RF_FILE_EXEC_CHANGE (1ULL << 9) #define QUARK_RF_EVENT_SCOPE (1ULL << 10) #define QUARK_RF_POD_NAME (1ULL << 11) -#define QUARK_RF_CONTAINER_IMAGE_NAME (1ULL << 12) +#define QUARK_RF_CONTAINER_IMAGE (1ULL << 12) enum quark_rule_scope { QUARK_RULE_SCOPE_INVALID, diff --git a/nova_queue.c b/nova_queue.c index 3dd8700..46988b6 100644 --- a/nova_queue.c +++ b/nova_queue.c @@ -178,9 +178,9 @@ nova_rule_from_quark(struct nova_queue *nqq, qwarn("pod.name is not supported in nova backend"); return (-1); break; - case QUARK_RF_CONTAINER_IMAGE_NAME: + case QUARK_RF_CONTAINER_IMAGE: errno = ENOTSUP; - qwarn("container.image.name is not supported in nova backend"); + qwarn("container.image is not supported in nova backend"); return (-1); break; default: diff --git a/parse.y b/parse.y index b8c09ca..03b06fb 100644 --- a/parse.y +++ b/parse.y @@ -56,7 +56,7 @@ int quark_lex(YYSTYPE *, struct quark_parser_ctx *); %token PASS DROP POISON ON ANY STRING %token PROCESS_PID PROCESS_PPID PROCESS_UID PROCESS_GID PROCESS_SID %token PROCESS_EXE FILE_PATH FILE_EXEC_CHANGE EVENT_SCOPE -%token POD_NAME CONTAINER_IMAGE_NAME +%token POD_NAME CONTAINER_IMAGE %% grammar: /* empty */ @@ -135,8 +135,8 @@ matchfield: PROCESS_PID num_u32 { } | POD_NAME STRING { $$.rf.code = QUARK_RF_POD_NAME; $$.rf.wild.pre = (char *)$2.str; - } | CONTAINER_IMAGE_NAME STRING { - $$.rf.code = QUARK_RF_CONTAINER_IMAGE_NAME; + } | CONTAINER_IMAGE STRING { + $$.rf.code = QUARK_RF_CONTAINER_IMAGE; $$.rf.wild.pre = (char *)$2.str; } | POISON num_u64 { $$.rf.code = QUARK_RF_POISON; @@ -214,7 +214,7 @@ static struct keyword { { "file.exec_change", FILE_EXEC_CHANGE }, { "event.scope", EVENT_SCOPE }, { "pod.name", POD_NAME }, - { "container.image.name", CONTAINER_IMAGE_NAME }, + { "container.image", CONTAINER_IMAGE }, }; /* diff --git a/quark-mon.8 b/quark-mon.8 index ccf2e83..dd070a2 100644 --- a/quark-mon.8 +++ b/quark-mon.8 @@ -157,17 +157,22 @@ A single wildcard is accepted, as in .Dq pod.name nginx-* . The -.Dq container.image.name -field matches events from a process in a container whose image name is -exactly the given string. -The image name is the last path component of the image without the -registry and tag, so -.Pa registry.k8s.io/e2e-test-images/agnhost:2.39 -has the image name -.Dq agnhost . -No wildcard is accepted. -Both fields require Kubernetes metadata from quark-kube-talker, see -.Fl K . +.Dq container.image +field matches events from a process in a container whose image reference +matches the given string. +The image reference is compared as reported by the container runtime, +registry and tag included, as in +.Dq container.image docker.io/library/nginx:1.25 . +A single +.Sq * +wildcard is accepted, so +.Dq container.image */nginx:1.25 +matches that image from any registry, and +.Dq container.image quay.io/* +matches every image from that registry. +Both fields require container metadata, from quark-kube-talker, see +.Fl K , +or from the container API of the library. An event without a process, or from a process whose container is unknown, matches neither field. These fields are not supported by the NOVA backend. diff --git a/quark-test.c b/quark-test.c index 5fe310e..5fd355c 100644 --- a/quark-test.c +++ b/quark-test.c @@ -3343,8 +3343,9 @@ t_rule_parser(const struct test *t, struct quark_queue_attr *qa) "pass on pod.name nginx-*\n", "drop on pod.name *-sidecar\n", "drop on pod.name \"my pod\"\n", - "drop on container.image.name nginx\n", - "pass on container.image.name agnhost event.scope container\n", + "drop on container.image docker.io/library/nginx:1.25\n", + "drop on container.image */nginx:1.25\n", + "pass on container.image registry.k8s.io/* event.scope container\n", "drop on process.exe /foo/bar\n", "drop on process.exe /foo/*\n", "drop on process.exe */bar\n", @@ -3379,9 +3380,8 @@ t_rule_parser(const struct test *t, struct quark_queue_attr *qa) "drop on event.scope guest\n", "drop on pod.name\n", "drop on pod.name a*b*\n", - "drop on container.image.name\n", - "drop on container.image.name nginx*\n", - "drop on container.image.name *\n", + "drop on container.image\n", + "drop on container.image */nginx:*\n", NULL }; @@ -3503,12 +3503,13 @@ t_rule_scope(const struct test *t, struct quark_queue_attr *qa) /* - * Set up a bare queue with a process linked to a container in a pod. - * The pod name and image name come from the caller. + * Set up a process linked to a new container in a pod through the public + * API, as an embedder would. The pod name and image come from the caller, + * each cid must be unique within a test. */ static struct quark_process * rule_kube_process(struct quark_queue *qq, u32 pid, const char *cid, - const char *pod_uid, const char *pod_name, const char *image_name) + const char *pod_uid, const char *pod_name, const char *image) { struct quark_pod *pod; struct quark_container *container; @@ -3520,23 +3521,21 @@ rule_kube_process(struct quark_queue *qq, u32 pid, const char *cid, if (pod_uid != NULL) { pod = quark_pod_get(qq, pod_uid); assert(pod != NULL); - if (pod_name != NULL && pod->name == NULL) { + assert(pod->name == NULL); + if (pod_name != NULL) { pod->name = strdup(pod_name); assert(pod->name != NULL); } } - container = quark_container_get(qq, container_id, pod_uid); + container = quark_container_create(qq, container_id, pod_uid, NULL, + image); assert(container != NULL); free(container_id); - if (image_name != NULL) { - container->image_name = strdup(image_name); - assert(container->image_name != NULL); - } + /* The container exists, so the event assembly links the process */ assert(asprintf(&cgroup, "/system.slice/docker-%s.scope", cid) != -1); qp = test_process_event(qq, pid, cgroup); free(cgroup); - link_container_data(qq, qp); assert(qp->container == container); return (qp); @@ -3619,7 +3618,7 @@ t_rule_pod_name(const struct test *t, struct quark_queue_attr *qa) } static int -t_rule_container_image_name(const struct test *t, struct quark_queue_attr *qa) +t_rule_container_image(const struct test *t, struct quark_queue_attr *qa) { struct quark_queue qq; struct quark_event qev; @@ -3634,8 +3633,11 @@ t_rule_container_image_name(const struct test *t, struct quark_queue_attr *qa) bzero(&bare, sizeof(bare)); ruleset_from_string(&ruleset, - "drop on container.image.name nginx\n" - "pass on container.image.name agnhost pod.name e2e-*\n"); + "drop on container.image docker.io/library/nginx:1.25\n" + "drop on container.image */busybox:1.36\n" + "drop on container.image quay.io/*\n" + "pass on container.image registry.k8s.io/e2e-test-images/agnhost:2.39" + " pod.name e2e-*\n"); /* No process, no match. */ assert(quark_ruleset_match(&ruleset, &qev) == NULL); @@ -3644,56 +3646,91 @@ t_rule_container_image_name(const struct test *t, struct quark_queue_attr *qa) qev.process = &bare; assert(quark_ruleset_match(&ruleset, &qev) == NULL); - /* Exact match. */ - qp = rule_kube_process(&qq, 100, "aaa", "pod-a", "web", "nginx"); + /* Exact match on the full image reference. */ + qp = rule_kube_process(&qq, 100, "aaa", "pod-a", "web", + "docker.io/library/nginx:1.25"); qev.process = qp; rule = quark_ruleset_match(&ruleset, &qev); assert(rule != NULL && rule->number == 0 && rule->action == QUARK_RA_DROP); - /* Prefix and suffix are not a match, exact only. */ + /* A different tag or registry of the same image is not exact. */ qp = rule_kube_process(&qq, 101, "bbb", "pod-b", "web", - "nginx-unprivileged"); + "docker.io/library/nginx:1.26"); qev.process = qp; assert(quark_ruleset_match(&ruleset, &qev) == NULL); qp = rule_kube_process(&qq, 102, "ccc", "pod-c", "web", - "my-nginx"); + "nginx:1.25"); qev.process = qp; assert(quark_ruleset_match(&ruleset, &qev) == NULL); - /* Container without an image name, no match. */ - qp = rule_kube_process(&qq, 103, "ddd", "pod-d", "web", NULL); + /* Prefix wildcard covers the registry. */ + qp = rule_kube_process(&qq, 103, "ddd", "pod-d", "web", + "docker.io/library/busybox:1.36"); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 1); + qp = rule_kube_process(&qq, 104, "eee", "pod-e", "web", + "localhost:5000/busybox:1.36"); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 1); + /* A different tag is not a match. */ + qp = rule_kube_process(&qq, 105, "fff", "pod-f", "web", + "docker.io/library/busybox:1.37"); qev.process = qp; - assert(qp->container->image_name == NULL); assert(quark_ruleset_match(&ruleset, &qev) == NULL); - /* Container without a pod still matches on the image name. */ - qp = rule_kube_process(&qq, 104, "eee", NULL, NULL, "nginx"); + /* Suffix wildcard matches a whole registry, digests included. */ + qp = rule_kube_process(&qq, 106, "ggg", "pod-g", "web", + "quay.io/prometheus/node-exporter@sha256:0123456789abcdef"); qev.process = qp; rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 2); + /* The wildcard may be empty, but the prefix must be complete. */ + qp = rule_kube_process(&qq, 107, "hhh", "pod-h", "web", "quay.io/"); + qev.process = qp; + rule = quark_ruleset_match(&ruleset, &qev); + assert(rule != NULL && rule->number == 2); + qp = rule_kube_process(&qq, 112, "mmm", "pod-m", "web", "quay.io"); + qev.process = qp; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Container without an image, no match. */ + qp = rule_kube_process(&qq, 108, "iii", "pod-i", "web", NULL); + qev.process = qp; + assert(qp->container->image == NULL); + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* Container without a pod still matches on the image. */ + qp = rule_kube_process(&qq, 109, "jjj", NULL, NULL, + "docker.io/library/nginx:1.25"); + qev.process = qp; + assert(qp->container->pod == NULL); + rule = quark_ruleset_match(&ruleset, &qev); assert(rule != NULL && rule->number == 0); /* Combined with pod.name, both must match. */ - qp = rule_kube_process(&qq, 105, "fff", "pod-f", "e2e-test", - "agnhost"); + qp = rule_kube_process(&qq, 110, "kkk", "pod-k", "e2e-test", + "registry.k8s.io/e2e-test-images/agnhost:2.39"); qev.process = qp; rule = quark_ruleset_match(&ruleset, &qev); - assert(rule != NULL && rule->number == 1 && + assert(rule != NULL && rule->number == 3 && rule->action == QUARK_RA_PASS); - qp = rule_kube_process(&qq, 106, "ggg", "pod-g", "prod-test", - "agnhost"); + qp = rule_kube_process(&qq, 111, "lll", "pod-l", "prod-test", + "registry.k8s.io/e2e-test-images/agnhost:2.39"); qev.process = qp; assert(quark_ruleset_match(&ruleset, &qev) == NULL); quark_ruleset_clear(&ruleset); - /* A wildcard is rejected by the field API. */ + /* Empty and double wildcard are rejected by the field API. */ quark_ruleset_init(&ruleset); rule = quark_ruleset_append_rule(&ruleset, QUARK_RA_PASS, 0); assert(rule != NULL); bzero(&rf, sizeof(rf)); - rf.code = QUARK_RF_CONTAINER_IMAGE_NAME; - rf.wild.pre = (char *)"nginx*"; + rf.code = QUARK_RF_CONTAINER_IMAGE; + rf.wild.pre = (char *)"*/nginx:*"; errno = 0; assert(quark_rule_match_field(rule, rf) == -1); assert(errno == EINVAL); @@ -4487,7 +4524,7 @@ struct test all_tests[] = { T_EBPF(t_rule_parser), T(t_rule_scope), T(t_rule_pod_name), - T(t_rule_container_image_name), + T(t_rule_container_image), T_EBPF(t_trusted_pid), T_EBPF(t_trusted_map_rdonly), T_EBPF(t_map_freeze), diff --git a/quark.c b/quark.c index 975ec59..0704068 100644 --- a/quark.c +++ b/quark.c @@ -5348,7 +5348,7 @@ quark_ruleset_clear(struct quark_ruleset *ruleset) case QUARK_RF_FILEPATH: /* FALLTHROUGH */ case QUARK_RF_EXE: /* FALLTHROUGH */ case QUARK_RF_POD_NAME: /* FALLTHROUGH */ - case QUARK_RF_CONTAINER_IMAGE_NAME: + case QUARK_RF_CONTAINER_IMAGE: free(rule->fields[j].wild.pre); break; default: @@ -5469,12 +5469,10 @@ quark_rule_field_match(struct quark_rule *rule, struct quark_rule_field *field, qp->container->pod->name != NULL) return (path_match(field, qp->container->pod->name)); break; - case QUARK_RF_CONTAINER_IMAGE_NAME: - /* Exact match, wild_init() rejected any wildcard at load time */ + case QUARK_RF_CONTAINER_IMAGE: if (qp != NULL && qp->container != NULL && - qp->container->image_name != NULL) - return (!strcmp(field->wild.pre, - qp->container->image_name)); + qp->container->image != NULL) + return (path_match(field, qp->container->image)); break; case QUARK_RF_EVENT_SCOPE: if (qp == NULL || qp->cgroup == NULL) @@ -5569,12 +5567,12 @@ quark_ruleset_append_rule(struct quark_ruleset *ruleset, int action, u64 poison_ /* * Initialize a wildcard from the user string in w->pre, which is copied. - * As in foo*bar: pre = foo, post = bar. Only one * is allowed, none if - * allow_star is 0. On error w->pre is left untouched and nothing is - * allocated. Returns -1 with errno set. + * As in foo*bar: pre = foo, post = bar. Only one * is allowed. On error + * w->pre is left untouched and nothing is allocated. Returns -1 with + * errno set. */ static int -wild_init(struct quark_wild *w, int allow_star) +wild_init(struct quark_wild *w) { char *copy, *star; size_t len; @@ -5585,10 +5583,8 @@ wild_init(struct quark_wild *w, int allow_star) if (w->pre == NULL || (len = strlen(w->pre)) == 0 || len >= PATH_MAX) return (errno = EINVAL, -1); star = strchr(w->pre, '*'); - if (star != NULL) { - if (!allow_star || strchr(star + 1, '*') != NULL) - return (errno = EINVAL, -1); - } + if (star != NULL && strchr(star + 1, '*') != NULL) + return (errno = EINVAL, -1); if ((copy = strdup(w->pre)) == NULL) return (-1); /* Rebase star into the copy */ @@ -5636,14 +5632,9 @@ quark_rule_match_field(struct quark_rule *rule, struct quark_rule_field rf) break; case QUARK_RF_EXE: /* FALLTHROUGH */ case QUARK_RF_FILEPATH: /* FALLTHROUGH */ - case QUARK_RF_POD_NAME: - if (wild_init(&rf.wild, 1) == -1) - goto bad; - path = rf.wild.pre; - break; - case QUARK_RF_CONTAINER_IMAGE_NAME: - /* Exact match only, no wildcard */ - if (wild_init(&rf.wild, 0) == -1) + case QUARK_RF_POD_NAME: /* FALLTHROUGH */ + case QUARK_RF_CONTAINER_IMAGE: + if (wild_init(&rf.wild) == -1) goto bad; path = rf.wild.pre; break;