feat/sandbox allow explicit dangerous pattern override (#239)

* feat(sandbox): allow opt-out of mandatory deny via explicit allow rules

Mandatory deny patterns (.env, .aws, .ssh, .gcloud, .kube, .gnupg,
.docker/config.json, .git/config) can now be opted out by listing the
exact literal post-expansion path in policy filesystem.allow_read /
allow_write, OR via --sandbox-allow read=... / write=... at runtime.
Both channels are treated at par.

Suppression is exact-match. Listing the CWD-absolute or HOME-absolute
form of a dangerous file additionally suppresses its **/<file> glob
sibling on the same direction so a single opt-out is sufficient.
Broad globs (${CWD}/**) and relative paths in user allow lists do not
suppress. The unnamed absolute form remains denied. .git/hooks is
unconditional and never suppressible (arbitrary code execution risk).

GetMandatoryDenyPatterns now returns split DenyRead / DenyWrite
slices and reports SuppressedRead / SuppressedWrite for audit. Both
translators emit per-direction deny rules and log.Warnf each
suppression. On Linux/bubblewrap, the tmpfs hide is restricted to the
intersection of DenyRead and DenyWrite; one-sided suppression falls
back to /dev/null (write) or the user's allow_read --ro-bind (read).
bwrap has no primitive that allows writes while denying reads, so
write-only opt-outs warn that the read-side mandatory deny is
unenforceable.

Updates docs/sandbox.md to document the opt-out, exact-match
semantics, and the Linux platform limitation. Updates pmg-e2e.yml to
create ./.env so the sandbox e2e test exercises the BLOCK case.

Closes #232

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Code review fixes

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhisek Datta
2026-05-06 12:45:36 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent b56a8e2a43
commit d6755d3f44
12 changed files with 813 additions and 153 deletions
+2
View File
@@ -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: |
+10 -2
View File
@@ -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_<KEY>` 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
4. Built-in defaults
+67 -10
View File
@@ -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`).
<details>
<summary>Detailed rules</summary>
- **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.
</details>
### 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 `**/<file>` 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 `**/<file>` 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.
<details>
<summary>Custom policy overrides using Policy Templates</summary>
@@ -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.
</details>
<details>
@@ -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.
<details>
<summary>Detailed concepts</summary>
### 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.
</details>
## 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.
<details>
<summary>Detailed assumptions</summary>
- 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
</details>
## Enforcement
The sandbox implementation currently only support `block` mode. This means, any policy violation will block the execution of the
+1 -1
View File
@@ -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
+28 -6
View File
@@ -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=
@@ -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
}
@@ -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)
}
+52 -7
View File
@@ -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
}
@@ -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()
}
+116 -45
View File
@@ -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 "**/<file>" 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
}
+216 -73
View File
@@ -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)
})
}
+38
View File
@@ -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
// ============================================