Skip to content
Closed
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: 5 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,11 @@ service,https://github.com/acme/service.git,0123456789abcdef0123456789abcdef0123
`--workers` limits concurrent scans and `--max-attempts` retries failures.
Results remain under `--output-dir`; rerun the same command to resume.

Bulk-scan inventories are limited to 8 MiB and 1,000 repositories. Interactive
GitHub discovery applies the same repository limit and stops before creating an
inventory if the selected account exceeds it. Split larger campaigns into
separate repository lists and output directories.

### Scan history and reruns

`npx @openai/codex-security scans list` lists scans for the current repository. Pass a
Expand Down
7 changes: 7 additions & 0 deletions sdk/typescript/src/bulk-scan-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { promisify } from "node:util";
import { confirm, input, search } from "@inquirer/prompts";
import { Octokit } from "@octokit/core";
import Papa from "papaparse";
import {
bulkScanRepositoryLimitError,
MAX_BULK_SCAN_REPOSITORIES,
} from "./bulk-scan-limits.js";
import { resolveTrustedExecutable } from "./trusted-executable.js";

const execFile = promisify(execFileCallback);
Expand Down Expand Up @@ -270,6 +274,9 @@ async function discoverGitHubRepositories(
url: `https://${host}/${repository.nameWithOwner}.git`,
revision: repository.defaultBranchRef.target.oid.toLowerCase(),
});
if (repositories.length > MAX_BULK_SCAN_REPOSITORIES) {
throw bulkScanRepositoryLimitError();
}
}
cursor = connection.pageInfo.hasNextPage
? connection.pageInfo.endCursor
Expand Down
8 changes: 8 additions & 0 deletions sdk/typescript/src/bulk-scan-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const MAX_BULK_SCAN_INVENTORY_BYTES = 8 * 1_024 * 1_024;
export const MAX_BULK_SCAN_REPOSITORIES = 1_000;

export function bulkScanRepositoryLimitError(): Error {
return new Error(
"Bulk scans support at most 1,000 repositories per campaign. Split the repository list into smaller inventories.",
);
}
208 changes: 154 additions & 54 deletions sdk/typescript/src/multiscan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFile as execFileCallback } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import {
lstat,
mkdir,
Expand All @@ -8,13 +9,20 @@ import {
realpath,
rename,
rm,
stat,
truncate,
writeFile,
} from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { Transform } from "node:stream";
import { promisify } from "node:util";
import Papa from "papaparse";
import type { CodexSecurity } from "./api.js";
import {
bulkScanRepositoryLimitError,
MAX_BULK_SCAN_INVENTORY_BYTES,
MAX_BULK_SCAN_REPOSITORIES,
} from "./bulk-scan-limits.js";
import type { CodexSecurityConfig } from "./config.js";
import type { ScanCost } from "./cost.js";
import type { ScanMode } from "./targets.js";
Expand Down Expand Up @@ -82,10 +90,11 @@ export async function runMultiscan(
if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 1) {
throw new Error("Multiscan max attempts must be a positive integer.");
}
const tasks = parseInventory(
await readFile(options.inputPath, "utf8"),
const tasks = await parseInventory(
options.inputPath,
dirname(resolve(options.inputPath)),
options.mode,
options.signal,
);
const output = resolve(options.outputDir);
await ensureOutputDirectory(output);
Expand Down Expand Up @@ -347,19 +356,106 @@ async function hasArtifacts(path: string): Promise<boolean> {
}
}

function parseInventory(
source: string,
async function parseInventory(
inputPath: string,
directory: string,
defaultMode: ScanMode,
): MultiscanTask[] {
const { data: rows, errors } = Papa.parse<string[]>(source, {
delimiter: ",",
skipEmptyLines: "greedy",
});
if (errors.length > 0) {
throw new Error(`Multiscan CSV could not be parsed: ${errors[0]!.message}`);
signal?: AbortSignal,
): Promise<MultiscanTask[]> {
signal?.throwIfAborted();
const metadata = await stat(inputPath);
signal?.throwIfAborted();
if (!metadata.isFile()) {
throw new Error("Multiscan inventory must be a regular CSV file.");
}
if (metadata.size > MAX_BULK_SCAN_INVENTORY_BYTES) {
throw new Error("Multiscan CSV must not exceed 8 MiB.");
}
const headers = rows.shift();

const tasks: MultiscanTask[] = [];
const seen = new Set<string>();
let headers: string[] | undefined;
let bytesRead = 0;
const input = createReadStream(inputPath, {
highWaterMark: 64 * 1_024,
...(signal === undefined ? {} : { signal }),
});
const bounded = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytesRead += chunk.length;
callback(
bytesRead > MAX_BULK_SCAN_INVENTORY_BYTES
? new Error("Multiscan CSV must not exceed 8 MiB.")
: null,
chunk,
);
},
});
input.pipe(bounded);

return await new Promise<MultiscanTask[]>((resolveTasks, rejectTasks) => {
let settled = false;
const fail = (error: unknown): void => {
if (settled) return;
settled = true;
const failure = error instanceof Error ? error : new Error(String(error));
input.destroy();
bounded.destroy();
rejectTasks(failure);
};
input.once("error", fail);
bounded.once("error", fail);

Papa.parse<string[]>(bounded as unknown as Papa.LocalFile, {
delimiter: ",",
skipEmptyLines: "greedy",
beforeFirstChunk: (chunk) =>
chunk.charCodeAt(0) === 0xfeff ? chunk.slice(1) : chunk,
step: ({ data: fields, errors }) => {
try {
signal?.throwIfAborted();
if (errors.length > 0) {
throw new Error(
`Multiscan CSV could not be parsed: ${errors[0]!.message}`,
);
}
if (headers === undefined) {
headers = fields;
validateInventoryHeaders(headers);
return;
}
if (tasks.length >= MAX_BULK_SCAN_REPOSITORIES) {
throw bulkScanRepositoryLimitError();
}
tasks.push(
parseInventoryRow(fields, headers, directory, defaultMode, seen),
);
} catch (error) {
fail(error);
}
},
complete: () => {
if (settled) return;
try {
signal?.throwIfAborted();
if (headers === undefined) validateInventoryHeaders(undefined);
if (tasks.length === 0) {
throw new Error(
"Multiscan CSV must contain at least one repository.",
);
}
settled = true;
resolveTasks(tasks);
} catch (error) {
fail(error);
}
},
error: fail,
});
});
}

