Jump to content

Module:Sensitive IP addresses/API

Permanently protected module
From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Mr. Stradivarius (talk | contribs) at 14:52, 9 August 2016 (use Module:Arguments for the summary function). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

-- 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
local libraryUtil = require('libraryUtil')
local checkType = libraryUtil.checkType

-------------------------------------------------------------------------------
-- 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 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.
	checkType('matchesIPOrRange', 1, str, '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'}
-- }
--
-- 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)
		return {['error'] = {
			code = code,
			info = info,
			['*'] = 'See https://en.wikipedia.org/wiki/Module:Sensitive_IP_addresses for API usage',
		}}
	end

	-- Construct result
	local result = {}

	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"
		)
	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)
				)
			)
		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)
					)
				)
			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
						)
					)
				end
				if isMatch then
					-- The string was a sensitive IP address or subnet.

					-- Set up result subtables
					result.matches = result.matches or {}
					result['matched-ranges'] = result['matched-ranges'] or {}
					result.entities = result.entities or {}
					result['entity-ids'] = result['entity-ids'] or {}

					-- 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)
				)
			)
		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)
					)
				)
			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.
			result.entities = result.entities or {}
			result['entity-ids'] = result['entity-ids'] or {}
			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.
	if result.entities then
		for id, entityData in pairs(result.entities) do
			entityData.reason = entityData.reason or 'political'
		end
	end

	-- Wrap the result in an outer layer like the MediaWiki Action API does.
	result = {sensitiveips = result}

	if options.format == 'json' then
		return mw.text.jsonEncode(result)
	else
		return result
	end
end

-------------------------------------------------------------------------------
-- Summary table
-- A table of sensitive IP data to be used in
-- [[Template:Sensitive IP addresses]].
-------------------------------------------------------------------------------

local function makeSummaryTable(options)
	options = options or {}
	local data = query{entities={'all'}}
	if data['error'] then
		error(string.format('%s: %s', data['error'].code, data['error'].info))
	end
	local root = mw.html.create('table')
	root
		:addClass('wikitable')
		:addClass('sortable')

	-- Add headers
	root:tag('tr')
		:tag('th')
			:wikitext('[[IPv4]]')
			:done()
		:tag('th')
			:wikitext('[[IPv6]]')
			:done()
		:tag('th')
			:wikitext('Description')

	for i, id in ipairs(data.sensitiveips['entity-ids']) do
		local entityData = data.sensitiveips.entities[id]
		if not options.reason or options.reason == entityData.reason then
			root:tag('tr')
				:tag('td')
					:wikitext(entityData.ipv4Ranges
						and table.concat(entityData.ipv4Ranges, ', ')
						or nil
					)
					:done()
				:tag('td')
					:wikitext(entityData.ipv6Ranges
						and table.concat(entityData.ipv6Ranges, ', ')
						or nil
					)
					:done()
				:tag('td')
					:wikitext(entityData.description or entityData.name)
		end
	end

	return tostring(root)
end

--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------

local p = {}

function p.isValidSensitivityReason(s)
	-- Return true if s is a valid sensitivity reason; otherwise return false.
	checkType('isValidSensitivityReason', 1, s, 'string')
	return SensitiveEntity.reasons[s] ~= nil
end

function p.getSensitivityReasons()
	-- Return an array of valid sensitivity reasons, ordered alphabetically.
	local ret = {}
	for reason in pairs(SensitiveEntity.reasons) do
		ret[#ret + 1] = reason
	end
	table.sort(ret)
	return ret
end

-- Export the API query function
p.query = query

-- Export the summary table function
function p.summary(frame)
	local args = require('Module:Arguments').getArgs(frame, {
		wrappers = 'Template:Sensitive IP addresses'
	})
	return makeSummaryTable(args)
end

return p