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:
2026-10-14 13:03:56 +02:00
committed by Lillem4n
parent 273dd45128
commit 2d86f4f0c0
9 changed files with 209 additions and 141 deletions
+7 -9
View File
@@ -66,10 +66,8 @@ jobs:
docker build -f release-tooling/package-repos.Dockerfile --target checks \ docker build -f release-tooling/package-repos.Dockerfile --target checks \
--secret id=gpg,env=PACKAGES_GPG_KEY --secret id=rsa,env=PACKAGES_RSA_KEY . --secret id=gpg,env=PACKAGES_GPG_KEY --secret id=rsa,env=PACKAGES_RSA_KEY .
# Not a required check: a vulnerability published in Go, or an outage of vuln.go.dev,
# holds the release but no merge.
vulnerabilities: vulnerabilities:
name: Known vulnerabilities in this Go release and the module name: Known vulnerabilities in this Go version and the required modules
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
timeout-minutes: 10 timeout-minutes: 10
permissions: permissions:
@@ -84,22 +82,22 @@ jobs:
- name: File each one in todo.md - name: File each one in todo.md
id: file id: file
run: python3 release-tooling/file_vulnerabilities.py "$RUNNER_TEMP/vulnerabilities/report.json" todo.md run: python3 release-tooling/file_vulnerabilities.py "$RUNNER_TEMP/vulnerabilities/report.json" todo.md
# A pull request the workflow's token opens starts no workflow, so this starts the tests.
- name: Propose the filed items - name: Propose the filed items
if: failure() && steps.file.outcome == 'failure' && github.event_name != 'pull_request' if: failure() && steps.file.outcome == 'failure' && github.ref == 'refs/heads/main'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
if git diff --quiet todo.md || git ls-remote --exit-code --heads origin vulnerabilities; then if git diff --quiet todo.md || [ "$(gh pr list --head vulnerabilities --state open --json number --jq length)" != 0 ]; then
exit 0 exit 0
fi fi
git config user.name 'github-actions[bot]' git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git switch -c vulnerabilities git switch -C vulnerabilities
git commit -qam "File the known vulnerabilities govulncheck reports" git commit -qam "File the known vulnerabilities govulncheck reports"
git push -q "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" vulnerabilities git push -qf "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" vulnerabilities
gh pr create --head vulnerabilities --title "File the known vulnerabilities govulncheck reports" \ gh pr create --head vulnerabilities --title "File the known vulnerabilities govulncheck reports" \
--body "govulncheck reports known vulnerabilities in the Go release or the module, and no release is cut until they are gone. This files them atop the next release in todo.md; the fix is the bump its item names." --body "govulncheck reports known vulnerabilities in the Go version or a required module, and no release is cut until they are gone. This files them atop the next release in todo.md; each item names the bump that fixes it."
# A pull request the workflow's token opens starts no workflow.
gh workflow run test.yml --ref vulnerabilities gh workflow run test.yml --ref vulnerabilities
release: release:
+2 -2
View File
@@ -1,6 +1,6 @@
# Rules # Rules
- Go runs only through `docker compose run --rm <test|vet|fmt|build|cyclo|generate>`; the merge gate is what the README's Development section lists. - Go runs only through `docker compose run --rm <test|vet|fmt|build|cyclo|generate|vulncheck>`; the merge gate is what the README's Development section lists.
- Tests first, in their own commit; the implementation follows in the next. A re-pin of seeded output or of `testdata/shipped_shape.txt` is its own commit. - Tests first, in their own commit; the implementation follows in the next. A re-pin of seeded output or of `testdata/shipped_shape.txt` is its own commit.
- A change under `data/` or to the shape pin adds its `CHANGELOG.md` entry under `Unreleased` in the same PR, and so does a change to a flag, an exit code, an exported name, a fence, a builtin or the lowest Go; what is major is the README's Versioning table. - A change under `data/` or to the shape pin adds its `CHANGELOG.md` entry under `Unreleased` in the same PR, and so does a change to a flag, an exit code, an exported name, a fence, a builtin or the lowest Go; what is major is the README's Versioning table.
- One-line commit messages: no ticket prefix, no repo name, no authorship trailers. - One-line commit messages: no ticket prefix, no repo name, no authorship trailers.
@@ -124,7 +124,7 @@ In [docs/decisions.md](docs/decisions.md):
- A country with no open address register covering it whole ships its places and postal codes without streets - A country with no open address register covering it whole ships its places and postal codes without streets
- The apt, dnf and apk repositories are static files on GitHub Pages, holding the latest release only - The apt, dnf and apk repositories are static files on GitHub Pages, holding the latest release only
- The image is pushed to GHCR and copied to Docker Hub, whose name the README shows - The image is pushed to GHCR and copied to Docker Hub, whose name the README shows
- A known vulnerability in the Go release or the module blocks a release, called or not, and no merge - A known vulnerability in the Go version or a required module blocks a release, even in code fejkdata never calls, and blocks no merge
# Scoring run # Scoring run
+1
View File
@@ -21,6 +21,7 @@ COPY --from=vulnerabilities /report.json /
FROM python:3.14.8-slim AS release-tooling FROM python:3.14.8-slim AS release-tooling
COPY release-tooling/ /release-tooling/ COPY release-tooling/ /release-tooling/
COPY todo.md /todo.md
RUN python3 -m unittest discover -s /release-tooling -p '*_test.py' && touch /tested RUN python3 -m unittest discover -s /release-tooling -p '*_test.py' && touch /tested
FROM goreleaser/goreleaser:v2.18.3 AS packaging FROM goreleaser/goreleaser:v2.18.3 AS packaging
+12 -13
View File
@@ -1170,7 +1170,7 @@ internal/rows/ a table's rows: the TSV, the options proved over it, the links b
data/ the shipped modules, each a Go package embedding its categories (JSON, and a TSV per table) and its manifest; and Modules, which lists them data/ the shipped modules, each a Go package embedding its categories (JSON, and a TSV per table) and its manifest; and Modules, which lists them
data-import/ the scripts that rebuild each sourced table (see DATA-LICENSES.md), and the modules they share data-import/ the scripts that rebuild each sourced table (see DATA-LICENSES.md), and the modules they share
docs/ the decision log, the register research behind the shipped data, the survey of other libraries, panels' scores and rulings on how readable the code is, and what each chunk cost an agent to read docs/ the decision log, the register research behind the shipped data, the survey of other libraries, panels' scores and rulings on how readable the code is, and what each chunk cost an agent to read
release-tooling/ the release CI publishes from the changelog heading, and the binaries, packages, image and package repositories built for it, and the filing of known vulnerabilities release-tooling/ the release CI publishes from the changelog heading, and the binaries, packages, image and package repositories built for it; and the filing of known vulnerabilities
testdata/ the pinned shipped shape (see Versioning) testdata/ the pinned shipped shape (see Versioning)
``` ```
@@ -1219,20 +1219,19 @@ check and the release tooling's tests:
docker build . # latest docker build . # latest
docker build --build-arg GO_VERSION=1.22.12 --target portable . # lowest supported docker build --build-arg GO_VERSION=1.22.12 --target portable . # lowest supported
GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the image build GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the image build
docker build --no-cache-filter vulnerabilities --target vulnerability-report --output /tmp/fejkdata-vulnerabilities .
python3 release-tooling/file_vulnerabilities.py /tmp/fejkdata-vulnerabilities/report.json todo.md
``` ```
The last two lines run `govulncheck` over the Go release the gate builds with and the module's A known vulnerability in the Go version the gate builds with, or in a module fejkdata
requirements, and fails on any known vulnerability, whether or not fejkdata calls it: the requires, holds the release and no merge. CI checks on every pull request, every push to
release binaries carry that Go release, and image scanners flag it. CI runs it on every pull `main`, and daily. On `main` and in the daily run, CI files each vulnerability as a
request and push, and daily; GitHub pauses the daily run after 60 days with no activity in `security` item atop the next release in `todo.md`, through a pull request from the branch
the repository. It is not part of the gate: a pull request merges while it fails, but no `vulnerabilities` unless one is open; each item names the version to raise to. A failed run
release is cut. Each vulnerability it finds is filed as a `security` item atop the next that names no vulnerability means vuln.go.dev did not answer: rerun it. When GitHub pauses
release in `todo.md`; on `main` and in the daily run CI opens a pull request from the branch the daily run after 60 days with no activity, re-enable it under Actions. The same check:
`vulnerabilities` with that item, unless the branch exists. The fix is raising
`GO_VERSION`, which Renovate opens a pull request for; a failure that names no ```sh
vulnerability is vuln.go.dev failing to answer. docker compose run --rm vulncheck
```
A change to Go code, `release-tooling/`, the `Dockerfile`, `.dockerignore`, the licence A change to Go code, `release-tooling/`, the `Dockerfile`, `.dockerignore`, the licence
files or either workflow also runs the package-repository checks. They build the release files or either workflow also runs the package-repository checks. They build the release
+4
View File
@@ -35,6 +35,10 @@ services:
<<: *go <<: *go
command: go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 14 -ignore _test . command: go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 14 -ignore _test .
vulncheck:
<<: *go
command: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -scan module
build: build:
<<: *go <<: *go
command: go build ./... command: go build ./...
+15 -11
View File
@@ -990,17 +990,21 @@ runners, which Docker Hub's per-IP limit on anonymous pulls blocks. Leaving Dock
costs each user an edit of every `docker run` line and pinned script that names the bare costs each user an edit of every `docker run` line and pinned script that names the bare
`larvit/fejkdata`. `larvit/fejkdata`.
## A known vulnerability in the Go release or the module blocks a release, called or not, and no merge ## A known vulnerability in the Go version or a required module blocks a release, even in code fejkdata never calls, and blocks no merge
2026-10-14, decided by the maintainer after Docker Hub flagged v0.1.0's image. Serves goal 2026-10-14, decided by the maintainer after Docker Hub flagged v0.1.0's image. Serves goal
1.1. `govulncheck -scan module` fails on any known vulnerability in the Go release the gate 1.1. `govulncheck -scan module` fails on any known vulnerability in the Go version the gate
builds with or in the module's requirements, even one fejkdata never calls: the release builds with or in a module fejkdata requires, even one fejkdata never calls: the release
binaries carry that Go release, and image scanners flag it whatever the code reaches. It binaries carry that Go version, and image scanners flag it whatever the code reaches. It
runs on every pull request and push and daily, as a job the release job needs but branch runs on every pull request and push and daily, as a job the release job needs but branch
protection does not require. A vulnerability published in Go, or an outage of vuln.go.dev, protection does not require. So a vulnerability published in Go, or an outage of
so holds the release and leaves unrelated pull requests mergeable. Each vulnerability is vuln.go.dev, holds the release and leaves unrelated pull requests mergeable. Each
filed as a `security` item atop the next release in `todo.md`, by a pull request CI opens, vulnerability is filed as a `security` item atop the next release in `todo.md`, by a pull
so the plan names it before anyone reads a red run. Where no Go release request CI opens, so the plan names it before anyone reads a red run.
fixes a vulnerability yet, govulncheck has no ignore list: the maintainer drops the job `release-tooling/file_vulnerabilities.py` writes that item in `todo.md`'s shape and scores it
from the release's `needs` until one does, and records why here. Valid while the binaries by `todo.md`'s formula, because CI cannot reach `rank_todo.py`; a test files one into the
are built from the gate's Go release. repository's own `todo.md`. When the next release has no item left and waits to be cut,
`todo.md` names no version for it, so the item lands in the release after; the maintainer
moves it. Where no Go version fixes a vulnerability yet, govulncheck has no ignore list: the
maintainer drops the job from the release's `needs` until one does, and records why here.
Valid while the binaries are built from the gate's Go version.
+85 -56
View File
@@ -9,13 +9,28 @@ import ast
import json import json
import re import re
import sys import sys
from dataclasses import dataclass, field
ROW = re.compile(r"^\| (\d+) \| ([^|]+?) \| [^|]*\| \*\*", re.M) ROW = re.compile(r"^\| (\d+) \| ([^|]+?) \| [^|]*\| \*\*", re.M)
WEIGHT = re.compile(r"^\| ([\d.]+) \| ([\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} SCORES = {"R": 1, "S": 1, "C": 1, "A": 8, "G": 8}
GOAL = "1.1" 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): def messages(report: str):
decoder, at = json.JSONDecoder(), 0 decoder, at = json.JSONDecoder(), 0
while at < len(report): while at < len(report):
@@ -26,17 +41,25 @@ def messages(report: str):
yield message 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): def version_key(version: str):
return [int(part) for part in re.findall(r"\d+", version)] 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): def evaluate(node, values):
match node: match node:
case ast.Constant(value=value): case ast.Constant(value=value):
@@ -45,75 +68,81 @@ def evaluate(node, values):
return values[name] return values[name]
case ast.UnaryOp(op=ast.USub(), operand=operand): case ast.UnaryOp(op=ast.USub(), operand=operand):
return -evaluate(operand, values) return -evaluate(operand, values)
case ast.BinOp(left=left, op=op, right=right): case ast.BinOp(left=left, op=ast.Add(), right=right):
a, b = evaluate(left, values), evaluate(right, values) return 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)] case ast.BinOp(left=left, op=ast.Sub(), right=right):
raise SystemExit(f"todo.md's formula holds {ast.dump(node)}, which this script cannot evaluate") 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]: def score(todo: str) -> str:
if module == "stdlib": formula = re.search(r"`Score = (.+?)`", todo).group(1)
tagline = f"Raise Go to {fixed.lstrip('v')}, so the release binaries carry none of its known vulnerabilities." values = dict(SCORES, W=float(dict(WEIGHT.findall(todo))[GOAL]))
where = f"Go {found_in.lstrip('v')}" return f"{evaluate(ast.parse(formula, mode='eval').body, values):.1f}"
else:
tagline = f"Raise `{module}` to {fixed}, so the module requires none of its known vulnerabilities."
where = f"`{module}` {found_in}" 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 = [] lines = []
for osv in sorted(osvs, key=lambda o: o["id"]): for osv in sorted(module.osvs.values(), key=lambda o: o["id"]):
aliases = f" ({', '.join(osv['aliases'])})" if osv.get("aliases") else "" aliases = f" ({', '.join(osv['aliases'])})" if osv["aliases"] else ""
lines.append(f"- {osv['id']}{aliases}: {osv.get('summary', '')}") lines.append(f"- {osv['id']}{aliases}: {osv['summary']}")
return tagline, f"govulncheck found these in {where}:\n\n" + "\n".join(lines) + "\n" 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]: 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 how many are known.""" """Return todo.md with an item for each module whose vulnerabilities it does not yet name, and the items' IDs."""
osvs, modules = {}, {} filed = []
for message in messages(report): for path in sorted(modules, key=lambda m: (m != "stdlib", m)):
if "osv" in message: module = modules[path]
osvs[message["osv"]["id"]] = message["osv"] if all(osv in todo for osv in module.osvs):
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"]):
continue continue
first = ROW.search(todo) first = ROW.search(todo)
if first is None: if first is None:
raise SystemExit("todo.md has no item to take the next release from") raise ValueError("todo.md has no item to take the next release from")
next_id = int(re.search(r"`Next ID = (\d+)`", todo).group(1)) next_id = int(NEXT_ID.search(todo).group(1))
tagline, detail = item(module, entry["found_in"], entry["fixed"], [osvs.get(i, {"id": i}) for i in entry["ids"]]) line = tagline(path, module)
scores = " | ".join(str(SCORES[k]) for k in "RSCAG") 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[: first.start()] + row + todo[first.start() :]
todo = todo.replace(f"`Next ID = {next_id}`", f"`Next ID = {next_id + 1}`", 1) 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) todo = todo.replace("## Details\n\n", f"## Details\n\n### {next_id}. {line}\n\n{detail(path, module)}\n", 1)
return todo, known filed.append(next_id)
return todo, filed
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int:
report_path, todo_path = argv report_path, todo_path = argv
with open(report_path, encoding="utf-8") as f: with open(report_path, encoding="utf-8") as f:
report = f.read() modules = vulnerable_modules(f.read())
with open(todo_path, encoding="utf-8") as f: if not modules:
todo = f.read()
filed, known = file_items(report, todo)
if not known:
print("No known vulnerabilities.") print("No known vulnerabilities.")
return 0 return 0
if filed == todo: count = sum(len(m.osvs) for m in modules.values())
print(f"{known} known vulnerabilities, already planned in todo.md.") 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 return 1
with open(todo_path, "w", encoding="utf-8") as f: with open(todo_path, "w", encoding="utf-8") as f:
f.write(filed) f.write(todo)
next_id = re.compile(r"`Next ID = (\d+)`") items = ", ".join(map(str, filed))
ids = range(int(next_id.search(todo).group(1)), int(next_id.search(filed).group(1))) print(f"{known}; todo.md {'item ' + items + ' plans' if len(filed) == 1 else 'items ' + items + ' plan'} the fix.")
print(f"{known} known vulnerabilities; todo.md item {', '.join(map(str, ids))} plans the fix.")
return 1 return 1
+82 -49
View File
@@ -35,17 +35,6 @@ TODO = """# todo
Why. 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 = ( OSVS = (
{"id": "GO-2026-0002", "summary": "Denial of service in net/http", "aliases": ["CVE-2026-2"]}, {"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"]}, {"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"),
("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): class FileItems(unittest.TestCase):
def test_no_finding_files_nothing(self): 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): def test_files_one_security_item_per_module_atop_the_next_release(self):
todo, found = file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), TODO) todo, filed = file(TODO, *STDLIB)
self.assertEqual(found, 2) self.assertEqual(filed, [7])
self.assertIn("`Next ID = 8`", todo) self.assertIn("`Next ID = 8`", todo)
rows = [line for line in todo.splitlines() if "| **" in line] self.assertEqual(rows(todo)[0], f"| 7 | 0.2.0 | security | **{GO_TAGLINE}** | 1 | 1 | 1 | 8 | 8 | 1.1 | 29.1 |")
self.assertEqual( self.assertTrue(rows(todo)[1].startswith("| 5 |"))
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.assertTrue( self.assertTrue(
detail.startswith( todo.split("## Details\n\n", 1)[1].startswith(
"### 7. Raise Go to 1.27.10, so the release binaries carry none of its known vulnerabilities.\n\n" f"### 7. {GO_TAGLINE}\n\n"
"govulncheck found these in Go 1.27.1:\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-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" "- GO-2026-0002 (CVE-2026-2): Denial of service in net/http\n\n"
), "### 5. Add a thing."
detail, )
) )
self.assertIn("### 5. Add a thing.", detail)
def test_a_module_requirement_gets_its_own_item(self): def test_a_required_module_gets_its_own_item(self):
findings = STDLIB + (("GO-2026-0003", "golang.org/x/text", "v0.3.0", "v0.3.8"),) todo, filed = file(TODO, *STDLIB, *TEXT)
todo, found = file_vulnerabilities.file_items(report(*findings, osvs=OSVS), TODO) self.assertEqual(filed, [7, 8])
self.assertEqual(found, 3) self.assertIn(
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) "| 8 | 0.2.0 | security | **Raise `golang.org/x/text` to v0.3.8, so fejkdata requires no version"
self.assertIn("- GO-2026-0003: Panic in x/text\n", todo) " 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) self.assertIn("`Next ID = 9`", todo)
def test_a_vulnerability_already_planned_is_not_filed_again(self): def test_a_vulnerability_already_planned_is_not_filed_again(self):
filed, _ = file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), TODO) planned, _ = file(TODO, *STDLIB)
self.assertEqual(file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), filed), (filed, 2)) 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): def test_a_plan_with_no_items_is_refused(self):
empty = TODO.split("| 5 |")[0].rstrip("\n") + "\n\n## Details\n" empty = TODO.split("| 5 |")[0].rstrip("\n") + "\n\n## Details\n"
with self.assertRaises(SystemExit): with self.assertRaises(ValueError):
file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), empty) 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): class Main(unittest.TestCase):
@@ -113,9 +145,9 @@ class Main(unittest.TestCase):
with open("todo.md", "w", encoding="utf-8") as f: with open("todo.md", "w", encoding="utf-8") as f:
f.write(TODO) f.write(TODO)
def run_main(self, text): def run_main(self, *findings):
with open("report.json", "w", encoding="utf-8") as f: with open("report.json", "w", encoding="utf-8") as f:
f.write(text) f.write(report(*findings))
out = io.StringIO() out = io.StringIO()
with redirect_stdout(out): with redirect_stdout(out):
code = file_vulnerabilities.main(["report.json", "todo.md"]) code = file_vulnerabilities.main(["report.json", "todo.md"])
@@ -123,19 +155,20 @@ class Main(unittest.TestCase):
return code, out.getvalue(), f.read() return code, out.getvalue(), f.read()
def test_a_clean_report_passes_and_leaves_the_plan(self): 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): def test_a_finding_fails_and_files_it(self):
code, out, todo = self.run_main(report(*STDLIB, osvs=OSVS)) code, out, todo = self.run_main(*STDLIB)
self.assertEqual(code, 1) self.assertEqual((code, out), (1, "2 known vulnerabilities; todo.md item 7 plans the fix.\n"))
self.assertEqual(out, "2 known vulnerabilities; todo.md item 7 plans the fix.\n")
self.assertIn("| 7 | 0.2.0 | security |", todo) self.assertIn("| 7 | 0.2.0 | security |", todo)
def test_a_finding_already_planned_still_fails(self): def test_findings_in_two_modules_name_both_items(self):
self.run_main(report(*STDLIB, osvs=OSVS)) _, out, _ = self.run_main(*STDLIB, *TEXT)
code, out, _ = self.run_main(report(*STDLIB, osvs=OSVS)) self.assertEqual(out, "3 known vulnerabilities; todo.md items 7, 8 plan the fix.\n")
self.assertEqual(code, 1)
self.assertEqual(out, "2 known vulnerabilities, already planned in todo.md.\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__": if __name__ == "__main__":
+1 -1
View File
@@ -27,7 +27,7 @@
{ {
"customType": "regex", "customType": "regex",
"datasourceTemplate": "go", "datasourceTemplate": "go",
"description": "Go tools the gate runs with go run, by their module path", "description": "Go tools the Dockerfile and compose.yaml run with go run, by their module path",
"managerFilePatterns": [ "managerFilePatterns": [
"/^Dockerfile$/", "/^Dockerfile$/",
"/^compose\\.yaml$/" "/^compose\\.yaml$/"