Module:Sensitive IP addresses/API
Jump to navigation
Jump to search
Documentation for this module may be created at Module:Sensitive IP addresses/API/doc
-- This module provides functions for handling sensitive IP addresses. -- Load modules local mIP = require('Module:IP') local IPAddress = mIP.IPAddress local Subnet = mIP.Subnet local IPv4Collection = mIP.IPv4Collection local IPv6Collection = mIP.IPv6Collection -- Lazily load the jf-JSON module local JSON ------------------------------------------------------------------------------- -- Helper functions ------------------------------------------------------------------------------- local function deepCopy(val) -- Make a deep copy of a value, but don't worry about self-references or -- metatables as mw.clone does. If a table in val has a self-reference, -- you will get an infinite loop, so don't do that. if type(val) == 'table' then local ret = {} for k, v in pairs(val) do ret[k] = deepCopy(v) end return ret else return val end end local function deepCopyInto(source, dest) -- Do a deep copy of a source table into a destination table, ignoring -- self-references and metatables. If a table in source has a self-reference -- you will get an infinite loop. for k, v in pairs(source) do if type(v) == 'table' then dest[k] = {} deepCopyInto(v, dest[k]) else dest[k] = v end end end local function removeDuplicates(t) -- Return a copy of an array with duplicate values removed. local keys, ret = {}, {} for i, v in ipairs(t) do if not keys[v] then table.insert(ret, v) keys[v] = true end end return ret end ------------------------------------------------------------------------------- -- SensitiveEntity class -- A country or organization for which blocks must be handled with care. -- Media organizations may inspect block messages for IP addresses and ranges -- belonging to these entities and those messages may end up in the press. ------------------------------------------------------------------------------- local SensitiveEntity = {} SensitiveEntity.__index = SensitiveEntity SensitiveEntity.reasons = { -- The reasons that an entity may be sensitive. Used to verify data in -- Module:Sensitive IP addresses/list. political = true, technical = true, } do -- Private methods local function addRanges(self, key, collectionConstructor, ranges) if ranges and ranges[1] then self[key] = collectionConstructor() for i, range in ipairs(ranges) do self[key]:addSubnet(Subnet.new(range)) end end end -- Constructor function SensitiveEntity.new(data) local self = setmetatable({}, SensitiveEntity) -- Set data self.data = data addRanges(self, 'v4Collection', IPv4Collection.new, data.ipv4Ranges) addRanges(self, 'v6Collection', IPv6Collection.new, data.ipv6Ranges) return self end end function SensitiveEntity:matchesIPOrRange(str) -- Returns true, matchObj, queryObj if there is a match for the IP address -- string or CIDR range str in the sensitive entity. Returns false -- otherwise. matchObj is the Subnet object that was matched, and queryObj -- is the IPAddress or Subnet object corresponding to the input string. -- Get the IPAddress or Subnet object for str local isIP, isSubnet, obj isIP, obj = pcall(IPAddress.new, str) if isIP and not obj then isIP = false end if not isIP then isSubnet, obj = pcall(Subnet.new, str) if not isSubnet or not obj then error(string.format( "'%s' is not a valid IP address or CIDR string", str ), 2) end end -- Try matching the object to the appropriate collection local function isInCollection(collection, obj, isIP) if isIP then if collection then local isMatch, matchObj = collection:containsIP(obj) return isMatch, matchObj, obj else return false end else if collection then local isMatch, matchObj = collection:overlapsSubnet(obj) return isMatch, matchObj, obj else return false end end end if obj:isIPv4() then return isInCollection(self.v4Collection, obj, isIP) else return isInCollection(self.v6Collection, obj, isIP) end end ------------------------------------------------------------------------------- -- Sensitive IP API ------------------------------------------------------------------------------- -- This API is used by external tools and gadgets, so it should be kept -- backwards-compatible. Clients query the API with a query table, and the -- API returns a response table. The response table is available as a Lua table -- for other Lua modules, and as JSON for external clients. -- Example query tables: -- -- Query IP addresses and ranges: -- { -- test = {'1.2.3.4', '4.5.6.0/24', '2001:db8::ff00:12:3456', '2001:db8::ff00:12:0/112'}, -- } -- -- Query specific entities: -- { -- entities = {'ussenate', 'ushr'} -- } -- -- Query all entities: -- { -- entities = {'all'} -- } -- -- Query all entities and format the result as a JSON string: -- { -- entities = {'all'}, -- format = 'json' -- } -- -- Combined query: -- { -- test = {'1.2.3.4', '4.5.6.0/24', '2001:db8::ff00:12:3456', '2001:db8::ff00:12:0/112'}, -- entities = {'ussenate', 'ushr'} -- } -- Example response: -- -- { -- sensitiveips = { -- matches = { -- { -- ip = '1.2.3.4', -- type = 'ip', -- ['ip-version'] = 'IPv4', -- ['matches-range'] = '1.2.3.0/24', -- ['entity-id'] = 'entityid' -- }, -- { -- range = '4.5.6.0/24', -- type = 'range', -- ['ip-version'] = 'IPv4', -- ['matches-range'] = '4.5.0.0/16', -- ['entity-id'] = 'entityid' -- } -- }, -- ['matched-ranges'] = { -- ['1.2.3.0/24'] = { -- range = '1.2.3.0/24', -- ['ip-version'] = 'IPv4', -- ['entity-id'] = 'entityid' -- }, -- ['4.5.0.0/16'] = { -- range = '4.5.0.0/16', -- ['ip-version'] = 'IPv4', -- ['entity-id'] = 'entityid' -- } -- }, -- entities = { -- ['entityid'] = { -- id = 'entityid', -- name = 'The entity name', -- description = 'A description of the entity', -- ['ipv4-ranges'] = { -- '1.2.3.0/24', -- '4.5.0.0/16' -- '6.7.0.0/16' -- }, -- ['ipv6-ranges'] = { -- '2001:db8::ff00:12:0/112' -- }, -- notes = 'Notes about the entity or its ranges' -- } -- } -- ['entity-ids'] = { -- 'entityid' -- } -- } -- } -- -- Response with errors: -- -- { -- error = { -- code = 'example-error', -- info = 'There was an error', -- ['*'] = 'See https://en.wikipedia.org/wiki/Module:Sensitive_IP_addresses for API usage' -- } -- } local function query(options) -- Make entity objects local entities, entityIndexes = {}, {} local data = mw.loadData('Module:Sensitive IP addresses/list') for i, entityData in ipairs(data) do entities[entityData.id] = SensitiveEntity.new(entityData) entityIndexes[entityData.id] = i -- Keep track of the original order end local function makeError(code, info, format) local ret = {['error'] = { code = code, info = info, ['*'] = 'See https://en.wikipedia.org/wiki/Module:Sensitive_IP_addresses/API for API usage', }} if format == 'json' then return mw.text.jsonEncode(ret) else return ret end end -- Construct result local result = { matches = {}, ['matched-ranges'] = {}, entities = {}, ['entity-ids'] = {} } if type(options) ~= 'table' then return makeError( 'sipa-options-type-error', string.format( "type error in argument #1 of 'query' (expected table, received %s)", type(options) ) ) elseif not options.test and not options.entities then return makeError( 'sipa-blank-options', "the options table didn't contain a 'test' or an 'entities' key", options.format ) end if options.test then if type(options.test) ~= 'table' then return makeError( 'sipa-test-type-error', string.format( "'test' options key was type %s (expected table)", type(options.test) ), options.format ) end for i, testString in ipairs(options.test) do if type(testString) ~= 'string' then return makeError( 'sipa-test-string-type-error', string.format( "type error in item #%d in the 'test' array (expected string, received %s)", i, type(testString) ), options.format ) end for k, entity in pairs(entities) do -- Try to match the range with the current sensitive entity. local success, isMatch, matchObj, queryObj = pcall( entity.matchesIPOrRange, entity, testString ) if not success then -- The string was invalid. return makeError( 'sipa-invalid-test-string', string.format( "test string #%d '%s' was not a valid IP address or CIDR string", i, testString ), options.format ) end if isMatch then -- The string was a sensitive IP address or subnet. -- Add match data local match = {} -- Quick and dirty hack to find if queryObj is an IPAddress object. local isIP = queryObj.getNextIP ~= nil and queryObj.isInSubnet ~= nil if isIP then match.type = 'ip' match.ip = tostring(queryObj) else match.type = 'range' match.range = tostring(queryObj) end match['ip-version'] = queryObj:getVersion() match['matches-range'] = matchObj:getCIDR() match['entity-id'] = entity.data.id table.insert(result.matches, match) -- Add the matched range data. result['matched-ranges'][match['matches-range']] = { range = match['matches-range'], ['ip-version'] = match['ip-version'], ['entity-id'] = match['entity-id'], } -- Add the entity data for the entity we matched. result.entities[match['entity-id']] = deepCopy( entities[match['entity-id']].data ) -- Add the entity ID for the entity we matched. table.insert(result['entity-ids'], match['entity-id']) end end end end -- Add entity data requested explicitly. if options.entities then if type(options.entities) ~= 'table' then return makeError( 'sipa-entities-type-error', string.format( "'entities' options key was type %s (expected table)", type(options.test) ), options.format ) end -- Check the type of all the entity strings, and check if 'all' has -- been specified. local isAll = false for i, entityString in ipairs(options.entities) do if type(entityString) ~= 'string' then return makeError( 'sipa-entity-string-type-error', string.format( "type error in item #%d in the 'entities' array (expected string, received %s)", i, type(entityString) ), options.format ) end if entityString == 'all' then isAll = true end end if isAll then -- Add all the entity data. -- As the final result will contain all the entity data, we can -- just create the entities and entity-ids subtables from scratch -- without worrying about what any existing values might be. result.entities = {} result['entity-ids'] = {} for i, entityData in ipairs(data) do result.entities[entityData.id] = deepCopy(entityData) result['entity-ids'][i] = entityData.id end else -- Add data for the entities specified. -- Insert the entity and entity-id subtables if they aren't already -- present. for i, entityString in ipairs(options.entities) do if entities[entityString] then result.entities[entityString] = deepCopy( entities[entityString].data ) table.insert(result['entity-ids'], entityString) end end result['entity-ids'] = removeDuplicates(result['entity-ids']) table.sort(result['entity-ids'], function(s1, s2) return entityIndexes[s1] < entityIndexes[s2] end) end end -- Add any missing reason fields from entities. for id, entityData in pairs(result.entities) do entityData.reason = entityData.reason or 'political' end -- Wrap the result in an outer layer like the MediaWiki Action API does. result = {sensitiveips = result} if options.format == 'json' then -- Load jf-JSON JSON = JSON or require('Module:jf-JSON') JSON.strictTypes = true -- Necessary for correct blank-object encoding -- Decode a skeleton result JSON string. This ensures that blank objects -- are re-encoded as blank objects and not as blank arrays. local jsonResult = JSON:decode([[{"sensitiveips": { "matches": [], "matched-ranges": {}, "entities": {}, "entity-ids": [] }}]]) for i, key in ipairs{'matches', 'matched-ranges', 'entities', 'entity-ids'} do deepCopyInto(result.sensitiveips[key], jsonResult.sensitiveips[key]) end return JSON:encode(jsonResult) elseif options.format == nil or options.format == 'lua' then return result elseif type(options.format) ~= 'string' then return makeError( 'sipa-format-type-error', string.format( "'format' options key was type %s (expected string or nil)", type(options.format) ) ) else return makeError( 'sipa-invalid-format', string.format( "invalid format '%s' (expected 'json' or 'lua')", type(options.format) ) ) end end -------------------------------------------------------------------------------- -- Exports -------------------------------------------------------------------------------- local p = {} function p._isValidSensitivityReason(s) -- Return true if s is a valid sensitivity reason; otherwise return false. return s ~= nil and SensitiveEntity.reasons[s] ~= nil end function p._getSensitivityReasons(separator, conjunction) -- Return an string of valid sensitivity reasons, ordered alphabetically. -- The reasons are separated by an optional separator; if conjunction is -- specified it is used instead of the last separator, as in -- mw.text.listToText. -- Get an array of valid sensitivity reasons. local reasons = {} for reason in pairs(SensitiveEntity.reasons) do reasons[#reasons + 1] = reason end table.sort(reasons) -- Convert arguments if we are being called from wikitext. if type(separator) == 'table' and type(separator.getParent) == 'function' then -- separator is a frame object local frame = separator separator = frame.args[1] conjunction = frame.args[2] end -- Return a formatted string return mw.text.listToText(reasons, separator, conjunction) end -- Export the API query function p.query = query return p