#!/usr/bin/env python3 """File each module's known vulnerabilities as one security item atop todo.md's next release. Usage: file_vulnerabilities.py Exits 1 while any vulnerability is known, filed now or before, and 0 on a clean report. """ import ast import json import re import sys from dataclasses import dataclass, field ROW = re.compile(r"^\| (\d+) \| ([^|]+?) \| [^|]*\| \*\*", re.M) WEIGHT = re.compile(r"^\| ([\d.]+) \| ([\d.]+) \|$", re.M) NEXT_ID = re.compile(r"`Next ID = (\d+)`") SCORES = {"R": 1, "S": 1, "C": 1, "A": 8, "G": 8} GOAL = "1.1" @dataclass class Module: path: str found_in: str fixed: list[str] = field(default_factory=list) osvs: dict[str, dict] = field(default_factory=dict) @property def name(self) -> str: return "Go" if self.path == "stdlib" else f"`{self.path}`" def version(self, version: str) -> str: return version.removeprefix("v") if self.path == "stdlib" else version @property def carried(self) -> str: return f"{self.name} {self.version(self.found_in)}" def messages(report: str): decoder, at = json.JSONDecoder(), 0 while at < len(report): if report[at].isspace(): at += 1 continue message, at = decoder.raw_decode(report, at) yield message def vulnerable_modules(report: str) -> list[Module]: """Each module govulncheck found a vulnerability in, Go (stdlib) first, then by path.""" osvs, modules = {}, {} for message in messages(report): if "osv" in message: osv = message["osv"] osvs[osv["id"]] = {"id": osv["id"], "aliases": osv.get("aliases", []), "summary": osv.get("summary", "")} elif "finding" in message: finding = message["finding"] frame = finding["trace"][0] module = modules.setdefault(frame["module"], Module(frame["module"], frame["version"])) module.osvs[finding["osv"]] = osvs[finding["osv"]] module.fixed.append(finding.get("fixed_version", "")) return sorted(modules.values(), key=lambda m: (m.path != "stdlib", m.path)) def version_key(version: str): return [int(part) for part in re.findall(r"\d+", version)] def evaluate(node, values): match node: case ast.Constant(value=value): return value case ast.Name(id=name): return values[name] case ast.UnaryOp(op=ast.USub(), operand=operand): return -evaluate(operand, values) case ast.BinOp(left=left, op=ast.Add(), right=right): return evaluate(left, values) + evaluate(right, values) case ast.BinOp(left=left, op=ast.Sub(), right=right): return evaluate(left, values) - evaluate(right, values) case ast.BinOp(left=left, op=ast.Mult(), right=right): return evaluate(left, values) * evaluate(right, values) case ast.BinOp(left=left, op=ast.Div(), right=right): return evaluate(left, values) / evaluate(right, values) raise ValueError(f"todo.md's formula holds {ast.dump(node)}, which this script cannot evaluate") def score(todo: str) -> str: formula = re.search(r"`Score = (.+?)`", todo).group(1) values = dict(SCORES, W=float(dict(WEIGHT.findall(todo))[GOAL])) return f"{evaluate(ast.parse(formula, mode='eval').body, values):.1f}" def tagline(module: Module) -> str: if "" in module.fixed: return f"Ship no release while fejkdata carries {module.carried}, some of whose known vulnerabilities no version fixes yet." fixed = module.version(max(module.fixed, key=version_key)) if module.path == "stdlib": return f"Raise Go to {fixed}, so the release binaries carry none of {module.carried}'s known vulnerabilities." return f"Raise {module.name} to {fixed}, so fejkdata requires no version of it with a known vulnerability." def detail(module: Module) -> str: lines = [] for osv in sorted(module.osvs.values(), key=lambda o: o["id"]): aliases = f" ({', '.join(osv['aliases'])})" if osv["aliases"] else "" summary = f": {osv['summary']}" if osv["summary"] else "" lines.append(f"- {osv['id']}{aliases}{summary}") return f"govulncheck found these in {module.carried}:\n\n" + "\n".join(lines) + "\n" def file_items(modules: list[Module], todo: str) -> tuple[str, list[int]]: """Return todo.md with an item for each module whose vulnerabilities it does not yet name, and the items' IDs.""" filed = [] for module in modules: if all(osv in todo for osv in module.osvs): continue first = ROW.search(todo) if first is None: raise ValueError("todo.md has no item to take the next release from") next_id = int(NEXT_ID.search(todo).group(1)) line = tagline(module) scores = " | ".join(str(SCORES[k]) for k in "RSCAG") row = f"| {next_id} | {first.group(2)} | security | **{line}** | {scores} | {GOAL} | {score(todo)} |\n" todo = todo[: first.start()] + row + todo[first.start() :] todo = todo.replace(f"`Next ID = {next_id}`", f"`Next ID = {next_id + 1}`", 1) todo = todo.replace("## Details\n\n", f"## Details\n\n### {next_id}. {line}\n\n{detail(module)}\n", 1) filed.append(next_id) return todo, filed def main(argv: list[str]) -> int: report_path, todo_path = argv with open(report_path, encoding="utf-8") as f: modules = vulnerable_modules(f.read()) if not modules: print("No known vulnerabilities.") return 0 count = sum(len(m.osvs) for m in modules) known = f"{count} known {'vulnerability' if count == 1 else 'vulnerabilities'}" with open(todo_path, encoding="utf-8") as f: todo, filed = file_items(modules, f.read()) if not filed: print(f"{known}, already planned in todo.md.") return 1 with open(todo_path, "w", encoding="utf-8") as f: f.write(todo) items = ", ".join(map(str, filed)) print(f"{known}; todo.md {'item ' + items + ' plans' if len(filed) == 1 else 'items ' + items + ' plan'} the fix.") return 1 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))