forked from GoogleCloudPlatform/professional-services
-
Notifications
You must be signed in to change notification settings - Fork 0
/
code.js
395 lines (350 loc) · 12.2 KB
/
code.js
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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
/**
* Copyright 2018 Google LLC. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the “License”);
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an “AS IS” BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Any software provided by Google hereunder is distributed “AS IS”, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, and is not intended for production use.
*/
/*
Google Netblock Monitor
Polls dns.google.com for TXT record information on various Google Netblocks,
as defined in https://support.google.com/a/answer/60764. Reports back changes.
A comparison of current blocks is done against previously known IP blocks
stored in Apps Script properties. If/when IP blocks are found to be added or
removed, an email is generated with the specific details.
*/
/**
* Script Configuration
*
* Modify the below variables to match requirements.
* [REQ] DISTRIBUTION_LIST: include emails that will receive notifications.
* [OPT] DAILY_TRIGGER_HOUR is the hour when the script should run each day.
* [OPT] DAILY_TRIGGER_TZ is the timezone that maps to the hour.
*/
/** @const {!Array<string>} */
var DISTRIBUTION_LIST = [
];
/** @const {number} */
var DAILY_TRIGGER_HOUR = 8;
/** @const {string} */
var DAILY_TRIGGER_TZ = 'America/New_York';
/**
* Google Netblock Configuration
*/
/** @const {string} */
var DNS_RECORD_TYPE = 'TXT';
/** @const {string} */
var GOOGLE_DNS_URL = 'https://dns.google.com/resolve?name=%DOMAIN%&type=%RECORD%';
/** @const {string} */
var GOOGLE_SPF_RECORD = '_spf.google.com';
/**
* Email Configuration
*/
/** @const {string} */
var EMAIL_SUBJECT = 'Google Netblock Changes Detected';
/** @const {string} */
var EMAIL_HTML_BODY = '<table><tr><th>Action</th><th>IP Type</th>' +
'<th>IP Range</th><th>Source</th></tr>%CHANGE_RECORDS%</table>';
/** @enum {string} */
var ChangeRecordFormat = {
HTML: '<tr><td>%ACTION%</td><td>%IPTYPE%</td><td>%IP%</td>' +
'<td>%SOURCE%</td></tr>',
PLAIN: 'Action: %ACTION% IP Type: %IPTYPE% ' +
'IP Range: %IP% Source: %SOURCE%\n'
};
/**
* Script Objects
*/
/** @enum {string} */
var ChangeAction = {
ADD: 'add',
REMOVE: 'remove'
};
/** @enum {string} */
var IpType = {
V4: 'ip4',
V6: 'ip6'
};
/**
* ChangeRecord object that details relevant info when a netblock is changed.
* @typedef {{action:!ChangeAction, ipType:!IpType, ip:string, source:string}}
*/
var ChangeRecord;
/**
* Public Functions
*/
/**
* Initializes the Apps Script project by ensuring that the prompt for
* permissions occurs before the trigger is set, script assets (triggers,
* properties) start in a clean state, data is populated, and an email is sent
* containing the current state of the netblocks.
*/
function initializeMonitor() {
// Ensures the script is in a default state.
clearProperties_();
// Clears and initiates a single daily trigger.
resetTriggers_();
// Kicks off first fetch of IPs for storage. This will generate an email.
executeUpdateWorkflow();
// Logs the storage for manual validation.
logScriptProperties();
}
/**
* Kicks off the workflow to fetch the netblocks, analyze/store the results,
* and email any changes.
*/
function executeUpdateWorkflow() {
var fullIpToNetblockMap = {};
try {
var netblocks = getNetblocksFromSpf_();
netblocks.forEach(function(netblock) {
var ipToNetblockMap = getIpsFromNetblock_(netblock);
consolidateObjects_(fullIpToNetblockMap, ipToNetblockMap);
});
var netblockChanges = getNetblockChanges_(fullIpToNetblockMap);
if (netblockChanges.length) {
emailChanges_(netblockChanges);
Logger.log('Changes found: %s', netblockChanges.length);
} else {
Logger.log('No changes found.');
}
} catch(err) {
Logger.log(err);
}
}
/**
* Writes the contents of the script's properties to logs for manual inspection.
*/
function logScriptProperties() {
var knownNetblocks = PropertiesService.getScriptProperties().getProperties();
Object.keys(knownNetblocks).forEach(function(ip) {
Logger.log('IP: %s Source: %s', ip, knownNetblocks[ip]);
});
}
/**
* Workflow (Private) Functions
*/
/**
* Queries for Google's netblocks from the known SPF record and returns them.
* @private
* @return {!Array<string>} List of netblocks.
*/
function getNetblocksFromSpf_() {
var spfResponse = getNsLookupResponse_(GOOGLE_SPF_RECORD, DNS_RECORD_TYPE);
return Object.keys(parseDNSResponse_(spfResponse));
}
/**
* Queries for Google's IPs from a given netblock and returns them.
* @private
* @param {string} netblock The netblock to lookup.
* @return {!Object<string, string>} Key value map of an IP address to source
* netblock. e.g. {'64.233.160.0/19': '_netblocks.google.com'}
*/
function getIpsFromNetblock_(netblock) {
var response = getNsLookupResponse_(netblock, DNS_RECORD_TYPE);
return parseDNSResponse_(response);
}
/**
* Performs the equivalent of nslookup leveraging Google DNS.
* @private
* @param {string} domain Domain to lookup (e.g. _spf.google.com).
* @param {string} recordType DNS record type (e.g. MX, TXT, etc.)
* @return {!Object<string, string|number>} Google DNS response content.
*/
function getNsLookupResponse_(domain, recordType) {
var url = GOOGLE_DNS_URL.replace('%DOMAIN%', domain)
.replace('%RECORD%', recordType);
var result = UrlFetchApp.fetch(url,{muteHttpExceptions:true});
if (result.getResponseCode() !== 200) {
throw new Error(result.message);
}
return /** @type {!Object<string, string|number>} */ (
JSON.parse(result.getContentText()));
}
/**
* Finds and parses IP address information from a Google DNS record.
* @private
* @param {!Object} response Google DNS response content.
* @return {!Object<string, string>} Key value map of an IP address to source
* netblock. e.g. {'64.233.160.0/19': '_netblocks.google.com'}
*/
function parseDNSResponse_(response) {
var netblockMap = {};
// Google Netblocks only have one TXT record.
var answer = response['Answer'][0];
var dns = answer['name'];
var components = answer['data'].split(' ');
// An example response will follow the following format, only with more
// IP addresses: 'v=spf1 ip4:64.233.160.0/19 ip4:66.102.0.0/20 ~all'
// Since we're only interested in the IP addresses, we can remove the first
// and last index from the split(' ') array.
components.shift();
components.pop();
components.forEach(function(component, index) {
// For the queries we're making, examples of the two components would be:
// include:_netblocks.google.com or ip4:64.233.160.0/19. In both cases,
// we're only interested in the contents after the colon.
var ip = component.substring(component.indexOf(':') + 1);
netblockMap[ip] = dns;
});
return netblockMap;
}
/**
* Compares the new netblock IP blocks to the known items in storage.
* @private
* @param {!Object<string, string>} ipToNetblockMap A key value map of an IP
* address to source netblock.
* e.g. {'64.233.160.0/19': '_netblocks.google.com'}
* @return {!Array<?ChangeRecord>} List of ChangeRecord(s) representing
* detected changes and whether the action should be to add or remove them.
*/
function getNetblockChanges_(ipToNetblockMap) {
if (!ipToNetblockMap) {
return [];
}
var changes = [];
var newProperties = {};
var oldProperties = PropertiesService.getScriptProperties().getProperties();
// First check to see which previous IPs still exist. Keep those that are,
// and remove those that no longer exist.
Object.keys(oldProperties).forEach(function(previousIP) {
if(ipToNetblockMap.hasOwnProperty(previousIP)) {
newProperties[previousIP] = oldProperties[previousIP];
}
else {
changes.push(
getChangeRecord_(ChangeAction.REMOVE, getIPType_(previousIP),
previousIP, oldProperties[previousIP]));
}
});
// Then check to see which current IPs didn't exist previously and add them.
Object.keys(ipToNetblockMap).forEach(function(currentIP) {
if(!oldProperties[currentIP]) {
changes.push(
getChangeRecord_(ChangeAction.ADD, getIPType_(currentIP),
currentIP, ipToNetblockMap[currentIP]));
newProperties[currentIP] = ipToNetblockMap[currentIP];
}
});
// Replace the existing list of IPs and netblocks (within script storage)
// with the current state.
PropertiesService.getScriptProperties().setProperties(newProperties, true);
return changes;
}
/**
* Generates an email that includes a formatted display of all changes.
* @private
* @param {!Array<!ChangeRecord>} changeRecords List of detected changes.
*/
function emailChanges_(changeRecords) {
var changePlain = '';
var changeHTML = '';
changeRecords.forEach(function(changeRecord) {
changePlain += formatChangeForDisplay_(
changeRecord, ChangeRecordFormat.PLAIN);
changeHTML += formatChangeForDisplay_(
changeRecord, ChangeRecordFormat.HTML);
});
GmailApp.sendEmail(
DISTRIBUTION_LIST.join(', '),
EMAIL_SUBJECT,
changePlain,
// The HTML formatted records, represented as table rows (<tr>), need to be
// inserted into the table (<table>), along with the table headers (<th>).
{'htmlBody': EMAIL_HTML_BODY.replace('%CHANGE_RECORDS%', changeHTML)}
);
}
/**
* Helper Functions
*/
/**
* Creates and returns a record object that reflects changes in netblocks.
* @private
* @param {!ChangeAction} action The type change that occurred.
* @param {!IpType} ipType The type of IP address.
* @param {string} ip The IP range.
* @param {string} source The netblock source the IP came from.
* @return {!ChangeRecord} Change record object.
*/
function getChangeRecord_(action, ipType, ip, source) {
return {
action: action,
ipType: ipType,
ip: ip,
source: source
};
}
/**
* Decides whether or not an IP block is IP4 or IP6 based on formatting.
* @private
* @param {string} ip IP address.
* @return {!IpType} IP address type classification.
*/
function getIPType_(ip) {
return (ip.indexOf(':') > -1) ? IpType.V6 : IpType.V4;
}
/**
* Creates a formatted record of an change based on a template.
* @private
* @param {!ChangeRecord} changeRecord Record representing a netblock change.
* @param {!ChangeRecordFormat} emailChangeFormat HTML or PLAIN.
* @return {string} - Formatted change that includes the values.
*/
function formatChangeForDisplay_(changeRecord, emailChangeFormat) {
return emailChangeFormat.replace('%ACTION%', changeRecord.action)
.replace('%IPTYPE%', changeRecord.ipType)
.replace('%IP%', changeRecord.ip)
.replace('%SOURCE%', changeRecord.source);
}
/**
* Merges one key/value object into a another key/value object. Duplicate keys
* take the value from the merger (newer) object.
* @private
* @param {?Object} absorbingObject Object to absorb values.
* @param {?Object} objectToBeAbsorbed Object that will be absorbed.
* @return {?Object} Resultant superset object that includes master and merger.
*/
function consolidateObjects_(absorbingObject, objectToBeAbsorbed) {
if (!absorbingObject) { absorbingObject = {}; }
if (objectToBeAbsorbed) {
Object.keys(objectToBeAbsorbed).forEach(function(key) {
absorbingObject[key] = objectToBeAbsorbed[key];
});
}
return absorbingObject;
}
/**
* Clears all Apps Script internal storage.
* @private
*/
function clearProperties_() {
PropertiesService.getScriptProperties().deleteAllProperties();
}
/**
* Resets script tiggers by clearing them and adding a single daily trigger.
* @private
*/
function resetTriggers_() {
// First clear all the triggers.
var triggers = ScriptApp.getProjectTriggers();
triggers.forEach(function(trigger) {
ScriptApp.deleteTrigger(trigger);
});
// Then initialize a single daily trigger.
ScriptApp.newTrigger('executeUpdateWorkflow').timeBased()
.atHour(DAILY_TRIGGER_HOUR).everyDays(1)
.inTimezone(DAILY_TRIGGER_TZ).create();
}