Skip to content
Open
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ unset OPENAI_API_KEY CODEX_API_KEY
```

Scan history is stored in the Codex Security workbench state directory. If that
directory cannot be written, set `CODEX_SECURITY_STATE_DIR` to a writable
directory outside the repository.
directory cannot be written or has unsafe parent permissions, set
`CODEX_SECURITY_STATE_DIR` to a private directory with trusted parents outside
the repository. See [output and state directory permissions](sdk/typescript/README.md#output-and-state-directory-permissions).

`findings list [repository]` shows open findings across a repository's scans
and identifies findings not confirmed in its latest scan.
Expand Down
41 changes: 40 additions & 1 deletion sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ reduction and result delivery, including at the 96-hour maximum.
`bulk-scan --workers` controls how many repositories are scanned concurrently.

On macOS/Linux, an existing output directory must be private to the current
user (`chmod 700`).
user (`chmod 700`) and have trusted parent directories. See [output and state
directory permissions](#output-and-state-directory-permissions) if a parent is
group- or world-writable.

If the output directory already contains results, add `--archive-existing`.
The CLI moves them to `<output-dir>.previous-<timestamp>-<id>` and starts the
Expand Down Expand Up @@ -663,6 +665,43 @@ const directPublication = await publishScan("/path/to/completed-scan", {
});
```

### Output and state directory permissions

On macOS and Linux, scan output must be private to the current user. Every
parent directory must be owned by the current user or root. A group- or
world-writable parent is accepted only when it has the sticky bit. The state
directory itself must be private; the sticky-bit exception applies only to its
parents. History, sign-in, and publication operations use the same private-state
rule. State-directory aliases must have trusted link owners and trusted lexical
and resolved parents. New state directories are created privately; existing
directories are not automatically changed.

An error that names a parent with mode `0775` refers to that parent, not just
the final output directory. Creating a `0700` child beneath it does not stop
another user from renaming or replacing that child. Choose a location with
trusted parents, or remove group- and world-write access from the named parent
only if you own it and it is safe to change. Do not change a shared home,
workspace, mount, or system directory merely to suppress the error.

Use the setting for the location that failed:

- For explicitly selected scan results, choose another `--output-dir` (SDK
`outputDir`) outside the scanned repository.
- For persistent history, default artifacts, and stored sign-in, choose a
private `CODEX_SECURITY_STATE_DIR` outside the repository. On macOS and Linux,
an existing state directory must be owned by you and private (`0700`). Change
its permissions only if it is your dedicated state directory and is safe to
change. Selecting a new directory does not move existing history, results, or
credentials.
- For temporary runtime files, choose a suitable `TMPDIR` (`TEMP` on Windows).
A fresh private child under a trusted, sticky system temporary directory is
suitable for temporary work; it is not a replacement for persistent history.

The CLI does not automatically change parent permissions or move an explicitly
selected directory. `scan --dry-run` checks the ancestry of an explicitly
selected output directory and the configured state root without creating scan
output or initializing the runtime.

### Scan history and reruns

`scans` or `scans list` lists scans for the current repository. Pass a repository
Expand Down
137 changes: 111 additions & 26 deletions sdk/typescript/_bundled_plugin/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,63 @@ def stale_claim_before(seconds: int = CLAIM_LEASE_SECONDS) -> str:
)


def state_dir() -> Path:
def requested_state_dir() -> str:
state_dir = os.environ.get("CODEX_SECURITY_STATE_DIR")
if state_dir:
return Path(state_dir).expanduser().resolve()
codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
return (codex_home / "state" / "plugins" / "codex-security").resolve()
requested = state_dir
else:
codex_home = os.environ.get("CODEX_HOME", "~/.codex")
requested = os.path.join(codex_home, "state", "plugins", "codex-security")
requested = os.path.expanduser(requested)
if requested.startswith("~"):
raise RuntimeError("Could not determine home directory.")
return requested if os.path.isabs(requested) else os.path.join(os.getcwd(), requested)


def state_dir() -> Path:
return Path(requested_state_dir()).resolve()


def database_path() -> Path:
return state_dir() / "workbench.sqlite3"


