diff --git a/.github/workflows/pmg-e2e.yml b/.github/workflows/pmg-e2e.yml index 71654e5..0637c69 100644 --- a/.github/workflows/pmg-e2e.yml +++ b/.github/workflows/pmg-e2e.yml @@ -539,6 +539,7 @@ jobs: touch ~/.gcloud/credentials.json touch ~/.kube/config touch ~/.ssh/id_rsa + touch ./.env - name: Run Sandbox E2E Test run: pmg --sandbox --sandbox-enforce npm exec -- node test/sandbox-e2e.js @@ -599,6 +600,7 @@ jobs: touch ~/.ssh/id_rsa touch ~/.gnupg/pubring.kbx touch ~/.docker/config.json + touch ./.env - name: Disable AppArmor for Bubblewrap run: | diff --git a/docs/config.md b/docs/config.md index 77b7df7..546c9aa 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,11 +12,18 @@ To see the configuration file path and activated configuration, run: pmg setup info ``` +To edit configuration file: + +```bash +pmg setup edit +``` + See [config template](../config/config.template.yml) for the configuration schema. ## Environment Variables -Any configuration key can be overridden using environment variables, without modifying the config file. This is useful for CI/CD pipelines or temporary overrides. +Any configuration key can be overridden using environment variables, without modifying the config +file. This is useful for CI/CD pipelines or temporary overrides. **Format:** `PMG_` where the key is the config key uppercased, with nested keys joined by `_`. @@ -47,4 +54,5 @@ PMG_PROXY_INSTALL_ONLY=true pmg npm install express 1. CLI flags 2. Environment variables (`PMG_*`) 3. Config file (`config.yml`) -4. Built-in defaults \ No newline at end of file +4. Built-in defaults + diff --git a/docs/sandbox.md b/docs/sandbox.md index 82839c6..9533e76 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -2,21 +2,50 @@ PMG sandbox design goal is to protect against unknown supply chain attacks using principle of least privilege. -We do not want to re-invent sandbox and likely rely on OS native sandbox primitives. This is at the cost of developer experience, -where we have to work within the limitations of the sandbox implementations that we use. +We do not want to re-invent sandbox and rely on OS native sandbox primitives. This is at the cost of +developer experience, where we have to work within the limitations of the sandbox implementations +that we use. ## Security Model +The sandbox is default-deny. Every operation is blocked unless the policy allows it, and deny rules +win over allow rules when both match. PMG ships mandatory denies for credential files (`.env`, +`.ssh`, `.aws`, `.gcloud` and more). See [dangerous.go](../sandbox/dangerous.go) for the full list. +This protects against accidental credential leaks and some classes of supply chain attacks that +attempt to access credentials. To allow legitimate access, you can opt out of a mandatory deny +via an exact match entry in `allow_read` or `allow_write` (in the policy or via `--sandbox-allow`). + +
+Detailed rules + - **Default deny**: All operations are blocked unless explicitly allowed by the policy. An empty policy grants no access. - **Deny rules override allow rules**: When a path appears in both allow and deny lists, the deny rule wins. Deny rules are placed after allow rules in the generated sandbox profile to ensure this. -- **Credential and sensitive file protection**: The sandbox automatically blocks read and write access to credential files regardless of user configuration. Protected files include `.env`, `.env.*`, `.aws`, `.gcloud`, `.kube`, `.ssh`, `.gnupg`, and `.docker/config.json`. These mandatory deny patterns are injected at translation time and cannot be removed by policy configuration or runtime overrides. +- **Credential and sensitive file protection**: The sandbox blocks read and write access to known list of credential files by default. - **Git hooks are always blocked**: Write access to `.git/hooks/` in both `$CWD` and `$HOME` is always denied to prevent arbitrary code execution via repository hooks. - **Git config is blocked by default**: Write access to `.git/config` is denied unless `allow_git_config: true` is set in the policy. This prevents credential helper manipulation. -- **Runtime overrides remove only exact-match deny entries**: When `--sandbox-allow` adds a path to an allow list, only a literal string match in the corresponding deny list is removed. Glob and wildcard deny patterns (e.g., `/etc/**`) are never removed. Mandatory deny patterns (credentials, git hooks) cannot be overridden because they are re-injected at translation time. +- **Runtime overrides remove only exact-match deny entries**: When `--sandbox-allow` adds a path to an allow list, only a literal string match in the corresponding deny list is removed. Glob and wildcard deny patterns (e.g., `/etc/**`) are never removed. An exact-match entry in `allow_read` or `allow_write` (policy or runtime) opts out of the mandatory deny for that credential file. `.git/hooks` does not accept opt-outs. - **Profile inheritance is single-level**: A profile can inherit from one built-in profile. Allow and deny lists are merged using union semantics. Boolean fields (`allow_pty`, `allow_git_config`) in the child override the parent. - **Variable expansion is runtime-only**: Policy paths use `${HOME}`, `${CWD}`, and `${TMPDIR}` which are expanded when the sandbox is set up, not when the policy is defined. - **Process-level isolation only**: The sandbox restricts the package manager process and its children. It does not enforce CPU, memory, or disk quotas. Network filtering is coarse-grained — host-level filtering is not enforced on either platform. +
+ +### Mandatory Credential Protection + +PMG maintains a known list of credential and sensitive files at +[dangerous.go](../sandbox/dangerous.go) that are blocked by default in the sandbox. PMG injects three deny patterns per file: + +- The path under `${CWD}` +- The path under `${HOME}` +- A `**/` glob. + +To opt out, list the literal path in `allow_read` or `allow_write` in your policy, or pass +`--sandbox-allow read=...` / `write=...` at runtime. Listing a CWD-absolute or HOME-absolute path +also suppresses the matching `**/` glob on the same direction, so `--sandbox-allow +read=./.env` is enough to read `${CWD}/.env`. Suppression is exact post-expansion match; broad globs +like `${HOME}/**` do not opt out of `${HOME}/.aws`. The unnamed absolute form stays denied. +`.git/hooks` does not accept opt-outs because hooks can execute arbitrary code. + ## Requirements - Bubblewrap on Linux @@ -44,7 +73,7 @@ See [Bubblewrap Installation](https://github.com/containers/bubblewrap#installat ## Usage -- Make sure sandbox is enabled in your `config.yml` file. +- Make sure sandbox is enabled in your `config.yml` file. See [configuration](./config.md) for the configuration schema. - Make sure sandbox profiles are configured for the package managers you want to sandbox. See [configuration](./config.md) and [config/config.template.yml](../config/config.template.yml) for the configuration schema. @@ -68,7 +97,8 @@ pmg --sandbox --sandbox-profile=/path/to/custom-policy.yml npm install express ### Runtime Allow Overrides -Use `--sandbox-allow` to make one-off exceptions without creating a custom profile. This is useful when a command needs access that the default profile blocks. +Use `--sandbox-allow` to make one-off exceptions without creating a custom profile. This is useful +when a command needs access that the default profile blocks. ```bash # Allow writing to a specific file @@ -92,7 +122,13 @@ pmg \ Supported types: `read`, `write`, `exec`, `net-connect`, `net-bind`. -Overrides are non-persistent (apply to current invocation only) and logged in the event log for auditing. An override adds the path to the allow list and removes an exact match entry from the corresponding deny list. Glob deny patterns are never removed. Mandatory security protections (`.env`, `.ssh`, `.aws`, `.git/hooks`, etc.) cannot be bypassed by overrides. +Overrides are non-persistent (apply to current invocation only) and logged in the event log for +auditing. An override adds the path to the allow list and removes an exact match entry from the +corresponding deny list. Glob deny patterns are never removed. An exact-match entry in `allow_read` +or `allow_write` (in the policy or via `--sandbox-allow read=...` / `write=...`) opts out of the +mandatory deny for that credential file. PMG treats both channels as explicit user intent. +Suppression is exact-match only; broad paths or globs do not opt out. `.git/hooks` does not accept +opt-outs.
Custom policy overrides using Policy Templates @@ -189,6 +225,13 @@ coarse-grained fallback strategies when glob patterns match many files. **Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific filtering is not enforced. +**Per-direction mandatory deny is asymmetric on Linux**: bwrap has no primitive that allows writes +while denying reads for the same path. `--bind` exposes both directions; `--tmpfs` and +`--ro-bind /dev/null` block both. If you opt out of write for a mandatory deny path (e.g., list it +in `allow_write` but not `allow_read`), the bind mount also exposes reads, and PMG cannot enforce +the read-side mandatory deny. PMG warns via `log.Warnf` when it detects this case. macOS Seatbelt +does not have this limitation; its `file-read*` and `file-write*` rules are independent. +
@@ -200,9 +243,12 @@ filtering is not enforced. ## Concepts -1. Policy -2. Profile -3. Policy Template +PMG layers three concepts: a **Policy** is the rule set defining what is allowed and denied. A +**Profile** is a named binding from a package manager to a policy. A **Policy Template** maps a +profile name to a YAML file so you can override the built-ins. + +
+Detailed concepts ### Policy @@ -231,13 +277,24 @@ See [sandbox/profiles/README.md](../sandbox/profiles/README.md) for more details Policy template is a configuration primitive for overriding a built-in profile or creating a custom profile. It is used to map a profile name to a path. See [config/config.template.yml](../config/config.template.yml) for an example. +
+ ## Threat Model +PMG trusts policy files and the operator's CLI as the source of intent. The sandbox implementation +enforces what the policy declares. Translation from PMG's YAML to the native sandbox format must +not weaken it. Variable interpolation consumes only trusted sources. + +
+Detailed assumptions + - Policy files are trusted - Policy enforcement is a sandbox implementation concern - YAML to sandbox specific policy translation must not make the policy weaker than the original policy - Variable interpolation in policy files must consider only trusted sources +
+ ## Enforcement The sandbox implementation currently only support `block` mode. This means, any policy violation will block the execution of the diff --git a/go.mod b/go.mod index 25fc0b1..fae4b74 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/term v0.42.0 google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -83,7 +84,6 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.70.0 // indirect diff --git a/go.sum b/go.sum index 49d4cab..3acb791 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,10 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/osv-scalibr v0.2.1 h1:d1SpwzXfNiRafUMNpei3tQg8dDLSWe7JAPNEHxhzxDk= github.com/google/osv-scalibr v0.2.1/go.mod h1:gTmbCPgh9ooYnU55N32qPxHgFubkxgiDxsoCAMQc2Nc= +github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e h1:FJta/0WsADCe1r9vQjdHbd3KuiLPu7Y9WlyLGwMUNyE= +github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= @@ -166,8 +170,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq 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/safedep/dry v0.0.0-20260409102613-51a5bb88eb14 h1:ajMAtCe1asSa4jz8Vl65CNtzTgWTAaHdeWRu0lDn8p4= -github.com/safedep/dry v0.0.0-20260409102613-51a5bb88eb14/go.mod h1:JOiCF9w4plbcMS6XnLNpDaSoUEabdscP+eFk+kROHrs= github.com/safedep/dry v0.0.0-20260411074023-b589e91de472 h1:8CztWcu+C7alCjAS5AVDRhxr/LL/9y6lHwpZKmxZjvE= github.com/safedep/dry v0.0.0-20260411074023-b589e91de472/go.mod h1:JOiCF9w4plbcMS6XnLNpDaSoUEabdscP+eFk+kROHrs= github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12 h1:NzARvPtncPbVI8a8Z0JKpJ7XJCSPpsRstV7wAgEtmOU= @@ -192,6 +194,8 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -269,12 +273,8 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -290,6 +290,8 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -328,13 +330,33 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/sandbox/platform/bubblewrap_translator_linux.go b/sandbox/platform/bubblewrap_translator_linux.go index e3b49aa..9df5422 100644 --- a/sandbox/platform/bubblewrap_translator_linux.go +++ b/sandbox/platform/bubblewrap_translator_linux.go @@ -230,14 +230,46 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox } // 3. Process deny_write rules (mount /dev/null to prevent access) - allowGitConfig := utils.SafelyGetValue(policy.AllowGitConfig) - denyPatterns := append([]string{}, policy.Filesystem.DenyWrite...) + expandedAllowRead, err := expandAll(policy.Filesystem.AllowRead) + if err != nil { + log.Warnf("sandbox: failed to expand allow_read for mandatory deny suppression, all mandatory denies preserved: %v", err) + expandedAllowRead = nil + } + expandedAllowWrite, err := expandAll(policy.Filesystem.AllowWrite) + if err != nil { + log.Warnf("sandbox: failed to expand allow_write for mandatory deny suppression, all mandatory denies preserved: %v", err) + expandedAllowWrite = nil + } - // Add mandatory deny patterns (credentials - these get completely hidden) - mandatoryDenies := util.GetMandatoryDenyPatterns(allowGitConfig) - denyPatterns = append(denyPatterns, mandatoryDenies...) + mandatoryResult := util.GetMandatoryDenyPatterns(util.MandatoryDenyOptions{ + AllowGitConfig: utils.SafelyGetValue(policy.AllowGitConfig), + AllowRead: expandedAllowRead, + AllowWrite: expandedAllowWrite, + }) - for _, pattern := range denyPatterns { + for _, p := range mandatoryResult.SuppressedRead { + log.Warnf("sandbox: mandatory deny %q suppressed for read by explicit allow rule in policy %q", p, policy.Name) + } + for _, p := range mandatoryResult.SuppressedWrite { + log.Warnf("sandbox: mandatory deny %q suppressed for write by explicit allow rule in policy %q", p, policy.Name) + } + + // bwrap has no primitive that denies reads while allowing writes — --bind + // exposes both, and read-blocking mounts (--tmpfs, --ro-bind /dev/null) + // also block writes. When the user opts out of write but not read for a + // mandatory path, the read-side deny is unenforceable; warn so it's not + // silent. + suppressedWriteSet := make(map[string]bool, len(mandatoryResult.SuppressedWrite)) + for _, p := range mandatoryResult.SuppressedWrite { + suppressedWriteSet[p] = true + } + for _, p := range mandatoryResult.DenyRead { + if suppressedWriteSet[p] { + log.Warnf("sandbox: read-side mandatory deny %q cannot be enforced on linux because allow_write for the same path exposes both read and write; consider also listing the path in allow_read if read access is intended, or remove from allow_write if not", p) + } + } + + for _, pattern := range policy.Filesystem.DenyWrite { expanded, err := util.ExpandVariables(pattern) if err != nil { log.Warnf("Failed to expand variables in deny pattern '%s': %v", pattern, err) @@ -253,10 +285,40 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox args = append(args, denyArgs...) } - // 4. Process mandatory credential directories - completely hide them with tmpfs - // This blocks both read AND write access (more secure than read-only mount) + // Skip mandatory write denies for paths the user listed in allow_read: the + // allow_read --ro-bind already denies writes (EROFS), and overlaying + // /dev/null on top would also mask reads, breaking the read-side opt-out. + // User-listed deny_write entries above are unaffected — "deny wins" still + // applies to explicit user rules. + allowReadSet := make(map[string]bool, len(expandedAllowRead)) + for _, p := range expandedAllowRead { + allowReadSet[filepath.Clean(p)] = true + } + for _, pattern := range mandatoryResult.DenyWrite { + if allowReadSet[filepath.Clean(pattern)] { + continue + } + + expanded, err := util.ExpandVariables(pattern) + if err != nil { + log.Warnf("Failed to expand variables in deny pattern '%s': %v", pattern, err) + continue + } + + denyArgs, err := t.processDenyRule(expanded) + if err != nil { + log.Debugf("Deny rule '%s' skipped: %v", expanded, err) + continue + } + + args = append(args, denyArgs...) + } + + // 4. Tmpfs-hide credential directories. Tmpfs blocks both directions, so + // only paths denied on both sides qualify. + tmpfsCandidates := intersectStrings(mandatoryResult.DenyRead, mandatoryResult.DenyWrite) hiddenDirs := make(map[string]bool) - for _, pattern := range mandatoryDenies { + for _, pattern := range tmpfsCandidates { expanded, err := util.ExpandVariables(pattern) if err != nil { continue @@ -709,3 +771,30 @@ func (t *bubblewrapPolicyTranslator) addTmpdirSupport() []string { return args } + +func expandAll(patterns []string) ([]string, error) { + out := make([]string, 0, len(patterns)) + for _, p := range patterns { + expanded, err := util.ExpandVariables(p) + if err != nil { + return nil, fmt.Errorf("failed to expand pattern %q: %w", p, err) + } + out = append(out, expanded) + } + return out, nil +} + +// intersectStrings returns the order-preserving intersection of a and b. +func intersectStrings(a, b []string) []string { + bset := make(map[string]bool, len(b)) + for _, x := range b { + bset[x] = true + } + out := []string{} + for _, x := range a { + if bset[x] { + out = append(out, x) + } + } + return out +} diff --git a/sandbox/platform/bubblewrap_translator_linux_test.go b/sandbox/platform/bubblewrap_translator_linux_test.go index dca942b..bf470d9 100644 --- a/sandbox/platform/bubblewrap_translator_linux_test.go +++ b/sandbox/platform/bubblewrap_translator_linux_test.go @@ -11,6 +11,7 @@ import ( "github.com/safedep/dry/utils" "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/sandbox/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -915,3 +916,128 @@ func argSliceToString(args []string) string { return result } + +func TestBubblewrapMandatoryDenySuppression(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + t.Run("read-side opt-out preserves real ro-bind and skips tmpfs and /dev/null", func(t *testing.T) { + // Real .env in an isolated CWD so processDenyRule does not skip the + // path as non-existent. + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + require.NoError(t, os.WriteFile(envPath, []byte("X=1\n"), 0o600)) + + origCwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { + _ = os.Chdir(origCwd) + }) + + policy := &sandbox.SandboxPolicy{ + Name: "test", + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{envPath}, + }, + } + args := translateForTest(t, policy) + + assertNoTmpfsAt(t, args, envPath) + // /dev/null overlay would mask reads; allow_read --ro-bind already + // denies writes via EROFS, so the mandatory write deny is redundant. + assertNoDevNullMount(t, args, envPath) + assertReadBind(t, args, envPath) + }) + + t.Run("user deny_write still wins for paths also in allow_read", func(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + require.NoError(t, os.WriteFile(envPath, []byte("X=1\n"), 0o600)) + + origCwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { + _ = os.Chdir(origCwd) + }) + + policy := &sandbox.SandboxPolicy{ + Name: "test", + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{envPath}, + DenyWrite: []string{envPath}, + }, + } + args := translateForTest(t, policy) + + assertDevNullMount(t, args, envPath) + }) + + t.Run("write-side opt-out skips both tmpfs and /dev/null for that path", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Filesystem: sandbox.FilesystemPolicy{ + AllowWrite: []string{filepath.Join(cwd, ".env")}, + }, + } + args := translateForTest(t, policy) + + assertNoTmpfsAt(t, args, filepath.Join(cwd, ".env")) + assertNoDevNullMount(t, args, filepath.Join(cwd, ".env")) + }) + + t.Run("no opt-out: tmpfs fires for the path", func(t *testing.T) { + // tmpfs only fires for paths that exist on the host; assert at the + // GetMandatoryDenyPatterns level instead of the translator output. + r := util.GetMandatoryDenyPatterns(util.MandatoryDenyOptions{}) + assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".env")) + assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".env")) + }) +} + +func translateForTest(t *testing.T, policy *sandbox.SandboxPolicy) []string { + t.Helper() + tr := newBubblewrapPolicyTranslator(newDefaultBubblewrapConfig()) + args, err := tr.translate(policy) + require.NoError(t, err) + return args +} + +func assertNoTmpfsAt(t *testing.T, args []string, path string) { + t.Helper() + for i := 0; i+1 < len(args); i++ { + if args[i] == "--tmpfs" && args[i+1] == path { + t.Fatalf("expected no --tmpfs at %q, but found one", path) + } + } +} + +func assertDevNullMount(t *testing.T, args []string, path string) { + t.Helper() + for i := 0; i+2 < len(args); i++ { + if (args[i] == "--ro-bind" || args[i] == "--bind") && args[i+1] == "/dev/null" && args[i+2] == path { + return + } + } + t.Fatalf("expected /dev/null mount at %q, not found in args: %v", path, args) +} + +func assertNoDevNullMount(t *testing.T, args []string, path string) { + t.Helper() + for i := 0; i+2 < len(args); i++ { + if (args[i] == "--ro-bind" || args[i] == "--bind") && args[i+1] == "/dev/null" && args[i+2] == path { + t.Fatalf("expected no /dev/null mount at %q, but found one", path) + } + } +} + +func assertReadBind(t *testing.T, args []string, path string) { + t.Helper() + for i := 0; i+2 < len(args); i++ { + if (args[i] == "--ro-bind" || args[i] == "--ro-bind-try") && args[i+1] == path && args[i+2] == path { + return + } + } + t.Fatalf("expected --ro-bind %q %q, not found in args: %v", path, path, args) +} diff --git a/sandbox/platform/seatbelt_translator_darwin.go b/sandbox/platform/seatbelt_translator_darwin.go index 272fbe0..fe362d4 100644 --- a/sandbox/platform/seatbelt_translator_darwin.go +++ b/sandbox/platform/seatbelt_translator_darwin.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/safedep/dry/log" "github.com/safedep/dry/utils" "github.com/safedep/pmg/sandbox" "github.com/safedep/pmg/sandbox/util" @@ -482,28 +483,60 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo sb.WriteString("\n") if t.enableDangerousFileBlocking { - // Add mandatory deny patterns for security (credentials, git hooks, etc.) sb.WriteString(";; Mandatory security denies (credentials, git hooks, etc.)\n") - mandatoryDenies := util.GetMandatoryDenyPatterns(utils.SafelyGetValue(policy.AllowGitConfig)) - for _, pattern := range mandatoryDenies { - // Expand variables if needed + expandedAllowRead, err := expandAll(policy.Filesystem.AllowRead) + if err != nil { + log.Warnf("sandbox: failed to expand allow_read for mandatory deny suppression, all mandatory denies preserved: %v", err) + expandedAllowRead = nil + } + expandedAllowWrite, err := expandAll(policy.Filesystem.AllowWrite) + if err != nil { + log.Warnf("sandbox: failed to expand allow_write for mandatory deny suppression, all mandatory denies preserved: %v", err) + expandedAllowWrite = nil + } + + mandatoryResult := util.GetMandatoryDenyPatterns(util.MandatoryDenyOptions{ + AllowGitConfig: utils.SafelyGetValue(policy.AllowGitConfig), + AllowRead: expandedAllowRead, + AllowWrite: expandedAllowWrite, + }) + + for _, p := range mandatoryResult.SuppressedRead { + log.Warnf("sandbox: mandatory deny %q suppressed for read by explicit allow rule in policy %q", p, policy.Name) + } + for _, p := range mandatoryResult.SuppressedWrite { + log.Warnf("sandbox: mandatory deny %q suppressed for write by explicit allow rule in policy %q", p, policy.Name) + } + + for _, pattern := range mandatoryResult.DenyWrite { expanded, err := util.ExpandVariables(pattern) if err != nil { return fmt.Errorf("failed to expand mandatory deny pattern %s: %w", pattern, err) } - // Use regex matching for glob patterns, subpath for literals if util.ContainsGlob(expanded) { regexPattern := util.GlobToRegex(expanded) sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag)) - sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag)) } else { sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag)) - sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag)) } expandedDenyWrite = append(expandedDenyWrite, expanded) } + for _, pattern := range mandatoryResult.DenyRead { + expanded, err := util.ExpandVariables(pattern) + if err != nil { + return fmt.Errorf("failed to expand mandatory deny pattern %s: %w", pattern, err) + } + + if util.ContainsGlob(expanded) { + regexPattern := util.GlobToRegex(expanded) + sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag)) + } else { + sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag)) + } + } + sb.WriteString("\n") } @@ -616,3 +649,15 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic return nil } + +func expandAll(patterns []string) ([]string, error) { + out := make([]string, 0, len(patterns)) + for _, p := range patterns { + expanded, err := util.ExpandVariables(p) + if err != nil { + return nil, fmt.Errorf("failed to expand pattern %q: %w", p, err) + } + out = append(out, expanded) + } + return out, nil +} diff --git a/sandbox/platform/seatbelt_translator_darwin_test.go b/sandbox/platform/seatbelt_translator_darwin_test.go index 637872b..3f532da 100644 --- a/sandbox/platform/seatbelt_translator_darwin_test.go +++ b/sandbox/platform/seatbelt_translator_darwin_test.go @@ -6,11 +6,14 @@ package platform import ( "fmt" "os" + "path/filepath" + "strings" "testing" "github.com/safedep/dry/utils" "github.com/safedep/pmg/sandbox" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSeatbeltTranslatorDarwinCommonTranslation(t *testing.T) { @@ -657,3 +660,59 @@ func TestSeatbeltTranslatorDarwinLogTag(t *testing.T) { translator = &seatbeltPolicyTranslator{logTag: "test"} assert.Equal(t, "test", translator.LogTag()) } + +func TestSeatbeltMandatoryDenySuppression(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + t.Run("allow_read with literal CWD .env removes file-read* deny but keeps file-write* deny", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{filepath.Join(cwd, ".env")}, + }, + } + out := translateFilesystemForTest(t, policy) + + assert.NotContains(t, out, fmt.Sprintf(`(deny file-read* (subpath "%s")`, filepath.Join(cwd, ".env"))) + assert.Contains(t, out, fmt.Sprintf(`(deny file-write* (subpath "%s")`, filepath.Join(cwd, ".env"))) + }) + + t.Run("allow_write with literal CWD .env removes file-write* deny but keeps file-read* deny", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Filesystem: sandbox.FilesystemPolicy{ + AllowWrite: []string{filepath.Join(cwd, ".env")}, + }, + } + out := translateFilesystemForTest(t, policy) + + assert.NotContains(t, out, fmt.Sprintf(`(deny file-write* (subpath "%s")`, filepath.Join(cwd, ".env"))) + assert.Contains(t, out, fmt.Sprintf(`(deny file-read* (subpath "%s")`, filepath.Join(cwd, ".env"))) + }) + + t.Run("broad glob does NOT remove mandatory denies", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{filepath.Join(cwd, "**")}, + }, + } + out := translateFilesystemForTest(t, policy) + + home, err := os.UserHomeDir() + require.NoError(t, err) + + assert.Contains(t, out, fmt.Sprintf(`(deny file-read* (subpath "%s")`, filepath.Join(cwd, ".env"))) + assert.Contains(t, out, fmt.Sprintf(`(deny file-read* (subpath "%s")`, filepath.Join(home, ".env"))) + assert.Contains(t, out, fmt.Sprintf(`(deny file-read* (subpath "%s")`, filepath.Join(cwd, ".ssh"))) + }) +} + +func translateFilesystemForTest(t *testing.T, policy *sandbox.SandboxPolicy) string { + t.Helper() + tr := newSeatbeltPolicyTranslator() + var sb strings.Builder + require.NoError(t, tr.translateFilesystem(policy, &sb)) + return sb.String() +} diff --git a/sandbox/util/dangerous.go b/sandbox/util/dangerous.go index ab769bf..5d967ca 100644 --- a/sandbox/util/dangerous.go +++ b/sandbox/util/dangerous.go @@ -5,77 +5,148 @@ import ( "path/filepath" ) -// DANGEROUS_FILES are files that should always be blocked from write access -// to prevent credential theft and security compromise. +// DANGEROUS_FILES are credential and config files blocked by default. +// Users opt out via allow_read / allow_write (see GetMandatoryDenyPatterns). var DANGEROUS_FILES = []string{ ".env", ".env.*", ".aws", + ".azure", ".gcloud", + ".config/gcloud", ".kube", ".ssh", ".gnupg", ".docker/config.json", + ".netrc", + ".git-credentials", + ".pgpass", + ".config/gh", } -// GetMandatoryDenyPatterns returns filesystem paths that should always be blocked -// from write access for security reasons. These are automatically injected into -// all sandbox policies regardless of user configuration. +// MandatoryDenyOptions configures GetMandatoryDenyPatterns. AllowRead and +// AllowWrite must be already expanded (post-ExpandVariables); the function +// does not call ExpandVariables itself. +type MandatoryDenyOptions struct { + AllowGitConfig bool + AllowRead []string + AllowWrite []string +} + +// MandatoryDenyResult splits mandatory denies by direction and reports the +// patterns the user opted out of (for audit logging by translators). +type MandatoryDenyResult struct { + DenyRead []string + DenyWrite []string + SuppressedRead []string + SuppressedWrite []string +} + +// GetMandatoryDenyPatterns returns mandatory deny patterns for both directions, +// suppressing any pattern the user has explicitly named in the corresponding +// allow list. Suppression is exact post-expansion byte-equal match — broad +// globs in user allow lists do not suppress. // -// Parameters: -// - allowGitConfig: if false, blocks write access to .git/config (recommended) -// -// Returns patterns in both absolute (from HOME) and glob forms for comprehensive coverage. -func GetMandatoryDenyPatterns(allowGitConfig bool) []string { - patterns := []string{} +// .git/hooks is never suppressed (arbitrary code execution risk). +// .git/config is emitted only when !AllowGitConfig and may be suppressed. +func GetMandatoryDenyPatterns(opts MandatoryDenyOptions) MandatoryDenyResult { + allowReadSet := toSet(opts.AllowRead) + allowWriteSet := toSet(opts.AllowWrite) - // Get current working directory for CWD-relative patterns cwd, err := os.Getwd() if err != nil { - // Fallback to basic patterns if we can't get CWD cwd = "." } - // Get home directory for HOME-relative patterns home, err := os.UserHomeDir() if err != nil { - // If we can't get home, skip home-based patterns home = "" } - // Add dangerous files from CWD + // Naming an absolute form (CWD or HOME) of a dangerous file also suppresses + // the corresponding "**/" glob on the same direction — otherwise the + // glob deny would still block the user's explicit opt-out. The unnamed + // absolute form remains mandatory. + absToDangerous := make(map[string]string) for _, fileName := range DANGEROUS_FILES { - // Absolute path in CWD - patterns = append(patterns, filepath.Join(cwd, fileName)) - // Glob pattern to catch in subdirectories - patterns = append(patterns, filepath.Join("**", fileName)) - } - - // Add dangerous files from HOME (if available) - if home != "" { - for _, fileName := range DANGEROUS_FILES { - patterns = append(patterns, filepath.Join(home, fileName)) - } - } - - // Git hooks are blocked in CWD and HOME for security (can execute arbitrary code) - // We don't use global globs like **/.git/hooks to allow legitimate temp dir operations - // (e.g., npx cloning repos to /tmp) - patterns = append(patterns, filepath.Join(cwd, ".git/hooks")) - patterns = append(patterns, filepath.Join(cwd, ".git/hooks/**")) - - if home != "" { - patterns = append(patterns, filepath.Join(home, ".git/hooks")) - patterns = append(patterns, filepath.Join(home, ".git/hooks/**")) - } - - // Git config is conditionally blocked in CWD and HOME - if !allowGitConfig { - patterns = append(patterns, filepath.Join(cwd, ".git/config")) + absToDangerous[filepath.Clean(filepath.Join(cwd, fileName))] = fileName if home != "" { - patterns = append(patterns, filepath.Join(home, ".git/config")) + absToDangerous[filepath.Clean(filepath.Join(home, fileName))] = fileName } } - return patterns + readGlobAlsoSuppressed := make(map[string]bool) + for entry := range allowReadSet { + if fileName, ok := absToDangerous[entry]; ok { + readGlobAlsoSuppressed[filepath.Clean(filepath.Join("**", fileName))] = true + } + } + writeGlobAlsoSuppressed := make(map[string]bool) + for entry := range allowWriteSet { + if fileName, ok := absToDangerous[entry]; ok { + writeGlobAlsoSuppressed[filepath.Clean(filepath.Join("**", fileName))] = true + } + } + + suppressible := []string{} + + for _, fileName := range DANGEROUS_FILES { + suppressible = append(suppressible, filepath.Join(cwd, fileName)) + suppressible = append(suppressible, filepath.Join("**", fileName)) + if home != "" { + suppressible = append(suppressible, filepath.Join(home, fileName)) + } + } + + if !opts.AllowGitConfig { + suppressible = append(suppressible, filepath.Join(cwd, ".git/config")) + if home != "" { + suppressible = append(suppressible, filepath.Join(home, ".git/config")) + } + } + + result := MandatoryDenyResult{} + + for _, pattern := range suppressible { + cleaned := filepath.Clean(pattern) + + if allowReadSet[cleaned] || readGlobAlsoSuppressed[cleaned] { + result.SuppressedRead = append(result.SuppressedRead, cleaned) + } else { + result.DenyRead = append(result.DenyRead, cleaned) + } + + if allowWriteSet[cleaned] || writeGlobAlsoSuppressed[cleaned] { + result.SuppressedWrite = append(result.SuppressedWrite, cleaned) + } else { + result.DenyWrite = append(result.DenyWrite, cleaned) + } + } + + // Git hooks can execute arbitrary code; never suppressible. + gitHooks := []string{ + filepath.Join(cwd, ".git/hooks"), + filepath.Join(cwd, ".git/hooks/**"), + } + if home != "" { + gitHooks = append(gitHooks, + filepath.Join(home, ".git/hooks"), + filepath.Join(home, ".git/hooks/**"), + ) + } + for _, p := range gitHooks { + cleaned := filepath.Clean(p) + result.DenyRead = append(result.DenyRead, cleaned) + result.DenyWrite = append(result.DenyWrite, cleaned) + } + + return result +} + +func toSet(s []string) map[string]bool { + m := make(map[string]bool, len(s)) + for _, v := range s { + m[filepath.Clean(v)] = true + } + return m } diff --git a/sandbox/util/dangerous_test.go b/sandbox/util/dangerous_test.go index 3366ce7..a11dad0 100644 --- a/sandbox/util/dangerous_test.go +++ b/sandbox/util/dangerous_test.go @@ -6,101 +6,244 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestGetMandatoryDenyPatterns(t *testing.T) { - t.Run("always blocks dangerous files", func(t *testing.T) { - patterns := GetMandatoryDenyPatterns(false) +func emptyOpts() MandatoryDenyOptions { + return MandatoryDenyOptions{AllowGitConfig: false} +} - // Should contain patterns for each dangerous file - assert.Contains(t, patterns, "**/.env") - assert.Contains(t, patterns, "**/.ssh") - assert.Contains(t, patterns, "**/.aws") - assert.Contains(t, patterns, "**/.gcloud") - assert.Contains(t, patterns, "**/.kube") - assert.Contains(t, patterns, "**/.gnupg") - assert.Contains(t, patterns, "**/.docker/config.json") +func TestGetMandatoryDenyPatterns_NoAllowList(t *testing.T) { + t.Run("blocks dangerous file globs on both sides", func(t *testing.T) { + r := GetMandatoryDenyPatterns(emptyOpts()) + + for _, p := range []string{ + "**/.env", "**/.env.*", "**/.ssh", "**/.aws", "**/.azure", + "**/.gcloud", "**/.config/gcloud", "**/.kube", "**/.gnupg", + "**/.docker/config.json", "**/.netrc", "**/.git-credentials", + "**/.pgpass", "**/.config/gh", + } { + assert.Contains(t, r.DenyRead, p, "DenyRead missing %s", p) + assert.Contains(t, r.DenyWrite, p, "DenyWrite missing %s", p) + } + + assert.Empty(t, r.SuppressedRead) + assert.Empty(t, r.SuppressedWrite) }) - t.Run("always blocks git hooks in CWD and HOME", func(t *testing.T) { + t.Run("blocks git hooks unconditionally on both sides", func(t *testing.T) { cwd, err := os.Getwd() - assert.NoError(t, err) - + require.NoError(t, err) home, err := os.UserHomeDir() - assert.NoError(t, err) + require.NoError(t, err) - patterns := GetMandatoryDenyPatterns(false) + r := GetMandatoryDenyPatterns(emptyOpts()) - // Should block git hooks in CWD - assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks")) - assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks/**")) - - // Should block git hooks in HOME - assert.Contains(t, patterns, filepath.Join(home, ".git/hooks")) - assert.Contains(t, patterns, filepath.Join(home, ".git/hooks/**")) - }) - - t.Run("blocks git config when allowGitConfig is false", func(t *testing.T) { - cwd, err := os.Getwd() - assert.NoError(t, err) - - home, err := os.UserHomeDir() - assert.NoError(t, err) - - patterns := GetMandatoryDenyPatterns(false) - - // Should block git config in CWD and HOME - assert.Contains(t, patterns, filepath.Join(cwd, ".git/config")) - assert.Contains(t, patterns, filepath.Join(home, ".git/config")) - }) - - t.Run("allows git config when allowGitConfig is true", func(t *testing.T) { - patterns := GetMandatoryDenyPatterns(true) - - // Should NOT block git config - for _, pattern := range patterns { - assert.NotContains(t, pattern, ".git/config") + for _, p := range []string{ + filepath.Join(cwd, ".git/hooks"), + filepath.Join(cwd, ".git/hooks/**"), + filepath.Join(home, ".git/hooks"), + filepath.Join(home, ".git/hooks/**"), + } { + assert.Contains(t, r.DenyRead, p) + assert.Contains(t, r.DenyWrite, p) } }) - t.Run("includes CWD-relative patterns", func(t *testing.T) { + t.Run("blocks git config when AllowGitConfig is false", func(t *testing.T) { cwd, err := os.Getwd() - assert.NoError(t, err) - - patterns := GetMandatoryDenyPatterns(false) - - // Should include absolute paths in CWD - assert.Contains(t, patterns, filepath.Join(cwd, ".env")) - assert.Contains(t, patterns, filepath.Join(cwd, ".ssh")) - assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks")) - }) - - t.Run("includes HOME-relative patterns", func(t *testing.T) { + require.NoError(t, err) home, err := os.UserHomeDir() - assert.NoError(t, err) + require.NoError(t, err) - patterns := GetMandatoryDenyPatterns(false) + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{AllowGitConfig: false}) - // Should include absolute paths in HOME - assert.Contains(t, patterns, filepath.Join(home, ".env")) - assert.Contains(t, patterns, filepath.Join(home, ".ssh")) - assert.Contains(t, patterns, filepath.Join(home, ".aws")) + assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".git/config")) + assert.Contains(t, r.DenyWrite, filepath.Join(home, ".git/config")) + assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".git/config")) + assert.Contains(t, r.DenyRead, filepath.Join(home, ".git/config")) }) - t.Run("includes glob patterns for env variants", func(t *testing.T) { - patterns := GetMandatoryDenyPatterns(false) + t.Run("omits git config when AllowGitConfig is true", func(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + home, err := os.UserHomeDir() + require.NoError(t, err) - // Should include pattern for .env.* files - assert.Contains(t, patterns, "**/.env.*") + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{AllowGitConfig: true}) + + cwdGitConfig := filepath.Join(cwd, ".git/config") + homeGitConfig := filepath.Join(home, ".git/config") + + assert.NotContains(t, r.DenyRead, cwdGitConfig) + assert.NotContains(t, r.DenyRead, homeGitConfig) + assert.NotContains(t, r.DenyWrite, cwdGitConfig) + assert.NotContains(t, r.DenyWrite, homeGitConfig) + }) + + t.Run("includes CWD-absolute and HOME-absolute forms", func(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + home, err := os.UserHomeDir() + require.NoError(t, err) + + r := GetMandatoryDenyPatterns(emptyOpts()) + + assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".env")) + assert.Contains(t, r.DenyRead, filepath.Join(home, ".env")) + assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".aws")) + assert.Contains(t, r.DenyWrite, filepath.Join(home, ".aws")) }) t.Run("does not use global globs for git operations", func(t *testing.T) { - patterns := GetMandatoryDenyPatterns(false) + r := GetMandatoryDenyPatterns(emptyOpts()) - // Should NOT contain global globs for git hooks/config - // This allows legitimate git operations in temp directories (e.g., npx cloning repos) - assert.NotContains(t, patterns, "**/.git/hooks") - assert.NotContains(t, patterns, "**/.git/hooks/**") - assert.NotContains(t, patterns, "**/.git/config") + for _, side := range [][]string{r.DenyRead, r.DenyWrite} { + assert.NotContains(t, side, "**/.git/hooks") + assert.NotContains(t, side, "**/.git/hooks/**") + assert.NotContains(t, side, "**/.git/config") + } + }) +} + +func TestGetMandatoryDenyPatterns_Suppression(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + home, err := os.UserHomeDir() + require.NoError(t, err) + + cwdEnv := filepath.Join(cwd, ".env") + homeEnv := filepath.Join(home, ".env") + globEnv := filepath.Join("**", ".env") + homeAws := filepath.Join(home, ".aws") + cwdGitConfig := filepath.Join(cwd, ".git/config") + + t.Run("CWD-absolute form suppresses CWD form and glob form on same direction", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: []string{cwdEnv}, + }) + + assert.NotContains(t, r.DenyRead, cwdEnv) + assert.Contains(t, r.SuppressedRead, cwdEnv) + + assert.NotContains(t, r.DenyRead, globEnv) + assert.Contains(t, r.SuppressedRead, globEnv) + + assert.Contains(t, r.DenyRead, homeEnv) + + assert.Contains(t, r.DenyWrite, cwdEnv) + assert.Contains(t, r.DenyWrite, globEnv) + assert.Contains(t, r.DenyWrite, homeEnv) + assert.Empty(t, r.SuppressedWrite) + }) + + t.Run("HOME-absolute form suppresses HOME form and glob form on same direction", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: []string{homeAws}, + }) + + homeAwsGlob := filepath.Join("**", ".aws") + cwdAws := filepath.Join(cwd, ".aws") + + assert.NotContains(t, r.DenyRead, homeAws) + assert.Contains(t, r.SuppressedRead, homeAws) + + assert.NotContains(t, r.DenyRead, homeAwsGlob) + assert.Contains(t, r.SuppressedRead, homeAwsGlob) + + assert.Contains(t, r.DenyRead, cwdAws) + }) + + t.Run("glob form suppressed only when listed", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: []string{globEnv}, + }) + + assert.NotContains(t, r.DenyRead, globEnv) + assert.Contains(t, r.SuppressedRead, globEnv) + + assert.Contains(t, r.DenyRead, cwdEnv) + assert.Contains(t, r.DenyRead, homeEnv) + }) + + t.Run("read-side suppression does not affect write side", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: []string{cwdEnv}, + AllowWrite: []string{}, + }) + + assert.NotContains(t, r.DenyRead, cwdEnv) + assert.Contains(t, r.DenyWrite, cwdEnv) + }) + + t.Run("write-side suppression does not affect read side", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowWrite: []string{cwdEnv}, + }) + + assert.NotContains(t, r.DenyWrite, cwdEnv) + assert.Contains(t, r.DenyRead, cwdEnv) + }) + + t.Run("broad glob does NOT suppress", func(t *testing.T) { + broad := filepath.Join(cwd, "**") + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: []string{broad}, + }) + + assert.Empty(t, r.SuppressedRead) + assert.Contains(t, r.DenyRead, cwdEnv) + }) + + t.Run("relative path in allow list does NOT suppress absolute form", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: []string{".env"}, // post-Clean stays as ".env" + }) + + assert.Contains(t, r.DenyRead, cwdEnv) + assert.Empty(t, r.SuppressedRead) + }) + + t.Run("git config CWD form suppressible via allow_write", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowGitConfig: false, + AllowWrite: []string{cwdGitConfig}, + }) + + assert.NotContains(t, r.DenyWrite, cwdGitConfig) + assert.Contains(t, r.SuppressedWrite, cwdGitConfig) + }) + + t.Run("git hooks NEVER suppressed", func(t *testing.T) { + cwdHooks := filepath.Join(cwd, ".git/hooks") + cwdHooksGlob := filepath.Join(cwd, ".git/hooks/**") + homeHooks := filepath.Join(home, ".git/hooks") + homeHooksGlob := filepath.Join(home, ".git/hooks/**") + + hookPaths := []string{cwdHooks, cwdHooksGlob, homeHooks, homeHooksGlob} + + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: hookPaths, + AllowWrite: hookPaths, + }) + + for _, p := range hookPaths { + assert.Contains(t, r.DenyRead, p) + assert.Contains(t, r.DenyWrite, p) + assert.NotContains(t, r.SuppressedRead, p) + assert.NotContains(t, r.SuppressedWrite, p) + } + }) + + t.Run("multiple suppressions accumulate", func(t *testing.T) { + r := GetMandatoryDenyPatterns(MandatoryDenyOptions{ + AllowRead: []string{cwdEnv, globEnv}, + AllowWrite: []string{cwdEnv}, + }) + + // AllowWrite lists only cwdEnv; the absolute form auto-suppresses the + // **/.env glob on that side too. + assert.ElementsMatch(t, []string{cwdEnv, globEnv}, r.SuppressedRead) + assert.ElementsMatch(t, []string{cwdEnv, globEnv}, r.SuppressedWrite) }) } diff --git a/test/sandbox-e2e.js b/test/sandbox-e2e.js index 37f34dc..c2dd60b 100644 --- a/test/sandbox-e2e.js +++ b/test/sandbox-e2e.js @@ -218,6 +218,44 @@ test('BLOCK: .git/hooks in CWD is protected', () => { } }); +// $CWD/.env should be protected from reads. +// Two valid sandbox strategies: +// macOS/seatbelt: read is denied outright (EPERM/EACCES) +// Linux/bwrap: tmpfs masks the file with an empty placeholder +// Both prevent the sandboxed process from exfiltrating real secrets. +test('BLOCK: Read $CWD/.env', () => { + const envPath = path.join(process.cwd(), '.env'); + + if (!fs.existsSync(envPath)) { + console.log(' ⚠️ SKIP: $CWD/.env does not exist'); + return true; + } + + let contents; + try { + contents = fs.readFileSync(envPath, 'utf8'); + } catch (e) { + if (e.code === 'EPERM' || e.code === 'EACCES') { + console.log(' ✅ PASS: $CWD/.env read blocked (EPERM)'); + return true; + } + if (e.code === 'ENOENT') { + console.log(' ✅ PASS: $CWD/.env hidden by tmpfs (ENOENT)'); + return true; + } + console.log(` ✅ PASS: $CWD/.env read blocked (${e.code})`); + return true; + } + + if (contents.length === 0) { + console.log(' ✅ PASS: $CWD/.env masked by tmpfs (empty placeholder)'); + return true; + } + + console.log(' ❌ FAIL: Could read $CWD/.env contents'); + return false; +}); + // ============================================ // TESTS THAT SHOULD BE ALLOWED // ============================================