|
1
|
|
|
################################################################# |
|
2
|
|
|
# MET v2 Metadate Explorer Tool |
|
3
|
|
|
# |
|
4
|
|
|
# This Software is Open Source. See License: https://github.com/TERENA/met/blob/master/LICENSE.md |
|
5
|
|
|
# Copyright (c) 2012, TERENA All rights reserved. |
|
6
|
|
|
# |
|
7
|
|
|
# This Software is based on MET v1 developed for TERENA by Yaco Sistemas, http://www.yaco.es/ |
|
8
|
|
|
# MET v2 was developed for TERENA by Tamim Ziai, DAASI International GmbH, http://www.daasi.de |
|
9
|
|
|
# Current version of MET has been revised for performance improvements by Andrea Biancini, |
|
10
|
|
|
# Consortium GARR, http://www.garr.it |
|
11
|
|
|
########################################################################## |
|
12
|
|
|
|
|
13
|
|
|
from lxml import etree |
|
14
|
|
|
from cryptography import x509 |
|
15
|
|
|
from cryptography.hazmat.backends import default_backend |
|
16
|
|
|
import simplejson as json |
|
17
|
|
|
|
|
18
|
|
|
NAMESPACES = { |
|
19
|
|
|
'xml': 'http://www.w3.org/XML/1998/namespace', |
|
20
|
|
|
'xs': 'http://www.w3.org/2001/XMLSchema', |
|
21
|
|
|
'xsi': 'http://www.w3.org/2001/XMLSchema-instance', |
|
22
|
|
|
'md': 'urn:oasis:names:tc:SAML:2.0:metadata', |
|
23
|
|
|
'mdui': 'urn:oasis:names:tc:SAML:metadata:ui', |
|
24
|
|
|
'ds': 'http://www.w3.org/2000/09/xmldsig#', |
|
25
|
|
|
'saml': 'urn:oasis:names:tc:SAML:2.0:assertion', |
|
26
|
|
|
'samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', |
|
27
|
|
|
'mdrpi': 'urn:oasis:names:tc:SAML:metadata:rpi', |
|
28
|
|
|
'shibmd': 'urn:mace:shibboleth:metadata:1.0', |
|
29
|
|
|
'mdattr': 'urn:oasis:names:tc:SAML:metadata:attribute', |
|
30
|
|
|
} |
|
31
|
|
|
|
|
32
|
|
|
SAML_METADATA_NAMESPACE = NAMESPACES['md'] |
|
33
|
|
|
|
|
34
|
|
|
XML_NAMESPACE = NAMESPACES['xml'] |
|
35
|
|
|
XMLDSIG_NAMESPACE = NAMESPACES['ds'] |
|
36
|
|
|
MDUI_NAMESPACE = NAMESPACES['mdui'] |
|
37
|
|
|
|
|
38
|
|
|
DESCRIPTOR_TYPES = ('IDPSSODescriptor', 'SPSSODescriptor', 'AASSODescriptor') |
|
39
|
|
|
DESCRIPTOR_TYPES_DISPLAY = {} |
|
40
|
|
|
for item in DESCRIPTOR_TYPES: |
|
41
|
|
|
DESCRIPTOR_TYPES_DISPLAY[item] = item.replace('SSODescriptor', '') |
|
42
|
|
|
|
|
43
|
|
|
DESCRIPTOR_TYPES_UTIL = ["md:%s" % item for item in DESCRIPTOR_TYPES] |
|
44
|
|
|
|
|
45
|
|
|
|
|
46
|
|
|
def addns(node_name, namespace=SAML_METADATA_NAMESPACE): |
|
47
|
|
|
'''Return a node name qualified with the XML namespace''' |
|
48
|
|
|
return '{' + namespace + '}' + node_name |
|
49
|
|
|
|
|
50
|
|
|
|
|
51
|
|
|
def delns(node, namespace=SAML_METADATA_NAMESPACE): |
|
52
|
|
|
return node.replace('{' + namespace + '}', '') |
|
53
|
|
|
|
|
54
|
|
|
|
|
55
|
|
|
def getlang(node): |
|
56
|
|
|
if 'lang' in node.attrib: |
|
57
|
|
|
return node.attrib['lang'] |
|
58
|
|
|
elif addns('lang', NAMESPACES['xml']) in node.attrib: |
|
59
|
|
|
return node.attrib[addns('lang', NAMESPACES['xml'])] |
|
60
|
|
|
|
|
61
|
|
|
|
|
62
|
|
|
FEDERATION_ROOT_TAG = addns('EntitiesDescriptor') |
|
63
|
|
|
ENTITY_ROOT_TAG = addns('EntityDescriptor') |
|
64
|
|
|
|
|
65
|
|
|
|
|
66
|
|
|
class MetadataParser(object): |
|
67
|
|
|
def __init__(self, filename=None): |
|
68
|
|
|
if filename is None: |
|
69
|
|
|
raise ValueError('filename is required') |
|
70
|
|
|
|
|
71
|
|
|
self.filename = filename |
|
72
|
|
|
with open(filename, 'r') as myfile: |
|
73
|
|
|
data = myfile.read().replace('\n', '') |
|
74
|
|
|
self.rootelem = etree.fromstring(data) |
|
75
|
|
|
self.file_id = self.rootelem.get('ID', None) |
|
76
|
|
|
self.is_federation = self.rootelem.tag == FEDERATION_ROOT_TAG |
|
77
|
|
|
self.is_entity = not self.is_federation |
|
78
|
|
|
|
|
79
|
|
|
@staticmethod |
|
80
|
|
|
def _get_entity_details(element): |
|
81
|
|
|
entity = {} |
|
82
|
|
|
|
|
83
|
|
|
entity['xml'] = etree.tostring(element, pretty_print=True) |
|
84
|
|
|
|
|
85
|
|
|
entity['description'] = MetadataParser.entity_description(element) |
|
86
|
|
|
entity['infoUrl'] = MetadataParser.entity_information_url(element) |
|
87
|
|
|
entity['privacyUrl'] = MetadataParser.entity_privacy_url(element) |
|
88
|
|
|
entity['organization'] = MetadataParser.entity_organization(element) |
|
89
|
|
|
entity['logos'] = MetadataParser.entity_logos(element) |
|
90
|
|
|
entity['scopes'] = MetadataParser.entity_attribute_scope(element) |
|
91
|
|
|
entity['attr_requested'] = MetadataParser.entity_requested_attributes( |
|
92
|
|
|
element) |
|
93
|
|
|
entity['contacts'] = MetadataParser.entity_contacts(element) |
|
94
|
|
|
entity['registration_policy'] = MetadataParser.registration_policy( |
|
95
|
|
|
element) |
|
96
|
|
|
|
|
97
|
|
|
return entity |
|
98
|
|
|
|
|
99
|
|
|
@staticmethod |
|
100
|
|
|
def _entity_lang_seen(entity): |
|
101
|
|
|
languages = set() |
|
102
|
|
|
for key in ['description', 'infoUrl', 'privacyUrl', 'organization', 'displayName']: |
|
103
|
|
|
if key in entity.keys() and entity[key]: |
|
104
|
|
|
languages |= set(entity[key].keys()) |
|
105
|
|
|
|
|
106
|
|
|
return languages |
|
107
|
|
|
|
|
108
|
|
|
@staticmethod |
|
109
|
|
|
def _get_entity_by_id(context, entityid, details): |
|
110
|
|
|
for _, element in context: |
|
111
|
|
|
if element.attrib['entityID'] == entityid: |
|
112
|
|
|
entity = {} |
|
113
|
|
|
|
|
114
|
|
|
entity['entityid'] = entityid |
|
115
|
|
|
entity['file_id'] = element.get('ID', None) |
|
116
|
|
|
entity['displayName'] = MetadataParser.entity_displayname( |
|
117
|
|
|
element) |
|
118
|
|
|
reg_info = MetadataParser.registration_information(element) |
|
119
|
|
|
if reg_info and 'authority' in reg_info: |
|
120
|
|
|
entity['registration_authority'] = reg_info['authority'] |
|
121
|
|
|
if reg_info and 'instant' in reg_info: |
|
122
|
|
|
entity['registration_instant'] = reg_info['instant'] |
|
123
|
|
|
entity['entity_categories'] = MetadataParser.entity_categories( |
|
124
|
|
|
element) |
|
125
|
|
|
entity['entity_types'] = MetadataParser.entity_types(element) |
|
126
|
|
|
entity['protocols'] = MetadataParser.entity_protocols( |
|
127
|
|
|
element, entity['entity_types']) |
|
128
|
|
|
entity['certstats'] = MetadataParser.get_certstats(element) |
|
129
|
|
|
|
|
130
|
|
|
if details: |
|
131
|
|
|
entity_details = MetadataParser._get_entity_details( |
|
132
|
|
|
element) |
|
133
|
|
|
entity.update(entity_details) |
|
134
|
|
|
entity = dict((k, v) for k, v in entity.iteritems() if v) |
|
135
|
|
|
|
|
136
|
|
|
entity['languages'] = MetadataParser._entity_lang_seen(entity) |
|
137
|
|
|
yield entity |
|
138
|
|
|
|
|
139
|
|
|
element.clear() |
|
140
|
|
|
while element.getprevious() is not None: |
|
141
|
|
|
del element.getparent()[0] |
|
142
|
|
|
del context |
|
143
|
|
|
|
|
144
|
|
|
def get_federation(self): |
|
145
|
|
|
assert self.is_federation |
|
146
|
|
|
|
|
147
|
|
|
federation = {} |
|
148
|
|
|
federation['ID'] = self.rootelem.get('ID', None) |
|
149
|
|
|
federation['Name'] = self.rootelem.get('Name', None) |
|
150
|
|
|
|
|
151
|
|
|
return federation |
|
152
|
|
|
|
|
153
|
|
|
@staticmethod |
|
154
|
|
|
def _chunkstring(string, length): |
|
155
|
|
|
return (string[0 + i:length + i] for i in range(0, len(string), length)) |
|
156
|
|
|
|
|
157
|
|
|
def get_entity(self, entityid, details=True): |
|
158
|
|
|
context = etree.iterparse(self.filename, tag=addns( |
|
159
|
|
|
'EntityDescriptor'), events=('end',), huge_tree=True, remove_blank_text=True) |
|
160
|
|
|
element = None |
|
161
|
|
|
for element in MetadataParser._get_entity_by_id(context, entityid, details): |
|
162
|
|
|
return element |
|
163
|
|
|
|
|
164
|
|
|
raise ValueError("Entity not found: %s" % entityid) |
|
165
|
|
|
|
|
166
|
|
|
def entity_exist(self, entityid): |
|
167
|
|
|
entity_xpath = self.rootelem.xpath("//md:EntityDescriptor[@entityID='%s']" |
|
168
|
|
|
% entityid, namespaces=NAMESPACES) |
|
169
|
|
|
return len(entity_xpath) > 0 |
|
170
|
|
|
|
|
171
|
|
|
@staticmethod |
|
172
|
|
|
def _get_entities_id(context): |
|
173
|
|
|
for _, element in context: |
|
174
|
|
|
yield element.attrib['entityID'] |
|
175
|
|
|
element.clear() |
|
176
|
|
|
while element.getprevious() is not None: |
|
177
|
|
|
del element.getparent()[0] |
|
178
|
|
|
del context |
|
179
|
|
|
|
|
180
|
|
|
def get_entities(self): |
|
181
|
|
|
# Return entityid list |
|
182
|
|
|
context = etree.iterparse(self.filename, tag=addns( |
|
183
|
|
|
'EntityDescriptor'), events=('end',), huge_tree=True, remove_blank_text=True) |
|
184
|
|
|
return list(self._get_entities_id(context)) |
|
185
|
|
|
|
|
186
|
|
|
@staticmethod |
|
187
|
|
|
def entity_types(entity): |
|
188
|
|
|
expression = "|".join([desc for desc in DESCRIPTOR_TYPES_UTIL]) |
|
189
|
|
|
elements = entity.xpath(expression, namespaces=NAMESPACES) |
|
190
|
|
|
types = [element.tag.split("}")[1] for element in elements] |
|
191
|
|
|
if len(types) == 0: |
|
192
|
|
|
types = ['AASSODescriptor'] |
|
193
|
|
|
return types |
|
194
|
|
|
|
|
195
|
|
|
@staticmethod |
|
196
|
|
|
def entity_categories(entity): |
|
197
|
|
|
elements = entity.xpath(".//mdattr:EntityAttributes" |
|
198
|
|
|
"//saml:Attribute[@Name='http://macedir.org/entity-category-support' or @Name='http://macedir.org/entity-category' or @Name='urn:oasis:names:tc:SAML:attribute:assurance-certification']" |
|
199
|
|
|
"//saml:AttributeValue", |
|
200
|
|
|
namespaces=NAMESPACES) |
|
201
|
|
|
categories = [dnnode.text.strip() for dnnode in elements] |
|
202
|
|
|
return categories |
|
203
|
|
|
|
|
204
|
|
|
@staticmethod |
|
205
|
|
|
def entity_protocols(entity, entity_types): |
|
206
|
|
|
if isinstance(entity_types, list) and len(entity_types) > 0: |
|
207
|
|
|
e_type = entity_types[0] |
|
208
|
|
|
else: |
|
209
|
|
|
e_type = 'IDPSSODescriptor' |
|
210
|
|
|
|
|
211
|
|
|
raw_protocols = entity.xpath(".//md:%s" |
|
212
|
|
|
"/@protocolSupportEnumeration" % e_type, |
|
213
|
|
|
namespaces=NAMESPACES) |
|
214
|
|
|
if raw_protocols: |
|
215
|
|
|
protocols = raw_protocols[0] |
|
216
|
|
|
return protocols.split(' ') |
|
217
|
|
|
|
|
218
|
|
|
return [] |
|
219
|
|
|
|
|
220
|
|
|
@staticmethod |
|
221
|
|
|
def get_certstats(element): |
|
222
|
|
|
hashes = {} |
|
223
|
|
|
|
|
224
|
|
|
for x in element.xpath(".//ds:X509Certificate", namespaces=NAMESPACES): |
|
225
|
|
|
certName = 'invalid' |
|
226
|
|
|
|
|
227
|
|
|
try: |
|
228
|
|
|
text = x.text.replace("\n", "").replace( |
|
229
|
|
|
" ", "").replace("\t", "") |
|
230
|
|
|
text = "\n".join(MetadataParser._chunkstring(text, 64)) |
|
231
|
|
|
certText = "\n".join( |
|
232
|
|
|
["-----BEGIN CERTIFICATE-----", text, '-----END CERTIFICATE-----']) |
|
233
|
|
|
cert = x509.load_pem_x509_certificate( |
|
234
|
|
|
certText, default_backend()) |
|
235
|
|
|
certName = cert.signature_hash_algorithm.name |
|
236
|
|
|
except Exception, e: |
|
237
|
|
|
pass |
|
238
|
|
|
|
|
239
|
|
|
if certName not in hashes: |
|
240
|
|
|
hashes[certName] = 0 |
|
241
|
|
|
hashes[certName] += 1 |
|
242
|
|
|
|
|
243
|
|
|
return json.dumps(hashes) |
|
244
|
|
|
|
|
245
|
|
|
@staticmethod |
|
246
|
|
|
def entity_displayname(entity): |
|
247
|
|
|
languages = {} |
|
248
|
|
|
|
|
249
|
|
|
names = entity.xpath(".//mdui:UIInfo" |
|
250
|
|
|
"//mdui:DisplayName", |
|
251
|
|
|
namespaces=NAMESPACES) |
|
252
|
|
|
|
|
253
|
|
|
for dn_node in names: |
|
254
|
|
|
lang = getlang(dn_node) |
|
255
|
|
|
languages[lang] = dn_node.text |
|
256
|
|
|
|
|
257
|
|
|
if None in languages.keys(): |
|
258
|
|
|
del languages[None] |
|
259
|
|
|
return languages |
|
260
|
|
|
|
|
261
|
|
|
@staticmethod |
|
262
|
|
|
def entity_description(entity): |
|
263
|
|
|
languages = {} |
|
264
|
|
|
|
|
265
|
|
|
names = entity.xpath(".//mdui:UIInfo" |
|
266
|
|
|
"//mdui:Description", |
|
267
|
|
|
namespaces=NAMESPACES) |
|
268
|
|
|
|
|
269
|
|
|
for dn_node in names: |
|
270
|
|
|
lang = getlang(dn_node) |
|
271
|
|
|
languages[lang] = dn_node.text |
|
272
|
|
|
|
|
273
|
|
|
if None in languages.keys(): |
|
274
|
|
|
del languages[None] |
|
275
|
|
|
return languages |
|
276
|
|
|
|
|
277
|
|
|
@staticmethod |
|
278
|
|
|
def entity_information_url(entity): |
|
279
|
|
|
languages = {} |
|
280
|
|
|
|
|
281
|
|
|
names = entity.xpath(".//mdui:UIInfo" |
|
282
|
|
|
"//mdui:InformationURL", |
|
283
|
|
|
namespaces=NAMESPACES) |
|
284
|
|
|
|
|
285
|
|
|
for dn_node in names: |
|
286
|
|
|
lang = getlang(dn_node) |
|
287
|
|
|
languages[lang] = dn_node.text |
|
288
|
|
|
|
|
289
|
|
|
if None in languages.keys(): |
|
290
|
|
|
del languages[None] |
|
291
|
|
|
return languages |
|
292
|
|
|
|
|
293
|
|
|
@staticmethod |
|
294
|
|
|
def entity_privacy_url(entity): |
|
295
|
|
|
languages = {} |
|
296
|
|
|
|
|
297
|
|
|
names = entity.xpath(".//mdui:UIInfo" |
|
298
|
|
|
"//mdui:PrivacyStatementURL", |
|
299
|
|
|
namespaces=NAMESPACES) |
|
300
|
|
|
|
|
301
|
|
|
for dn_node in names: |
|
302
|
|
|
lang = getlang(dn_node) |
|
303
|
|
|
languages[lang] = dn_node.text |
|
304
|
|
|
|
|
305
|
|
|
if None in languages.keys(): |
|
306
|
|
|
del languages[None] |
|
307
|
|
|
return languages |
|
308
|
|
|
|
|
309
|
|
|
@staticmethod |
|
310
|
|
|
def entity_organization(entity): |
|
311
|
|
|
orgs = entity.xpath(".//md:Organization", |
|
312
|
|
|
namespaces=NAMESPACES) |
|
313
|
|
|
languages = {} |
|
314
|
|
|
for org_node in orgs: |
|
315
|
|
|
for attr in 'name', 'displayName', 'URL': |
|
316
|
|
|
node_name = 'Organization' + attr[0].upper() + attr[1:] |
|
317
|
|
|
for node in org_node.findall(addns(node_name)): |
|
318
|
|
|
lang = getlang(node) |
|
319
|
|
|
lang_dict = languages.setdefault(lang, {}) |
|
320
|
|
|
lang_dict[attr] = node.text |
|
321
|
|
|
|
|
322
|
|
|
if None in languages.keys(): |
|
323
|
|
|
del languages[None] |
|
324
|
|
|
return languages |
|
325
|
|
|
|
|
326
|
|
|
@staticmethod |
|
327
|
|
|
def entity_logos(entity): |
|
328
|
|
|
xmllogos = entity.xpath(".//mdui:UIInfo" |
|
329
|
|
|
"//mdui:Logo", |
|
330
|
|
|
namespaces=NAMESPACES) |
|
331
|
|
|
logos = [] |
|
332
|
|
|
for logo_node in xmllogos: |
|
333
|
|
|
if logo_node.text is None: |
|
334
|
|
|
continue # the file attribute is required |
|
335
|
|
|
logo = {} |
|
336
|
|
|
logo['width'] = int(logo_node.attrib.get('width', '0')) |
|
337
|
|
|
logo['height'] = int(logo_node.attrib.get('height', '0')) |
|
338
|
|
|
logo['file'] = logo_node.text |
|
339
|
|
|
logo['lang'] = getlang(logo_node) |
|
340
|
|
|
logos.append(logo) |
|
341
|
|
|
return logos |
|
342
|
|
|
|
|
343
|
|
|
@staticmethod |
|
344
|
|
|
def registration_information(entity): |
|
345
|
|
|
reg_info = entity.xpath(".//md:Extensions" |
|
346
|
|
|
"//mdrpi:RegistrationInfo", |
|
347
|
|
|
namespaces=NAMESPACES) |
|
348
|
|
|
info = {} |
|
349
|
|
|
if reg_info: |
|
350
|
|
|
info['authority'] = reg_info[0].attrib.get('registrationAuthority') |
|
351
|
|
|
info['instant'] = reg_info[0].attrib.get('registrationInstant') |
|
352
|
|
|
return info |
|
353
|
|
|
|
|
354
|
|
|
@staticmethod |
|
355
|
|
|
def registration_policy(entity): |
|
356
|
|
|
reg_policy = entity.xpath(".//md:Extensions" |
|
357
|
|
|
"//mdrpi:RegistrationInfo" |
|
358
|
|
|
"//mdrpi:RegistrationPolicy", |
|
359
|
|
|
namespaces=NAMESPACES) |
|
360
|
|
|
languages = {} |
|
361
|
|
|
for dn_node in reg_policy: |
|
362
|
|
|
lang = getlang(dn_node) |
|
363
|
|
|
if lang is None: |
|
364
|
|
|
continue # the lang attribute is required |
|
365
|
|
|
|
|
366
|
|
|
languages[lang] = dn_node.text |
|
367
|
|
|
|
|
368
|
|
|
return languages |
|
369
|
|
|
|
|
370
|
|
|
@staticmethod |
|
371
|
|
|
def entity_attribute_scope(entity): |
|
372
|
|
|
scope_node = entity.xpath(".//md:Extensions" |
|
373
|
|
|
"//shibmd:Scope", |
|
374
|
|
|
namespaces=NAMESPACES) |
|
375
|
|
|
|
|
376
|
|
|
scope = [] |
|
377
|
|
|
for cur_scope in scope_node: |
|
378
|
|
|
if not cur_scope.text in scope: |
|
379
|
|
|
scope.append(cur_scope.text) |
|
380
|
|
|
return scope |
|
381
|
|
|
|
|
382
|
|
|
@staticmethod |
|
383
|
|
|
def entity_requested_attributes(entity): |
|
384
|
|
|
xmllogos = entity.xpath(".//md:AttributeConsumingService" |
|
385
|
|
|
"//md:RequestedAttribute", |
|
386
|
|
|
namespaces=NAMESPACES) |
|
387
|
|
|
attrs = {} |
|
388
|
|
|
attrs['required'] = [] |
|
389
|
|
|
attrs['optional'] = [] |
|
390
|
|
|
for attr_node in xmllogos: |
|
391
|
|
|
required = attr_node.attrib.get('isRequired', 'false') |
|
392
|
|
|
index = 'required' if required == 'true' else 'optional' |
|
393
|
|
|
attrs[index].append([attr_node.attrib.get( |
|
394
|
|
|
'Name', None), attr_node.attrib.get('FriendlyName', None)]) |
|
395
|
|
|
return attrs |
|
396
|
|
|
|
|
397
|
|
|
@staticmethod |
|
398
|
|
|
def entity_contacts(entity): |
|
399
|
|
|
contacts = entity.xpath(".//md:ContactPerson", |
|
400
|
|
|
namespaces=NAMESPACES) |
|
401
|
|
|
cont = [] |
|
402
|
|
|
for cont_node in contacts: |
|
403
|
|
|
c_type = cont_node.attrib.get('contactType', '') |
|
404
|
|
|
name = cont_node.xpath(".//md:GivenName", namespaces=NAMESPACES) |
|
405
|
|
|
if name: |
|
406
|
|
|
name = name[0].text |
|
407
|
|
|
else: |
|
408
|
|
|
name = None |
|
409
|
|
|
surname = cont_node.xpath(".//md:SurName", namespaces=NAMESPACES) |
|
410
|
|
|
if surname: |
|
411
|
|
|
surname = surname[0].text |
|
412
|
|
|
else: |
|
413
|
|
|
surname = None |
|
414
|
|
|
email = cont_node.xpath( |
|
415
|
|
|
".//md:EmailAddress", namespaces=NAMESPACES) |
|
416
|
|
|
if email: |
|
417
|
|
|
email = email[0].text |
|
418
|
|
|
else: |
|
419
|
|
|
email = None |
|
420
|
|
|
cont.append({'type': c_type, 'name': name, |
|
421
|
|
|
'surname': surname, 'email': email}) |
|
422
|
|
|
return cont |
|
423
|
|
|
|