@@ -51,10 +51,109 @@ function isSafeExpectedFile(expectedFile: string): boolean {
5151 ! expectedFile . endsWith ( "/" ) &&
5252 ! expectedFile . includes ( "\\" ) &&
5353 ! expectedFile . includes ( "\0" ) &&
54- segments . every ( ( segment ) => segment !== "" && segment !== "." && segment !== ".." )
54+ segments . every (
55+ ( segment ) => segment !== "" && segment !== "." && segment !== ".." ,
56+ )
5557 ) ;
5658}
5759
60+ /**
61+ * Lists safe regular-file entries from a GitHub artifact ZIP without
62+ * extracting or inflating their contents. Structural ambiguity, links,
63+ * encryption, split archives, ZIP64, duplicate names, and excess entries are
64+ * rejected before callers select an allowlisted evidence file.
65+ */
66+ export function listValidatedArtifactZipEntries (
67+ archive : Buffer ,
68+ options : { maxEntries ?: number } ,
69+ ) : string [ ] | null {
70+ const maxEntries = options . maxEntries ?? 1000 ;
71+ if ( maxEntries < 1 ) return null ;
72+ const endOffset = findZipEndOfCentralDirectory ( archive ) ;
73+ if ( endOffset < 0 ) return null ;
74+ const entriesOnDisk = archive . readUInt16LE ( endOffset + 8 ) ;
75+ const totalEntries = archive . readUInt16LE ( endOffset + 10 ) ;
76+ const centralDirectorySize = archive . readUInt32LE ( endOffset + 12 ) ;
77+ const centralDirectoryOffset = archive . readUInt32LE ( endOffset + 16 ) ;
78+ if (
79+ archive . readUInt16LE ( endOffset + 4 ) !== 0 ||
80+ archive . readUInt16LE ( endOffset + 6 ) !== 0 ||
81+ entriesOnDisk !== totalEntries ||
82+ totalEntries < 1 ||
83+ totalEntries > maxEntries ||
84+ centralDirectoryOffset + centralDirectorySize !== endOffset
85+ ) {
86+ return null ;
87+ }
88+
89+ const names : string [ ] = [ ] ;
90+ const seen = new Set < string > ( ) ;
91+ let offset = centralDirectoryOffset ;
92+ for ( let index = 0 ; index < totalEntries ; index += 1 ) {
93+ if (
94+ offset + 46 > endOffset ||
95+ archive . readUInt32LE ( offset ) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE
96+ ) {
97+ return null ;
98+ }
99+ const creatorSystem = archive . readUInt8 ( offset + 5 ) ;
100+ const flags = archive . readUInt16LE ( offset + 8 ) ;
101+ const compressionMethod = archive . readUInt16LE ( offset + 10 ) ;
102+ const compressedSize = archive . readUInt32LE ( offset + 20 ) ;
103+ const fileNameLength = archive . readUInt16LE ( offset + 28 ) ;
104+ const extraLength = archive . readUInt16LE ( offset + 30 ) ;
105+ const commentLength = archive . readUInt16LE ( offset + 32 ) ;
106+ const diskStart = archive . readUInt16LE ( offset + 34 ) ;
107+ const externalAttributes = archive . readUInt32LE ( offset + 38 ) ;
108+ const localHeaderOffset = archive . readUInt32LE ( offset + 42 ) ;
109+ const entryEnd = offset + 46 + fileNameLength + extraLength + commentLength ;
110+ if ( entryEnd > endOffset ) return null ;
111+ const nameBytes = archive . subarray (
112+ offset + 46 ,
113+ offset + 46 + fileNameLength ,
114+ ) ;
115+ let name : string ;
116+ try {
117+ name = new TextDecoder ( "utf-8" , { fatal : true } ) . decode ( nameBytes ) ;
118+ } catch {
119+ return null ;
120+ }
121+ const unixFileType = ( externalAttributes >>> 16 ) & 0xf000 ;
122+ if (
123+ ! isSafeExpectedFile ( name ) ||
124+ seen . has ( name ) ||
125+ diskStart !== 0 ||
126+ ( flags & 0x1 ) !== 0 ||
127+ ( compressionMethod !== 0 && compressionMethod !== 8 ) ||
128+ ( creatorSystem !== 0 && creatorSystem !== 3 ) ||
129+ ( creatorSystem === 3 && unixFileType !== 0 && unixFileType !== 0x8000 ) ||
130+ localHeaderOffset + 30 > centralDirectoryOffset ||
131+ archive . readUInt32LE ( localHeaderOffset ) !== ZIP_LOCAL_FILE_SIGNATURE
132+ ) {
133+ return null ;
134+ }
135+ const localFlags = archive . readUInt16LE ( localHeaderOffset + 6 ) ;
136+ const localCompressionMethod = archive . readUInt16LE ( localHeaderOffset + 8 ) ;
137+ const localNameLength = archive . readUInt16LE ( localHeaderOffset + 26 ) ;
138+ const localExtraLength = archive . readUInt16LE ( localHeaderOffset + 28 ) ;
139+ const localNameEnd = localHeaderOffset + 30 + localNameLength ;
140+ const dataEnd = localNameEnd + localExtraLength + compressedSize ;
141+ if (
142+ localNameEnd > centralDirectoryOffset ||
143+ dataEnd > centralDirectoryOffset ||
144+ localFlags !== flags ||
145+ localCompressionMethod !== compressionMethod ||
146+ ! archive . subarray ( localHeaderOffset + 30 , localNameEnd ) . equals ( nameBytes )
147+ ) {
148+ return null ;
149+ }
150+ seen . add ( name ) ;
151+ names . push ( name ) ;
152+ offset = entryEnd ;
153+ }
154+ return offset === endOffset ? names . sort ( ) : null ;
155+ }
156+
58157/**
59158 * Reads the exact bytes of one safe relative file from a GitHub artifact ZIP
60159 * without extracting paths to disk. Duplicate targets, links, encryption,
@@ -67,7 +166,11 @@ export function readValidatedArtifactZipEntryBytes(
67166) : Buffer | null {
68167 const maxEntries = options . maxEntries ?? 1000 ;
69168 const expectedFileName = Buffer . from ( expectedFile , "utf8" ) ;
70- if ( ! isSafeExpectedFile ( expectedFile ) || options . maxBytes < 1 || maxEntries < 1 ) {
169+ if (
170+ ! isSafeExpectedFile ( expectedFile ) ||
171+ options . maxBytes < 1 ||
172+ maxEntries < 1
173+ ) {
71174 return null ;
72175 }
73176
@@ -96,14 +199,16 @@ export function readValidatedArtifactZipEntryBytes(
96199 for ( let entryIndex = 0 ; entryIndex < totalEntries ; entryIndex += 1 ) {
97200 if (
98201 centralEntryOffset + 46 > endOffset ||
99- archive . readUInt32LE ( centralEntryOffset ) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE
202+ archive . readUInt32LE ( centralEntryOffset ) !==
203+ ZIP_CENTRAL_DIRECTORY_SIGNATURE
100204 ) {
101205 return null ;
102206 }
103207 const fileNameLength = archive . readUInt16LE ( centralEntryOffset + 28 ) ;
104208 const extraLength = archive . readUInt16LE ( centralEntryOffset + 30 ) ;
105209 const commentLength = archive . readUInt16LE ( centralEntryOffset + 32 ) ;
106- const centralEntryEnd = centralEntryOffset + 46 + fileNameLength + extraLength + commentLength ;
210+ const centralEntryEnd =
211+ centralEntryOffset + 46 + fileNameLength + extraLength + commentLength ;
107212 if ( centralEntryEnd > endOffset ) return null ;
108213 const fileName = archive . subarray (
109214 centralEntryOffset + 46 ,
@@ -164,23 +269,31 @@ export function readValidatedArtifactZipEntryBytes(
164269 localFileNameEnd > centralDirectoryOffset ||
165270 localFlags !== flags ||
166271 localCompressionMethod !== compressionMethod ||
167- ! archive . subarray ( localHeaderOffset + 30 , localFileNameEnd ) . equals ( expectedFileName ) ||
272+ ! archive
273+ . subarray ( localHeaderOffset + 30 , localFileNameEnd )
274+ . equals ( expectedFileName ) ||
168275 compressedDataEnd > centralDirectoryOffset
169276 ) {
170277 return null ;
171278 }
172279
173- const compressedData = archive . subarray ( compressedDataOffset , compressedDataEnd ) ;
280+ const compressedData = archive . subarray (
281+ compressedDataOffset ,
282+ compressedDataEnd ,
283+ ) ;
174284 let contents : Buffer ;
175285 try {
176286 contents =
177287 compressionMethod === 0
178288 ? Buffer . from ( compressedData )
179- : zlib . inflateRawSync ( compressedData , { maxOutputLength : options . maxBytes } ) ;
289+ : zlib . inflateRawSync ( compressedData , {
290+ maxOutputLength : options . maxBytes ,
291+ } ) ;
180292 } catch {
181293 return null ;
182294 }
183- if ( contents . length !== uncompressedSize || crc32 ( contents ) !== expectedCrc ) return null ;
295+ if ( contents . length !== uncompressedSize || crc32 ( contents ) !== expectedCrc )
296+ return null ;
184297 return contents ;
185298}
186299
@@ -190,5 +303,11 @@ export function readValidatedArtifactZipEntry(
190303 expectedFile : string ,
191304 options : { maxBytes : number ; maxEntries ?: number } ,
192305) : string | null {
193- return readValidatedArtifactZipEntryBytes ( archive , expectedFile , options ) ?. toString ( "utf8" ) ?? null ;
306+ return (
307+ readValidatedArtifactZipEntryBytes (
308+ archive ,
309+ expectedFile ,
310+ options ,
311+ ) ?. toString ( "utf8" ) ?? null
312+ ) ;
194313}
0 commit comments