fix(cve-gate): un-invert the seccomp clone rule, and evaluate the profile before trusting it #83

Merged
ryangr0 merged 1 commit from fix/cve-gate-seccomp-clone into main 2026-08-02 20:41:41 +00:00
Owner

The gate ran for the first time in run 224 — and my own seccomp profile killed it.

runtime: failed to create new OS thread (have 2 already; errno=1)
fatal error: newosproc

errno=1 is EPERM, and it came from ops/security/seccomp/cve-gate.json.

The rule said the opposite of its comment

SCMP_CMP_MASKED_EQ tests (arg & value) == valueTwo. Docker's default profile allows clone when (flags & <all CLONE_NEW* bits>) == 0. That rule was copied here with the action changed to SCMP_ACT_ERRNO — which inverts it exactly:

clone flags mask result old rule effect
ordinary Go thread 0x00000000 matches EPERM
CLONE_NEWUSER 0x10000000 no match allowed

It denied every clone that creates no namespace, and permitted every clone that does. The mask value itself was right — I checked that when I wrote it. The direction was never checked, and the comment above it described the intent rather than the behaviour.

It isn't expressible as one rule: seccomp has no masked-not-equal, and multiple args within a rule are ANDed. "Any of these bits set" is one rule per bit — seven rules, each matching (flags & BIT) == BIT.

Why it took three releases to find

The profile was written three releases ago and never once executed. Every earlier release failed before reaching the container: 0.3.0 on docker cp into a read-only rootfs, 0.3.1 on a tag that was never built. Each fix has uncovered the next thing nobody had run yet.

So this PR adds the feedback loop, not just the fix.

ops/security/seccomp/verify-profile.py

Evaluates a profile the way the kernel would — rule order, arg comparison ops, first match wins — and asserts both directions:

  • an ordinary Go thread clone is permitted (the exact regression above)
  • every CLONE_NEW* flag is denied, alone and OR'd with the thread flags — how a real attempt would look, and a case a single-flag test would miss
  • the syscalls the profile exists to block (mount, ptrace, bpf, setns, unshare, io_uring, …) still are

31 checks. Against the fixed profile: 31/31 pass. Against the profile currently on main:

seccomp profile FAILED 15/31 checks:
  ✗ clone() for an ordinary Go thread must be permitted
      expected SCMP_ACT_ALLOW, got SCMP_ACT_ERRNO
  ✗ clone(CLONE_NEWUSER) must be denied
      expected SCMP_ACT_ERRNO, got SCMP_ACT_ALLOW
  … 13 more

The gate action now runs it against the exact file it is about to hand to --security-opt, so a profile can't be trusted for three releases without once being evaluated. Costs milliseconds; fails loudly at the step instead of at container start.

Known gap, recorded in the profile

clone3 passes its flags in a struct, so seccomp cannot read them and this filter does not apply to it. That's covered by unshare/setns being denied outright and by --cap-drop ALL removing CAP_SYS_ADMIN, which every namespace type except NEWUSER requires.

State of the tags

0.3.0, 0.3.1, 0.3.2 are all in Harbor unsigned, for three different reasons. Once a release finally signs, delete all three.

