diff --git a/src/domain/base64Schema.test.ts b/src/domain/base64Schema.test.ts new file mode 100644 index 000000000..d7c2e4a15 --- /dev/null +++ b/src/domain/base64Schema.test.ts @@ -0,0 +1,71 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { canonicalBase64Schema } from "./base64Schema.js"; +import { CANONICAL_BASE64_PATTERN } from "./stringPatterns.js"; + +it("keeps runtime and projected canonical base64 contracts equivalent", () => { + const samples = [ + "", + "AA==", + "AAA=", + "////", + "AB==", + "AAB=", + "A", + "AA", + "AAA", + "====", + "AA==\n", + "AA==\r\n", + "AA==\u2028", + "AA==\u2029", + "AA\nAA", + "AA=\n=", + "AA-_", + "a b c", + 42, + null, + ]; + for (const io of ["input", "output"] as const) { + const projected = z.toJSONSchema(canonicalBase64Schema, { io }); + const validate = new Ajv2020({ strict: true }).compile(projected); + expect(JSON.stringify(projected)).not.toMatch(/\(\? { + const encoded = Buffer.alloc(24 * 1024 * 1024 - 3, 0x81).toString("base64"); + const projected = z.toJSONSchema(canonicalBase64Schema); + const validate = new Ajv2020({ strict: true }).compile(projected); + const samples = [ + { value: encoded, expected: true }, + { value: `${encoded.slice(0, -1)}!`, expected: false }, + { value: `${encoded.slice(0, -3)}R==`, expected: false }, + { value: `${encoded}A`, expected: false }, + ]; + for (const { value, expected } of samples) { + expect(canonicalBase64Schema.safeParse(value).success).toBe(expected); + expect(validate(value)).toBe(expected); + for (const flag of ["", "u", "v"]) { + const positive = new RegExp(projected.pattern!, flag); + const negative = new RegExp( + (projected.not as { pattern: string }).pattern, + flag, + ); + expect(positive.test(value) && !negative.test(value), flag).toBe( + expected, + ); + } + } +}); diff --git a/src/domain/base64Schema.ts b/src/domain/base64Schema.ts new file mode 100644 index 000000000..0bab4bdd8 --- /dev/null +++ b/src/domain/base64Schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + BASE64_QUARTETS_PATTERN, + CANONICAL_BASE64_INVALID_PATTERN, +} from "./stringPatterns.js"; + +/** Exact canonical base64 using stack-bounded, portable JSON Schema patterns. */ +export const canonicalBase64Schema = z + .string() + .regex(BASE64_QUARTETS_PATTERN) + .refine((value) => !CANONICAL_BASE64_INVALID_PATTERN.test(value), { + message: "Expected canonical padded base64", + }) + // JSON Schema negation expresses the same rejection without look-around. + .meta({ not: { pattern: CANONICAL_BASE64_INVALID_PATTERN.source } }); diff --git a/src/domain/go/goBinary.ts b/src/domain/go/goBinary.ts index 8714159c3..fae20f85b 100644 --- a/src/domain/go/goBinary.ts +++ b/src/domain/go/goBinary.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { localPathStringSchema } from "../localPath.js"; -import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js"; +import { canonicalBase64Schema } from "../base64Schema.js"; const sourceRangeSchema = z.strictObject({ offset: z.number().int().nonnegative(), @@ -25,9 +25,7 @@ export const goModuleMetadataSchema = z.strictObject({ dependencies: z.array(goModuleSchema), settings: z.array(z.strictObject({ key: z.string(), value: z.string() })), unparsed_lines: z.array(z.string()), - unparsed_line_bytes_base64: z.array( - z.string().regex(CANONICAL_BASE64_PATTERN), - ), + unparsed_line_bytes_base64: z.array(canonicalBase64Schema), complete: z.boolean(), }); @@ -43,9 +41,9 @@ export const goBuildInfoSchema = z.strictObject({ header_offset: z.number().int().nonnegative(), encoding: z.enum(["inline", "pointer"]), go_version: z.string().nullable(), - go_version_bytes_base64: z.string().regex(CANONICAL_BASE64_PATTERN), + go_version_bytes_base64: canonicalBase64Schema, module_text: z.string().nullable(), - module_bytes_base64: z.string().regex(CANONICAL_BASE64_PATTERN), + module_bytes_base64: canonicalBase64Schema, version_location: sourceRangeSchema, module_location: sourceRangeSchema, module: goModuleMetadataSchema, diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts index cc37376dd..767ecd73d 100644 --- a/src/domain/native/binaryLayout.ts +++ b/src/domain/native/binaryLayout.ts @@ -2,16 +2,15 @@ import { z } from "zod"; import { canBorrowParsedJsonSnapshot } from "../immutableJson.js"; import { localPathStringSchema } from "../localPath.js"; -import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js"; +import { canonicalBase64Schema } from "../base64Schema.js"; const unsignedHex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/); const index = z.number().int().nonnegative(); const scalar = z.union([z.string(), z.number().int()]); const range = z.strictObject({ offset: unsignedHex, bytes: unsignedHex }); -const canonicalBase64 = z.string().regex(CANONICAL_BASE64_PATTERN); const name = z.strictObject({ display: z.string(), - bytes_base64: canonicalBase64.nullable(), + bytes_base64: canonicalBase64Schema.nullable(), location: range.nullable(), unknown_reason: z.string().nullable(), }); @@ -151,7 +150,7 @@ const binaryLayoutObjectSchema = z.strictObject({ z.strictObject({ section_index: index, location: range, - encoded_bytes_base64: canonicalBase64, + encoded_bytes_base64: canonicalBase64Schema, entries: z.array( z.strictObject({ decoded_index: index, reported_offset: unsignedHex }), ), diff --git a/src/domain/native/recordedCrash.ts b/src/domain/native/recordedCrash.ts index 8bd04cac1..7c9e42c6c 100644 --- a/src/domain/native/recordedCrash.ts +++ b/src/domain/native/recordedCrash.ts @@ -2,13 +2,12 @@ import { z } from "zod"; import type { BinaryArchitecture } from "../binaryTargetTypes.js"; import { localPathStringSchema } from "../localPath.js"; -import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js"; +import { canonicalBase64Schema } from "../base64Schema.js"; import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js"; const hex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/); const index = z.number().int().nonnegative(); const signed32 = z.number().int().min(-2147483648).max(2147483647); -const base64 = z.string().regex(CANONICAL_BASE64_PATTERN); const range = z.strictObject({ offset: hex, bytes: hex }); const diagnostics = z.strictObject({ stdout: z.string(), @@ -222,17 +221,17 @@ const recordedCrashObjectSchema = z.strictObject({ type: z.union([z.string(), index]), location: range, owner_location: range, - owner_bytes_base64: base64, + owner_bytes_base64: canonicalBase64Schema, owner_display: z.string().nullable(), descriptor_location: range, - descriptor_bytes_base64: base64, + descriptor_bytes_base64: canonicalBase64Schema, }), ), note_padding: z.array( z.strictObject({ segment_index: index, location: range, - bytes_base64: base64, + bytes_base64: canonicalBase64Schema, }), ), threads: z.array( diff --git a/src/domain/stringPatterns.ts b/src/domain/stringPatterns.ts index ab8994f17..0a195724d 100644 --- a/src/domain/stringPatterns.ts +++ b/src/domain/stringPatterns.ts @@ -5,16 +5,25 @@ export const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9._\x2d]*$/u; export const STABLE_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9._:\x2f\x2d]*$/u; // Repeated alphabet quartets exhaust V8's regexp stack on large bodies, -// including under JSON Schema's Unicode-set mode. Check alphabet and terminal -// padding separately, leaving an unconstrained fixed-width loop for length. -const PADDED_BASE64_PREFIX = String.raw`^(?!.*[^A-Za-z0-9+\x2f=])(?!.*=[^=])(?!.*===)`; +// especially under JSON Schema's Unicode-set mode. Check invalid content +// separately from an unconstrained fixed-width loop for length. +export const BASE64_QUARTETS_PATTERN = /^(?:....)*$/; +const PADDED_BASE64_INVALID_PATTERN = /[^A-Za-z0-9+\x2f=]|=[^=]|===/; +export const CANONICAL_BASE64_INVALID_PATTERN = new RegExp( + `${PADDED_BASE64_INVALID_PATTERN.source}|[^AQgw]==$|[^AEIMQUYcgkosw048=]=$`, +); + +const excludingInvalidBase64 = (invalid: RegExp): RegExp => + new RegExp( + `^(?!.*(?:${invalid.source}))${BASE64_QUARTETS_PATTERN.source.slice(1)}`, + ); /** Padded base64 syntax, allowing nonzero pad bits and the empty encoding. */ -export const PADDED_BASE64_PATTERN = new RegExp( - `${PADDED_BASE64_PREFIX}(?:....)*$`, +export const PADDED_BASE64_PATTERN = excludingInvalidBase64( + PADDED_BASE64_INVALID_PATTERN, ); /** Canonical padded base64, including zero pad bits and the empty encoding. */ -export const CANONICAL_BASE64_PATTERN = new RegExp( - `${PADDED_BASE64_PREFIX}(?!.*[^AQgw]==$)(?!.*[^AEIMQUYcgkosw048=]=$)(?:....)*$`, +export const CANONICAL_BASE64_PATTERN = excludingInvalidBase64( + CANONICAL_BASE64_INVALID_PATTERN, ); diff --git a/tests/boundary/process/recordedCrashTracePolicy.test.ts b/tests/boundary/process/recordedCrashTracePolicy.test.ts index 3d55986b2..11a513484 100644 --- a/tests/boundary/process/recordedCrashTracePolicy.test.ts +++ b/tests/boundary/process/recordedCrashTracePolicy.test.ts @@ -176,16 +176,6 @@ it.each([ }); it.each([ - "prlimit64(900, RLIMIT_NOFILE, NULL, {rlim_cur=1024, rlim_max=4096}) = 0", - "sched_getaffinity(900, 8, [0]) = 8", - "sched_setaffinity(900, 8, [0]) = 0", - "sched_getparam(900, {sched_priority=0}) = 0", - "sched_setparam(900, {sched_priority=0}) = 0", - "sched_getscheduler(900) = 0", - "sched_setscheduler(900, SCHED_OTHER, {sched_priority=0}) = 0", - "sched_getattr(900, {}, 56, 0) = 0", - "sched_setattr(900, {}, 0) = 0", - "sched_rr_get_interval(900, {tv_sec=0, tv_nsec=0}) = 0", "getpgid(900) = 900", "getsid(900) = 900", "get_robust_list(900, [0x1], [24]) = 0", @@ -238,3 +228,26 @@ it.each([ ])("retains unresolved target facts: %s", (line) => { expect(() => check(101, line)).toThrow("Unresolved process target"); }); + +it.each([ + "prlimit64(900, RLIMIT_NOFILE, NULL, {rlim_cur=1024, rlim_max=1024}) = 0", + "prlimit64(900, RLIMIT_NOFILE, {rlim_cur=1024, rlim_max=1024}, NULL) = 0", + "sched_setaffinity(900, 128, [0 1]) = 0", + "sched_getaffinity(900, 128, [0 1]) = 8", + "sched_setscheduler(900, SCHED_OTHER, {sched_priority=0}) = 0", + "sched_getscheduler(900) = SCHED_OTHER", + "sched_setparam(900, {sched_priority=0}) = 0", + "sched_getparam(900, {sched_priority=0}) = 0", + "sched_setattr(900, {size=56, sched_policy=SCHED_OTHER}, 0) = 0", + "sched_getattr(900, {size=56, sched_policy=SCHED_OTHER}, 56, 0) = 0", + "sched_rr_get_interval(900, {tv_sec=0, tv_nsec=0}) = 0", +])("rejects historical PID resource and scheduler access: %s", (line) => { + for (const live of [true, false]) { + expect(() => check(101, line, live)).toThrow("non-inventory lookup"); + expect(() => + check(101, line.replace(/ = .*/u, " = -1 ESRCH (No such process)"), live), + ).toThrow("non-inventory lookup"); + } + expect(check(101, line.replace("(900", "(9000"))).toEqual([]); + expect(check(101, line.replace("(900", "(0"))).toEqual([]); +});