-
Notifications
You must be signed in to change notification settings - Fork 353
Expand file tree
/
Copy pathfirewall_blocked_domains.cjs
More file actions
232 lines (199 loc) · 7.32 KB
/
firewall_blocked_domains.cjs
File metadata and controls
232 lines (199 loc) · 7.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
// @ts-check
/// <reference types="@actions/github-script" />
/**
* Firewall Blocked Domains Module
*
* This module handles reading firewall logs and extracting blocked domains
* for display in AI-generated footers.
*/
const fs = require("fs");
const path = require("path");
const { sanitizeDomainName } = require("./sanitize_content_core.cjs");
/**
* Parses a single firewall log line
* Format: timestamp client_ip:port domain dest_ip:port proto method status decision url user_agent
* @param {string} line - Log line to parse
* @returns {object|null} Parsed entry or null if invalid
*/
function parseFirewallLogLine(line) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
return null;
}
// Split by whitespace but preserve quoted strings
const fields = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g);
if (!fields || fields.length < 10) {
return null;
}
// Only validate timestamp (essential for log format detection)
const timestamp = fields[0];
if (!/^\d+(\.\d+)?$/.test(timestamp)) {
return null;
}
return {
timestamp,
clientIpPort: fields[1],
domain: fields[2],
destIpPort: fields[3],
proto: fields[4],
method: fields[5],
status: fields[6],
decision: fields[7],
url: fields[8],
userAgent: fields[9]?.replace(/^"|"$/g, "") || "-",
};
}
/**
* Determines if a request was blocked based on decision and status
* @param {string} decision - Decision field (e.g., TCP_TUNNEL:HIER_DIRECT, NONE_NONE:HIER_NONE)
* @param {string} status - Status code (e.g., 200, 403, 0)
* @returns {boolean} True if request was blocked
*/
function isRequestBlocked(decision, status) {
// Check status code first
const statusCode = parseInt(status, 10);
if (statusCode === 403 || statusCode === 407) {
return true;
}
// Check decision field
if (decision.includes("NONE_NONE") || decision.includes("TCP_DENIED")) {
return true;
}
// Check for allowed indicators
if (statusCode === 200 || statusCode === 206 || statusCode === 304) {
return false;
}
if (decision.includes("TCP_TUNNEL") || decision.includes("TCP_HIT") || decision.includes("TCP_MISS")) {
return false;
}
// Default to blocked for safety
return true;
}
/**
* Extracts the base domain from a domain:port string and sanitizes it
* @param {string} domainWithPort - Domain with port (e.g., "example.com:443")
* @returns {string} Sanitized base domain (e.g., "example.com")
*/
function extractAndSanitizeDomain(domainWithPort) {
if (!domainWithPort || domainWithPort === "-") {
return "";
}
// Remove port by taking everything before the last colon
const lastColonIndex = domainWithPort.lastIndexOf(":");
const domain = lastColonIndex > 0 ? domainWithPort.substring(0, lastColonIndex) : domainWithPort;
// Sanitize the domain using the same function as content sanitization
return sanitizeDomainName(domain);
}
/**
* Reads firewall logs and extracts blocked domains
*
* This function checks two possible locations for firewall logs:
* 1. /tmp/gh-aw/sandbox/firewall/logs/ (original location during agent execution)
* 2. Path specified by logsDir parameter (for safe-outputs jobs with downloaded artifacts)
*
* @param {string} [logsDir] - Path to firewall logs directory. Defaults to /tmp/gh-aw/sandbox/firewall/logs
* @returns {string[]} Array of unique blocked domains (sanitized, sorted)
*/
function getBlockedDomains(logsDir) {
const squidLogsDir = logsDir || "/tmp/gh-aw/sandbox/firewall/logs/";
// Check if logs directory exists
if (!fs.existsSync(squidLogsDir)) {
return [];
}
// Find all .log files
let files;
try {
files = fs.readdirSync(squidLogsDir).filter(file => file.endsWith(".log"));
} catch (error) {
// If we can't read the directory, return empty array
return [];
}
if (files.length === 0) {
return [];
}
// Parse all log files and collect blocked domains
const blockedDomainsSet = new Set();
for (const file of files) {
const filePath = path.join(squidLogsDir, file);
let content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch (error) {
// Skip files we can't read
continue;
}
const lines = content.split("\n").filter(line => line.trim());
for (const line of lines) {
const entry = parseFirewallLogLine(line);
if (!entry) {
continue;
}
// Skip internal Squid error entries (client IP ::1, no domain, no destination)
// These are internal Squid connection errors (e.g., error:transaction-end-before-headers)
// and are not actual external network requests.
// Example: 1773003472.027 ::1:52010 - -:- 0.0 - 0 NONE_NONE:HIER_NONE error:transaction-end-before-headers "-"
if (entry.clientIpPort.startsWith("::1:") && entry.domain === "-" && (entry.destIpPort === "-:-" || entry.destIpPort === "-")) {
continue;
}
// Check if request was blocked
const isBlocked = isRequestBlocked(entry.decision, entry.status);
if (isBlocked) {
// When domain is "-" (iptables-dropped traffic not visible to Squid),
// fall back to dest IP:port so blocked requests show their actual destination instead of "-"
// Only fall back if destIpPort is a valid host:port (not "-" or "-:-" which are placeholder values)
let domainField = entry.domain;
if (domainField === "-" && entry.destIpPort !== "-" && entry.destIpPort !== "-:-") {
domainField = entry.destIpPort;
}
const sanitizedDomain = extractAndSanitizeDomain(domainField);
if (sanitizedDomain && sanitizedDomain !== "-") {
blockedDomainsSet.add(sanitizedDomain);
}
}
}
}
// Convert to sorted array
return Array.from(blockedDomainsSet).sort();
}
/**
* Generates HTML details/summary section for blocked domains wrapped in a GitHub warning alert
* @param {string[]} blockedDomains - Array of blocked domain names
* @returns {string} GitHub warning alert with details section, or empty string if no blocked domains
*/
function generateBlockedDomainsSection(blockedDomains) {
if (!blockedDomains || blockedDomains.length === 0) {
return "";
}
const domainCount = blockedDomains.length;
const domainWord = domainCount === 1 ? "domain" : "domains";
let section = "\n\n> [!WARNING]\n";
section += `> **⚠️ Firewall blocked ${domainCount} ${domainWord}**\n`;
section += `>\n`;
section += `> The following ${domainWord} ${domainCount === 1 ? "was" : "were"} blocked by the firewall during workflow execution:\n`;
section += `>\n`;
// List domains as bullet points (within the alert)
for (const domain of blockedDomains) {
section += `> - \`${domain}\`\n`;
}
section += `>\n`;
section += `> To allow these domains, add them to the \`network.allowed\` list in your workflow frontmatter:\n`;
section += `>\n`;
section += `> \`\`\`yaml\n`;
section += `> network:\n`;
section += `> allowed:\n`;
section += `> - defaults\n`;
for (const domain of blockedDomains) {
section += `> - "${domain}"\n`;
}
section += `> \`\`\`\n`;
section += `>\n`;
section += `> See [Network Configuration](https://github.github.com/gh-aw/reference/network/) for more information.\n`;
return section;
}
module.exports = {
parseFirewallLogLine,
isRequestBlocked,
extractAndSanitizeDomain,
getBlockedDomains,
generateBlockedDomainsSection,
};