@@ -31716,15 +31716,84 @@ function parseEventsNDJSON(filePath) {
3171631716 }
3171731717}
3171831718
31719+ // Collapse events into one row per distinct connection (same protocol,
31720+ // source/destination IP, destination port+domain, process, UID, exit code) —
31721+ // repeated connections (e.g. keep-alive requests with a new ephemeral source
31722+ // port each time) differ only in timestamp/source port/pid, which is what's
31723+ // aggregated here. Process/UID/exit code are kept as grouping keys (not
31724+ // collapsed): exit code distinguishes a successful connection from a refused
31725+ // one (must never merge into the same row), and process name is the signal
31726+ // this tracker exists to surface — though process.name is attacker-settable
31727+ // (prctl/argv0), so raw PIDs are also retained per group (see formatCompactSet)
31728+ // as a kernel-verified identity that a spoofed comm name can't hide behind.
31729+ // Returns groups sorted by count descending.
31730+ function groupEvents(events) {
31731+ const groups = new Map();
31732+ for (const e of events) {
31733+ const protocol = e.network?.protocol || 'N/A';
31734+ const sourceIp = e.source?.ip || 'N/A';
31735+ const destIp = e.destination?.ip || 'N/A';
31736+ const destPort = e.destination?.port ?? 'N/A';
31737+ const destDomain = e.destination?.domain || 'N/A';
31738+ const proc = e.process?.name || 'N/A';
31739+ const uid = e.user?.id || 'N/A';
31740+ const exitCode = e.process?.exit_code ?? 'N/A';
31741+ const key = [protocol, sourceIp, destIp, destPort, destDomain, proc, uid, exitCode].join(' ');
31742+
31743+ let g = groups.get(key);
31744+ if (!g) {
31745+ g = {
31746+ protocol, sourceIp, destIp, destPort, destDomain, process: proc, uid, exitCode,
31747+ count: 0, srcPorts: new Set(), pids: new Set(), firstSeen: null, lastSeen: null,
31748+ };
31749+ groups.set(key, g);
31750+ }
31751+ g.count += 1;
31752+ if (e.source?.port !== undefined && e.source?.port !== null) g.srcPorts.add(e.source.port);
31753+ if (e.process?.pid !== undefined && e.process?.pid !== null) g.pids.add(e.process.pid);
31754+ const ts = e.timestamp;
31755+ if (ts) {
31756+ if (g.firstSeen === null || ts < g.firstSeen) g.firstSeen = ts;
31757+ if (g.lastSeen === null || ts > g.lastSeen) g.lastSeen = ts;
31758+ }
31759+ }
31760+ return [...groups.values()].sort((a, b) => b.count - a.count);
31761+ }
31762+
31763+ // Exact values for low-cardinality sets (source ports, PIDs); a count for the
31764+ // rest, so the cell never becomes an unreadable pile of unrelated numbers.
31765+ function formatCompactSet(values) {
31766+ if (values.size === 0) return 'N/A';
31767+ if (values.size <= 3) return [...values].sort((a, b) => a - b).join(', ');
31768+ return `${values.size} distinct`;
31769+ }
31770+
31771+ // Neutralizes markdown table-breaking characters in attacker-influenced
31772+ // fields (process name is set via prctl/argv0; domain comes from a reverse-DNS
31773+ // PTR record — both are outside our control) before they go into a table row.
31774+ function escapeMdCell(value) {
31775+ return String(value)
31776+ .replace(/\\/g, '\\\\')
31777+ .replace(/\|/g, '\\|')
31778+ .replace(/\r\n|\r|\n/g, ' ');
31779+ }
31780+
31781+ // Leaves headroom under GitHub's 1024 KiB step-summary hard limit.
31782+ const STEP_SUMMARY_BUDGET_BYTES = 900 * 1024;
31783+
3171931784// Write a markdown summary table to GITHUB_STEP_SUMMARY.
31720- function printStepSummary(events) {
31785+ // `fullDataLocation` (an S3 URI or "the job log") is referenced if the table
31786+ // still has to be cut down to fit the size limit.
31787+ function printStepSummary(events, fullDataLocation) {
3172131788 const summaryPath = process.env.GITHUB_STEP_SUMMARY;
3172231789 if (!summaryPath) return;
3172331790
3172431791 const repo = process.env.GITHUB_REPOSITORY || 'N/A';
3172531792 const workflow = process.env.GITHUB_WORKFLOW || 'N/A';
3172631793 const runId = process.env.GITHUB_RUN_ID || 'N/A';
3172731794
31795+ const groups = groupEvents(events);
31796+
3172831797 // Aggregate stats.
3172931798 const uniqueDsts = new Set(events.map((e) => e.destination?.ip)).size;
3173031799 const byProto = {};
@@ -31744,44 +31813,60 @@ function printStepSummary(events) {
3174431813 '| Repository | Workflow | Run ID |\n' +
3174531814 '| --- | --- | --- |\n' +
3174631815 `| ${repo} | ${workflow} | ${runId} |\n\n` +
31747- '| Total Events | Unique Destination IPs | Protocols |\n' +
31748- '| --- | --- | --- |\n' +
31749- `| ${events.length} | ${uniqueDsts} | ${protoLine} |\n\n` +
31816+ '| Total Events | Unique Connections | Unique Destination IPs | Protocols |\n' +
31817+ '| --- | --- | --- | --- | \n' +
31818+ `| ${events.length} | ${groups.length} | ${ uniqueDsts} | ${protoLine} |\n\n` +
3175031819 '---\n\n';
3175131820
31752- // Connection table.
31821+ // Connection table: one row per distinct connection, not per raw event .
3175331822 const columns = [
31754- 'Timestamp', 'Protocol',
31755- 'Source IP', 'Src Port',
31756- 'Destination IP', 'Dst Port', 'Dst Domain',
31757- 'PID', 'Process', 'UID',
31823+ 'Protocol', 'Source IP', 'Destination IP', 'Dst Port', 'Dst Domain',
31824+ 'Process', 'PIDs', 'UID', 'Exit Code', 'Count', 'Src Ports',
31825+ 'First Seen (UTC)', 'Last Seen (UTC)',
3175831826 ];
31827+ const headerLines = [
31828+ `| ${columns.join(' | ')} |`,
31829+ `| ${columns.map(() => '---').join(' | ')} |`,
31830+ ];
31831+ const rowLines = groups.map((g) => {
31832+ const row = [
31833+ g.protocol, g.sourceIp, g.destIp, g.destPort, g.destDomain,
31834+ g.process, formatCompactSet(g.pids), g.uid, g.exitCode, g.count,
31835+ formatCompactSet(g.srcPorts), g.firstSeen || 'N/A', g.lastSeen || 'N/A',
31836+ ].map(escapeMdCell);
31837+ return `| ${row.join(' | ')} |`;
31838+ });
3175931839
31760- const mdRows = [];
31761- mdRows.push(`| ${columns.join(' | ')} |`);
31762- mdRows.push(`| ${columns.map(() => '---').join(' | ')} |`);
31840+ const sectionHeader = '## Network Events\n\n';
31841+ const footer = '\n\n---\n';
3176331842
31764- for (const e of events) {
31765- const row = [
31766- e.timestamp || 'N/A',
31767- e.network?.protocol || 'N/A ',
31768- e.source?.ip || 'N/A',
31769- e.source?.port ?? 'N/A',
31770- e.destination?.ip || 'N/A',
31771- e.destination?.port ?? 'N/A',
31772- e.destination?.domain || 'N/A',
31773- e.process?.pid ?? 'N/A',
31774- e.process?.name || 'N/A',
31775- e.user?.id || 'N/A',
31776- ] ;
31777- mdRows.push(`| ${row.join(' | ')} |`) ;
31843+ // Safety net: only trims in genuinely high-cardinality cases (e.g. many
31844+ // one-off unique destinations), since grouping already collapses repeats.
31845+ const fixedBytes = Buffer.byteLength(
31846+ metadataMd + sectionHeader + headerLines.join('\n') + '\n' + footer, 'utf8 ',
31847+ );
31848+ const budgetForRows = STEP_SUMMARY_BUDGET_BYTES - fixedBytes;
31849+
31850+ let usedBytes = 0;
31851+ const includedRows = [];
31852+ for (const line of rowLines) {
31853+ const lineBytes = Buffer.byteLength(line + '\n', 'utf8');
31854+ if (usedBytes + lineBytes > budgetForRows) break;
31855+ includedRows.push(line) ;
31856+ usedBytes += lineBytes ;
3177831857 }
3177931858
31859+ const omittedCount = rowLines.length - includedRows.length;
31860+ const truncationNote = omittedCount > 0
31861+ ? `\n> ${omittedCount} of ${rowLines.length} connections omitted to stay under the step summary size limit — full data is in ${fullDataLocation}.\n`
31862+ : '';
31863+
3178031864 try {
3178131865 fs.appendFileSync(summaryPath, metadataMd);
31782- fs.appendFileSync(summaryPath, '## Network Events\n\n');
31783- fs.appendFileSync(summaryPath, mdRows.join('\n'));
31784- fs.appendFileSync(summaryPath, '\n\n---\n');
31866+ fs.appendFileSync(summaryPath, sectionHeader);
31867+ fs.appendFileSync(summaryPath, [...headerLines, ...includedRows].join('\n'));
31868+ fs.appendFileSync(summaryPath, truncationNote);
31869+ fs.appendFileSync(summaryPath, footer);
3178531870 } catch (err) {
3178631871 core.warning(`Could not write step summary: ${err.message}`);
3178731872 }
@@ -31828,12 +31913,25 @@ async function run() {
3182831913 return;
3182931914 }
3183031915
31916+ // Compute the S3 destination up front (pure string-building, no I/O) so the
31917+ // step summary can name it if its connection table has to be truncated.
31918+ let s3Uri = null;
31919+ if (s3Bucket) {
31920+ const repo = process.env.GITHUB_REPOSITORY || 'unknown-repo';
31921+ const workflow = process.env.GITHUB_WORKFLOW || 'unknown-workflow';
31922+ const runId = process.env.GITHUB_RUN_ID || 'unknown-run';
31923+ const workflowSlug = workflow.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
31924+ const s3Key = `${repo}/${workflowSlug}/${runId}-network.json`;
31925+ s3Uri = `s3://${s3Bucket}/${s3Key}`;
31926+ }
31927+ const fullDataLocation = s3Uri ? `the uploaded artifact (\`${s3Uri}\`)` : 'the job log output for this step';
31928+
3183131929 // Resolve domains, enrich the artifact in-place, then print step summary.
3183231930 if (fs.existsSync(EVENTS_FILE)) {
3183331931 const events = parseEventsNDJSON(EVENTS_FILE);
3183431932 const enriched = await resolveEventDomains(events);
3183531933 writeEventsNDJSON(EVENTS_FILE, enriched);
31836- printStepSummary(enriched);
31934+ printStepSummary(enriched, fullDataLocation );
3183731935 }
3183831936
3183931937 if (!s3Bucket) {
@@ -31854,14 +31952,6 @@ async function run() {
3185431952 return;
3185531953 }
3185631954
31857- const repo = process.env.GITHUB_REPOSITORY || 'unknown-repo';
31858- const workflow = process.env.GITHUB_WORKFLOW || 'unknown-workflow';
31859- const runId = process.env.GITHUB_RUN_ID || 'unknown-run';
31860-
31861- const workflowSlug = workflow.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
31862- const s3Key = `${repo}/${workflowSlug}/${runId}-network.json`;
31863- const s3Uri = `s3://${s3Bucket}/${s3Key}`;
31864-
3186531955 core.info(`Uploading ${EVENTS_FILE} to ${s3Uri}`);
3186631956
3186731957 try {
@@ -31873,7 +31963,9 @@ async function run() {
3187331963 ], { env: buildS3UploadEnv() });
3187431964 core.info('Upload complete');
3187531965 try {
31876- fs.unlinkSync(EVENTS_FILE);
31966+ // EVENTS_FILE is root-owned (written via `sudo tee`) and /tmp has the
31967+ // sticky bit set, so only root can unlink it
31968+ await exec.exec('sudo', ['rm', '-f', EVENTS_FILE]);
3187731969 } catch (cleanupErr) {
3187831970 core.warning(`Could not delete ${EVENTS_FILE}: ${cleanupErr.message}`);
3187931971 }
0 commit comments