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
69 changes: 56 additions & 13 deletions sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@
"json": "exports/findings.json",
"sarif": "exports/results.sarif",
}
WINDOWS_INVALID_PATH_CHARACTERS = frozenset('<>:"|?*')
WINDOWS_RESERVED_DEVICE_NAMES = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you ask your agent to simplify this part? IMHO we should let codex handle this

"AUX",
"CON",
"CONIN$",
"CONOUT$",
"NUL",
"PRN",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}


class ContractError(ValueError):
Expand Down Expand Up @@ -218,17 +231,39 @@ def _require_safe_relative_path(value: str, context: str, *, allow_dot: bool = F
path = PurePosixPath(value)
normalized = path.as_posix()
if (
not value
not value.strip()
or (normalized == "." and not allow_dot)
or (len(value) >= 2 and value[0].isalpha() and value[1] == ":")
or "\\" in value
or "\0" in value
or any(ord(character) < 32 for character in value)
or path.is_absolute()
or ".." in path.parts
):
raise ContractError(f"{context}: expected a safe repository-relative POSIX path")
return normalized


def _require_portable_relative_path(
value: str, context: str, *, allow_dot: bool = False
) -> str:
normalized = _require_safe_relative_path(value, context, allow_dot=allow_dot)
if any(_windows_unsafe_path_component(part) for part in value.split("/")):
raise ContractError(f"{context}: expected a safe scan-relative POSIX path")
return normalized


def _windows_unsafe_path_component(value: str) -> bool:
if not value or value == ".":
return False
return (
any(character in WINDOWS_INVALID_PATH_CHARACTERS for character in value)
or any(ord(character) < 32 for character in value)
or value.endswith((" ", "."))
or value.split(".", 1)[0].upper() in WINDOWS_RESERVED_DEVICE_NAMES
)


def _require_scan_directory(scan_dir: Path) -> Path:
scan_dir = scan_dir.absolute()
try:
Expand Down Expand Up @@ -340,7 +375,7 @@ def _open_scan_local_directory(root_fd: int, parts: tuple[str, ...], *, create:

def open_scan_local_file_descriptor(scan_dir: Path, relative_path: str, context: str) -> int:
scan_dir = _require_scan_directory(scan_dir)
relative_path = _require_safe_relative_path(relative_path, context)
relative_path = _require_portable_relative_path(relative_path, context)
if not _descriptor_relative_reads_available():
if not _is_windows():
raise ContractError("scan-local input requires descriptor-relative file operations")
Expand Down Expand Up @@ -471,7 +506,7 @@ def write_scan_local_bytes(
if relative_path in {"", ".", ".."} or "/" in relative_path or "\0" in relative_path:
raise ContractError("external output path: expected a safe file name")
else:
relative_path = _require_safe_relative_path(relative_path, "scan-local output path")
relative_path = _require_portable_relative_path(relative_path, "scan-local output path")
path = scan_dir / relative_path
if not _descriptor_relative_writes_available():
if not _is_windows():
Expand Down Expand Up @@ -522,7 +557,7 @@ def write_scan_local_bytes(

def _remove_scan_local_file_if_exists(scan_dir: Path, relative_path: str) -> None:
scan_dir = _require_scan_directory(scan_dir)
relative_path = _require_safe_relative_path(relative_path, "scan-local cleanup path")
relative_path = _require_portable_relative_path(relative_path, "scan-local cleanup path")
if not _descriptor_relative_writes_available():
if not _is_windows():
raise ContractError("scan-local cleanup requires descriptor-relative file operations")
Expand Down Expand Up @@ -921,7 +956,7 @@ def _recover_unsealed_coverage(
try:
if not isinstance(ref, str):
raise ContractError(f"{ref_context}: expected a string")
normalized_ref = _require_safe_relative_path(ref, ref_context)
normalized_ref = _require_portable_relative_path(ref, ref_context)
if not normalized_ref.startswith("artifacts/"):
raise ContractError(
f"{ref_context}: expected a file under artifacts/"
Expand Down Expand Up @@ -1291,7 +1326,9 @@ def _validate_coverage(manifest: dict[str, Any], coverage: dict[str, Any], scan_
for ref_index, ref in enumerate(receipt_refs):
if not isinstance(ref, str):
raise ContractError(f"{context}.receiptRefs[{ref_index}]: expected a string")
normalized_ref = _require_safe_relative_path(ref, f"{context}.receiptRefs[{ref_index}]")
normalized_ref = _require_portable_relative_path(
ref, f"{context}.receiptRefs[{ref_index}]"
)
if not normalized_ref.startswith("artifacts/"):
raise ContractError(
f"{context}.receiptRefs[{ref_index}]: expected a file under artifacts/"
Expand Down Expand Up @@ -1336,16 +1373,19 @@ def _validate_manifest(manifest: dict[str, Any]) -> None:
if not artifacts:
raise ContractError("manifest.scan.artifacts: expected generated artifact records")
artifact_paths: set[str] = set()
artifact_collision_keys: set[str] = set()
for index, artifact in enumerate(artifacts):
context = f"manifest.scan.artifacts[{index}]"
if not isinstance(artifact, dict):
raise ContractError(f"{context}: expected an object")
path = _require_safe_relative_path(
path = _require_portable_relative_path(
_require_str(artifact, "path", context), f"{context}.path"
)
if path in artifact_paths:
collision_key = path.lower()
if collision_key in artifact_collision_keys:
raise ContractError(f"{context}.path: duplicate artifact path")
artifact_paths.add(path)
artifact_collision_keys.add(collision_key)
_require_str(artifact, "sha256", context)
_require_str(artifact, "mediaType", context)
for required_path in ("findings.json", "coverage.json"):
Expand Down Expand Up @@ -1804,7 +1844,7 @@ def _validate_sarif(sarif: dict[str, Any]) -> None:
def _artifact_record(
scan_dir: Path, relative_path: str, media_type: str, contents: bytes | None = None
) -> dict[str, str]:
relative_path = _require_safe_relative_path(relative_path, "artifact path")
relative_path = _require_portable_relative_path(relative_path, "artifact path")
if contents is not None:
_require_scan_local_file(scan_dir, relative_path, relative_path)
return {
Expand All @@ -1825,7 +1865,7 @@ def _coverage_receipt_refs(coverage: dict[str, Any]) -> list[str]:

def _validate_sealed_coverage_receipts(scan: dict[str, Any], coverage: dict[str, Any]) -> None:
artifact_paths = {
_require_safe_relative_path(artifact["path"], "sealed artifact path")
_require_portable_relative_path(artifact["path"], "sealed artifact path")
for artifact in scan["artifacts"]
}
for ref in _coverage_receipt_refs(coverage):
Expand All @@ -1848,16 +1888,19 @@ def _validate_existing_seal(
if not isinstance(artifacts, list) or not artifacts:
raise ContractError("manifest.scan.artifacts: sealed manifest requires artifact records")
artifact_paths: set[str] = set()
artifact_collision_keys: set[str] = set()
for index, artifact in enumerate(artifacts):
context = f"manifest.scan.artifacts[{index}]"
if not isinstance(artifact, dict):
raise ContractError(f"{context}: expected an object")
path = _require_safe_relative_path(
path = _require_portable_relative_path(
_require_str(artifact, "path", context), f"{context}.path"
)
if path in artifact_paths:
collision_key = path.lower()
if collision_key in artifact_collision_keys:
raise ContractError(f"{context}.path: duplicate artifact path")
artifact_paths.add(path)
artifact_collision_keys.add(collision_key)
expected_sha256 = _require_str(artifact, "sha256", context)
contents = (artifact_contents or {}).get(path)
actual_sha256 = (
Expand Down Expand Up @@ -2081,7 +2124,7 @@ def write_export_output(scan_dir: Path, output: Path, export_format: str, conten
scan = _require_dict(manifest, "scan", "manifest")
artifacts = _require_list(scan, "artifacts", "manifest.scan")
artifact_paths = [
_require_safe_relative_path(
_require_portable_relative_path(
_require_str(artifact, "path", f"manifest.scan.artifacts[{index}]"),
f"manifest.scan.artifacts[{index}].path",
)
Expand Down
20 changes: 20 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ class InventoryError(ValueError):
"""Raised when the repository, scope, or inventory cannot be used safely."""


def windows_stream_component(path: Path) -> str | None:
"""Return the first NTFS alternate-data-stream component."""

if os.name != "nt":
return None
return next(
(
component
for component in path.parts
if component != path.anchor and ":" in component
),
None,
)


def resolve_repository(value: str) -> Path:
"""Resolve the repository once so every scope is bound to its real root."""
try:
Expand All @@ -32,6 +47,11 @@ def resolve_scope(repository: Path, value: str) -> str:
raise InventoryError("--scope: expected a non-empty file or directory")

requested = Path(value).expanduser()
stream = windows_stream_component(requested)
if stream is not None:
raise InventoryError(
f"--scope: NTFS alternate data streams are not supported: {stream}"
)
scope = requested if requested.is_absolute() else repository / requested
try:
resolved = scope.resolve(strict=True)
Expand Down
18 changes: 18 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,21 @@ def path_is_excluded(path: Path) -> bool:
return path.name.endswith((".min.js", ".map"))


def windows_stream_component(path: Path) -> str | None:
"""Return the first NTFS alternate-data-stream component."""

if os.name != "nt":
return None
return next(
(
component
for component in path.parts
if component != path.anchor and ":" in component
Comment thread
faizan-oai marked this conversation as resolved.
),
None,
)


def resolve_scope(
repo: Path,
scope: str,
Expand All @@ -301,6 +316,9 @@ def resolve_scope(
reject_symlinks: bool = False,
) -> Path:
scope_path = Path(scope).expanduser() if expand_user else Path(scope)
stream = windows_stream_component(scope_path)
if stream is not None:
raise SystemExit(f"Scope must not use an NTFS alternate data stream: {stream}")
if not scope_path.is_absolute():
scope_path = repo / scope_path
if reject_symlinks:
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ const distFiles = new Set(
"targets",
"trusted-executable",
"version",
"windows-path",
"worker-progress",
].flatMap((module) =>
["js", "js.map", "d.ts", "d.ts.map"].map(
Expand Down
7 changes: 6 additions & 1 deletion sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
import type { ScanResult } from "./result.js";
import {
bundledPluginRoot,
canonicalizeModelSafePath,
codexSecurityCredentialHome,
codexSecurityStateDirectory,
expandHome,
Expand Down Expand Up @@ -1342,7 +1343,11 @@ export async function main(
const scanRoot =
options.scanRoot === undefined
? undefined
: resolveCliPath(directory, options.scanRoot);
: process.platform === "win32"
? await canonicalizeModelSafePath(
resolveCliPath(directory, options.scanRoot),
)
: resolveCliPath(directory, options.scanRoot);
const repository =
scanRoot !== undefined && args.repository === undefined
? undefined
Expand Down
27 changes: 20 additions & 7 deletions sdk/typescript/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
requireSecureOutputAncestry,
} from "./runtime.js";
import type { NormalizedTarget, ScanMode } from "./targets.js";
import { isWindowsUnsafePathComponent } from "./windows-path.js";

const DOCUMENTS = {
"scan-manifest.json": "scan-manifest.schema.json",
Expand Down Expand Up @@ -301,7 +302,7 @@ async function requireCheckedScanFile(
const checkedRoot = await requireScanRoot(scanDirectory, signal);
const scanDir = checkedRoot.path;
throwIfAborted(signal);
const safePath = safeRelativePath(relativePath, context);
const safePath = portableRelativePath(relativePath, context);
const parts = safePath.split("/");
let current = scanDir;
try {
Expand Down Expand Up @@ -376,16 +377,19 @@ async function validateSeal(
}

const artifactPaths = new Set<string>();
const artifactCollisionKeys = new Set<string>();
for (const [index, artifact] of scan.artifacts.entries()) {
throwIfAborted(signal);
const context = `manifest.scan.artifacts[${index}]`;
const normalized = safeRelativePath(artifact.path, `${context}.path`);
if (artifactPaths.has(normalized)) {
const normalized = portableRelativePath(artifact.path, `${context}.path`);
const collisionKey = normalized.toLowerCase();
if (artifactCollisionKeys.has(collisionKey)) {
throw new ContractValidationError(
`${context}.path: duplicate artifact path.`,
);
}
artifactPaths.add(normalized);
artifactCollisionKeys.add(collisionKey);
const digest =
documentDigests.get(normalized) ??
(await sha256ScanFile(
Expand All @@ -405,7 +409,7 @@ async function validateSeal(
for (const surface of coverage.surfaces) {
for (const receipt of surface.receiptRefs) {
throwIfAborted(signal);
const normalized = safeRelativePath(receipt, "coverage receipt");
const normalized = portableRelativePath(receipt, "coverage receipt");
if (!normalized.startsWith("artifacts/")) {
throw new ContractValidationError(
`Coverage receipt must be under artifacts/: ${receipt}`,
Expand Down Expand Up @@ -609,15 +613,14 @@ async function verifyScanRoot(
function safeRelativePath(value: string, context: string): string {
const parts = value.split("/");
if (
value.length === 0 ||
value.trim().length === 0 ||
!isWellFormedUnicode(value) ||
value === "." ||
value.startsWith("/") ||
/^[A-Za-z]:/.test(value) ||
parts.includes("..") ||
value.includes("\\") ||
value.includes("\0") ||
parts.some((part) => part.includes(":"))
/[\u0000-\u001f]/u.test(value)
) {
throw new ContractValidationError(
`${context}: expected a safe scan-relative POSIX path.`,
Expand All @@ -636,6 +639,16 @@ function safeRelativePath(value: string, context: string): string {
return normalized;
}

function portableRelativePath(value: string, context: string): string {
const normalized = safeRelativePath(value, context);
if (value.split("/").some(isWindowsUnsafePathComponent)) {
throw new ContractValidationError(
`${context}: expected a safe scan-relative POSIX path.`,
);
}
return normalized;
}

function safeScopePath(value: string): string {
return value === "."
? value
Expand Down
5 changes: 4 additions & 1 deletion sdk/typescript/src/multiscan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const REQUIRED_ARTIFACTS = [
];
const LOCK_LEASE_MS = 30_000;
const LOCK_HEARTBEAT_MS = 5_000;
const WINDOWS_DEVICE_PATH_NAME =
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;

interface MultiscanTask {
id: string;
Expand Down Expand Up @@ -721,7 +723,7 @@ function parseInventory(
if (
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(id) ||
id.endsWith(".") ||
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(id)
WINDOWS_DEVICE_PATH_NAME.test(id)
) {
throw new Error("Multiscan task IDs must be safe, unique path names.");
}
Expand All @@ -743,6 +745,7 @@ function parseInventory(
(isAbsolute(scope) ||
scope.includes("\\") ||
scope.split("/").includes("..") ||
(process.platform === "win32" && scope.includes(":")) ||
scope.includes("\0"))
) {
throw new Error("Multiscan scope must stay inside its repository.");
Expand Down
Loading
Loading