Skip to content

Commit a4a2673

Browse files
tsigouris007claude
andcommitted
fix: cleanup EPERM and shrink oversized step summary in post.js
The events file is written via `sudo tee` so it ends up root-owned, and /tmp's sticky bit blocks the unprivileged post-step from unlinking it directly. The step summary was one markdown row per raw event, which blew past GitHub's 1MB limit on noisy runs with lots of repeated short-lived connections. Now it's one row per distinct connection (grouped by proto/ips/port/domain/process/uid) with a Count, a condensed source port list, and a first/last seen time range. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4b1fa21 commit a4a2673

4 files changed

Lines changed: 264 additions & 80 deletions

File tree

dist/post/index.js

Lines changed: 131 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"@actions/tool-cache": "^2.0.1"
1414
},
1515
"devDependencies": {
16-
"@vercel/ncc": "^0.38.1"
16+
"@vercel/ncc": "^0.38.4"
1717
},
1818
"overrides": {
1919
"@actions/http-client": "^3.0.2"

0 commit comments

Comments
 (0)