120 lines
5.0 KiB
JavaScript
120 lines
5.0 KiB
JavaScript
export const SERVER_JOB_OUTPUT_SCHEMA = 1;
|
|
export const SERVER_JOB_OUTPUT_LIMITS = Object.freeze({
|
|
stdoutBytes: 64 * 1024,
|
|
stderrBytes: 64 * 1024,
|
|
totalBytes: 128 * 1024,
|
|
});
|
|
|
|
const REDACTION = "<redacted>";
|
|
const PATH_REDACTION = "<internal-path>";
|
|
const TRUNCATION = "\n<output-truncated>";
|
|
const CREDENTIAL_KEY = "(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)";
|
|
const CREDENTIAL_ASSIGNMENT = /(\b(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*)(["']?)([^\s,;"']+)\2/giu;
|
|
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu;
|
|
const BASIC = /\bBasic\s+[A-Za-z0-9+/=]+/giu;
|
|
const SECRET_HEADER = /(\b(?:authorization|proxy-authorization)\s*:\s*)([^\r\n]+)/giu;
|
|
const UNIX_PATH = /\/(?:[^\s/\\:*?"<>|]+\/)*[^\s/\\:*?"<>|]+/gu;
|
|
const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\s\\/:*?"<>|]+\\)*[^\s\\/:*?"<>|]*/gu;
|
|
const FILE_URL = /\bfile:\/\/[^\s"']+/giu;
|
|
|
|
function invalid(message) {
|
|
throw new Error(`SERVER_JOB_OUTPUT_INVALID: ${message}`);
|
|
}
|
|
|
|
function assertText(value, field) {
|
|
if (typeof value !== "string") invalid(`${field} must be a string`);
|
|
return value;
|
|
}
|
|
|
|
function assertLimit(value, field) {
|
|
if (!Number.isSafeInteger(value) || value < 1 || value > SERVER_JOB_OUTPUT_LIMITS[field]) {
|
|
invalid(`${field} is outside the fixed output budget`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function redactCredentials(value) {
|
|
let text = value;
|
|
let count = 0;
|
|
const replace = (pattern, replacement) => {
|
|
text = text.replace(pattern, (...args) => {
|
|
count += 1;
|
|
return typeof replacement === "function" ? replacement(...args) : replacement;
|
|
});
|
|
};
|
|
replace(CREDENTIAL_ASSIGNMENT, (_match, prefix, quote) => `${prefix}${quote}${REDACTION}${quote}`);
|
|
replace(BEARER, `Bearer ${REDACTION}`);
|
|
replace(BASIC, `Basic ${REDACTION}`);
|
|
replace(SECRET_HEADER, (_match, prefix) => `${prefix}${REDACTION}`);
|
|
return { text, count };
|
|
}
|
|
|
|
function redactPaths(value) {
|
|
let count = 0;
|
|
let text = value.replace(FILE_URL, () => { count += 1; return PATH_REDACTION; });
|
|
text = text.replace(WINDOWS_PATH, () => { count += 1; return PATH_REDACTION; });
|
|
text = text.replace(UNIX_PATH, (match, offset, source) => {
|
|
// Keep URL paths and protocol markers readable; only redact filesystem-looking paths.
|
|
const before = source.slice(Math.max(0, offset - 8), offset);
|
|
if (/https?:$|https?:\/\/$/iu.test(before) || match === "/") return match;
|
|
count += 1;
|
|
return PATH_REDACTION;
|
|
});
|
|
return { text, count };
|
|
}
|
|
|
|
function truncateUtf8(value, maxBytes) {
|
|
const source = Buffer.from(value, "utf8");
|
|
if (source.byteLength <= maxBytes) return { text: value, truncated: false };
|
|
const marker = Buffer.from(TRUNCATION, "utf8");
|
|
const available = Math.max(0, maxBytes - marker.byteLength);
|
|
let end = Math.min(available, source.byteLength);
|
|
while (end > 0 && (source[end] & 0xc0) === 0x80) end -= 1;
|
|
return { text: `${source.subarray(0, end).toString("utf8")}${TRUNCATION}`, truncated: true };
|
|
}
|
|
|
|
export function sanitizeServerJobOutput(value, maxBytes) {
|
|
assertText(value, "output");
|
|
assertLimit(maxBytes, "stdoutBytes");
|
|
const originalBytes = Buffer.byteLength(value, "utf8");
|
|
const credentials = redactCredentials(value);
|
|
const paths = redactPaths(credentials.text);
|
|
const truncated = truncateUtf8(paths.text, maxBytes);
|
|
return Object.freeze({
|
|
text: truncated.text,
|
|
originalBytes,
|
|
emittedBytes: Buffer.byteLength(truncated.text, "utf8"),
|
|
redactionCount: credentials.count + paths.count,
|
|
truncated: truncated.truncated,
|
|
});
|
|
}
|
|
|
|
export function createServerJobOutputReceipt(value, limits = SERVER_JOB_OUTPUT_LIMITS) {
|
|
if (!value || typeof value !== "object") invalid("receipt input is invalid");
|
|
if (!limits || typeof limits !== "object") invalid("limits are invalid");
|
|
const stdoutBytes = assertLimit(limits.stdoutBytes, "stdoutBytes");
|
|
const stderrBytes = assertLimit(limits.stderrBytes, "stderrBytes");
|
|
const totalBytes = assertLimit(limits.totalBytes, "totalBytes");
|
|
if (stdoutBytes + stderrBytes > totalBytes) invalid("stream budgets exceed total budget");
|
|
const stdout = sanitizeServerJobOutput(value.stdout ?? "", "stdoutBytes" in limits ? stdoutBytes : stdoutBytes);
|
|
const stderr = sanitizeServerJobOutput(value.stderr ?? "", "stderrBytes" in limits ? stderrBytes : stderrBytes);
|
|
const totalOriginalBytes = stdout.originalBytes + stderr.originalBytes;
|
|
const totalEmittedBytes = stdout.emittedBytes + stderr.emittedBytes;
|
|
const totalTruncated = stdout.truncated || stderr.truncated || totalOriginalBytes > totalBytes;
|
|
return Object.freeze({
|
|
schemaVersion: SERVER_JOB_OUTPUT_SCHEMA,
|
|
stdout,
|
|
stderr,
|
|
limits: Object.freeze({ stdoutBytes, stderrBytes, totalBytes }),
|
|
totalOriginalBytes,
|
|
totalEmittedBytes,
|
|
totalRedactions: stdout.redactionCount + stderr.redactionCount,
|
|
totalTruncated,
|
|
execution: "DISABLED",
|
|
});
|
|
}
|
|
|
|
export function redactServerJobOutput(value, maxBytes) {
|
|
return sanitizeServerJobOutput(value, maxBytes).text;
|
|
}
|