tools/netblock-monitor/code.js (172 lines of code) (raw):

/** * 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 = [ 'email@domain.com', 'email2@domain.com', 'email3@domain.com' ]; /** @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(); }