function validateInventoryHeaders(headers: string[] | undefined): void {
if (
headers === undefined ||
!["id", "repository", "revision"].every((name) => headers.includes(name)) ||
Expand All @@ -369,48 +465,52 @@ function parseInventory(
"Multiscan CSV requires id, repository, and revision columns.",
);
}
if (rows.length === 0)
throw new Error("Multiscan CSV must contain at least one repository.");
const seen = new Set<string>();
return rows.map((fields) => {
if (fields.length !== headers.length) {
throw new Error("Multiscan CSV rows must match their header columns.");
}
const get = (name: string): string =>
fields[headers.indexOf(name)]?.trim() ?? "";
const id = get("id");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(id)) {
throw new Error("Multiscan task IDs must be safe, unique path names.");
}
if (seen.has(id.toLowerCase()))
throw new Error("Multiscan task IDs must be unique.");
seen.add(id.toLowerCase());
const revision = get("revision").toLowerCase();
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(revision)) {
throw new Error("Multiscan revisions must be full immutable Git SHAs.");
}
const mode = get("mode") || defaultMode;
if (mode !== "standard" && mode !== "deep") {
throw new Error("Multiscan mode must be standard or deep.");
}
const scope = get("scope");
if (
scope &&
(isAbsolute(scope) ||
scope.includes("\\") ||
scope.split("/").includes("..") ||
scope.includes("\0"))
) {
throw new Error("Multiscan scope must stay inside its repository.");
}
return {
id,
repository: normalizeRepository(get("repository"), directory),
revision,
mode,
...(scope ? { scope } : {}),
};
});
}

function parseInventoryRow(
fields: string[],
headers: string[],
directory: string,
defaultMode: ScanMode,
seen: Set<string>,
): MultiscanTask {
if (fields.length !== headers.length) {
throw new Error("Multiscan CSV rows must match their header columns.");
}
const get = (name: string): string =>
fields[headers.indexOf(name)]?.trim() ?? "";
const id = get("id");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(id)) {
throw new Error("Multiscan task IDs must be safe, unique path names.");
}
if (seen.has(id.toLowerCase()))
throw new Error("Multiscan task IDs must be unique.");
seen.add(id.toLowerCase());
const revision = get("revision").toLowerCase();
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(revision)) {
throw new Error("Multiscan revisions must be full immutable Git SHAs.");
}
const mode = get("mode") || defaultMode;
if (mode !== "standard" && mode !== "deep") {
throw new Error("Multiscan mode must be standard or deep.");
}
const scope = get("scope");
if (
scope &&
(isAbsolute(scope) ||
scope.includes("\\") ||
scope.split("/").includes("..") ||
scope.includes("\0"))
) {
throw new Error("Multiscan scope must stay inside its repository.");
}
return {
id,
repository: normalizeRepository(get("repository"), directory),
revision,
mode,
...(scope ? { scope } : {}),
};
}

function normalizeRepository(repository: string, directory: string): string {
Expand Down
37 changes: 37 additions & 0 deletions sdk/typescript/tests-ts/bulk-scan-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type BulkScanDiscoveryDependencies,
type BulkScanPrompt,
} from "../src/bulk-scan-discovery.js";
import { MAX_BULK_SCAN_REPOSITORIES } from "../src/bulk-scan-limits.js";

const temporaryDirectories: string[] = [];
const NOW = Date.parse("2026-07-22T12:00:00.000Z");
Expand Down Expand Up @@ -255,6 +256,42 @@ describe("bulk scan repository discovery", () => {
expect(csv).not.toContain("acme--old");
});

test("enforces the discovery repository limit before creating an inventory", async () => {
const atLimit = Array.from(
{ length: MAX_BULK_SCAN_REPOSITORIES },
(_value, index) => ({ fullName: `acme/service-${index}` }),
);
const acceptedRoot = await temporaryDirectory();
const accepted = discoveryDependencies(acceptedRoot, {
repositories: atLimit,
});
accepted.prompt.confirms = [false];
expect(await runBulkScanWizard(accepted.dependencies)).toBeNull();
expect(accepted.prompt.messages.join("")).toContain(
`Found ${MAX_BULK_SCAN_REPOSITORIES} repositories`,
);
expect(
accepted.requests.filter(({ path }) => path === "/graphql"),
).toHaveLength(10);

const overflowRoot = await temporaryDirectory();
const overflow = discoveryDependencies(overflowRoot, {
repositories: [
...atLimit,
{ fullName: `acme/service-${MAX_BULK_SCAN_REPOSITORIES}` },
],
});
await expect(runBulkScanWizard(overflow.dependencies)).rejects.toThrow(
"at most 1,000 repositories",
);
expect(
overflow.requests.filter(({ path }) => path === "/graphql"),
).toHaveLength(11);
await expect(
lstat(join(overflowRoot, "security-scans")),
).rejects.toMatchObject({ code: "ENOENT" });
});

test("shows all repositories and scans only the selected ones", async () => {
const root = await temporaryDirectory();
const { dependencies, prompt } = discoveryDependencies(root, {
Expand Down
Loading