**The gate ran for the first time in run 224 — and my own seccomp profile killed it.** ``` runtime: failed to create new OS thread (have 2 already; errno=1) fatal error: newosproc ``` `errno=1` is EPERM, and it came from `ops/security/seccomp/cve-gate.json`. ## The rule said the opposite of its comment `SCMP_CMP_MASKED_EQ` tests `(arg & value) == valueTwo`. Docker's default profile **allows** clone when `(flags & <all CLONE_NEW* bits>) == 0`. That rule was copied here with the action changed to `SCMP_ACT_ERRNO` — which inverts it exactly: | clone flags | mask result | old rule | effect | | --- | --- | --- | --- | | ordinary Go thread | `0x00000000` | matches | **EPERM** | | `CLONE_NEWUSER` | `0x10000000` | no match | **allowed** | It denied every clone that creates no namespace, and permitted every clone that does. The mask value itself was right — I checked that when I wrote it. The *direction* was never checked, and the comment above it described the intent rather than the behaviour. It isn't expressible as one rule: seccomp has no masked-not-equal, and multiple args within a rule are ANDed. "Any of these bits set" is one rule per bit — seven rules, each matching `(flags & BIT) == BIT`. ## Why it took three releases to find The profile was written three releases ago and **never once executed**. Every earlier release failed before reaching the container: 0.3.0 on `docker cp` into a read-only rootfs, 0.3.1 on a tag that was never built. Each fix has uncovered the next thing nobody had run yet. So this PR adds the feedback loop, not just the fix. ## `ops/security/seccomp/verify-profile.py` Evaluates a profile the way the kernel would — rule order, arg comparison ops, first match wins — and asserts **both directions**: - an ordinary Go thread clone is permitted (the exact regression above) - every `CLONE_NEW*` flag is denied, alone *and* OR'd with the thread flags — how a real attempt would look, and a case a single-flag test would miss - the syscalls the profile exists to block (`mount`, `ptrace`, `bpf`, `setns`, `unshare`, io_uring, …) still are 31 checks. Against the fixed profile: **31/31 pass**. Against the profile currently on main: ``` seccomp profile FAILED 15/31 checks: ✗ clone() for an ordinary Go thread must be permitted expected SCMP_ACT_ALLOW, got SCMP_ACT_ERRNO ✗ clone(CLONE_NEWUSER) must be denied expected SCMP_ACT_ERRNO, got SCMP_ACT_ALLOW … 13 more ``` The gate action now runs it against the exact file it is about to hand to `--security-opt`, so a profile can't be trusted for three releases without once being evaluated. Costs milliseconds; fails loudly at the step instead of at container start. ## Known gap, recorded in the profile `clone3` passes its flags in a struct, so seccomp cannot read them and this filter does not apply to it. That's covered by `unshare`/`setns` being denied outright and by `--cap-drop ALL` removing `CAP_SYS_ADMIN`, which every namespace type except `NEWUSER` requires. ## State of the tags `0.3.0`, `0.3.1`, `0.3.2` are all in Harbor unsigned, for three different reasons. Once a release finally signs, delete all three.
cve-gate 0.3.2 is unsigned in Harbor. The gate ran for the first time and the container died
before main:

    runtime: failed to create new OS thread (have 2 already; errno=1)
    fatal error: newosproc

errno=1 is EPERM, from this repository's own seccomp profile.

SCMP_CMP_MASKED_EQ tests (arg & value) == valueTwo. Docker's default profile ALLOWS clone when
(flags & <all CLONE_NEW* bits>) == 0. That rule was copied here with the action changed to
SCMP_ACT_ERRNO, which inverts its meaning exactly: it denied every clone that creates NO
namespace — i.e. every ordinary thread — and permitted every clone that does. The comment above
it described the intended behaviour, which was also the opposite of the behaviour.

It is not expressible as one rule. seccomp has no masked-not-equal, and multiple args within a
rule are ANDed, so "any of these bits set" is one rule per bit: seven rules, each matching
(flags & BIT) == BIT.

The profile was written three releases ago and never executed until now, because every earlier
release failed before reaching the container (0.3.0 on docker cp, 0.3.1 on a missing tag). Each
fix has revealed the next untested thing, so this adds the missing feedback loop rather than
just the fix:

ops/security/seccomp/verify-profile.py evaluates a profile the way the kernel would — rule
order, arg comparisons, first match wins — and asserts both directions: an ordinary Go thread
clone is permitted, every CLONE_NEW* flag is denied alone AND combined with the thread flags,
and the syscalls the profile exists to block still are. 31 checks. It passes on the fixed
profile and fails 15 on the broken one.

The gate action runs it against the exact file it is about to pass to --security-opt, so a
profile can never again be trusted for three releases without once being evaluated.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
webgrip/infrastructure!83
No description provided.