@@ -150,15 +150,68 @@ function parseEventsNDJSON(filePath) {
150150 }
151151}
152152
153+ // Collapse events into one row per distinct connection (same protocol,
154+ // source/destination IP, destination port+domain, process, UID) — repeated
155+ // connections (e.g. keep-alive requests with a new ephemeral source port each
156+ // time) differ only in timestamp/source port, which is what's aggregated here.
157+ // Process/UID are kept as grouping keys (not collapsed) since which process
158+ // made a connection is the signal this tracker exists to surface.
159+ // Returns groups sorted by count descending.
160+ function groupEvents ( events ) {
161+ const groups = new Map ( ) ;
162+ for ( const e of events ) {
163+ const protocol = e . network ?. protocol || 'N/A' ;
164+ const sourceIp = e . source ?. ip || 'N/A' ;
165+ const destIp = e . destination ?. ip || 'N/A' ;
166+ const destPort = e . destination ?. port ?? 'N/A' ;
167+ const destDomain = e . destination ?. domain || 'N/A' ;
168+ const proc = e . process ?. name || 'N/A' ;
169+ const uid = e . user ?. id || 'N/A' ;
170+ const key = [ protocol , sourceIp , destIp , destPort , destDomain , proc , uid ] . join ( ' ' ) ;
171+
172+ let g = groups . get ( key ) ;
173+ if ( ! g ) {
174+ g = {
175+ protocol, sourceIp, destIp, destPort, destDomain, process : proc , uid,
176+ count : 0 , srcPorts : new Set ( ) , firstSeen : null , lastSeen : null ,
177+ } ;
178+ groups . set ( key , g ) ;
179+ }
180+ g . count += 1 ;
181+ if ( e . source ?. port !== undefined && e . source ?. port !== null ) g . srcPorts . add ( e . source . port ) ;
182+ const ts = e . timestamp ;
183+ if ( ts ) {
184+ if ( g . firstSeen === null || ts < g . firstSeen ) g . firstSeen = ts ;
185+ if ( g . lastSeen === null || ts > g . lastSeen ) g . lastSeen = ts ;
186+ }
187+ }
188+ return [ ...groups . values ( ) ] . sort ( ( a , b ) => b . count - a . count ) ;
189+ }
190+
191+ // Exact ports for low-cardinality groups; a count for the rest, so the cell
192+ // never becomes an unreadable pile of unrelated ephemeral port numbers.
193+ function formatSrcPorts ( srcPorts ) {
194+ if ( srcPorts . size === 0 ) return 'N/A' ;
195+ if ( srcPorts . size <= 3 ) return [ ...srcPorts ] . sort ( ( a , b ) => a - b ) . join ( ', ' ) ;
196+ return `${ srcPorts . size } distinct` ;
197+ }
198+
199+ // Leaves headroom under GitHub's 1024 KiB step-summary hard limit.
200+ const STEP_SUMMARY_BUDGET_BYTES = 900 * 1024 ;
201+
153202// Write a markdown summary table to GITHUB_STEP_SUMMARY.
154- function printStepSummary ( events ) {
203+ // `fullDataLocation` (an S3 URI or "the job log") is referenced if the table
204+ // still has to be cut down to fit the size limit.
205+ function printStepSummary ( events , fullDataLocation ) {
155206 const summaryPath = process . env . GITHUB_STEP_SUMMARY ;
156207 if ( ! summaryPath ) return ;
157208
158209 const repo = process . env . GITHUB_REPOSITORY || 'N/A' ;
159210 const workflow = process . env . GITHUB_WORKFLOW || 'N/A' ;
160211 const runId = process . env . GITHUB_RUN_ID || 'N/A' ;
161212
213+ const groups = groupEvents ( events ) ;
214+
162215 // Aggregate stats.
163216 const uniqueDsts = new Set ( events . map ( ( e ) => e . destination ?. ip ) ) . size ;
164217 const byProto = { } ;
@@ -178,44 +231,59 @@ function printStepSummary(events) {
178231 '| Repository | Workflow | Run ID |\n' +
179232 '| --- | --- | --- |\n' +
180233 `| ${ repo } | ${ workflow } | ${ runId } |\n\n` +
181- '| Total Events | Unique Destination IPs | Protocols |\n' +
182- '| --- | --- | --- |\n' +
183- `| ${ events . length } | ${ uniqueDsts } | ${ protoLine } |\n\n` +
234+ '| Total Events | Unique Connections | Unique Destination IPs | Protocols |\n' +
235+ '| --- | --- | --- | --- | \n' +
236+ `| ${ events . length } | ${ groups . length } | ${ uniqueDsts } | ${ protoLine } |\n\n` +
184237 '---\n\n' ;
185238
186- // Connection table.
239+ // Connection table: one row per distinct connection, not per raw event .
187240 const columns = [
188- 'Timestamp' , 'Protocol' ,
189- 'Source IP' , 'Src Port' ,
190- 'Destination IP' , 'Dst Port' , 'Dst Domain' ,
191- 'PID' , 'Process' , 'UID' ,
241+ 'Protocol' , 'Source IP' , 'Destination IP' , 'Dst Port' , 'Dst Domain' ,
242+ 'Process' , 'UID' , 'Count' , 'Src Ports' , 'First Seen (UTC)' , 'Last Seen (UTC)' ,
192243 ] ;
193-
194- const mdRows = [ ] ;
195- mdRows . push ( `| ${ columns . join ( ' | ' ) } |` ) ;
196- mdRows . push ( `| ${ columns . map ( ( ) => '---' ) . join ( ' | ' ) } |` ) ;
197-
198- for ( const e of events ) {
244+ const headerLines = [
245+ `| ${ columns . join ( ' | ' ) } |` ,
246+ `| ${ columns . map ( ( ) => '---' ) . join ( ' | ' ) } |` ,
247+ ] ;
248+ const rowLines = groups . map ( ( g ) => {
199249 const row = [
200- e . timestamp || 'N/A' ,
201- e . network ?. protocol || 'N/A' ,
202- e . source ?. ip || 'N/A' ,
203- e . source ?. port ?? 'N/A' ,
204- e . destination ?. ip || 'N/A' ,
205- e . destination ?. port ?? 'N/A' ,
206- e . destination ?. domain || 'N/A' ,
207- e . process ?. pid ?? 'N/A' ,
208- e . process ?. name || 'N/A' ,
209- e . user ?. id || 'N/A' ,
250+ g . protocol , g . sourceIp , g . destIp , g . destPort , g . destDomain ,
251+ g . process , g . uid , g . count , formatSrcPorts ( g . srcPorts ) ,
252+ g . firstSeen || 'N/A' , g . lastSeen || 'N/A' ,
210253 ] ;
211- mdRows . push ( `| ${ row . join ( ' | ' ) } |` ) ;
254+ return `| ${ row . join ( ' | ' ) } |` ;
255+ } ) ;
256+
257+ const sectionHeader = '## Network Events\n\n' ;
258+ const footer = '\n\n---\n' ;
259+
260+ // Safety net: only trims in genuinely high-cardinality cases (e.g. many
261+ // one-off unique destinations), since grouping already collapses repeats.
262+ const fixedBytes = Buffer . byteLength (
263+ metadataMd + sectionHeader + headerLines . join ( '\n' ) + '\n' + footer , 'utf8' ,
264+ ) ;
265+ const budgetForRows = STEP_SUMMARY_BUDGET_BYTES - fixedBytes ;
266+
267+ let usedBytes = 0 ;
268+ const includedRows = [ ] ;
269+ for ( const line of rowLines ) {
270+ const lineBytes = Buffer . byteLength ( line + '\n' , 'utf8' ) ;
271+ if ( usedBytes + lineBytes > budgetForRows ) break ;
272+ includedRows . push ( line ) ;
273+ usedBytes += lineBytes ;
212274 }
213275
276+ const omittedCount = rowLines . length - includedRows . length ;
277+ const truncationNote = omittedCount > 0
278+ ? `\n> ${ omittedCount } of ${ rowLines . length } connections omitted to stay under the step summary size limit — full data is in ${ fullDataLocation } .\n`
279+ : '' ;
280+
214281 try {
215282 fs . appendFileSync ( summaryPath , metadataMd ) ;
216- fs . appendFileSync ( summaryPath , '## Network Events\n\n' ) ;
217- fs . appendFileSync ( summaryPath , mdRows . join ( '\n' ) ) ;
218- fs . appendFileSync ( summaryPath , '\n\n---\n' ) ;
283+ fs . appendFileSync ( summaryPath , sectionHeader ) ;
284+ fs . appendFileSync ( summaryPath , [ ...headerLines , ...includedRows ] . join ( '\n' ) ) ;
285+ fs . appendFileSync ( summaryPath , truncationNote ) ;
286+ fs . appendFileSync ( summaryPath , footer ) ;
219287 } catch ( err ) {
220288 core . warning ( `Could not write step summary: ${ err . message } ` ) ;
221289 }
@@ -307,7 +375,9 @@ async function run() {
307375 ] , { env : buildS3UploadEnv ( ) } ) ;
308376 core . info ( 'Upload complete' ) ;
309377 try {
310- fs . unlinkSync ( EVENTS_FILE ) ;
378+ // EVENTS_FILE is root-owned (written via `sudo tee`) and /tmp has the
379+ // sticky bit set, so only root can unlink it
380+ await exec . exec ( 'sudo' , [ 'rm' , '-f' , EVENTS_FILE ] ) ;
311381 } catch ( cleanupErr ) {
312382 core . warning ( `Could not delete ${ EVENTS_FILE } : ${ cleanupErr . message } ` ) ;
313383 }
0 commit comments