Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ Paths support git-style glob patterns on macOS, similar to `.gitignore` syntax:
- `*` - Matches any characters except `/` (e.g., `*.ts` matches `foo.ts` but not `foo/bar.ts`)
- `**` - Matches any characters including `/` (e.g., `src/**/*.ts` matches all `.ts` files in `src/`)
- `?` - Matches any single character except `/` (e.g., `file?.txt` matches `file1.txt`)
- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`)
- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`). Whatever stands between the brackets is a member, a `*` or a `?` too (`[*]` matches a `*` in a name). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`) or there is nothing in them (`list[].ts`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`)

Examples:

Expand All @@ -557,7 +557,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS:
- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one with a wildcard in its first path component, such as `/**/*.pem` or `/opt*/keys/**`, is skipped on Linux. **A skipped pattern hides nothing**: as a `denyRead`, or as the path of a `mode: "deny"` credential file, it leaves every file it matches readable. `SandboxManager.getLinuxGlobPatternWarnings()` returns each such entry as configured, after the write patterns above, for the embedder to show; the only other sign is a line under `SRT_DEBUG`. Only directories the pattern can match beneath are listed (`certs/*.pem` lists `certs` alone).
- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is an EMPTY WRITABLE directory, so a command that used to write through a read-denied `build/` still writes, into the tmpfs, and loses that output when the command exits. A file added to the directory on the host afterwards is hidden too. A matched directory that is empty when the command is wrapped gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable.
- A directory the expansion cannot list is denied as a whole, and nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. So is an entry that may be a directory for all the expansion can learn: one the file system lists without a type and that cannot be looked at either. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say), or that leads to `/`, hides the nearest directory above it instead, in the same way.
- Symlinked directories are descended. A directory is listed once for each way the pattern can carry on beneath it, however many links lead to it, so the cost of the expansion follows the size of the tree and the length of the pattern, not the number or length of the names its links offer; what is found through a link is reported, and denied, where it really is. A link that leads back up the tree (to the directory holding it or above, or to the pattern's starting directory or above) is not descended. A `**` written against other text (`**.pem`, `a**/x`) and a bracket expression that can match `/` span directories as they do on macOS, through symlinked directories too. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to, but no symlinked directory is descended for it, so what it matches only by a name that goes through one is not denied. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it; a link back up the tree is not descended, and denies what it reaches only when it is itself a match, which denies all of it, as a literal deny of the link would. A link that resolves to nothing is skipped. A link whose target cannot be looked at (a directory above the target is not searchable, say) is not followed: when the pattern would continue through it rather than match it, nothing is denied for it and what lies behind it is not masked. A link of that kind that is itself a match is still denied, under its own spelling. `allowRead` globs are not expanded through symlinks: they match the link itself.
- Symlinked directories are descended. A directory is listed once for each way the pattern can carry on beneath it, however many links lead to it, so the cost of the expansion follows the size of the tree and the length of the pattern, not the number or length of the names its links offer; what is found through a link is reported, and denied, where it really is. A link that leads back up the tree (to the directory holding it or above, or to the pattern's starting directory or above) is not descended. A `**` written against other text (`**.pem`, `a**/x`) and a bracket expression that can match `/` span directories as they do on macOS, through symlinked directories too. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it; a link back up the tree is not descended, and denies what it reaches only when it is itself a match, which denies all of it, as a literal deny of the link would. A link that resolves to nothing is skipped. A link whose target cannot be looked at (a directory above the target is not searchable, say) is not followed: when the pattern would continue through it rather than match it, nothing is denied for it and what lies behind it is not masked. A link of that kind that is itself a match is still denied, under its own spelling. `allowRead` globs are not expanded through symlinks: they match the link itself.
- An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox.
- `denyRead: ["/"]` denies each directory in `/` (`/proc`, `/dev` and `/sys` aside); a symlink there (`/bin`, `/lib` on a usr-merged system) gets no mount of its own, because what it leads to is denied together with the directory that holds it.

Expand Down
253 changes: 253 additions & 0 deletions src/sandbox/glob-pattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
/**
* The glob syntax of a filesystem entry. {@link parseGlob} alone reads it;
* what else needs it goes by the tokens.
*/

import { logForDebugging } from '../utils/debug.js'

export enum GlobTokenKind {
/** Characters that stand for themselves, none of them a separator. */
Text = 'text',
/** `/` */
Separator = 'separator',
/** `?`: one character of a name. */
AnyChar = 'anyChar',
/** `*`: any number of characters of a name. */
Star = 'star',
/** `**`: any number of characters, separators among them. */
Globstar = 'globstar',
/** `**` with the `/` after it: any number of directories. */
GlobstarSlash = 'globstarSlash',
/** `[abc]`, `[a-z]`: one character of a set. */
Set = 'set',
}

export type GlobToken =
| { kind: GlobTokenKind.Text; text: string }
| {
kind: GlobTokenKind.Set
/** What is written between the brackets. */
members: string
/** `[+-9]` does: a `/` lies between the two. */
matchesSeparator: boolean
}
| { kind: GlobTokenKind.Separator | GlobTokenKind.AnyChar | WildcardKind }

/** The kinds that match any number of characters. */
type WildcardKind =
| GlobTokenKind.Star
| GlobTokenKind.Globstar
| GlobTokenKind.GlobstarSlash

/**
* The ranges that `members` spells, a single character as a range of one, in
* UTF-16 code units as a regular expression without the `u` flag reads them.
* A `-` is a member where it is the first or the last. Undefined where they
* spell no set: there are none, or a range runs backwards (`[user-id]`: `r-i`).
*/
function setRanges(members: string): [from: number, to: number][] | undefined {
if (members === '') return undefined
const ranges: [number, number][] = []
for (let i = 0; i < members.length; i++) {
const isRange = members[i + 1] === '-' && i + 2 < members.length
const from = members.charCodeAt(i)
if (isRange) i += 2
const to = members.charCodeAt(i)
if (to < from) return undefined
ranges.push([from, to])
}
return ranges
}

const SEPARATOR = '/'.charCodeAt(0)

/**
* `pattern` as its tokens, in time that grows with its length. Every string
* is a pattern, and no character escapes another:
*
* - A set runs from a `[` to the first `]` after it, be that the next
* character. All between the two are members, a `*`, a `?`, a `/` or another
* `[` too, and `^` and `!` do not negate.
* - Brackets that spell no set (see {@link setRanges}) are text, as POSIX
* reads a `[` that opens none, and what is between them keeps its meaning.
* So are a `[` that nothing closes and a `]` that nothing opens.
* - Of a run of `*`, the last two go with a `/` that follows. Two or more of
* the rest are a `**`.
*/
export function parseGlob(pattern: string): GlobToken[] {
const tokens: GlobToken[] = []
let textStart = 0
const endText = (at: number): void => {
if (textStart < at) {
const text = pattern.slice(textStart, at)
tokens.push({ kind: GlobTokenKind.Text, text })
}
}
/**
* Adds a wildcard that stands right after the last token. Wildcards side by
* side are kept to the fewest that match the same, two at most: a regular
* expression tries every way to share a path out among them, which takes a
* power of its length.
*/
const addWildcard = (kind: WildcardKind): void => {
const { Star, Globstar, GlobstarSlash } = GlobTokenKind
const kindAt = (fromEnd: number): GlobTokenKind | undefined =>
tokens[tokens.length - fromEnd]?.kind
// A `**` matches whatever there is, with any of them after it. And
// `**/**/` is `**/`.
if (kindAt(1) === Globstar || kindAt(1) === kind) return
// So it does with any before it. `*` `**/` `*` and `**/` `*` `**/` are
// one too: whatever there is, is directories and then a name.
const isGlobstar =
kind === Globstar ||
(kindAt(2) === kind &&
(kindAt(1) === Star || kindAt(1) === GlobstarSlash))
if (!isGlobstar) {
tokens.push({ kind })
return
}
while (kindAt(1) === Star || kindAt(1) === GlobstarSlash) tokens.pop()
tokens.push({ kind: Globstar })
}
// Found to open no set once, for all of them: looking for a `]` from every
// `[` anew takes the square of the length.
let bracketsAreTextUntil = 0
const noSets: string[] = []

let i = 0
while (i < pattern.length) {
switch (pattern[i]) {
case '/':
endText(i)
tokens.push({ kind: GlobTokenKind.Separator })
textStart = ++i
break
case '?':
endText(i)
tokens.push({ kind: GlobTokenKind.AnyChar })
textStart = ++i
break
case '*': {
endText(i)
const start = i
while (pattern[i] === '*') i++
const withSeparator = i - start >= 2 && pattern[i] === '/'
const rest = i - start - (withSeparator ? 2 : 0)
if (rest >= 2) addWildcard(GlobTokenKind.Globstar)
if (rest === 1) addWildcard(GlobTokenKind.Star)
if (withSeparator) addWildcard(GlobTokenKind.GlobstarSlash)
textStart = withSeparator ? ++i : i
break
}
case '[': {
if (i < bracketsAreTextUntil) {
i++
break
}
const close = pattern.indexOf(']', i + 1)
if (close === -1) {
noSets.push(pattern.slice(i))
bracketsAreTextUntil = pattern.length
i++
break
}
const members = pattern.slice(i + 1, close)
const ranges = setRanges(members)
if (ranges === undefined) {
noSets.push(`[${members}]`)
bracketsAreTextUntil = close + 1
i++
break
}
endText(i)
tokens.push({
kind: GlobTokenKind.Set,
members,
matchesSeparator: ranges.some(
([from, to]) => from <= SEPARATOR && SEPARATOR <= to,
),
})
textStart = i = close + 1
break
}
default:
i++
}
}
endText(i)
if (noSets.length > 0) {
logForDebugging(
`[Sandbox] The brackets in ${noSets.join(', ')} spell no set of characters, so they are read as text: ${pattern}`,
{ level: 'warn' },
)
}
return tokens
}

/** A regular expression that matches `text` and nothing else. */
export function escapeRegexLiteral(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/** The regular expression for what `token` matches. */
export function globTokenToRegex(token: GlobToken): string {
switch (token.kind) {
case GlobTokenKind.Text:
return escapeRegexLiteral(token.text)
case GlobTokenKind.Separator:
return '/'
case GlobTokenKind.AnyChar:
return '[^/]'
case GlobTokenKind.Star:
return '[^/]*'
case GlobTokenKind.Globstar:
return '.*'
case GlobTokenKind.GlobstarSlash:
return '(.*/)?'
case GlobTokenKind.Set:
// A `-` stays what it is to the set, and the rest are its members.
return `[${token.members.replace(/[.^$+{}()|\\]/g, '\\$&')}]`
}
}

/**
* Convert a glob pattern to a regular expression
*
* This implements gitignore-style pattern matching to match the behavior of the
* `ignore` library used by the permission system.
*
* Supported patterns:
* - * matches any characters except / (e.g., *.ts matches foo.ts but not foo/bar.ts)
* - ** matches any characters including / (e.g., src/**\/*.ts matches all .ts files in src/)
* - ? matches any single character except / (e.g., file?.txt matches file1.txt)
* - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt)
*
* Whatever a pattern spells, what is returned compiles, so that no entry keeps
* a sandbox from starting (see {@link parseGlob}). Only its size can be more
* than an engine takes.
*
* Shared between macOS sandbox profiles and Linux glob expansion.
*/
export function globToRegex(globPattern: string): string {
return `^${parseGlob(globPattern).map(globTokenToRegex).join('')}$`
}

/**
* Regex for a glob used in a DENY rule: {@link globToRegex} plus an optional
* `/…` tail, so the deny covers everything beneath each match the way
* `subpath` does for literals. Callers strip a trailing `/**` before the
* pattern gets here (removeTrailingGlobSuffix), so `**\/secrets/**` arrives
* as `**\/secrets` and, matched exactly, would deny only the directory
* vnode while `secrets/key` stayed readable. This is what the Linux backend
* already does (a deny masks the whole subtree). Only ever widens a deny.
*
* Takes a whole pattern, so every character in it is glob syntax: right for
* the pattern reading of a spelling the caller wrote, which is what
* `readRuleCovers` passes. A pattern anchored at a directory that is a
* name on disk goes through the macOS `denyGlobEntryRegex`, which splices
* that directory back in escaped and calls this for the tail.
*/
export function denyGlobRegex(normalizedGlob: string): string {
// globToRegex() always returns '^…$'.
return globToRegex(normalizedGlob).slice(0, -1) + '(/.*)?$'
}
12 changes: 5 additions & 7 deletions src/sandbox/macos-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ import {
encodeSandboxedCommand,
decodeSandboxedCommand,
containsGlobChars,
globToRegex,
denyGlobRegex,
isAtOrUnder,
isStrictlyUnder as isPathStrictlyUnder,
nameLocation,
pathSpellings,
DANGEROUS_FILES,
getDangerousDirectories,
} from './sandbox-utils.js'
import {
denyGlobRegex,
escapeRegexLiteral,
globToRegex,
} from './glob-pattern.js'
import { shouldIgnoreViolation } from './sandbox-violation-store.js'
import { literalReadings, type PathListKind } from './path-entries.js'

Expand Down Expand Up @@ -662,11 +665,6 @@ const SBPL_STRING_MAX_BYTES = 900
*/
const MIN_DENY_GROUP_SIZE = 4

/** Escape a literal path component for use inside a regex. */
function escapeRegexLiteral(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/**
* Literal deny paths of the shape `<dir>/<name>/<leaf>` that share `dir` and
* the same set of leaves, e.g. git's per-worktree registry
Expand Down
Loading
Loading