Answer architecture round 2: 1 🔴, 3 🟡; read the report and edit the plan in two functions, propose only while no pull request is open, and test filing into the repository's own todo.md
This commit is contained in:
@@ -9,13 +9,28 @@ 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:
|
||||
found_in: str
|
||||
fixed: list[str] = field(default_factory=list)
|
||||
osvs: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
def name(self, path: str) -> str:
|
||||
return "Go" if path == "stdlib" else f"`{path}`"
|
||||
|
||||
def version(self, path: str, version: str) -> str:
|
||||
return version.removeprefix("v") if path == "stdlib" else version
|
||||
|
||||
|
||||
def messages(report: str):
|
||||
decoder, at = json.JSONDecoder(), 0
|
||||
while at < len(report):
|
||||
@@ -26,17 +41,25 @@ def messages(report: str):
|
||||
yield message
|
||||
|
||||
|
||||
def vulnerable_modules(report: str) -> dict[str, Module]:
|
||||
"""Each module govulncheck found a vulnerability in, by module path, stdlib being Go."""
|
||||
osvs, modules = {}, {}
|
||||
for message in messages(report):
|
||||
if "osv" in message:
|
||||
osvs[message["osv"]["id"]] = message["osv"]
|
||||
elif "finding" in message:
|
||||
finding = message["finding"]
|
||||
frame = finding["trace"][0]
|
||||
module = modules.setdefault(frame["module"], Module(frame["version"]))
|
||||
module.osvs[finding["osv"]] = osvs[finding["osv"]]
|
||||
module.fixed.append(finding.get("fixed_version", ""))
|
||||
return modules
|
||||
|
||||
|
||||
def version_key(version: str):
|
||||
return [int(part) for part in re.findall(r"\d+", version)]
|
||||
|
||||
|
||||
def score(todo: str) -> str:
|
||||
formula = re.search(r"`Score = (.+?)`", todo).group(1)
|
||||
weights = dict(WEIGHT.findall(todo))
|
||||
values = dict(SCORES, W=float(weights[GOAL]))
|
||||
return f"{evaluate(ast.parse(formula, mode='eval').body, values):.1f}"
|
||||
|
||||
|
||||
def evaluate(node, values):
|
||||
match node:
|
||||
case ast.Constant(value=value):
|
||||
@@ -45,75 +68,81 @@ def evaluate(node, values):
|
||||
return values[name]
|
||||
case ast.UnaryOp(op=ast.USub(), operand=operand):
|
||||
return -evaluate(operand, values)
|
||||
case ast.BinOp(left=left, op=op, right=right):
|
||||
a, b = evaluate(left, values), evaluate(right, values)
|
||||
return {ast.Add: a + b, ast.Sub: a - b, ast.Mult: a * b, ast.Div: a / b if b else 0}[type(op)]
|
||||
raise SystemExit(f"todo.md's formula holds {ast.dump(node)}, which this script cannot evaluate")
|
||||
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 item(module: str, found_in: str, fixed: str, osvs: list[dict]) -> tuple[str, str]:
|
||||
if module == "stdlib":
|
||||
tagline = f"Raise Go to {fixed.lstrip('v')}, so the release binaries carry none of its known vulnerabilities."
|
||||
where = f"Go {found_in.lstrip('v')}"
|
||||
else:
|
||||
tagline = f"Raise `{module}` to {fixed}, so the module requires none of its known vulnerabilities."
|
||||
where = f"`{module}` {found_in}"
|
||||
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(path: str, module: Module) -> str:
|
||||
carried = f"{module.name(path)} {module.version(path, module.found_in)}"
|
||||
if "" in module.fixed:
|
||||
return f"Ship no release while fejkdata carries {carried}, some of whose known vulnerabilities no version fixes yet."
|
||||
fixed = module.version(path, max(module.fixed, key=version_key))
|
||||
if path == "stdlib":
|
||||
return f"Raise Go to {fixed}, so the release binaries carry none of {carried}'s known vulnerabilities."
|
||||
return f"Raise {module.name(path)} to {fixed}, so fejkdata requires no version of it with a known vulnerability."
|
||||
|
||||
|
||||
def detail(path: str, module: Module) -> str:
|
||||
lines = []
|
||||
for osv in sorted(osvs, key=lambda o: o["id"]):
|
||||
aliases = f" ({', '.join(osv['aliases'])})" if osv.get("aliases") else ""
|
||||
lines.append(f"- {osv['id']}{aliases}: {osv.get('summary', '')}")
|
||||
return tagline, f"govulncheck found these in {where}:\n\n" + "\n".join(lines) + "\n"
|
||||
for osv in sorted(module.osvs.values(), key=lambda o: o["id"]):
|
||||
aliases = f" ({', '.join(osv['aliases'])})" if osv["aliases"] else ""
|
||||
lines.append(f"- {osv['id']}{aliases}: {osv['summary']}")
|
||||
carried = f"{module.name(path)} {module.version(path, module.found_in)}"
|
||||
return f"govulncheck found these in {carried}:\n\n" + "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def file_items(report: str, todo: str) -> tuple[str, int]:
|
||||
"""Return todo.md with an item for each module whose vulnerabilities it does not yet name, and how many are known."""
|
||||
osvs, modules = {}, {}
|
||||
for message in messages(report):
|
||||
if "osv" in message:
|
||||
osvs[message["osv"]["id"]] = message["osv"]
|
||||
elif "finding" in message:
|
||||
finding = message["finding"]
|
||||
module, found_in = finding["trace"][0]["module"], finding["trace"][0].get("version", "")
|
||||
entry = modules.setdefault(module, {"found_in": found_in, "fixed": "", "ids": set()})
|
||||
entry["ids"].add(finding["osv"])
|
||||
if version_key(finding.get("fixed_version", "")) > version_key(entry["fixed"]):
|
||||
entry["fixed"] = finding["fixed_version"]
|
||||
known = sum(len(entry["ids"]) for entry in modules.values())
|
||||
for module in sorted(modules, key=lambda m: (m != "stdlib", m)):
|
||||
entry = modules[module]
|
||||
if all(osv in todo for osv in entry["ids"]):
|
||||
def file_items(modules: dict[str, 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 path in sorted(modules, key=lambda m: (m != "stdlib", m)):
|
||||
module = modules[path]
|
||||
if all(osv in todo for osv in module.osvs):
|
||||
continue
|
||||
first = ROW.search(todo)
|
||||
if first is None:
|
||||
raise SystemExit("todo.md has no item to take the next release from")
|
||||
next_id = int(re.search(r"`Next ID = (\d+)`", todo).group(1))
|
||||
tagline, detail = item(module, entry["found_in"], entry["fixed"], [osvs.get(i, {"id": i}) for i in entry["ids"]])
|
||||
raise ValueError("todo.md has no item to take the next release from")
|
||||
next_id = int(NEXT_ID.search(todo).group(1))
|
||||
line = tagline(path, module)
|
||||
scores = " | ".join(str(SCORES[k]) for k in "RSCAG")
|
||||
row = f"| {next_id} | {first.group(2)} | security | **{tagline}** | {scores} | {GOAL} | {score(todo)} |\n"
|
||||
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}. {tagline}\n\n{detail}\n", 1)
|
||||
return todo, known
|
||||
todo = todo.replace("## Details\n\n", f"## Details\n\n### {next_id}. {line}\n\n{detail(path, 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:
|
||||
report = f.read()
|
||||
with open(todo_path, encoding="utf-8") as f:
|
||||
todo = f.read()
|
||||
filed, known = file_items(report, todo)
|
||||
if not known:
|
||||
modules = vulnerable_modules(f.read())
|
||||
if not modules:
|
||||
print("No known vulnerabilities.")
|
||||
return 0
|
||||
if filed == todo:
|
||||
print(f"{known} known vulnerabilities, already planned in todo.md.")
|
||||
count = sum(len(m.osvs) for m in modules.values())
|
||||
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(filed)
|
||||
next_id = re.compile(r"`Next ID = (\d+)`")
|
||||
ids = range(int(next_id.search(todo).group(1)), int(next_id.search(filed).group(1)))
|
||||
print(f"{known} known vulnerabilities; todo.md item {', '.join(map(str, ids))} plans the fix.")
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -35,17 +35,6 @@ TODO = """# todo
|
||||
Why.
|
||||
"""
|
||||
|
||||
|
||||
def report(*findings, osvs=()):
|
||||
messages = [{"config": {"go_version": "go1.27.1", "scan_level": "module"}}]
|
||||
messages += [{"osv": osv} for osv in osvs]
|
||||
messages += [
|
||||
{"finding": {"osv": osv, "fixed_version": fixed, "trace": [{"module": module, "version": version}]}}
|
||||
for osv, module, version, fixed in findings
|
||||
]
|
||||
return "\n".join(json.dumps(m, indent=2) for m in messages)
|
||||
|
||||
|
||||
OSVS = (
|
||||
{"id": "GO-2026-0002", "summary": "Denial of service in net/http", "aliases": ["CVE-2026-2"]},
|
||||
{"id": "GO-2026-0001", "summary": "Cross-site scripting in html/template", "aliases": ["CVE-2026-1"]},
|
||||
@@ -56,51 +45,94 @@ STDLIB = (
|
||||
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
|
||||
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
|
||||
)
|
||||
TEXT = (("GO-2026-0003", "golang.org/x/text", "v0.3.0", "v0.3.8"),)
|
||||
GO_TAGLINE = "Raise Go to 1.27.10, so the release binaries carry none of Go 1.27.1's known vulnerabilities."
|
||||
|
||||
|
||||
def report(*findings):
|
||||
messages = [{"config": {"go_version": "go1.27.1", "scan_level": "module"}}]
|
||||
messages += [{"osv": osv} for osv in OSVS]
|
||||
for osv, module, version, fixed in findings:
|
||||
finding = {"osv": osv, "trace": [{"module": module, "version": version}]}
|
||||
if fixed:
|
||||
finding["fixed_version"] = fixed
|
||||
messages.append({"finding": finding})
|
||||
return "\n".join(json.dumps(m, indent=2) for m in messages)
|
||||
|
||||
|
||||
def file(todo, *findings):
|
||||
return file_vulnerabilities.file_items(file_vulnerabilities.vulnerable_modules(report(*findings)), todo)
|
||||
|
||||
|
||||
def rows(todo):
|
||||
return [line for line in todo.splitlines() if "| **" in line]
|
||||
|
||||
|
||||
class FileItems(unittest.TestCase):
|
||||
def test_no_finding_files_nothing(self):
|
||||
self.assertEqual(file_vulnerabilities.file_items(report(osvs=OSVS), TODO), (TODO, 0))
|
||||
self.assertEqual(file(TODO), (TODO, []))
|
||||
|
||||
def test_files_one_security_item_per_module_at_the_top_of_the_next_release(self):
|
||||
todo, found = file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), TODO)
|
||||
self.assertEqual(found, 2)
|
||||
def test_files_one_security_item_per_module_atop_the_next_release(self):
|
||||
todo, filed = file(TODO, *STDLIB)
|
||||
self.assertEqual(filed, [7])
|
||||
self.assertIn("`Next ID = 8`", todo)
|
||||
rows = [line for line in todo.splitlines() if "| **" in line]
|
||||
self.assertEqual(
|
||||
rows[0],
|
||||
"| 7 | 0.2.0 | security | **Raise Go to 1.27.10, so the release binaries carry none of"
|
||||
" its known vulnerabilities.** | 1 | 1 | 1 | 8 | 8 | 1.1 | 29.1 |",
|
||||
)
|
||||
self.assertTrue(rows[1].startswith("| 5 |"))
|
||||
detail = todo.split("## Details\n\n", 1)[1]
|
||||
self.assertEqual(rows(todo)[0], f"| 7 | 0.2.0 | security | **{GO_TAGLINE}** | 1 | 1 | 1 | 8 | 8 | 1.1 | 29.1 |")
|
||||
self.assertTrue(rows(todo)[1].startswith("| 5 |"))
|
||||
self.assertTrue(
|
||||
detail.startswith(
|
||||
"### 7. Raise Go to 1.27.10, so the release binaries carry none of its known vulnerabilities.\n\n"
|
||||
todo.split("## Details\n\n", 1)[1].startswith(
|
||||
f"### 7. {GO_TAGLINE}\n\n"
|
||||
"govulncheck found these in Go 1.27.1:\n\n"
|
||||
"- GO-2026-0001 (CVE-2026-1): Cross-site scripting in html/template\n"
|
||||
"- GO-2026-0002 (CVE-2026-2): Denial of service in net/http\n\n"
|
||||
),
|
||||
detail,
|
||||
"### 5. Add a thing."
|
||||
)
|
||||
)
|
||||
self.assertIn("### 5. Add a thing.", detail)
|
||||
|
||||
def test_a_module_requirement_gets_its_own_item(self):
|
||||
findings = STDLIB + (("GO-2026-0003", "golang.org/x/text", "v0.3.0", "v0.3.8"),)
|
||||
todo, found = file_vulnerabilities.file_items(report(*findings, osvs=OSVS), TODO)
|
||||
self.assertEqual(found, 3)
|
||||
self.assertIn("| 8 | 0.2.0 | security | **Raise `golang.org/x/text` to v0.3.8, so the module requires none of its known vulnerabilities.** |", todo)
|
||||
self.assertIn("- GO-2026-0003: Panic in x/text\n", todo)
|
||||
def test_a_required_module_gets_its_own_item(self):
|
||||
todo, filed = file(TODO, *STDLIB, *TEXT)
|
||||
self.assertEqual(filed, [7, 8])
|
||||
self.assertIn(
|
||||
"| 8 | 0.2.0 | security | **Raise `golang.org/x/text` to v0.3.8, so fejkdata requires no version"
|
||||
" of it with a known vulnerability.** |",
|
||||
todo,
|
||||
)
|
||||
self.assertIn("govulncheck found these in `golang.org/x/text` v0.3.0:\n\n- GO-2026-0003: Panic in x/text\n", todo)
|
||||
self.assertIn("`Next ID = 9`", todo)
|
||||
|
||||
def test_a_vulnerability_already_planned_is_not_filed_again(self):
|
||||
filed, _ = file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), TODO)
|
||||
self.assertEqual(file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), filed), (filed, 2))
|
||||
planned, _ = file(TODO, *STDLIB)
|
||||
self.assertEqual(file(planned, *STDLIB), (planned, []))
|
||||
|
||||
def test_a_module_newly_vulnerable_is_filed_beside_one_already_planned(self):
|
||||
planned, _ = file(TODO, *STDLIB)
|
||||
todo, filed = file(planned, *STDLIB, *TEXT)
|
||||
self.assertEqual(filed, [8])
|
||||
self.assertIn("| 8 | 0.2.0 | security | **Raise `golang.org/x/text`", todo)
|
||||
|
||||
def test_a_vulnerability_no_version_fixes_names_no_target(self):
|
||||
todo, _ = file(TODO, *STDLIB, ("GO-2026-0003", "stdlib", "v1.27.1", ""))
|
||||
self.assertIn(
|
||||
"| 7 | 0.2.0 | security | **Ship no release while fejkdata carries Go 1.27.1, some of whose known"
|
||||
" vulnerabilities no version fixes yet.** |",
|
||||
todo,
|
||||
)
|
||||
|
||||
def test_a_plan_with_no_items_is_refused(self):
|
||||
empty = TODO.split("| 5 |")[0].rstrip("\n") + "\n\n## Details\n"
|
||||
with self.assertRaises(SystemExit):
|
||||
file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), empty)
|
||||
with self.assertRaises(ValueError):
|
||||
file(empty, *STDLIB)
|
||||
|
||||
def test_the_repositorys_plan_takes_an_item_in_its_own_shape(self):
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "todo.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
plan = f.read()
|
||||
todo, filed = file(plan, *STDLIB)
|
||||
header = next(line for line in plan.splitlines() if line.startswith("| ID |"))
|
||||
row = rows(todo)[0]
|
||||
self.assertEqual(row.count("|"), header.count("|"))
|
||||
self.assertEqual(row.split(" | ")[1], rows(plan)[0].split(" | ")[1])
|
||||
self.assertRegex(row, rf"^\| {filed[0]} \| .* \| \d+\.\d \|$")
|
||||
self.assertIn(f"`Next ID = {filed[0] + 1}`", todo)
|
||||
|
||||
|
||||
class Main(unittest.TestCase):
|
||||
@@ -113,9 +145,9 @@ class Main(unittest.TestCase):
|
||||
with open("todo.md", "w", encoding="utf-8") as f:
|
||||
f.write(TODO)
|
||||
|
||||
def run_main(self, text):
|
||||
def run_main(self, *findings):
|
||||
with open("report.json", "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.write(report(*findings))
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
code = file_vulnerabilities.main(["report.json", "todo.md"])
|
||||
@@ -123,19 +155,20 @@ class Main(unittest.TestCase):
|
||||
return code, out.getvalue(), f.read()
|
||||
|
||||
def test_a_clean_report_passes_and_leaves_the_plan(self):
|
||||
self.assertEqual(self.run_main(report(osvs=OSVS)), (0, "No known vulnerabilities.\n", TODO))
|
||||
self.assertEqual(self.run_main(), (0, "No known vulnerabilities.\n", TODO))
|
||||
|
||||
def test_a_finding_fails_and_files_it(self):
|
||||
code, out, todo = self.run_main(report(*STDLIB, osvs=OSVS))
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(out, "2 known vulnerabilities; todo.md item 7 plans the fix.\n")
|
||||
code, out, todo = self.run_main(*STDLIB)
|
||||
self.assertEqual((code, out), (1, "2 known vulnerabilities; todo.md item 7 plans the fix.\n"))
|
||||
self.assertIn("| 7 | 0.2.0 | security |", todo)
|
||||
|
||||
def test_a_finding_already_planned_still_fails(self):
|
||||
self.run_main(report(*STDLIB, osvs=OSVS))
|
||||
code, out, _ = self.run_main(report(*STDLIB, osvs=OSVS))
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(out, "2 known vulnerabilities, already planned in todo.md.\n")
|
||||
def test_findings_in_two_modules_name_both_items(self):
|
||||
_, out, _ = self.run_main(*STDLIB, *TEXT)
|
||||
self.assertEqual(out, "3 known vulnerabilities; todo.md items 7, 8 plan the fix.\n")
|
||||
|
||||
def test_one_finding_already_planned_still_fails(self):
|
||||
self.run_main(*TEXT)
|
||||
self.assertEqual(self.run_main(*TEXT)[:2], (1, "1 known vulnerability, already planned in todo.md.\n"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user