Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16b5e0767c | |||
| 7739c09163 | |||
| 16afcff09c | |||
| 5aca8a1fe8 | |||
| 5a6928750c | |||
| 15965e7a77 | |||
| 6a7da08002 | |||
| 9049f90cc5 | |||
| 2d86f4f0c0 | |||
| 273dd45128 |
@@ -14,6 +14,7 @@ permissions:
|
||||
jobs:
|
||||
test:
|
||||
name: vet + fmt + tests
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -22,7 +23,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Relevant changes
|
||||
id: changes
|
||||
if: github.event_name != 'schedule'
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF"
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
else
|
||||
base='${{ github.event.before }}'
|
||||
fi
|
||||
if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data release-tooling testdata README.md; then
|
||||
if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data release-tooling testdata vulnerability-filing README.md todo.md; then
|
||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -42,7 +42,6 @@ jobs:
|
||||
fi
|
||||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||
- name: CHANGELOG entry for data changes
|
||||
if: github.event_name != 'schedule'
|
||||
run: |
|
||||
base='${{ steps.changes.outputs.base }}'
|
||||
if ! git diff --quiet "$base"...HEAD -- data testdata/shipped_shape.txt && git diff --quiet "$base"...HEAD -- CHANGELOG.md; then
|
||||
@@ -59,11 +58,6 @@ jobs:
|
||||
- name: Lowest supported Go
|
||||
if: ${{ !cancelled() && steps.changes.outputs.code == 'true' }}
|
||||
run: docker build --build-arg GO_VERSION=1.22.12 --target portable .
|
||||
# Runs on every event, and daily, since a vulnerability is published without a change
|
||||
# here; `--no-cache-filter` keeps a cached pass from hiding one.
|
||||
- name: Known vulnerabilities in this Go release and the module
|
||||
if: ${{ !cancelled() }}
|
||||
run: docker build --no-cache-filter vulnerabilities --target vulnerabilities .
|
||||
- name: Package repositories, signed with throwaway keys
|
||||
if: ${{ !cancelled() && steps.changes.outputs.packaging == 'true' }}
|
||||
run: |
|
||||
@@ -72,9 +66,44 @@ jobs:
|
||||
docker build -f release-tooling/package-repos.Dockerfile --target checks \
|
||||
--secret id=gpg,env=PACKAGES_GPG_KEY --secret id=rsa,env=PACKAGES_RSA_KEY .
|
||||
|
||||
vulnerabilities:
|
||||
name: Known vulnerabilities in this Go version and the required modules
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Fetch govulncheck's report from vuln.go.dev
|
||||
run: docker build --no-cache-filter vulnerabilities --target vulnerability-report --output "$RUNNER_TEMP/vulnerabilities" .
|
||||
- name: File each one in todo.md
|
||||
id: file
|
||||
run: python3 vulnerability-filing/file_vulnerabilities.py "$RUNNER_TEMP/vulnerabilities/report.json" todo.md
|
||||
- name: Propose the filed items
|
||||
if: failure() && steps.file.outcome == 'failure' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if git diff --quiet todo.md || [ "$(gh pr list --head vulnerabilities --state open --json number --jq length)" != 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git switch -C vulnerabilities
|
||||
git commit -qam "File the known vulnerabilities govulncheck reports"
|
||||
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" \
|
||||
--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, scored 0.0 until rank_todo.py scores them; 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
|
||||
|
||||
release:
|
||||
name: Release from CHANGELOG.md
|
||||
needs: test
|
||||
needs: [test, vulnerabilities]
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rules
|
||||
|
||||
- Go runs only through `docker compose run --rm <test|vet|fmt|build|cyclo|generate>`; the merge gate is `docker build .`, CI's changelog check, its known-vulnerabilities check and, for a change the README's Development section lists, its package-repository checks.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -124,6 +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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
+11
-1
@@ -14,12 +14,21 @@ RUN go vet ./... && \
|
||||
FROM golang:${GO_VERSION} AS vulnerabilities
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -scan module
|
||||
RUN go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -scan module -format json > /report.json
|
||||
|
||||
FROM scratch AS vulnerability-report
|
||||
COPY --from=vulnerabilities /report.json /
|
||||
|
||||
FROM python:3.14.8-slim AS release-tooling
|
||||
COPY release-tooling/ /release-tooling/
|
||||
RUN python3 -m unittest discover -s /release-tooling -p '*_test.py' && touch /tested
|
||||
|
||||
FROM python:3.14.8-slim AS vulnerability-filing
|
||||
COPY vulnerability-filing/ /vulnerability-filing/
|
||||
# The filer's test files an item into the plan itself.
|
||||
COPY todo.md /todo.md
|
||||
RUN python3 -m unittest discover -s /vulnerability-filing -p '*_test.py' && touch /tested
|
||||
|
||||
FROM goreleaser/goreleaser:v2.18.3 AS packaging
|
||||
WORKDIR /check
|
||||
COPY release-tooling/goreleaser.yaml ./
|
||||
@@ -28,6 +37,7 @@ RUN git init -q && git remote add origin https://github.com/larvit/fejkdata.git
|
||||
FROM portable
|
||||
# BuildKit skips a stage nothing copies from.
|
||||
COPY --from=release-tooling /tested /tmp/release-tooling-tested
|
||||
COPY --from=vulnerability-filing /tested /tmp/vulnerability-filing-tested
|
||||
COPY --from=packaging /tested /tmp/packaging-tested
|
||||
RUN unformatted="$(gofmt -l .)"; test -z "$unformatted" || \
|
||||
{ echo "unformatted files:"; echo "$unformatted"; exit 1; }
|
||||
|
||||
@@ -1172,6 +1172,7 @@ data-import/ the scripts that rebuild each sourced table (see DATA-LICENSES.m
|
||||
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
|
||||
testdata/ the pinned shipped shape (see Versioning)
|
||||
vulnerability-filing/ the script CI runs to file each known vulnerability govulncheck reports as a `todo.md` item
|
||||
```
|
||||
|
||||
## Development
|
||||
@@ -1207,26 +1208,33 @@ docker compose run --rm --user root --entrypoint chown test -R "$(id -u):$(id -g
|
||||
```
|
||||
|
||||
Every pull request runs `docker build .` against both the latest and the lowest
|
||||
supported Go, and must pass before it can be merged — unless it changes none of
|
||||
the files the build and its tests read, nor the workflow itself, in which case
|
||||
it's skipped. That build runs vet, complexity, the format check, the Go tests and the
|
||||
release tooling's tests; the rest of the gate is the changelog check, which CI runs
|
||||
against the PR base, and the package-repository checks below. Run `docker build .`
|
||||
locally before pushing. The lowest Go builds the `portable` stage, which skips the format
|
||||
check and the release tooling's tests:
|
||||
supported Go, and must pass before it can be merged — unless it changes none of the
|
||||
files the build and its tests read, nor the workflow itself, in which case it's skipped.
|
||||
That build runs vet, complexity, the format check, the Go tests, the release tooling's
|
||||
tests and the vulnerability filer's tests, which file an item into `todo.md` itself, so
|
||||
a change to the plan's shape can fail it; the rest of the gate is the changelog check,
|
||||
which CI runs against the PR base, and the package-repository checks below. Run `docker
|
||||
build .` locally before pushing. The lowest Go builds the `portable` stage, which skips
|
||||
the format check and both Python test suites:
|
||||
|
||||
```sh
|
||||
docker build . # latest
|
||||
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
|
||||
docker build --no-cache-filter vulnerabilities --target vulnerabilities . # known vulnerabilities
|
||||
```
|
||||
|
||||
The last line runs `govulncheck` over the Go release the gate builds with and the module's
|
||||
requirements, and fails on any known vulnerability, whether or not fejkdata calls it: the
|
||||
release binaries carry that Go release, and image scanners flag it. CI runs it on every pull
|
||||
request and push, and daily, and a release waits for it. A failure is fixed by raising
|
||||
`GO_VERSION`, which Renovate opens a pull request for.
|
||||
A known vulnerability in the Go version the gate builds with, or in a module fejkdata
|
||||
requires, holds the release and no merge. CI checks on every pull request, every push to
|
||||
`main`, and daily. On `main` and in the daily run, CI files each vulnerability as a
|
||||
`security` item atop the next release in `todo.md`, through a pull request from the branch
|
||||
`vulnerabilities` unless one is open; each item names the version to raise to. When the
|
||||
step "Fetch govulncheck's report from vuln.go.dev" fails, vuln.go.dev did not answer:
|
||||
rerun it. When GitHub pauses the daily run after 60 days with no activity, re-enable it
|
||||
under Actions. The same check:
|
||||
|
||||
```sh
|
||||
docker compose run --rm vulncheck
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -35,6 +35,10 @@ services:
|
||||
<<: *go
|
||||
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:
|
||||
<<: *go
|
||||
command: go build ./...
|
||||
|
||||
@@ -682,3 +682,41 @@ Ruling: same
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | same | same | same | same |
|
||||
| Inherited architect | same | same | same | same | same |
|
||||
|
||||
## 2026-10-14T11:34:34Z, PR #225 at f734c9e, against f90b4d6
|
||||
|
||||
Ruling: worse
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | worse | same | same | same |
|
||||
| Inherited architect | same | worse | same | same | worse |
|
||||
| Maintainability senior | same | worse | same | worse | worse |
|
||||
| Junior A | same | worse | same | worse | worse |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Locality: `Dockerfile:26` `vulnerability-filing`: the filer's test reads the real todo.md inside docker build ., so a plan edit can turn the Go gate red
|
||||
- Locality: `vulnerability-filing/file_vulnerabilities.py:24` `ROW`: the filer splices todo.md as text, assuming its row shape, the Next ID marker and the Details heading
|
||||
- Locality: `.github/workflows/test.yml:106` `release`: a release now also waits on vuln.go.dev's answer, visible only in a needs list
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Locality, Overall: `Dockerfile:26` `vulnerability-filing`: the plan is copied into the merge-gate build, so a planning edit that changes the table's shape fails docker build .
|
||||
- Locality: `vulnerability-filing/file_vulnerabilities.py:101` `file_items`: edits todo.md as text; a missing Next ID crashes with exit 1, which reads like a vulnerability already planned
|
||||
- Locality: `.github/workflows/test.yml:69` `vulnerabilities`: whether a release happens depends on vuln.go.dev that day, and every PR shows a red job while one is known
|
||||
|
||||
Maintainability senior, decided by:
|
||||
|
||||
- Locality: `vulnerability-filing/file_vulnerabilities.py:101` `file_items`: finds the release by an unnamed regex group, never checks the Next ID match, and splices Details by a replace that does nothing when the heading is missing
|
||||
- Locality, Self-sufficiency: `.github/workflows/test.yml:83` `File each one in todo.md`: one exit code holds the release and triggers the proposal, which must tell filed, planned and crashed apart by a git diff
|
||||
- Locality: `Dockerfile:26` `vulnerability-filing`: copies todo.md into the gate build, and the path filter lists todo.md
|
||||
- Self-sufficiency: `README.md:1213` "Development": says the build runs the release tooling's tests and leaves out the filer's
|
||||
- Self-sufficiency: `vulnerability-filing/file_vulnerabilities.py:2` module docstring: builds on rank_todo.py, which the repository does not hold
|
||||
|
||||
Junior A, decided by:
|
||||
|
||||
- Locality, Overall: `Dockerfile:26` `vulnerability-filing`: the Go merge gate copies todo.md and files an item into it, so editing the plan can turn the build red
|
||||
- Locality: `.github/workflows/test.yml:106` `release`: whether a push to main releases also depends on vuln.go.dev's answer that day
|
||||
- Self-sufficiency: `vulnerability-filing/file_vulnerabilities.py:24` `ROW`: the regex and the Details splice encode todo.md's layout, which the script never shows
|
||||
- Self-sufficiency: `README.md:1213` "Development": the gate paragraph leaves out the filer's tests while AGENTS.md now defers to it
|
||||
|
||||
+22
-1
@@ -988,4 +988,25 @@ change of path. GoReleaser pushes there, and a job of its own copies the image t
|
||||
Hub, so a Docker Hub failure holds up no other channel. GHCR also serves CI on shared
|
||||
runners, which Docker Hub's per-IP limit on anonymous pulls blocks. Leaving Docker Hub
|
||||
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 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
|
||||
1.1. `govulncheck -scan module` fails on any known vulnerability in the Go version the gate
|
||||
builds with or in a module fejkdata requires, even one fejkdata never calls: the release
|
||||
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
|
||||
protection does not require. So a vulnerability published in Go, or an outage of
|
||||
vuln.go.dev, holds the release and leaves unrelated pull requests mergeable. Each
|
||||
vulnerability is filed as a `security` item atop the next release in `todo.md`, by a pull
|
||||
request CI opens, so the plan names it before anyone reads a red run.
|
||||
`vulnerability-filing/file_vulnerabilities.py` writes that item in `todo.md`'s shape with a
|
||||
Score of 0.0, since CI cannot reach `rank_todo.py`, which scores it on the next commit to the
|
||||
plan; a test files one into the 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.
|
||||
The paired ruling against f90b4d6 found the project reads worse for it: the gate now reads
|
||||
`todo.md`, and a release waits on vuln.go.dev. The maintainer merged it anyway, as the cost
|
||||
of filing into the plan. Valid while the binaries are built from the gate's Go version.
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
import file_vulnerabilities
|
||||
|
||||
TODO = """# todo
|
||||
|
||||
## Scoring
|
||||
|
||||
`Score = -R - S/4 + 2*A + 2*G*W`
|
||||
|
||||
`Bar = 9`
|
||||
|
||||
`Next ID = 7`
|
||||
|
||||
| Goal | W |
|
||||
|---|---|
|
||||
| 1 | 1.00 |
|
||||
| 1.1 | 0.90 |
|
||||
|
||||
## Items
|
||||
|
||||
| ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 5 | 0.2.0 | | **Add a thing.** | 2 | 2 | 2 | 7 | 8 | 1 | 26.5 |
|
||||
|
||||
## Details
|
||||
|
||||
### 5. Add a thing.
|
||||
|
||||
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"]},
|
||||
{"id": "GO-2026-0003", "summary": "Panic in x/text", "aliases": []},
|
||||
)
|
||||
STDLIB = (
|
||||
("GO-2026-0002", "stdlib", "v1.27.1", "v1.27.2"),
|
||||
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
|
||||
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
|
||||
)
|
||||
|
||||
|
||||
class FileItems(unittest.TestCase):
|
||||
def test_no_finding_files_nothing(self):
|
||||
self.assertEqual(file_vulnerabilities.file_items(report(osvs=OSVS), TODO), (TODO, 0))
|
||||
|
||||
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)
|
||||
self.assertIn("`Next ID = 8`", todo)
|
||||
rows = [line for line in todo.splitlines() if line.startswith("| ") and line[2].isdigit()]
|
||||
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.assertTrue(
|
||||
detail.startswith(
|
||||
"### 7. Raise Go to 1.27.10, so the release binaries carry none of its known vulnerabilities.\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,
|
||||
)
|
||||
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)
|
||||
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))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Main(unittest.TestCase):
|
||||
def setUp(self):
|
||||
cwd = os.getcwd()
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
os.chdir(tmp.name)
|
||||
self.addCleanup(os.chdir, cwd)
|
||||
with open("todo.md", "w", encoding="utf-8") as f:
|
||||
f.write(TODO)
|
||||
|
||||
def run_main(self, text):
|
||||
with open("report.json", "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
code = file_vulnerabilities.main(["report.json", "todo.md"])
|
||||
with open("todo.md", encoding="utf-8") as f:
|
||||
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))
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -23,6 +23,18 @@
|
||||
"matchStrings": [
|
||||
"goreleaser/goreleaser-action@\\S+\\s+with:\\s+version: (?<currentValue>\\S+)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"datasourceTemplate": "go",
|
||||
"description": "Go tools the Dockerfile and compose.yaml run with go run, by their module path",
|
||||
"managerFilePatterns": [
|
||||
"/^Dockerfile$/",
|
||||
"/^compose\\.yaml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"go run (?<depName>[^@\\s]+?)(/cmd/[^@\\s]+)?@(?<currentValue>v\\S+)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"extends": [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`Bar = 9`
|
||||
|
||||
`Next ID = 253`
|
||||
`Next ID = 255`
|
||||
|
||||
| Goal | W |
|
||||
|---|---|
|
||||
@@ -178,6 +178,8 @@
|
||||
| 174 | 0.2.0 | | **Spell once the fact that a walk's next step leaves a selected row, which `pathPos.fromRow` and `tableRoute.nextLinked` each spell today.** | 1 | 1 | 3 | 1 | 5 | 3.2 | 9.8 |
|
||||
| 108 | 0.2.0 | defect | **Name item 26 in the decision "No builtin reads the clock, so a date is bounded by days, never by an age", as the item that ends its conflict with goal 4.** | 1 | 1 | 1 | 1 | 4 | 3.4 | 8.0 |
|
||||
| 169 | 0.2.0 | question | **Reword the decision "A parent row with no child row is a load error" to say what each geo import does with such a row: drop it, or exit where `geo-se.py` would lose a kommun or a län.** | 1 | 1 | 2 | 1 | 4 | 3 | 8.0 |
|
||||
| 253 | 0.3.0 | | **Let `date()` take bounds relative to the date the caller gives, such as 30 days before it to 14 days after it.** | 2 | 3 | 4 | 6 | 6 | 7.3, 10.1 | 17.6 |
|
||||
| 254 | 0.3.0 | | **Let a choice deal its items in exact counts across a run's renders, so 200 issues hold exactly 4 in progress, where weights only approach it.** | 3 | 4 | 6 | 6 | 6 | 5, 10 | 17.6 |
|
||||
| 38 | 0.3.0 | defect | **Compare `calcParser.binary`'s operator as a rune.** | 1 | 1 | 2 | 3 | 6 | 6 | 13.8 |
|
||||
| 45 | 0.3.0 | | **Name the node a selector follows in `childNamed`'s refusal.** | 1 | 1 | 2 | 3 | 6 | 6 | 13.8 |
|
||||
| 49 | 0.3.0 | | **Report a CRLF rows file holding only its header as having no rows.** | 1 | 1 | 2 | 3 | 6 | 6 | 13.8 |
|
||||
@@ -671,6 +673,14 @@ Item 26 revises the decision, but the decision does not point back to it.
|
||||
|
||||
Its entry says "The import script drops or fills such rows". There are two geo imports, and `geo-se.py` exits when `geo.fence` drops a kommun or a län, while `geo-us.py` drops counties and states. Rewording a decision is the maintainer's call.
|
||||
|
||||
### 253. Let `date()` take bounds relative to the date the caller gives, such as 30 days before it to 14 days after it.
|
||||
|
||||
Needs item 26, which makes the caller give the date and revises the decision "No builtin reads the clock, so a date is bounded by days, never by an age". Today a due date or a sprint window "around now" is two fixed days, re-pinned whenever the fixture should look current. A relative bound reads the caller's date, never the clock, so the same seed and date still give the same output (goal 10). Bench the spelling with the hand fixture author and the bulk fixture author, such as `date(-30d,+14d,'2006-01-02')`; the README's Functions row and the clock decision change with it.
|
||||
|
||||
### 254. Let a choice deal its items in exact counts across a run's renders, so 200 issues hold exactly 4 in progress, where weights only approach it.
|
||||
|
||||
A fixture often needs a fixed mix: of 200 issues, 4 in progress, 30 done and the rest to do. A weighted choice gives about 4, and the count shifts with the seed, so a test asserting on it, or a board built from it, needs a loader to fix the counts. A dealt choice is a deck, drawn without replacement until it runs out. Decide what a render past the last card does: refuse at load where `--repeat` is known to exceed the deck, start a fresh deck, or fall back to the weights. Decide too which renders share one deck: the run, as `{seq()}` shares one counter, or each `repeat`. Bench the spelling with the bulk fixture author. The deck is per-generator state beside `seq()`'s counters, so it must stay reproducible from one goroutine (goal 10). Where the change touches `render.go`'s `readField` or the pick state in `pick.go`, places the comprehension seats found hard, make it easier to read.
|
||||
|
||||
### 38. Compare `calcParser.binary`'s operator as a rune.
|
||||
|
||||
`byte(p.rs[p.pos])` reads U+012B `ī` as `+`, so `{calc(a ī b)}` compiles as `a + b`.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""File each module's known vulnerabilities as one security item atop todo.md's next release.
|
||||
|
||||
Usage: file_vulnerabilities.py <govulncheck -format json report> <todo.md>
|
||||
Exits 1 while any vulnerability is known, filed now or before, 0 on a clean report, and 2 on
|
||||
a todo.md it cannot read.
|
||||
|
||||
todo.md's `Next ID = N` gives the item's ID, and the release of the plan's top row, the next
|
||||
release, gives its release. Its Score is 0.0 until rank_todo.py, which CI cannot run, scores
|
||||
it; a security item ranks first in its release whatever it scores. The test that files into
|
||||
the repository's own todo.md fails the gate when the plan's shape drifts from what this
|
||||
script reads.
|
||||
|
||||
Scores, as todo.md's table spells them: R (risk), S (size) and C (difficulty) are 1, since
|
||||
the fix is a version bump; A (value to the audience) and G (value to the goal) are 8, the goal
|
||||
being README goal 1.1, installing fejkdata the way one installs other tools, which a flagged
|
||||
binary works against.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
ROW = re.compile(r"^\| (?P<id>\d+) \| (?P<release>[^|]+?) \| [^|]*\| \*\*", re.M)
|
||||
NEXT_ID = re.compile(r"`Next ID = (\d+)`")
|
||||
DETAILS = "## Details\n\n"
|
||||
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)
|
||||
unfixed: bool = False
|
||||
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"]]
|
||||
if "fixed_version" in finding:
|
||||
module.fixed.append(finding["fixed_version"])
|
||||
else:
|
||||
module.unfixed = True
|
||||
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 tagline(module: Module) -> str:
|
||||
if module.unfixed:
|
||||
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."""
|
||||
first = ROW.search(todo)
|
||||
if first is None:
|
||||
raise ValueError("todo.md has no item to take the next release from")
|
||||
if NEXT_ID.search(todo) is None:
|
||||
raise ValueError("todo.md has no `Next ID = N` line to number the item by")
|
||||
if DETAILS not in todo:
|
||||
raise ValueError(f"todo.md has no {DETAILS.strip()!r} heading to put the item's detail under")
|
||||
release, rows_at = first.group("release"), first.start()
|
||||
details_at = todo.index(DETAILS) + len(DETAILS)
|
||||
filed = []
|
||||
for module in modules:
|
||||
if all(osv in todo for osv in module.osvs):
|
||||
continue
|
||||
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} | {release} | security | **{line}** | {scores} | {GOAL} | 0.0 |\n"
|
||||
text = f"### {next_id}. {line}\n\n{detail(module)}\n"
|
||||
todo = todo[:rows_at] + row + todo[rows_at:]
|
||||
rows_at += len(row)
|
||||
details_at += len(row)
|
||||
todo = todo[:details_at] + text + todo[details_at:]
|
||||
details_at += len(text)
|
||||
todo = NEXT_ID.sub(f"`Next ID = {next_id + 1}`", todo, count=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:
|
||||
try:
|
||||
todo, filed = file_items(modules, f.read())
|
||||
except ValueError as err:
|
||||
print(err, file=sys.stderr)
|
||||
return 2
|
||||
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:]))
|
||||
@@ -0,0 +1,195 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import file_vulnerabilities
|
||||
|
||||
TODO = """# todo
|
||||
|
||||
## Scoring
|
||||
|
||||
`Score = -R - S/4 + 2*A + 2*G*W`
|
||||
|
||||
`Bar = 9`
|
||||
|
||||
`Next ID = 7`
|
||||
|
||||
| Goal | W |
|
||||
|---|---|
|
||||
| 1 | 1.00 |
|
||||
| 1.1 | 0.90 |
|
||||
|
||||
## Items
|
||||
|
||||
| ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 5 | 0.2.0 | | **Add a thing.** | 2 | 2 | 2 | 7 | 8 | 1 | 26.5 |
|
||||
|
||||
## Details
|
||||
|
||||
### 5. Add a thing.
|
||||
|
||||
Why.
|
||||
"""
|
||||
|
||||
OSVS = (
|
||||
{"id": "GO-2026-0002", "summary": "Denial of service in net/http", "aliases": ["GHSA-2222", "CVE-2026-2"]},
|
||||
{"id": "GO-2026-0001", "summary": "Cross-site scripting in html/template", "aliases": ["CVE-2026-1"]},
|
||||
{"id": "GO-2026-0003", "summary": "Panic in x/text"},
|
||||
{"id": "GO-2026-0004"},
|
||||
)
|
||||
STDLIB = (
|
||||
("GO-2026-0002", "stdlib", "v1.27.1", "v1.27.2"),
|
||||
("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(TODO), (TODO, []))
|
||||
|
||||
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)
|
||||
self.assertEqual(rows(todo)[0], f"| 7 | 0.2.0 | security | **{GO_TAGLINE}** | 1 | 1 | 1 | 8 | 8 | 1.1 | 0.0 |")
|
||||
self.assertTrue(rows(todo)[1].startswith("| 5 |"))
|
||||
self.assertTrue(
|
||||
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 (GHSA-2222, CVE-2026-2): Denial of service in net/http\n\n"
|
||||
"### 5. Add a thing."
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
self.assertEqual([row.split(" | ")[0] for row in rows(todo)], ["| 7", "| 8", "| 5"])
|
||||
details = todo.split("## Details\n\n", 1)[1]
|
||||
self.assertLess(details.index("### 7."), details.index("### 8."))
|
||||
|
||||
def test_a_vulnerability_already_planned_is_not_filed_again(self):
|
||||
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-0004", "stdlib", "v1.27.1", ""))
|
||||
self.assertIn("- GO-2026-0004\n", todo)
|
||||
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_next_id_is_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
file(TODO.replace("`Next ID = 7`", ""), *STDLIB)
|
||||
|
||||
def test_a_plan_with_no_details_heading_is_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
file(TODO.replace("## Details", "## Notes"), *STDLIB)
|
||||
|
||||
def test_a_plan_with_no_items_is_refused(self):
|
||||
empty = TODO.split("| 5 |")[0].rstrip("\n") + "\n\n## Details\n"
|
||||
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]} \| .* \| 0\.0 \|$")
|
||||
self.assertIn(f"`Next ID = {filed[0] + 1}`", todo)
|
||||
|
||||
|
||||
class Main(unittest.TestCase):
|
||||
def setUp(self):
|
||||
cwd = os.getcwd()
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
os.chdir(tmp.name)
|
||||
self.addCleanup(os.chdir, cwd)
|
||||
with open("todo.md", "w", encoding="utf-8") as f:
|
||||
f.write(TODO)
|
||||
|
||||
def run_main(self, *findings):
|
||||
with open("report.json", "w", encoding="utf-8") as f:
|
||||
f.write(report(*findings))
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
code = file_vulnerabilities.main(["report.json", "todo.md"])
|
||||
with open("todo.md", encoding="utf-8") as f:
|
||||
return code, out.getvalue(), f.read()
|
||||
|
||||
def test_a_clean_report_passes_and_leaves_the_plan(self):
|
||||
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(*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_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_a_plan_it_cannot_read_exits_2(self):
|
||||
with open("todo.md", "w", encoding="utf-8") as f:
|
||||
f.write(TODO.replace("`Next ID = 7`", ""))
|
||||
with redirect_stderr(io.StringIO()) as err:
|
||||
self.assertEqual(self.run_main(*STDLIB)[:2], (2, ""))
|
||||
self.assertEqual(err.getvalue(), "todo.md has no `Next ID = N` line to number the item by\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__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user