From a592efd448520e391029a8d9383cd2d8ec1ecf5c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:41:59 -0800 Subject: [PATCH] fix(audit): expand only the image loader's runtime tokens A runtime search path token is expanded only by the loader of the image's format. The ELF loader expands $ORIGIN, $LIB and $PLATFORM; dyld expands @executable_path, @loader_path and @rpath. Each is literal text to the other loader, so -Wl,-rpath,$ORIGIN:/opt/private-runtime on a Mach-O image names a literal directory, yet it was dropped and go.build_paths reported not_observed. Pick the token set from the native image format; the PE loader reads no search list, so it expands none. --- .../ReleaseExposureService.test.ts | 54 ++++++++++++++----- .../releaseExposure/ReleaseExposureService.ts | 26 +++++---- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/application/releaseExposure/ReleaseExposureService.test.ts b/src/application/releaseExposure/ReleaseExposureService.test.ts index 6f6425a392..87ccc6c9dd 100644 --- a/src/application/releaseExposure/ReleaseExposureService.test.ts +++ b/src/application/releaseExposure/ReleaseExposureService.test.ts @@ -1515,7 +1515,11 @@ describe("ReleaseExposureService Go forwarded linker search paths", () => { it.each([ ["-Wl,-rpath,@loader_path/lib:extra", "not_observed", []], - ["-Wl,-rpath,$ORIGIN:/opt/private-runtime", "not_observed", []], + [ + "-Wl,-rpath,$ORIGIN:/opt/private-runtime", + "exposed", + ["$ORIGIN:/opt/private-runtime"], + ], ["-Wl,-rpath,/opt/a:b", "exposed", ["/opt/a:b"]], ])( "reads a Mach-O search path in %s as one directory", @@ -1531,19 +1535,41 @@ describe("ReleaseExposureService Go forwarded linker search paths", () => { }, ); - it("does not read loader-expanded runtime search paths as build paths", async () => { - const report = await goReport( - goModule([ - { - key: "CGO_LDFLAGS", - value: - "-Wl,-rpath,$ORIGIN/../lib -Wl,-rpath=${ORIGIN}/lib -Wl,-rpath,@loader_path/Frameworks -Xlinker -rpath -Xlinker @executable_path", - }, - ]), - ); - const check = report.checks.find((item) => item.id === "go.build_paths"); - expect(check?.status).toBe("not_observed"); - }); + it.each([ + ["elf", "-Wl,-rpath,$ORIGIN/../lib -Wl,-rpath=${ORIGIN}/lib"], + [ + "mach-o", + "-Wl,-rpath,@loader_path/Frameworks -Xlinker -rpath -Xlinker @executable_path", + ], + ] as const)( + "does not read %s loader-expanded runtime search paths as build paths", + async (format, value) => { + const report = await goReport( + goModule([{ key: "CGO_LDFLAGS", value }]), + [], + format, + ); + const check = report.checks.find((item) => item.id === "go.build_paths"); + expect(check?.status).toBe("not_observed"); + }, + ); + + it.each([ + ["elf", "-Wl,-rpath,@loader_path/Frameworks", "@loader_path/Frameworks"], + ["mach-o", "-Wl,-rpath,$ORIGIN/../lib", "$ORIGIN/../lib"], + ] as const)( + "keeps a token only the other loader expands as a literal path in %s", + async (format, value, path) => { + const report = await goReport( + goModule([{ key: "CGO_LDFLAGS", value }]), + [], + format, + ); + const check = report.checks.find((item) => item.id === "go.build_paths"); + expect(check?.status).toBe("exposed"); + expect(check?.observation.samples).toEqual([path]); + }, + ); }); describe("ReleaseExposureService Go relative flag paths", () => { diff --git a/src/application/releaseExposure/ReleaseExposureService.ts b/src/application/releaseExposure/ReleaseExposureService.ts index 834ff03020..65fc7e523c 100644 --- a/src/application/releaseExposure/ReleaseExposureService.ts +++ b/src/application/releaseExposure/ReleaseExposureService.ts @@ -847,13 +847,20 @@ const GO_COMPILER_PATH_FLAG = */ const LINKER_PATH_FLAG = /^(?:-(?!T(?:bss|data|text|(?:ldata|rodata|text)-segment)(?:=|$))[ILT]|-{1,2}(?:dynamic-linker|library-path|rpath|rpath-link|script)(?==|$))=?(.*)$/su; -/** - * A runtime search path the loader expands, such as `$ORIGIN/../lib` or - * `@loader_path/Frameworks`, which names no directory on the build host. - */ -const LINKER_RUNTIME_TOKEN = - /^(?:\$(?:ORIGIN|LIB|PLATFORM)\b|\$\{(?:ORIGIN|LIB|PLATFORM)\}|@(?:executable_path|loader_path|rpath)(?:\/|$))/u; type NativeImageFormat = ReleaseExposureNativeImage["format"]; +/** + * A runtime search path the image's loader expands, which names no directory + * on the build host. The ELF loader expands `$ORIGIN/../lib`; dyld expands + * `@loader_path/Frameworks`. Each token is literal text to the other loader, + * and the PE loader reads no search list, so it expands neither. + */ +const DYLD_RUNTIME_TOKEN = /^@(?:executable_path|loader_path|rpath)(?:\/|$)/u; +const LINKER_RUNTIME_TOKENS: Record = { + elf: /^(?:\$(?:ORIGIN|LIB|PLATFORM)\b|\$\{(?:ORIGIN|LIB|PLATFORM)\})/u, + "mach-o": DYLD_RUNTIME_TOKEN, + "mach-o-universal": DYLD_RUNTIME_TOKEN, + pe: null, +}; /** `-rpath` and `-rpath-link` take a search list, not one directory. */ const LINKER_SEARCH_LIST_FLAG = /^-{1,2}(?:rpath|rpath-link)(?==|$)/u; /** @@ -1037,8 +1044,8 @@ const linkerSearchLists = (flags: string[]): string[] => /** * Rooted and relative paths in flags a C compiler driver reads. A forwarded - * search list is read entry by entry, so a loader-expanded entry such as - * `$ORIGIN` drops only itself. Only a search list is loader-expanded: the + * search list is read entry by entry, so an entry the image's loader expands, + * such as `$ORIGIN` on ELF, drops only itself. Only a search list is loader-expanded: the * argument of `-T`, `-L` or `-I` is a literal path even when it reads `$ORIGIN`. * Only the ELF linker splits a search list at `:`. The Mach-O linker stores * each `-rpath` argument as one directory, so `@loader_path/lib:extra` is one @@ -1049,10 +1056,11 @@ const compilerPaths = (value: string, format: NativeImageFormat): string[] => { const { compiler, linker } = splitForwardedLinkerFlags(flags); const lists = linkerSearchLists(linker); const whole = new Set(lists); + const token = LINKER_RUNTIME_TOKENS[format]; const entries = lists.flatMap((list) => (format === "elf" ? list.split(LINKER_SEARCH_LIST_SEPARATOR) : [list]) .map((entry) => entry.trimEnd()) - .filter((entry) => entry.length > 0 && !LINKER_RUNTIME_TOKEN.test(entry)), + .filter((entry) => entry.length > 0 && token?.test(entry) !== true), ); return [ ...flagPaths(flags).filter((path) => !whole.has(path)),