diff --git a/CHANGELOG.md b/CHANGELOG.md index d9068d3..0fcadc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,10 @@ Nothing has been released yet. Everything below is on `main` and unversioned. first start and printed once. The default bind address is loopback. - **Per-sandbox network mode:** A sandbox is created with network mode `full` (the default, with normal outbound access) or `none`, which denies it a route - to the internet for its whole lifetime, including every fork or restore made - from it. The mode is fixed at creation and cannot be changed afterward. + to the internet. The mode is fixed at creation and cannot be changed + afterward. Restoring a sandbox always keeps its own mode. Forking a sandbox + from a snapshot inherits the snapshot's mode by default, but an explicit + `network` on the fork request overrides it. - **Optional gVisor runtime:** An operator can configure `orcald` to run every sandbox under gVisor instead of the default container runtime, trading some compatibility and performance for a smaller kernel attack surface. The choice diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0d8f444..befc98c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -56,8 +56,13 @@ on scopes to partition a shared one. ## Network modes A sandbox is created with a network mode that cannot be changed afterward — there is no -endpoint to move a running sandbox between modes, and forking or restoring a sandbox preserves -the network mode it had. +endpoint to move a running sandbox between modes. Restoring a sandbox always preserves that +sandbox's own mode: restore never changes it. Forking a sandbox from a snapshot is different — +the new sandbox inherits the snapshot's mode by default, but a `network` field on the fork +request overrides that default explicitly. A caller with `sandboxes:write` can therefore fork a +`none` snapshot with `"network": "full"` and get an internet-connected sandbox holding the +`none` sandbox's filesystem; that is deliberate, not a gap, but it means "created with `none`" +does not imply "every descendant stays `none`" unless the caller forking it says so. `full`, the default, attaches the sandbox to a bridge network with a normal route to the internet. `none` attaches the sandbox to a second, Docker-internal bridge network that carries @@ -119,7 +124,7 @@ construction, not by redaction. Events can be listed and filtered through `GET / gated behind the `audit:read` scope, and are pruned on both an age and a count basis according to `ORCAL_AUDIT_RETENTION_DAYS` and `ORCAL_AUDIT_MAX_EVENTS`. -Two gaps are worth naming explicitly: +Four gaps are worth naming explicitly: - **`stat` and `list` are not audited.** Every other file operation is, but checking whether a path exists or listing a directory's contents leaves no trace in the audit log. An attacker @@ -130,6 +135,16 @@ Two gaps are worth naming explicitly: deliberate trade-off — an audit store outage should not become an availability outage for the product — but it means the audit log's completeness is not guaranteed under a failing audit store, only best-effort. +- **A panicking handler leaves no audit event at all.** Recovery from a panic happens outside + the audit middleware, so a request that crashes its handler unwinds past the code that would + have written the event. This is distinct from the fail-open case above, which covers a + failed *insert*; here nothing is ever attempted. +- **An unauthenticated caller can crowd out real history.** Every rejected request is audited, + including ones from a caller with no valid token at all, and there is no rate limiting on the + API. Since pruning by count evicts the oldest events first, anyone who can reach `ORCAL_ADDR` + can flood it with denied requests and push genuine history out of the retention window. In + practice this is mitigated by the default loopback bind: it only matters once an operator + exposes `orcald` beyond `127.0.0.1`. ## Container hardening diff --git a/go.mod b/go.mod index 76e9687..c9c4a8b 100644 --- a/go.mod +++ b/go.mod @@ -5,48 +5,56 @@ go 1.25.0 require ( github.com/containerd/errdefs v1.0.0 github.com/docker/docker v28.5.2+incompatible - github.com/getkin/kin-openapi v0.135.0 + github.com/getkin/kin-openapi v0.146.0 github.com/google/uuid v1.6.0 + github.com/opencontainers/image-spec v1.1.1 github.com/spf13/cobra v1.9.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.39.0 ) require ( + github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/oasdiff/yaml v0.0.9 // indirect - github.com/oasdiff/yaml3 v0.0.9 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/woodsbury/decimal128 v1.3.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/sdk v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index c4034a3..1147c74 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,24 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -17,99 +29,135 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= -github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= +github.com/getkin/kin-openapi v0.146.0 h1:RA/1RdxrSJW4oc1+6IfnYB6AO9CaGy8GTKPh0k4Ordo= +github.com/getkin/kin-openapi v0.146.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= -github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= -github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= -github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= -github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= -github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= diff --git a/internal/api/audit_test.go b/internal/api/audit_test.go index ffc213d..6fc0ae7 100644 --- a/internal/api/audit_test.go +++ b/internal/api/audit_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/getorcal/orcal/internal/apigen" "github.com/getorcal/orcal/internal/audit" "github.com/getorcal/orcal/internal/auth" "github.com/getorcal/orcal/internal/runtime/fake" @@ -163,6 +164,91 @@ func TestUnauthorizedRequestsAreAudited(t *testing.T) { if got[0].ActorTokenID != "" { t.Fatal("an unauthenticated request has no actor") } + if got[0].Details["reason"] != "missing" { + t.Fatalf("a request with no token must be recorded with reason missing, got %v", got[0].Details) + } +} + +func TestUnauthorizedRequestsRecordTheSpecificDenialReason(t *testing.T) { + srv, svc, events := newAuditTestServer(t) + ctx := context.Background() + + expiredTok, plaintextExpired, err := svc.Create(ctx, + auth.CreateOptions{Name: "expired", Scopes: auth.Scopes{auth.ScopeSandboxesRead}, ExpiresIn: 1}, auth.Scopes{auth.ScopeAll}) + if err != nil { + t.Fatalf("create expired: %v", err) + } + + revokedPlaintext := mint(t, svc, "revoked", auth.Scopes{auth.ScopeSandboxesRead}) + tokens, err := svc.List(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + var revokedTok *auth.Token + for _, tok := range tokens { + if tok.Name == "revoked" { + revokedTok = tok + } + } + if revokedTok == nil { + t.Fatal("could not find the minted revoked token") + } + if err := svc.Revoke(ctx, revokedTok.ID); err != nil { + t.Fatalf("revoke: %v", err) + } + + cases := []struct { + name string + header string + wantReason string + wantPrefix string + }{ + {"missing", "", "missing", ""}, + {"malformed", "Basic whatever", "malformed", ""}, + {"unknown", "Bearer orcal_definitely-not-a-real-token", "unknown", ""}, + {"expired", "Bearer " + plaintextExpired, "expired", expiredTok.Prefix}, + {"revoked", "Bearer " + revokedPlaintext, "revoked", revokedTok.Prefix}, + } + for _, tc := range cases { + req := httptest.NewRequest(http.MethodGet, "/v1/sandboxes", nil) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s: expected 401, got %d", tc.name, rec.Code) + } + } + + got, err := events.List(ctx, audit.Filter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != len(cases) { + t.Fatalf("expected %d events, got %d", len(cases), len(got)) + } + byReason := make(map[string]*audit.Event, len(got)) + for _, e := range got { + byReason[e.Details["reason"].(string)] = e + } + for _, tc := range cases { + e, ok := byReason[tc.wantReason] + if !ok { + t.Errorf("no event recorded with reason %q", tc.wantReason) + continue + } + gotPrefix, hasPrefix := e.Details["token_prefix"] + if tc.wantPrefix == "" { + if hasPrefix { + t.Errorf("%s: token_prefix must be absent when the credential never resolved, got %v", tc.name, gotPrefix) + } + continue + } + if gotPrefix != tc.wantPrefix { + t.Errorf("%s: token_prefix = %v, want %q", tc.name, gotPrefix, tc.wantPrefix) + } + } } func TestForbiddenRequestsRecordTheRequiredScope(t *testing.T) { @@ -188,6 +274,60 @@ func TestForbiddenRequestsRecordTheRequiredScope(t *testing.T) { if got[0].Details["required_scope"] != "sandboxes:write" { t.Fatalf("the required scope must be recorded, got %v", got[0].Details) } + if got[0].Details["reason"] != "insufficient_scope" { + t.Fatalf("the denial reason must be insufficient_scope, got %v", got[0].Details) + } +} + +func TestSandboxCreationRecordsTheNameInAuditDetails(t *testing.T) { + srv, svc, events := newAuditTestServer(t) + token := mint(t, svc, "root", auth.Scopes{auth.ScopeAll}) + + if rec := postJSON(srv, "/v1/sandboxes", token, map[string]any{"image": "alpine", "name": "my-agent"}); rec.Code != http.StatusCreated { + t.Fatalf("create: %d", rec.Code) + } + got, err := events.List(context.Background(), audit.Filter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected one event, got %d", len(got)) + } + if got[0].Details["name"] != "my-agent" { + t.Fatalf("sandbox.create must record the sandbox name, got %v", got[0].Details) + } + if got[0].Details["image"] != "alpine" || got[0].Details["network"] != "full" { + t.Fatalf("sandbox.create must still record image and network, got %v", got[0].Details) + } +} + +func TestTokenCreationRecordsIDAndScopesInAuditDetails(t *testing.T) { + srv, svc, events := newAuditTestServer(t) + token := mint(t, svc, "root", auth.Scopes{auth.ScopeAll}) + + rec := postJSON(srv, "/v1/tokens", token, map[string]any{"name": "ci", "scopes": []string{"exec", "sandboxes:write"}}) + if rec.Code != http.StatusCreated { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + var created apigen.CreatedToken + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + + got, err := events.List(context.Background(), audit.Filter{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected one event, got %d", len(got)) + } + if got[0].Details["token_id"] != created.Id { + t.Fatalf("token.create must record token_id, got %v", got[0].Details) + } + scopes, ok := got[0].Details["scopes"].([]string) + if !ok || len(scopes) != 2 { + t.Fatalf("token.create must record the minted scopes, got %v (%T)", got[0].Details["scopes"], got[0].Details["scopes"]) + } } func TestNoEventEverCarriesASecret(t *testing.T) { diff --git a/internal/api/auth.go b/internal/api/auth.go index e1f6334..2e54254 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -36,12 +36,17 @@ func bearerToken(r *http.Request) string { func (s *Server) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, err := s.tokens.Authenticate(r.Context(), bearerToken(r)) + header := r.Header.Get("Authorization") + raw := bearerToken(r) + token, err := s.tokens.Authenticate(r.Context(), raw) if err != nil { if !errors.Is(err, auth.ErrUnauthorized) { s.writeError(w, r, err) return } + annotate(r.Context(), func(a *annotation) { + a.details = deniedAuthDetails(header, raw, token, err) + }) writeUnauthorized(w) return } @@ -53,14 +58,50 @@ func (s *Server) authenticate(next http.Handler) http.Handler { }) } +// deniedAuthDetails names the reason authenticate rejected a request, one of missing, malformed, +// unknown, expired, or revoked. The credential's prefix is included only when it resolved to a +// known token record — resolved is non-nil only for the expired and revoked cases — so an +// attacker-supplied bearer string is never echoed back into the audit log. +func deniedAuthDetails(header, raw string, resolved *auth.Token, err error) map[string]any { + reason := "unknown" + switch { + case header == "": + reason = "missing" + case raw == "": + reason = "malformed" + case errors.Is(err, auth.ErrTokenRevoked): + reason = "revoked" + case errors.Is(err, auth.ErrTokenExpired): + reason = "expired" + } + details := map[string]any{"reason": reason} + if resolved != nil { + details["token_prefix"] = resolved.Prefix + } + return details +} + +// missingScopeError carries the scope a 403 was refused for, so writeError can surface it as a +// structured field in the response's details rather than leaving it embedded only in the +// free-text message. +type missingScopeError struct { + scope auth.Scope +} + +func (e *missingScopeError) Error() string { + return fmt.Sprintf("%s: this token does not hold %s", ErrForbidden, e.scope) +} + +func (e *missingScopeError) Unwrap() error { return ErrForbidden } + func (s *Server) requireScope(want auth.Scope, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { principal := principalFrom(r.Context()) if principal == nil || !principal.Scopes.Has(want) { annotate(r.Context(), func(a *annotation) { - a.details = map[string]any{"required_scope": string(want)} + a.details = map[string]any{"required_scope": string(want), "reason": "insufficient_scope"} }) - s.writeError(w, r, fmt.Errorf("%w: this token does not hold %s", ErrForbidden, want)) + s.writeError(w, r, &missingScopeError{scope: want}) return } next(w, r) diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 4063ddf..6591c56 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "io" "log/slog" "net/http" @@ -9,6 +10,7 @@ import ( "strings" "testing" + "github.com/getorcal/orcal/internal/apigen" "github.com/getorcal/orcal/internal/audit" "github.com/getorcal/orcal/internal/auth" ) @@ -143,8 +145,12 @@ func TestWrongScopeIsForbiddenNotUnauthorized(t *testing.T) { if rec.Header().Get("WWW-Authenticate") != "" { t.Error("403 must not carry a challenge; re-presenting the same credential cannot help") } - if body := rec.Body.String(); !strings.Contains(body, "sandboxes:write") { - t.Errorf("403 must name the required scope, got %s", body) + var body apigen.Error + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Error.Details == nil || (*body.Error.Details)["required_scope"] != "sandboxes:write" { + t.Errorf("403 must name the required scope in details.required_scope, got %+v", body.Error.Details) } } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index bbb5ffe..409afa8 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -96,6 +96,20 @@ func TestResponsesMatchTheOpenAPIContract(t *testing.T) { assertMatchesContract(t, router, execReq, execResp, execBody) }) + tokenResp, tokenReq, tokenBody := h.doCapturing(t, http.MethodPost, "/v1/tokens", map[string]any{ + "name": "contract-token", "scopes": []string{"exec"}, + }) + if tokenResp.StatusCode != http.StatusCreated { + t.Fatalf("create token status = %d, want 201", tokenResp.StatusCode) + } + var createdToken apigen.CreatedToken + if err := json.Unmarshal(tokenBody, &createdToken); err != nil { + t.Fatalf("decode token: %v", err) + } + t.Run(tokenReq.Method+" "+tokenReq.URL.Path, func(t *testing.T) { + assertMatchesContract(t, router, tokenReq, tokenResp, tokenBody) + }) + runtimeID := h.fake.IDForSandbox(created.Id) h.fake.Seed(runtimeID, "/app/a.txt", 0o644, []byte("hello")) @@ -152,6 +166,8 @@ func TestResponsesMatchTheOpenAPIContract(t *testing.T) { {http.MethodGet, "/v1/sandboxes/my-agent/files/stat?path=/app/a.txt", nil, seedAppFile}, {http.MethodGet, "/v1/sandboxes/my-agent/files/list?path=/app", nil, seedAppFile}, {http.MethodGet, "/v1/sandboxes/my-agent/archive?path=/app", nil, seedAppFile}, + {http.MethodGet, "/v1/tokens", nil, nil}, + {http.MethodGet, "/v1/events", nil, nil}, {http.MethodDelete, "/v1/sandboxes/" + created.Id, nil, nil}, } @@ -174,4 +190,12 @@ func TestResponsesMatchTheOpenAPIContract(t *testing.T) { t.Run("DELETE /v1/snapshots/{ref}", func(t *testing.T) { assertMatchesContract(t, router, delReq, delResp, delBody) }) + + delTokenResp, delTokenReq, delTokenBody := h.doCapturing(t, http.MethodDelete, "/v1/tokens/"+createdToken.Id, nil) + if delTokenResp.StatusCode != http.StatusNoContent { + t.Fatalf("revoke token status = %d, want 204", delTokenResp.StatusCode) + } + t.Run("DELETE /v1/tokens/{id}", func(t *testing.T) { + assertMatchesContract(t, router, delTokenReq, delTokenResp, delTokenBody) + }) } diff --git a/internal/api/errors.go b/internal/api/errors.go index b49188b..588956f 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -93,6 +93,11 @@ func classify(err error) (int, ErrorCode) { func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) { status, code := classify(err) message := err.Error() + details := map[string]any{"request_id": requestIDFrom(r.Context())} + var scopeErr *missingScopeError + if errors.As(err, &scopeErr) { + details["required_scope"] = string(scopeErr.scope) + } if code == CodeInternalError { s.logger.ErrorContext(r.Context(), "request failed", slog.String("error", err.Error())) message = "an internal error occurred" @@ -100,7 +105,7 @@ func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) { writeJSON(w, status, apigen.Error{Error: apigen.ErrorBody{ Code: code, Message: message, - Details: &map[string]any{"request_id": requestIDFrom(r.Context())}, + Details: &details, }}) } diff --git a/internal/api/sandboxes.go b/internal/api/sandboxes.go index cbd6122..a57cc86 100644 --- a/internal/api/sandboxes.go +++ b/internal/api/sandboxes.go @@ -70,7 +70,7 @@ func (s *Server) handleCreateSandbox(w http.ResponseWriter, r *http.Request) { } a.resourceType = "sandbox" a.resourceID = created.ID - a.details = map[string]any{"image": created.Image, "network": string(created.Network)} + a.details = map[string]any{"image": created.Image, "network": string(created.Network), "name": created.Name} }) writeJSON(w, http.StatusCreated, toAPISandbox(created)) } diff --git a/internal/api/tokens.go b/internal/api/tokens.go index 00fed4c..1ca0895 100644 --- a/internal/api/tokens.go +++ b/internal/api/tokens.go @@ -40,6 +40,11 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) { annotate(r.Context(), func(a *annotation) { a.resourceType = "token" a.resourceID = created.ID + scopes := make([]string, len(created.Scopes)) + for i, scope := range created.Scopes { + scopes[i] = string(scope) + } + a.details = map[string]any{"token_id": created.ID, "scopes": scopes} }) writeJSON(w, http.StatusCreated, apigen.CreatedToken{Token: plaintext, Id: created.ID, Name: created.Name, Prefix: created.Prefix, Scopes: apiScopes(created.Scopes), CreatedAt: created.CreatedAt, diff --git a/internal/auth/errors.go b/internal/auth/errors.go index c367598..4e42aae 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -5,9 +5,13 @@ import "errors" var ( ErrNotFound = errors.New("auth: token not found") ErrNameTaken = errors.New("auth: token name is taken") + ErrHashTaken = errors.New("auth: token hash already exists") ErrInvalidScope = errors.New("auth: invalid scope") ErrScopeEscalation = errors.New("auth: requested scopes exceed the caller's own") ErrLastAdminToken = errors.New("auth: refusing to revoke the last admin-capable token") ErrUnauthorized = errors.New("auth: unauthorized") ErrInvalidName = errors.New("auth: invalid token name") + ErrUnknownToken = errors.New("auth: unknown token") + ErrTokenExpired = errors.New("auth: token expired") + ErrTokenRevoked = errors.New("auth: token revoked") ) diff --git a/internal/auth/memory.go b/internal/auth/memory.go index 7ee6afb..e20fcd0 100644 --- a/internal/auth/memory.go +++ b/internal/auth/memory.go @@ -20,6 +20,9 @@ func (m *MemoryRepo) Create(_ context.Context, t *Token) error { if existing.Name == t.Name && existing.RevokedAt == nil { return ErrNameTaken } + if existing.Hash == t.Hash { + return ErrHashTaken + } } clone := *t m.tokens = append(m.tokens, &clone) diff --git a/internal/auth/memory_test.go b/internal/auth/memory_test.go new file mode 100644 index 0000000..8c584aa --- /dev/null +++ b/internal/auth/memory_test.go @@ -0,0 +1,44 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestMemoryRepoCreateRejectsADuplicateHash(t *testing.T) { + repo := NewMemoryRepo() + ctx := context.Background() + + first := &Token{ID: "id-1", Name: "first", Hash: "same-hash"} + if err := repo.Create(ctx, first); err != nil { + t.Fatalf("create first: %v", err) + } + + second := &Token{ID: "id-2", Name: "second", Hash: "same-hash"} + if err := repo.Create(ctx, second); !errors.Is(err, ErrHashTaken) { + t.Fatalf("Create() error = %v, want ErrHashTaken", err) + } +} + +func TestMemoryRepoCreateRejectsADuplicateHashEvenAfterRevocation(t *testing.T) { + repo := NewMemoryRepo() + ctx := context.Background() + + first := &Token{ID: "id-1", Name: "first", Hash: "same-hash"} + if err := repo.Create(ctx, first); err != nil { + t.Fatalf("create first: %v", err) + } + revoked := *first + now := time.Now() + revoked.RevokedAt = &now + if err := repo.Update(ctx, &revoked); err != nil { + t.Fatalf("revoke: %v", err) + } + + second := &Token{ID: "id-2", Name: "second", Hash: "same-hash"} + if err := repo.Create(ctx, second); !errors.Is(err, ErrHashTaken) { + t.Fatalf("Create() error = %v, want ErrHashTaken; the hash index has no partial WHERE, unlike name's", err) + } +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 68e5ab8..093e3eb 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -102,6 +102,10 @@ func (s *Service) Revoke(ctx context.Context, id string) error { return s.repo.Update(ctx, tok) } +// Authenticate reports which sentinel a rejection wraps beyond ErrUnauthorized, so callers can +// annotate an audit event with the specific reason without changing what the caller returns to +// the client. On a revoked or expired token it also returns the resolved *Token alongside the +// error, so a caller can record that token's own prefix rather than the credential it was given. func (s *Service) Authenticate(ctx context.Context, plaintext string) (*Token, error) { if plaintext == "" { return nil, ErrUnauthorized @@ -109,14 +113,17 @@ func (s *Service) Authenticate(ctx context.Context, plaintext string) (*Token, e tok, err := s.repo.GetByHash(ctx, HashToken(plaintext)) if err != nil { if errors.Is(err, ErrNotFound) { - return nil, ErrUnauthorized + return nil, fmt.Errorf("%w: %w", ErrUnauthorized, ErrUnknownToken) } return nil, err } now := s.now() - if !tok.Live(now) { - return nil, ErrUnauthorized + if tok.RevokedAt != nil { + return tok, fmt.Errorf("%w: %w", ErrUnauthorized, ErrTokenRevoked) + } + if tok.ExpiresAt != nil && !tok.ExpiresAt.After(now) { + return tok, fmt.Errorf("%w: %w", ErrUnauthorized, ErrTokenExpired) } if tok.LastUsedAt == nil || now.Sub(*tok.LastUsedAt) >= lastUsedInterval { if err := s.repo.TouchLastUsed(ctx, tok.ID, now); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 50504e2..2ea37ca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,10 @@ import ( "strconv" ) +// time.Duration(days) * 24 * time.Hour overflows int64 nanoseconds above ~106751 days; +// 36500 (100 years) keeps every downstream computation well clear of that wraparound. +const maxAuditRetentionDays = 36500 + type Config struct { Addr string DataDir string @@ -70,6 +74,10 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + if retentionDays > maxAuditRetentionDays { + return Config{}, fmt.Errorf("config: ORCAL_AUDIT_RETENTION_DAYS must not exceed %d, got %d", + maxAuditRetentionDays, retentionDays) + } c.AuditRetentionDays = int(retentionDays) maxEvents, err := envPositiveInt64("ORCAL_AUDIT_MAX_EVENTS", 1000000) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 43ee589..397d6ac 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -157,6 +157,28 @@ func TestLoadAuditRetentionDefaults(t *testing.T) { } } +func TestLoadRejectsAnAuditRetentionThatWouldOverflowDuration(t *testing.T) { + for _, days := range []string{"36501", "200000", "213504"} { + t.Run(days, func(t *testing.T) { + t.Setenv("ORCAL_AUDIT_RETENTION_DAYS", days) + if _, err := Load(); err == nil { + t.Errorf("Load() error = nil, want a validation error for %s days", days) + } + }) + } +} + +func TestLoadAcceptsTheMaximumAuditRetention(t *testing.T) { + t.Setenv("ORCAL_AUDIT_RETENTION_DAYS", "36500") + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.AuditRetentionDays != 36500 { + t.Errorf("AuditRetentionDays = %d, want 36500", cfg.AuditRetentionDays) + } +} + func TestLoadAuditRetentionOverrides(t *testing.T) { t.Setenv("ORCAL_AUDIT_RETENTION_DAYS", "30") t.Setenv("ORCAL_AUDIT_MAX_EVENTS", "500") diff --git a/internal/runtime/docker/docker.go b/internal/runtime/docker/docker.go index ae91835..0a618dd 100644 --- a/internal/runtime/docker/docker.go +++ b/internal/runtime/docker/docker.go @@ -81,8 +81,12 @@ func (d *Docker) ResolveRuntime(ctx context.Context, configured string) (string, } func (d *Docker) EnsureNetwork(ctx context.Context, name string, internal bool) error { - _, err := d.cli.NetworkInspect(ctx, name, network.InspectOptions{}) + existing, err := d.cli.NetworkInspect(ctx, name, network.InspectOptions{}) if err == nil { + if existing.Internal != internal { + return fmt.Errorf("%w: network %q exists with internal=%t, but internal=%t was requested", + runtime.ErrInvalidSpec, name, existing.Internal, internal) + } return nil } if !cerrdefs.IsNotFound(err) { diff --git a/internal/runtime/docker/docker_test.go b/internal/runtime/docker/docker_test.go index 3cadffe..efe87b8 100644 --- a/internal/runtime/docker/docker_test.go +++ b/internal/runtime/docker/docker_test.go @@ -78,17 +78,25 @@ func newFakeDocker(t *testing.T, fi fakeInfo) *Docker { } type fakeDockerClient struct { - info system.Info + info system.Info + networks map[string]network.Inspect + networkCreates *[]network.CreateOptions } func (f fakeDockerClient) Info(context.Context) (system.Info, error) { return f.info, nil } -func (f fakeDockerClient) NetworkInspect(context.Context, string, network.InspectOptions) (network.Inspect, error) { - panic("fakeDockerClient: NetworkInspect not implemented") +func (f fakeDockerClient) NetworkInspect(_ context.Context, id string, _ network.InspectOptions) (network.Inspect, error) { + if n, ok := f.networks[id]; ok { + return n, nil + } + return network.Inspect{}, fmt.Errorf("network %s: %w", id, cerrdefs.ErrNotFound) } -func (f fakeDockerClient) NetworkCreate(context.Context, string, network.CreateOptions) (network.CreateResponse, error) { - panic("fakeDockerClient: NetworkCreate not implemented") +func (f fakeDockerClient) NetworkCreate(_ context.Context, name string, options network.CreateOptions) (network.CreateResponse, error) { + if f.networkCreates != nil { + *f.networkCreates = append(*f.networkCreates, options) + } + return network.CreateResponse{ID: name}, nil } func (f fakeDockerClient) ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error) { @@ -158,3 +166,43 @@ func (f fakeDockerClient) CopyFromContainer(context.Context, string, string) (io func (f fakeDockerClient) CopyToContainer(context.Context, string, string, io.Reader, container.CopyToContainerOptions) error { panic("fakeDockerClient: CopyToContainer not implemented") } + +func TestEnsureNetworkAcceptsAMatchingExistingNetwork(t *testing.T) { + d := &Docker{cli: fakeDockerClient{ + networks: map[string]network.Inspect{ + "orcal-isolated": {Name: "orcal-isolated", Internal: true}, + }, + }} + if err := d.EnsureNetwork(context.Background(), "orcal-isolated", true); err != nil { + t.Errorf("EnsureNetwork() = %v, want nil", err) + } +} + +func TestEnsureNetworkRejectsAMismatchedExistingNetwork(t *testing.T) { + d := &Docker{cli: fakeDockerClient{ + networks: map[string]network.Inspect{ + "orcal-isolated": {Name: "orcal-isolated", Internal: false}, + }, + }} + err := d.EnsureNetwork(context.Background(), "orcal-isolated", true) + if !errors.Is(err, runtime.ErrInvalidSpec) { + t.Errorf("EnsureNetwork() = %v, want wraps ErrInvalidSpec", err) + } +} + +func TestEnsureNetworkCreatesAnAbsentNetwork(t *testing.T) { + var created []network.CreateOptions + d := &Docker{cli: fakeDockerClient{ + networks: map[string]network.Inspect{}, + networkCreates: &created, + }} + if err := d.EnsureNetwork(context.Background(), "orcal-isolated", true); err != nil { + t.Fatalf("EnsureNetwork() = %v, want nil", err) + } + if len(created) != 1 { + t.Fatalf("NetworkCreate called %d times, want 1", len(created)) + } + if !created[0].Internal { + t.Errorf("NetworkCreate options.Internal = false, want true") + } +} diff --git a/internal/sandbox/restore_test.go b/internal/sandbox/restore_test.go index b5f2446..af9ea3f 100644 --- a/internal/sandbox/restore_test.go +++ b/internal/sandbox/restore_test.go @@ -27,13 +27,14 @@ func (r *hookRepo) Get(ctx context.Context, id string) (*sandbox.Sandbox, error) } type stubSnapshots struct { - ref string - id string - err error + ref string + id string + network string + err error } func (s stubSnapshots) Resolve(ctx context.Context, ref string) (snapshot.Resolved, error) { - return snapshot.Resolved{RuntimeRef: s.ref, ID: s.id}, s.err + return snapshot.Resolved{RuntimeRef: s.ref, ID: s.id, Network: s.network}, s.err } func TestForkCreatesANewSandboxFromTheSnapshotRef(t *testing.T) { diff --git a/internal/sandbox/service_network_test.go b/internal/sandbox/service_network_test.go index d936bb2..d78027e 100644 --- a/internal/sandbox/service_network_test.go +++ b/internal/sandbox/service_network_test.go @@ -60,6 +60,41 @@ func TestNetworkIsPersisted(t *testing.T) { } } +func TestForkInheritsTheSnapshotsNetworkWhenNoneIsRequested(t *testing.T) { + svc, f := newService(t) + svc.SetSnapshots(stubSnapshots{ref: "sha256:snap", id: "sn-1", network: string(sandbox.NetworkNone)}) + + forked, err := svc.Fork(context.Background(), "working-v1", sandbox.CreateOptions{Name: "experiment-a"}) + if err != nil { + t.Fatalf("Fork() error = %v", err) + } + if forked.Network != sandbox.NetworkNone { + t.Fatalf("Network = %q, want inherited none", forked.Network) + } + if got := f.LastCreateSpec().NetworkName; got != "orcal-isolated" { + t.Fatalf("a forked none sandbox must join the isolated network, got %q", got) + } +} + +func TestForkHonoursAnExplicitNetworkOverridingTheSnapshot(t *testing.T) { + svc, f := newService(t) + svc.SetSnapshots(stubSnapshots{ref: "sha256:snap", id: "sn-1", network: string(sandbox.NetworkNone)}) + + forked, err := svc.Fork(context.Background(), "working-v1", sandbox.CreateOptions{ + Name: "experiment-a", + Network: sandbox.NetworkFull, + }) + if err != nil { + t.Fatalf("Fork() error = %v", err) + } + if forked.Network != sandbox.NetworkFull { + t.Fatalf("Network = %q, want the explicit override full", forked.Network) + } + if got := f.LastCreateSpec().NetworkName; got != "orcal" { + t.Fatalf("an explicit full override must join the egress network, got %q", got) + } +} + func TestStartAndStopDoNotChangeTheNetwork(t *testing.T) { svc, _ := newService(t) ctx := context.Background() diff --git a/test/integration/gvisor_test.go b/test/integration/gvisor_test.go index d2e6b73..500bd38 100644 --- a/test/integration/gvisor_test.go +++ b/test/integration/gvisor_test.go @@ -115,6 +115,47 @@ func TestSandboxActuallyRunsUnderGvisor(t *testing.T) { } } +func TestNoneSandboxHasNoEgressUnderGvisor(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + + full := e.sandbox(t, "gvisor-egress-full") + fullSandbox, err := e.client.GetSandbox(ctx, full) + if err != nil { + t.Fatalf("GetSandbox(full) error = %v", err) + } + if fullSandbox.OciRuntime == nil || *fullSandbox.OciRuntime != "runsc" { + t.Fatalf("expected the full-network control leg to run under runsc, got %v", fullSandbox.OciRuntime) + } + + none, err := e.client.CreateSandbox(ctx, orcalclient.CreateSandboxParams{ + Name: "gvisor-egress-none", Image: testImage, Network: "none", + }) + if err != nil { + t.Fatalf("create isolated sandbox: %v", err) + } + e.sandboxes = append(e.sandboxes, none.Id) + if none.OciRuntime == nil || *none.OciRuntime != "runsc" { + t.Fatalf("expected the none sandbox to run under runsc, got %v", none.OciRuntime) + } + + command := []string{"wget", "-q", "-T", "5", "-O", "/dev/null", "http://example.com/"} + + _, fullCode := e.runToCompletion(t, full, command...) + if fullCode != 0 { + t.Fatalf("control leg failed: a full-network sandbox under runsc could not reach the network (exit %d). "+ + "This test is a paired control, not a single assertion: leg one proves the environment has "+ + "egress at all, and only then does leg two's failure prove the none network is isolating it "+ + "under gVisor specifically. The docker-tagged suite already covers runc; this covers runsc.", + fullCode) + } + + _, noneCode := e.runToCompletion(t, none.Id, command...) + if noneCode == 0 { + t.Fatal("a none-network sandbox under runsc reached the internet; the isolated network is not isolating") + } +} + func TestProductWorksEndToEndUnderGvisor(t *testing.T) { e := newEnv(t) ctx := context.Background()