def require_secure_state_ancestry(path: str) -> None:
if os.name == "nt":
return
geteuid = getattr(os, "geteuid", None)
effective_uid = geteuid() if geteuid is not None else None
pending = [path]
checked: set[str] = set()
while pending:
current = pending.pop()
while True:
current = current.rstrip(os.sep) or os.sep
if current in checked:
break
checked.add(current)
parent = os.path.dirname(current)
try:
metadata = os.lstat(current)
except FileNotFoundError:
metadata = None
if metadata is not None:
if stat.S_ISLNK(metadata.st_mode):
require_trusted_output_owner(metadata, effective_uid)
canonical = os.path.realpath(current, strict=True)
target = os.readlink(current)
# Preserve dot segments until the filesystem resolves the target.
lexical_target = (
target if os.path.isabs(target) else os.path.join(parent, target)
)
pending.extend((canonical, lexical_target))
else:
require_trusted_output_ancestor(current, effective_uid)
if parent == current:
break
current = parent


@contextmanager
def scan_completion_lock(scan_id: str) -> Any:
lock_dir = state_dir() / "completion-locks"
Expand Down Expand Up @@ -223,8 +268,48 @@ def release_completion_file_lock(descriptor: int) -> None:


def connect() -> sqlite3.Connection:
path = database_path()
path.parent.mkdir(parents=True, exist_ok=True)
requested = requested_state_dir()
root = Path(requested)
try:
require_secure_state_ancestry(requested)
root = root.resolve()
missing = []
existing = root
while True:
try:
metadata = existing.lstat()
break
except FileNotFoundError:
missing.append(existing)
parent = existing.parent
if parent == existing:
raise
existing = parent
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
raise SystemExit("State path must use real directories.")
if os.name != "nt":
geteuid = getattr(os, "geteuid", None)
effective_uid = geteuid() if geteuid is not None else None
for parent in (existing, *existing.parents):
require_trusted_output_ancestor(parent, effective_uid)
for directory in reversed(missing):
try:
directory.mkdir(mode=0o700)
except FileExistsError:
require_canonical_scan_directory(directory)
continue
if (
os.name != "nt"
and stat.S_IMODE(directory.stat().st_mode) & 0o700 != 0o700
):
directory.chmod(0o700)
root = require_canonical_scan_directory(root)
except (OSError, RuntimeError, SystemExit) as exc:
raise SystemExit(
f"Codex Security state directory is unsafe: {root}. {exc}"
) from exc
os.environ["CODEX_SECURITY_STATE_DIR"] = str(root)
path = root / "workbench.sqlite3"
for attempt in range(SQLITE_RETRY_ATTEMPTS):
connection = sqlite3.connect(path, timeout=5)
try:
Expand Down Expand Up @@ -3752,6 +3837,25 @@ def artifact_path(scan_dir: Path, file_name: str, *, required: bool) -> Path | N
return resolved


def require_trusted_output_owner(metadata: os.stat_result, effective_uid: int | None) -> None:
if effective_uid is not None and metadata.st_uid not in {0, effective_uid}:
raise SystemExit("Scan output parent must have a trusted owner.")


def require_trusted_output_ancestor(parent: Path | str, effective_uid: int | None) -> None:
try:
metadata = os.lstat(parent)
except OSError as exc:
raise SystemExit("Scan output parent could not be inspected.") from exc
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
raise SystemExit("Scan output parent must be a non-symlink directory.")
require_trusted_output_owner(metadata, effective_uid)
if stat.S_IMODE(metadata.st_mode) & 0o022 and not metadata.st_mode & stat.S_ISVTX:
raise SystemExit(
"Scan output parent must not be group- or world-writable without the sticky bit."
)


