diff --git a/CHANGES b/CHANGES index 22787338..04338761 100644 --- a/CHANGES +++ b/CHANGES @@ -207,11 +207,24 @@ 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. + 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 d04f2691..e58bb930 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 (1ULL << 12) enum quark_rule_scope { QUARK_RULE_SCOPE_INVALID, diff --git a/nova_queue.c b/nova_queue.c index ebc31fe0..46988b6e 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: + errno = ENOTSUP; + qwarn("container.image 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 5564afee..03b06fbb 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 %% 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 STRING { + $$.rf.code = QUARK_RF_CONTAINER_IMAGE; + $$.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", CONTAINER_IMAGE }, }; /* diff --git a/quark-mon.8 b/quark-mon.8 index ecdd1513..dd070a21 100644 --- a/quark-mon.8 +++ b/quark-mon.8 @@ -126,20 +126,56 @@ 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. +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 +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. .It Fl T Enable ptrace event tracing. .It Fl t diff --git a/quark-test.c b/quark-test.c index e7a77dec..5fd355cf 100644 --- a/quark-test.c +++ b/quark-test.c @@ -3340,6 +3340,12 @@ 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 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", @@ -3372,6 +3378,10 @@ 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\n", + "drop on container.image */nginx:*\n", NULL }; @@ -3492,6 +3502,249 @@ t_rule_scope(const struct test *t, struct quark_queue_attr *qa) } +/* + * 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) +{ + 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); + assert(pod->name == NULL); + if (pod_name != NULL) { + pod->name = strdup(pod_name); + assert(pod->name != NULL); + } + } + container = quark_container_create(qq, container_id, pod_uid, NULL, + image); + assert(container != NULL); + free(container_id); + + /* 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); + 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(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 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); + + /* Process without a container, no match. */ + qev.process = &bare; + assert(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* 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); + + /* A different tag or registry of the same image is not exact. */ + qp = rule_kube_process(&qq, 101, "bbb", "pod-b", "web", + "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", + "nginx:1.25"); + qev.process = qp; + assert(quark_ruleset_match(&ruleset, &qev) == 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(quark_ruleset_match(&ruleset, &qev) == NULL); + + /* 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, 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 == 3 && + rule->action == QUARK_RA_PASS); + 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); + + /* 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; + 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 +4523,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), 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 e9dffeb5..07040681 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: 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: + if (qp != NULL && qp->container != NULL && + qp->container->image != NULL) + return (path_match(field, qp->container->image)); + break; case QUARK_RF_EVENT_SCOPE: if (qp == NULL || qp->cgroup == NULL) break; @@ -5552,12 +5565,54 @@ 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. On error + * w->pre is left untouched and nothing is allocated. Returns -1 with + * errno set. + */ +static int +wild_init(struct quark_wild *w) +{ + 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 && 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 +5631,12 @@ 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: /* FALLTHROUGH */ + case QUARK_RF_CONTAINER_IMAGE: + if (wild_init(&rf.wild) == -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_FILE_EXEC_CHANGE: break;