-
Notifications
You must be signed in to change notification settings - Fork 123
feat(sandbox): Windows sandbox principals (foundation for #662, does not close it) #808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2133c7b
29df087
93c4974
7b75a15
4d91a0e
57421b2
655c872
da835b5
a594bbb
12b0221
52f843a
99fefdc
dbdbda1
3d49e20
d67267f
a1a0edc
f4a2b9b
71ad0f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,174 @@ | ||||||||||||||||||||||||||||||||||||||||
| package sandbox | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // ACLs for a sandbox principal. | ||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||
| // The capability-SID model this sits beside starts from "the caller can already | ||||||||||||||||||||||||||||||||||||||||
| // read everything" and narrows writes, because the sandboxed child runs as the | ||||||||||||||||||||||||||||||||||||||||
| // caller. A principal inverts that: a separate local account has no access to | ||||||||||||||||||||||||||||||||||||||||
| // the caller's profile at all, so the interesting direction is what to GRANT. | ||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||
| // That inversion is the point. Credential stores under the user's profile are | ||||||||||||||||||||||||||||||||||||||||
| // unreachable because the principal is a different account, not because a deny | ||||||||||||||||||||||||||||||||||||||||
| // rule enumerated them, which is what makes this able to close #662 and #675 on | ||||||||||||||||||||||||||||||||||||||||
| // Windows where a deny-read ACE against the caller's own SID never could. Deny | ||||||||||||||||||||||||||||||||||||||||
| // rules stay useful only for objects that are readable by everyone. | ||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||
| // Grants are explicit and narrow: the workspace and any extra write roots get | ||||||||||||||||||||||||||||||||||||||||
| // read+write, declared read-only roots get read, and the protected metadata | ||||||||||||||||||||||||||||||||||||||||
| // carve-outs the profile already defines stay denied so .git internals and | ||||||||||||||||||||||||||||||||||||||||
| // .zero/.agents cannot be rewritten from inside the sandbox. | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // WindowsACLAllowRead grants read and execute without write. It exists for the | ||||||||||||||||||||||||||||||||||||||||
| // principal model, where a read root must be granted rather than assumed. | ||||||||||||||||||||||||||||||||||||||||
| const WindowsACLAllowRead WindowsACLAction = "allow-read" | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // windowsPrincipalACLInput is everything needed to describe a principal's | ||||||||||||||||||||||||||||||||||||||||
| // access. It is deliberately a plain struct rather than the full command config | ||||||||||||||||||||||||||||||||||||||||
| // so the plan can be built and tested without a live sandbox. | ||||||||||||||||||||||||||||||||||||||||
| type windowsPrincipalACLInput struct { | ||||||||||||||||||||||||||||||||||||||||
| // PrincipalSID is the string SID of the sandbox account every ACE names. | ||||||||||||||||||||||||||||||||||||||||
| PrincipalSID string | ||||||||||||||||||||||||||||||||||||||||
| // WriteRoots receive read+write+execute. The workspace lives here. | ||||||||||||||||||||||||||||||||||||||||
| WriteRoots []WritableRoot | ||||||||||||||||||||||||||||||||||||||||
| // ReadRoots receive read+execute only. | ||||||||||||||||||||||||||||||||||||||||
| ReadRoots []string | ||||||||||||||||||||||||||||||||||||||||
| // DenyRead covers objects a principal could otherwise reach because they are | ||||||||||||||||||||||||||||||||||||||||
| // world-readable; per-user secrets need no entry. | ||||||||||||||||||||||||||||||||||||||||
| DenyRead []string | ||||||||||||||||||||||||||||||||||||||||
| // DenyWrite carries the policy's own deny-write paths. The capability plan | ||||||||||||||||||||||||||||||||||||||||
| // has always emitted these; the principal plan denied write only on | ||||||||||||||||||||||||||||||||||||||||
| // protected metadata and read-only subpaths inside write roots, so a policy | ||||||||||||||||||||||||||||||||||||||||
| // deny sitting anywhere else was simply not enforced once the runner used a | ||||||||||||||||||||||||||||||||||||||||
| // principal token, and a shell child could write where the restricted-token | ||||||||||||||||||||||||||||||||||||||||
| // backend would have blocked it. | ||||||||||||||||||||||||||||||||||||||||
| DenyWrite []string | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // buildWindowsPrincipalACLPlan turns a principal's access into ACL entries. | ||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||
| // Ordering matters at apply time: deny entries are emitted before allows so a | ||||||||||||||||||||||||||||||||||||||||
| // carve-out inside a granted root survives, which mirrors how Windows evaluates | ||||||||||||||||||||||||||||||||||||||||
| // an explicit DACL (deny ACEs first). | ||||||||||||||||||||||||||||||||||||||||
| func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPlan, error) { | ||||||||||||||||||||||||||||||||||||||||
| if input.PrincipalSID == "" { | ||||||||||||||||||||||||||||||||||||||||
| return WindowsACLPlan{}, errors.New("windows principal ACL plan requires a principal SID") | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if len(input.WriteRoots) == 0 && len(input.ReadRoots) == 0 { | ||||||||||||||||||||||||||||||||||||||||
| return WindowsACLPlan{}, errors.New("windows principal ACL plan requires at least one root") | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| entries := make([]WindowsACLEntry, 0, len(input.WriteRoots)*2+len(input.ReadRoots)+len(input.DenyRead)) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Deny first. A deny ACE inside a write root (protected metadata, git | ||||||||||||||||||||||||||||||||||||||||
| // internals) has to win over the grant that follows it. | ||||||||||||||||||||||||||||||||||||||||
| // Materialized, matching the capability plan. applyWindowsACLPlan skips a | ||||||||||||||||||||||||||||||||||||||||
| // target that does not exist, so without this a deny-read path created after | ||||||||||||||||||||||||||||||||||||||||
| // setup ran never got an ACE at all and the principal could read it. The | ||||||||||||||||||||||||||||||||||||||||
| // deny has to be in place before the object is. | ||||||||||||||||||||||||||||||||||||||||
| for _, path := range normalizeProfilePaths(input.DenyRead) { | ||||||||||||||||||||||||||||||||||||||||
| entries = append(entries, WindowsACLEntry{ | ||||||||||||||||||||||||||||||||||||||||
| Action: WindowsACLDenyRead, | ||||||||||||||||||||||||||||||||||||||||
| Path: path, | ||||||||||||||||||||||||||||||||||||||||
| Capability: input.PrincipalSID, | ||||||||||||||||||||||||||||||||||||||||
| Materialize: true, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| for _, path := range normalizeProfilePaths(input.DenyWrite) { | ||||||||||||||||||||||||||||||||||||||||
| entries = append(entries, WindowsACLEntry{ | ||||||||||||||||||||||||||||||||||||||||
| Action: WindowsACLDenyWrite, | ||||||||||||||||||||||||||||||||||||||||
| Path: path, | ||||||||||||||||||||||||||||||||||||||||
| Capability: input.PrincipalSID, | ||||||||||||||||||||||||||||||||||||||||
| Materialize: true, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| for _, root := range input.WriteRoots { | ||||||||||||||||||||||||||||||||||||||||
| // Normalized the same way as read and deny paths: a write root may arrive | ||||||||||||||||||||||||||||||||||||||||
| // with "~" or as a relative path, and an ACE has to name the same absolute, | ||||||||||||||||||||||||||||||||||||||||
| // symlink-resolved object the deny entries do or the two disagree. | ||||||||||||||||||||||||||||||||||||||||
| cleaned := normalizeProfilePath(root.Root) | ||||||||||||||||||||||||||||||||||||||||
| if cleaned == "" { | ||||||||||||||||||||||||||||||||||||||||
| return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: unusable write root %q", root.Root) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| // Materialized, like the metadata and policy denies below and above. | ||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||
| // These are the git control-plane carveouts (.git/config, .git/hooks). On a | ||||||||||||||||||||||||||||||||||||||||
| // workspace where git has not run yet they do not exist at setup time, and | ||||||||||||||||||||||||||||||||||||||||
| // applyWindowsACLPlan skips a target that is absent, so the ACEs were never | ||||||||||||||||||||||||||||||||||||||||
| // written. Once git created those paths the principal still held inherited | ||||||||||||||||||||||||||||||||||||||||
| // write access to the workspace and could install a hook or rewrite | ||||||||||||||||||||||||||||||||||||||||
| // credential.helper. The capability plan gets away without this because its | ||||||||||||||||||||||||||||||||||||||||
| // child runs as the caller; a separate principal account does not. | ||||||||||||||||||||||||||||||||||||||||
| for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) { | ||||||||||||||||||||||||||||||||||||||||
| entries = append(entries, WindowsACLEntry{ | ||||||||||||||||||||||||||||||||||||||||
| Action: WindowsACLDenyWrite, | ||||||||||||||||||||||||||||||||||||||||
| Path: subpath, | ||||||||||||||||||||||||||||||||||||||||
| Capability: input.PrincipalSID, | ||||||||||||||||||||||||||||||||||||||||
| Materialize: true, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| for _, name := range root.ProtectedMetadataNames { | ||||||||||||||||||||||||||||||||||||||||
| entries = append(entries, WindowsACLEntry{ | ||||||||||||||||||||||||||||||||||||||||
| Action: WindowsACLDenyWrite, | ||||||||||||||||||||||||||||||||||||||||
| Path: filepath.Join(cleaned, name), | ||||||||||||||||||||||||||||||||||||||||
| Capability: input.PrincipalSID, | ||||||||||||||||||||||||||||||||||||||||
| Materialize: true, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+115
to
+121
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Reject protected metadata names that escape the write root.
Proposed fix for _, name := range root.ProtectedMetadataNames {
+ if name == "" || name == "." || name == ".." || filepath.Base(name) != name {
+ return WindowsACLPlan{}, fmt.Errorf(
+ "windows principal ACL plan: invalid protected metadata name %q", name,
+ )
+ }
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: filepath.Join(cleaned, name),📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Then the grants the principal cannot work without. | ||||||||||||||||||||||||||||||||||||||||
| for _, root := range input.WriteRoots { | ||||||||||||||||||||||||||||||||||||||||
| cleaned := normalizeProfilePath(root.Root) | ||||||||||||||||||||||||||||||||||||||||
| if cleaned == "" { | ||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| entries = append(entries, WindowsACLEntry{ | ||||||||||||||||||||||||||||||||||||||||
| Action: WindowsACLAllowWrite, | ||||||||||||||||||||||||||||||||||||||||
| Path: cleaned, | ||||||||||||||||||||||||||||||||||||||||
| Capability: input.PrincipalSID, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| for _, path := range normalizeProfilePaths(input.ReadRoots) { | ||||||||||||||||||||||||||||||||||||||||
| entries = append(entries, WindowsACLEntry{ | ||||||||||||||||||||||||||||||||||||||||
| Action: WindowsACLAllowRead, | ||||||||||||||||||||||||||||||||||||||||
| Path: path, | ||||||||||||||||||||||||||||||||||||||||
| Capability: input.PrincipalSID, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // windowsPrincipalRevokePlan returns the entries whose ACEs should be removed | ||||||||||||||||||||||||||||||||||||||||
| // when a principal is retired. Revocation is by TRUSTEE rather than by path: | ||||||||||||||||||||||||||||||||||||||||
| // every ACE naming this principal is dropped, so cleanup does not depend on | ||||||||||||||||||||||||||||||||||||||||
| // remembering which paths were granted, and a grant added by an older version | ||||||||||||||||||||||||||||||||||||||||
| // is still removed. | ||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||
| // This is the removal path the capability-SID model never had, where a synthetic | ||||||||||||||||||||||||||||||||||||||||
| // SID left ACEs behind on the user's tree with nothing to match them against. | ||||||||||||||||||||||||||||||||||||||||
| func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACLPlan, error) { | ||||||||||||||||||||||||||||||||||||||||
| if principalSID == "" { | ||||||||||||||||||||||||||||||||||||||||
| return WindowsACLPlan{}, errors.New("windows principal revoke plan requires a principal SID") | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| cleaned := normalizeProfilePaths(paths) | ||||||||||||||||||||||||||||||||||||||||
| entries := make([]WindowsACLEntry, 0, len(cleaned)) | ||||||||||||||||||||||||||||||||||||||||
| for _, path := range cleaned { | ||||||||||||||||||||||||||||||||||||||||
| entries = append(entries, WindowsACLEntry{ | ||||||||||||||||||||||||||||||||||||||||
| Action: windowsACLRevoke, | ||||||||||||||||||||||||||||||||||||||||
| Path: path, | ||||||||||||||||||||||||||||||||||||||||
| Capability: principalSID, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| return WindowsACLPlan{Entries: entries}, nil | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // windowsACLRevoke removes every ACE naming the trustee on a path, whatever | ||||||||||||||||||||||||||||||||||||||||
| // access it granted or denied. | ||||||||||||||||||||||||||||||||||||||||
| const windowsACLRevoke WindowsACLAction = "revoke" | ||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.