def require_canonical_scan_directory(scan_dir: Path) -> Path:
scan_dir = scan_dir.absolute()
try:
Expand All @@ -3777,26 +3881,7 @@ def require_canonical_scan_directory(scan_dir: Path) -> Path:
if effective_uid is not None and metadata.st_uid != effective_uid:
raise SystemExit("Scan directory must be owned by the current user.")
for parent in scan_dir.parents:
try:
parent_metadata = parent.lstat()
except OSError as exc:
raise SystemExit("Scan output parent could not be inspected.") from exc
if not stat.S_ISDIR(parent_metadata.st_mode) or stat.S_ISLNK(
parent_metadata.st_mode
):
raise SystemExit("Scan output parent must be a non-symlink directory.")
if effective_uid is not None and parent_metadata.st_uid not in {
0,
effective_uid,
}:
raise SystemExit("Scan output parent must have a trusted owner.")
if (
stat.S_IMODE(parent_metadata.st_mode) & 0o022
and not parent_metadata.st_mode & stat.S_ISVTX
):
raise SystemExit(
"Scan output parent must not be group- or world-writable without the sticky bit."
)
require_trusted_output_ancestor(parent, effective_uid)
return scan_dir


Expand Down
25 changes: 9 additions & 16 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
type PluginInstall,
type ProcessEnvironment,
type WorkbenchCommandOptions,
validateCodexSecurityStateDirectory,
validateOutputDir,
} from "./runtime.js";
import {
Expand Down Expand Up @@ -1624,6 +1625,11 @@ export class CodexSecurity {
this.#runtime?.environment ?? this.#dependencies.environment,
"chatgpt",
);
if (this.#runtime?.persistentCredentialHome === true) {
await validateCodexSecurityStateDirectory(
codexSecurityStateDirectory(environment),
);
}
const codexHome =
this.#runtime?.codexHome ??
(await prepareCodexSecurityCredentialHome(environment));
Expand Down Expand Up @@ -1787,22 +1793,9 @@ export class CodexSecurity {
const stateDirectory = codexSecurityStateDirectory(
this.#dependencies.environment,
);
let canonicalStateDirectory = stateDirectory;
while (true) {
try {
canonicalStateDirectory = join(
await realpath(canonicalStateDirectory),
relative(canonicalStateDirectory, stateDirectory),
);
break;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
const parent = dirname(canonicalStateDirectory);
if (parent === canonicalStateDirectory) throw error;
canonicalStateDirectory = parent;
}
}
requireOutputOutsideRepository(protectedRoot, canonicalStateDirectory);
await validateCodexSecurityStateDirectory(stateDirectory, (canonical) =>
requireOutputOutsideRepository(protectedRoot, canonical),
);
return {
repository: repo,
target: normalized,
Expand Down
14 changes: 11 additions & 3 deletions sdk/typescript/src/publication-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
codexSecurityStateDirectory,
resolvePluginPython,
runWorkbench,
validateCodexSecurityStateDirectory,
} from "./runtime.js";

export async function preparePublicationStore(
Expand Down Expand Up @@ -92,7 +93,13 @@ async function runPublicationWorkbench(
environment: NodeJS.ProcessEnv,
issues?: readonly PublishedScanIssue[],
): Promise<Record<string, unknown>> {
const stateDirectory = codexSecurityStateDirectory(environment);
const stateDirectory = await validateCodexSecurityStateDirectory(
codexSecurityStateDirectory(environment),
);
const workbenchEnvironment = {
...environment,
CODEX_SECURITY_STATE_DIR: stateDirectory,
};
const database = join(stateDirectory, "workbench.sqlite3");
try {
if (!(await stat(database)).isFile()) throw new Error("not a regular file");
Expand All @@ -104,7 +111,7 @@ async function runPublicationWorkbench(
}
const [python, pluginRoot] = await Promise.all([
resolvePluginPython({
environment,
environment: workbenchEnvironment,
protectedRoot: publication.scanDirectory,
}),
bundledPluginRoot(),
Expand All @@ -113,6 +120,7 @@ async function runPublicationWorkbench(
findingId,
occurrenceId,
}));
await validateCodexSecurityStateDirectory(stateDirectory);
const directory = await mkdtemp(join(stateDirectory, "publication-"));
try {
const input = join(directory, "publication.json");
Expand All @@ -131,7 +139,7 @@ async function runPublicationWorkbench(
{
python,
pluginRoot,
environment,
environment: workbenchEnvironment,
failureMessage:
command === "prepare-linear-publication"
? "Cannot publish findings without their existing local Codex Security scan history"
Expand Down
Loading
Loading