fix(cve-gate): un-invert the seccomp clone rule, and evaluate the profile before trusting it #83
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/cve-gate-seccomp-clone"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The gate ran for the first time in run 224 — and my own seccomp profile killed it.
errno=1is EPERM, and it came fromops/security/seccomp/cve-gate.json.The rule said the opposite of its comment
SCMP_CMP_MASKED_EQtests(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 toSCMP_ACT_ERRNO— which inverts it exactly:0x00000000CLONE_NEWUSER0x10000000It 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 cpinto 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.pyEvaluates a profile the way the kernel would — rule order, arg comparison ops, first match wins — and asserts both directions:
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 missmount,ptrace,bpf,setns,unshare, io_uring, …) still are31 checks. Against the fixed profile: 31/31 pass. Against the profile currently on main:
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
clone3passes its flags in a struct, so seccomp cannot read them and this filter does not apply to it. That's covered byunshare/setnsbeing denied outright and by--cap-drop ALLremovingCAP_SYS_ADMIN, which every namespace type exceptNEWUSERrequires.State of the tags
0.3.0,0.3.1,0.3.2are 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.