3 # This program is licensed under the GPL, version 2
5 # WARNING: this code is only tested by a few people and should NOT be used
6 # on production systems at this stage. There are possible security holes and probably
7 # bugs in this code. If you test it please report ANY success or failure to
8 # me (genone@gentoo.org).
10 # The following planned features are currently on hold:
11 # - getting GLSAs from http/ftp servers (not really useful without the fixed ebuilds)
12 # - GPG signing/verification (until key policy is clear)
14 __author__ = "Marius Mauch <genone@gentoo.org>"
22 import xml.dom.minidom
23 from StringIO import StringIO
25 if sys.version_info[0:2] < (2,3):
26 raise NotImplementedError("Python versions below 2.3 have broken XML code " \
27 +"and are not supported")
32 sys.path.insert(0, "/usr/lib/portage/pym")
35 # Note: the space for rgt and rlt is important !!
36 opMapping = {"le": "<=", "lt": "<", "eq": "=", "gt": ">", "ge": ">=",
37 "rge": ">=~", "rle": "<=~", "rgt": " >~", "rlt": " <~"}
38 NEWLINE_ESCAPE = "!;\\n" # some random string to mark newlines that should be preserved
39 SPACE_ESCAPE = "!;_" # some random string to mark spaces that should be preserved
41 def center(text, width):
43 Returns a string containing I{text} that is padded with spaces on both
44 sides. If C{len(text) >= width} I{text} is returned unchanged.
47 @param text: the text to be embedded
49 @param width: the minimum length of the returned string
51 @return: the expanded string or I{text}
53 if len(text) >= width:
55 margin = (width-len(text))/2
58 if 2*margin + len(text) == width:
60 elif 2*margin + len(text) + 1 == width:
61 rValue += " "*(margin+1)
65 def wrap(text, width, caption=""):
67 Wraps the given text at column I{width}, optionally indenting
68 it so that no text is under I{caption}. It's possible to encode
69 hard linebreaks in I{text} with L{NEWLINE_ESCAPE}.
72 @param text: the text to be wrapped
74 @param width: the column at which the text should be wrapped
76 @param caption: this string is inserted at the beginning of the
77 return value and the paragraph is indented up to
80 @return: the wrapped and indented paragraph
84 text = text.replace(2*NEWLINE_ESCAPE, NEWLINE_ESCAPE+" "+NEWLINE_ESCAPE)
86 indentLevel = len(caption)+1
91 line = " "*indentLevel
92 if len(line)+len(w.replace(NEWLINE_ESCAPE, ""))+1 > width:
94 line = " "*indentLevel+w.replace(NEWLINE_ESCAPE, "\n")
95 elif w.find(NEWLINE_ESCAPE) >= 0:
96 if len(line.strip()) > 0:
97 rValue += line+" "+w.replace(NEWLINE_ESCAPE, "\n")
99 rValue += line+w.replace(NEWLINE_ESCAPE, "\n")
100 line = " "*indentLevel
102 if len(line.strip()) > 0:
107 rValue += line.replace(NEWLINE_ESCAPE, "\n")
108 rValue = rValue.replace(SPACE_ESCAPE, " ")
111 def checkconfig(myconfig):
113 takes a portage.config instance and adds GLSA specific keys if
114 they are not present. TO-BE-REMOVED (should end up in make.*)
117 "GLSA_DIR": portage.settings["PORTDIR"]+"/metadata/glsa/",
118 "GLSA_PREFIX": "glsa-",
119 "GLSA_SUFFIX": ".xml",
120 "CHECKFILE": "/var/lib/portage/glsa_injected",
121 "GLSA_SERVER": "www.gentoo.org/security/en/glsa/", # not completely implemented yet
122 "CHECKMODE": "local", # not completely implemented yet
125 for k in mysettings.keys():
126 if k not in myconfig:
127 myconfig[k] = mysettings[k]
130 def get_glsa_list(repository, myconfig):
132 Returns a list of all available GLSAs in the given repository
133 by comparing the filelist there with the pattern described in
136 @type repository: String
137 @param repository: The directory or an URL that contains GLSA files
138 (Note: not implemented yet)
139 @type myconfig: portage.config
140 @param myconfig: a GLSA aware config instance (see L{checkconfig})
142 @rtype: List of Strings
143 @return: a list of GLSA IDs in this repository
145 # TODO: remote fetch code for listing
149 if not os.access(repository, os.R_OK):
151 dirlist = os.listdir(repository)
152 prefix = myconfig["GLSA_PREFIX"]
153 suffix = myconfig["GLSA_SUFFIX"]
157 if f[:len(prefix)] == prefix and f[-1*len(suffix):] == suffix:
158 rValue.append(f[len(prefix):-1*len(suffix)])
163 def getListElements(listnode):
165 Get all <li> elements for a given <ol> or <ul> node.
167 @type listnode: xml.dom.Node
168 @param listnode: <ul> or <ol> list to get the elements for
169 @rtype: List of Strings
170 @return: a list that contains the value of the <li> elements
172 if not listnode.nodeName in ["ul", "ol"]:
173 raise GlsaFormatException("Invalid function call: listnode is not <ul> or <ol>")
174 rValue = [getText(li, format="strip") \
175 for li in listnode.childNodes \
176 if li.nodeType == xml.dom.Node.ELEMENT_NODE]
179 def getText(node, format, textfd = None):
181 This is the main parser function. It takes a node and traverses
182 recursive over the subnodes, getting the text of each (and the
183 I{link} attribute for <uri> and <mail>). Depending on the I{format}
184 parameter the text might be formatted by adding/removing newlines,
185 tabs and spaces. This function is only useful for the GLSA DTD,
186 it's not applicable for other DTDs.
188 @type node: xml.dom.Node
189 @param node: the root node to start with the parsing
191 @param format: this should be either I{strip}, I{keep} or I{xml}
192 I{keep} just gets the text and does no formatting.
193 I{strip} replaces newlines and tabs with spaces and
194 replaces multiple spaces with one space.
195 I{xml} does some more formatting, depending on the
196 type of the encountered nodes.
197 @type textfd: writable file-like object
198 @param textfd: the file-like object to write the output to
200 @return: the (formatted) content of the node and its subnodes
201 except if textfd was not none
208 if format in ["strip", "keep"]:
209 if node.nodeName in ["uri", "mail"]:
210 textfd.write(node.childNodes[0].data+": "+node.getAttribute("link"))
212 for subnode in node.childNodes:
213 if subnode.nodeName == "#text":
214 textfd.write(subnode.data)
216 getText(subnode, format, textfd)
217 else: # format = "xml"
218 for subnode in node.childNodes:
219 if subnode.nodeName == "p":
220 for p_subnode in subnode.childNodes:
221 if p_subnode.nodeName == "#text":
222 textfd.write(p_subnode.data.strip())
223 elif p_subnode.nodeName in ["uri", "mail"]:
224 textfd.write(p_subnode.childNodes[0].data)
225 textfd.write(" ( "+p_subnode.getAttribute("link")+" )")
226 textfd.write(NEWLINE_ESCAPE)
227 elif subnode.nodeName == "ul":
228 for li in getListElements(subnode):
229 textfd.write("-"+SPACE_ESCAPE+li+NEWLINE_ESCAPE+" ")
230 elif subnode.nodeName == "ol":
232 for li in getListElements(subnode):
234 textfd.write(str(i)+"."+SPACE_ESCAPE+li+NEWLINE_ESCAPE+" ")
235 elif subnode.nodeName == "code":
236 textfd.write(getText(subnode, format="keep").lstrip().replace("\n", NEWLINE_ESCAPE))
237 textfd.write(NEWLINE_ESCAPE)
238 elif subnode.nodeName == "#text":
239 textfd.write(subnode.data)
241 raise GlsaFormatException("Invalid Tag found: ", subnode.nodeName)
244 rValue = textfd.getvalue()
245 if format == "strip":
246 rValue = rValue.strip(" \n\t")
247 rValue = re.sub("[\s]{2,}", " ", rValue)
250 def getMultiTagsText(rootnode, tagname, format):
252 Returns a list with the text of all subnodes of type I{tagname}
253 under I{rootnode} (which itself is not parsed) using the given I{format}.
255 @type rootnode: xml.dom.Node
256 @param rootnode: the node to search for I{tagname}
257 @type tagname: String
258 @param tagname: the name of the tags to search for
260 @param format: see L{getText}
261 @rtype: List of Strings
262 @return: a list containing the text of all I{tagname} childnodes
264 rValue = [getText(e, format) \
265 for e in rootnode.getElementsByTagName(tagname)]
268 def makeAtom(pkgname, versionNode):
270 creates from the given package name and information in the
271 I{versionNode} a (syntactical) valid portage atom.
273 @type pkgname: String
274 @param pkgname: the name of the package for this atom
275 @type versionNode: xml.dom.Node
276 @param versionNode: a <vulnerable> or <unaffected> Node that
277 contains the version information for this atom
279 @return: the portage atom
281 rValue = opMapping[versionNode.getAttribute("range")] \
283 + "-" + getText(versionNode, format="strip")
285 slot = versionNode.getAttribute("slot").strip()
289 if slot and slot != "*":
293 def makeVersion(versionNode):
295 creates from the information in the I{versionNode} a
296 version string (format <op><version>).
298 @type versionNode: xml.dom.Node
299 @param versionNode: a <vulnerable> or <unaffected> Node that
300 contains the version information for this atom
302 @return: the version string
304 rValue = opMapping[versionNode.getAttribute("range")] \
305 +getText(versionNode, format="strip")
307 slot = versionNode.getAttribute("slot").strip()
311 if slot and slot != "*":
315 def match(atom, portdbname, match_type="default"):
317 wrapper that calls revisionMatch() or portage.dbapi.match() depending on
321 @param atom: a <~ or >~ atom or a normal portage atom that contains the atom to match against
322 @type portdb: portage.dbapi
323 @param portdb: one of the portage databases to use as information source
324 @type match_type: string
325 @param match_type: if != "default" passed as first argument to dbapi.xmatch
326 to apply the wanted visibility filters
328 @rtype: list of strings
329 @return: a list with the matching versions
331 db = portage.db["/"][portdbname].dbapi
333 return revisionMatch(atom, db, match_type=match_type)
334 elif match_type == "default" or not hasattr(db, "xmatch"):
335 return db.match(atom)
337 return db.xmatch(match_type, atom)
339 def revisionMatch(revisionAtom, portdb, match_type="default"):
341 handler for the special >~, >=~, <=~ and <~ atoms that are supposed to behave
342 as > and < except that they are limited to the same version, the range only
343 applies to the revision part.
345 @type revisionAtom: string
346 @param revisionAtom: a <~ or >~ atom that contains the atom to match against
347 @type portdb: portage.dbapi
348 @param portdb: one of the portage databases to use as information source
349 @type match_type: string
350 @param match_type: if != "default" passed as first argument to portdb.xmatch
351 to apply the wanted visibility filters
353 @rtype: list of strings
354 @return: a list with the matching versions
356 if match_type == "default" or not hasattr(portdb, "xmatch"):
357 if ":" in revisionAtom:
358 mylist = portdb.match(re.sub(r'-r[0-9]+(:[^ ]+)?$', r'\1', revisionAtom[2:]))
360 mylist = portdb.match(re.sub("-r[0-9]+$", "", revisionAtom[2:]))
362 if ":" in revisionAtom:
363 mylist = portdb.xmatch(match_type, re.sub(r'-r[0-9]+(:[^ ]+)?$', r'\1', revisionAtom[2:]))
365 mylist = portdb.xmatch(match_type, re.sub("-r[0-9]+$", "", revisionAtom[2:]))
368 r1 = portage.pkgsplit(v)[-1][1:]
369 r2 = portage.pkgsplit(revisionAtom[3:])[-1][1:]
370 if eval(r1+" "+revisionAtom[0:2]+" "+r2):
375 def getMinUpgrade(vulnerableList, unaffectedList, minimize=True):
377 Checks if the systemstate is matching an atom in
378 I{vulnerableList} and returns string describing
379 the lowest version for the package that matches an atom in
380 I{unaffectedList} and is greater than the currently installed
381 version. It will return an empty list if the system is affected,
382 and no upgrade is possible or None if the system is not affected.
383 Both I{vulnerableList} and I{unaffectedList} should have the
386 @type vulnerableList: List of Strings
387 @param vulnerableList: atoms matching vulnerable package versions
388 @type unaffectedList: List of Strings
389 @param unaffectedList: atoms matching unaffected package versions
390 @type minimize: Boolean
391 @param minimize: True for a least-change upgrade, False for emerge-like algorithm
393 @rtype: String | None
394 @return: the lowest unaffected version that is greater than
395 the installed version.
398 v_installed = reduce(operator.add, [match(v, "vartree") for v in vulnerableList], [])
399 u_installed = reduce(operator.add, [match(u, "vartree") for u in unaffectedList], [])
401 # remove all unaffected atoms from vulnerable list
402 v_installed = list(set(v_installed).difference(set(u_installed)))
407 # this tuple holds all vulnerable atoms, and the related upgrade atom
409 avail_updates = set()
410 for u in unaffectedList:
411 # TODO: This had match_type="match-all" before. I don't think it should
412 # since we disregarded masked items later anyway (match(=rValue, "porttree"))
413 avail_updates.update(match(u, "porttree"))
414 # if an atom is already installed, we should not consider it for upgrades
415 avail_updates.difference_update(u_installed)
417 for vuln in v_installed:
419 for c in avail_updates:
420 c_pv = portage.catpkgsplit(c)
421 i_pv = portage.catpkgsplit(vuln)
422 if portage.pkgcmp(c_pv[1:], i_pv[1:]) > 0 \
424 or (minimize ^ (portage.pkgcmp(c_pv[1:], portage.catpkgsplit(rValue)[1:]) > 0))) \
425 and portage.db["/"]["porttree"].dbapi.aux_get(c, ["SLOT"]) == portage.db["/"]["vartree"].dbapi.aux_get(vuln, ["SLOT"]):
426 update = c_pv[0]+"/"+c_pv[1]+"-"+c_pv[2]
427 if c_pv[3] != "r0": # we don't like -r0 for display
428 update += "-"+c_pv[3]
429 vuln_update.append([vuln, update])
433 def format_date(datestr):
435 Takes a date (announced, revised) date from a GLSA and formats
436 it as readable text (i.e. "January 1, 2008").
439 @param date: the date string to reformat
441 @return: a reformatted string, or the original string
442 if it cannot be reformatted.
444 splitdate = datestr.split("-", 2)
445 if len(splitdate) != 3:
448 # This cannot raise an error as we use () instead of []
449 splitdate = (int(x) for x in splitdate)
451 from datetime import date
457 # TODO We could format to local date format '%x' here?
458 return d.strftime("%B %d, %Y")
460 # simple Exception classes to catch specific errors
461 class GlsaTypeException(Exception):
462 def __init__(self, doctype):
463 Exception.__init__(self, "wrong DOCTYPE: %s" % doctype)
465 class GlsaFormatException(Exception):
468 class GlsaArgumentException(Exception):
471 # GLSA xml data wrapper class
474 This class is a wrapper for the XML data and provides methods to access
475 and display the contained data.
477 def __init__(self, myid, myconfig):
479 Simple constructor to set the ID, store the config and gets the
480 XML data by calling C{self.read()}.
483 @param myid: String describing the id for the GLSA object (standard
484 GLSAs have an ID of the form YYYYMM-nn) or an existing
485 filename containing a GLSA.
486 @type myconfig: portage.config
487 @param myconfig: the config that should be used for this object.
489 if re.match(r'\d{6}-\d{2}', myid):
491 elif os.path.exists(myid):
494 raise GlsaArgumentException("Given ID "+myid+" isn't a valid GLSA ID or filename.")
496 self.config = myconfig
501 Here we build the filename from the config and the ID and pass
502 it to urllib to fetch it from the filesystem or a remote server.
507 if self.config["CHECKMODE"] == "local":
508 repository = "file://" + self.config["GLSA_DIR"]
510 repository = self.config["GLSA_SERVER"]
511 if self.type == "file":
512 myurl = "file://"+self.nr
514 myurl = repository + self.config["GLSA_PREFIX"] + str(self.nr) + self.config["GLSA_SUFFIX"]
515 self.parse(urllib.urlopen(myurl))
518 def parse(self, myfile):
520 This method parses the XML file and sets up the internal data
521 structures by calling the different helper functions in this
525 @param myfile: Filename to grab the XML data from
529 self.DOM = xml.dom.minidom.parse(myfile)
530 if not self.DOM.doctype:
531 raise GlsaTypeException(None)
532 elif self.DOM.doctype.systemId == "http://www.gentoo.org/dtd/glsa.dtd":
534 elif self.DOM.doctype.systemId == "http://www.gentoo.org/dtd/glsa-2.dtd":
537 raise GlsaTypeException(self.DOM.doctype.systemId)
538 myroot = self.DOM.getElementsByTagName("glsa")[0]
539 if self.type == "id" and myroot.getAttribute("id") != self.nr:
540 raise GlsaFormatException("filename and internal id don't match:" + myroot.getAttribute("id") + " != " + self.nr)
542 # the simple (single, required, top-level, #PCDATA) tags first
543 self.title = getText(myroot.getElementsByTagName("title")[0], format="strip")
544 self.synopsis = getText(myroot.getElementsByTagName("synopsis")[0], format="strip")
545 self.announced = format_date(getText(myroot.getElementsByTagName("announced")[0], format="strip"))
548 # Support both formats of revised:
549 # <revised>December 30, 2007: 02</revised>
550 # <revised count="2">2007-12-30</revised>
551 revisedEl = myroot.getElementsByTagName("revised")[0]
552 self.revised = getText(revisedEl, format="strip")
553 if (revisedEl.attributes.has_key("count")):
554 count = revisedEl.getAttribute("count")
555 elif (self.revised.find(":") >= 0):
556 (self.revised, count) = self.revised.split(":")
558 self.revised = format_date(self.revised)
561 self.count = int(count)
563 # TODO should this rais a GlsaFormatException?
566 # now the optional and 0-n toplevel, #PCDATA tags and references
568 self.access = getText(myroot.getElementsByTagName("access")[0], format="strip")
571 self.bugs = getMultiTagsText(myroot, "bug", format="strip")
572 self.references = getMultiTagsText(myroot.getElementsByTagName("references")[0], "uri", format="keep")
574 # and now the formatted text elements
575 self.description = getText(myroot.getElementsByTagName("description")[0], format="xml")
576 self.workaround = getText(myroot.getElementsByTagName("workaround")[0], format="xml")
577 self.resolution = getText(myroot.getElementsByTagName("resolution")[0], format="xml")
578 self.impact_text = getText(myroot.getElementsByTagName("impact")[0], format="xml")
579 self.impact_type = myroot.getElementsByTagName("impact")[0].getAttribute("type")
581 self.background = getText(myroot.getElementsByTagName("background")[0], format="xml")
585 # finally the interesting tags (product, affected, package)
586 self.glsatype = myroot.getElementsByTagName("product")[0].getAttribute("type")
587 self.product = getText(myroot.getElementsByTagName("product")[0], format="strip")
588 self.affected = myroot.getElementsByTagName("affected")[0]
590 for p in self.affected.getElementsByTagName("package"):
591 name = p.getAttribute("name")
592 if not self.packages.has_key(name):
593 self.packages[name] = []
595 tmp["arch"] = p.getAttribute("arch")
596 tmp["auto"] = (p.getAttribute("auto") == "yes")
597 tmp["vul_vers"] = [makeVersion(v) for v in p.getElementsByTagName("vulnerable")]
598 tmp["unaff_vers"] = [makeVersion(v) for v in p.getElementsByTagName("unaffected")]
599 tmp["vul_atoms"] = [makeAtom(name, v) for v in p.getElementsByTagName("vulnerable")]
600 tmp["unaff_atoms"] = [makeAtom(name, v) for v in p.getElementsByTagName("unaffected")]
601 self.packages[name].append(tmp)
602 # TODO: services aren't really used yet
603 self.services = self.affected.getElementsByTagName("service")
606 def dump(self, outstream=sys.stdout, encoding="utf-8"):
608 Dumps a plaintext representation of this GLSA to I{outfile} or
609 B{stdout} if it is ommitted. You can specify an alternate
610 I{encoding} if needed (default is utf-8).
612 @type outstream: File
613 @param outfile: Stream that should be used for writing
614 (defaults to sys.stdout)
616 outstream = codecs.getwriter(encoding)(outstream)
617 width = int(self.config["PRINTWIDTH"])
618 outstream.write(center("GLSA %s: \n%s" % (self.nr, self.title), width)+"\n")
619 outstream.write((width*"=")+"\n")
620 outstream.write(wrap(self.synopsis, width, caption="Synopsis: ")+"\n")
621 outstream.write("Announced on: %s\n" % self.announced)
622 outstream.write("Last revised on: %s : %02d\n\n" % (self.revised, self.count))
623 if self.glsatype == "ebuild":
624 for k in self.packages.keys():
625 pkg = self.packages[k]
627 vul_vers = "".join(path["vul_vers"])
628 unaff_vers = "".join(path["unaff_vers"])
629 outstream.write("Affected package: %s\n" % k)
630 outstream.write("Affected archs: ")
631 if path["arch"] == "*":
632 outstream.write("All\n")
634 outstream.write("%s\n" % path["arch"])
635 outstream.write("Vulnerable: %s\n" % vul_vers)
636 outstream.write("Unaffected: %s\n\n" % unaff_vers)
637 elif self.glsatype == "infrastructure":
639 if len(self.bugs) > 0:
640 outstream.write("\nRelated bugs: ")
641 outstream.write(", ".join(self.bugs))
642 outstream.write("\n")
644 outstream.write("\n"+wrap(self.background, width, caption="Background: "))
645 outstream.write("\n"+wrap(self.description, width, caption="Description: "))
646 outstream.write("\n"+wrap(self.impact_text, width, caption="Impact: "))
647 outstream.write("\n"+wrap(self.workaround, width, caption="Workaround: "))
648 outstream.write("\n"+wrap(self.resolution, width, caption="Resolution: "))
649 myreferences = " ".join(r.replace(" ", SPACE_ESCAPE)+NEWLINE_ESCAPE for r in self.references)
650 outstream.write("\n"+wrap(myreferences, width, caption="References: "))
651 outstream.write("\n")
653 def isVulnerable(self):
655 Tests if the system is affected by this GLSA by checking if any
656 vulnerable package versions are installed. Also checks for affected
660 @returns: True if the system is affected, False if not
663 for k in self.packages.keys():
664 pkg = self.packages[k]
666 if path["arch"] == "*" or self.config["ARCH"] in path["arch"].split():
667 for v in path["vul_atoms"]:
669 or (None != getMinUpgrade([v,], path["unaff_atoms"]))
672 def isInjected(self):
674 Looks if the GLSA ID is in the GLSA checkfile to check if this
675 GLSA should be marked as applied.
678 @returns: True if the GLSA is in the inject file, False if not
680 if not os.access(self.config["CHECKFILE"], os.R_OK):
682 aList = portage.grabfile(self.config["CHECKFILE"])
683 return (self.nr in aList)
687 Puts the ID of this GLSA into the GLSA checkfile, so it won't
688 show up on future checks. Should be called after a GLSA is
689 applied or on explicit user request.
694 if not self.isInjected():
695 checkfile = open(self.config["CHECKFILE"], "a+")
696 checkfile.write(self.nr+"\n")
700 def getMergeList(self, least_change=True):
702 Returns the list of package-versions that have to be merged to
703 apply this GLSA properly. The versions are as low as possible
704 while avoiding downgrades (see L{getMinUpgrade}).
706 @type least_change: Boolean
707 @param least_change: True if the smallest possible upgrade should be selected,
708 False for an emerge-like algorithm
709 @rtype: List of Strings
710 @return: list of package-versions that have to be merged
712 return list(set(update for (vuln, update) in self.getAffectionTable(least_change) if update))
714 def getAffectionTable(self, least_change=True):
716 Will initialize the self.systemAffection list of
717 atoms installed on the system that are affected
718 by this GLSA, and the atoms that are minimal upgrades.
721 for pkg in self.packages.keys():
722 for path in self.packages[pkg]:
723 update = getMinUpgrade(path["vul_atoms"], path["unaff_atoms"], minimize=least_change)
725 systemAffection.extend(update)
726 return systemAffection