From 5f5e19e23eef6da0e702d3ded4edbb060ea22f2a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 07:43:28 +0000 Subject: [PATCH] feat: filter apt sources down to security suites for security-only patching --- internal/updates/aptsec.go | 111 ++++++++++++++++++++++++++++++++ internal/updates/aptsec_test.go | 80 +++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 internal/updates/aptsec.go create mode 100644 internal/updates/aptsec_test.go diff --git a/internal/updates/aptsec.go b/internal/updates/aptsec.go new file mode 100644 index 0000000..40e6fd9 --- /dev/null +++ b/internal/updates/aptsec.go @@ -0,0 +1,111 @@ +package updates + +import ( + "sort" + "strings" +) + +// isSecuritySuite reports whether an apt suite carries security fixes. Debian +// 11+ and every supported Ubuntu name them "-security". +func isSecuritySuite(s string) bool { return strings.HasSuffix(s, "-security") } + +// securitySources reduces a host's apt source files to only the entries that +// point at a security suite, so an upgrade run against them installs security +// fixes and nothing else. +// +// It is a pure function of file contents so it is tested on any platform. The +// caller writes list to a *.list file and deb822 to a *.sources file in a +// temporary SourceParts directory: keeping deb822 paragraphs as deb822 means +// an inline Signed-By key block survives verbatim, which a conversion to +// one-line format could not carry. +// +// ok is false when no security suite exists at all. The caller must then +// report unsupported, never fall back to installing everything. +func securitySources(files map[string]string) (list string, deb822 string, ok bool) { + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + sort.Strings(paths) // deterministic output + + var lb, db strings.Builder + for _, p := range paths { + if strings.HasSuffix(p, ".sources") { + db.WriteString(filterDeb822(files[p])) + } else { + lb.WriteString(filterOneLine(files[p])) + } + } + list, deb822 = lb.String(), db.String() + return list, deb822, list != "" || deb822 != "" +} + +func filterOneLine(content string) string { + var b strings.Builder + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "deb" { + continue + } + i := 1 + if strings.HasPrefix(fields[i], "[") { + // Options run until the token that closes the bracket. + for i < len(fields) && !strings.HasSuffix(fields[i], "]") { + i++ + } + i++ + } + // fields[i] is the URI, fields[i+1] the suite. + if i+1 < len(fields) && isSecuritySuite(fields[i+1]) { + b.WriteString(line) + b.WriteString("\n") + } + } + return b.String() +} + +func filterDeb822(content string) string { + var b strings.Builder + for _, para := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n\n") { + lines := strings.Split(strings.Trim(para, "\n"), "\n") + var out []string + isDeb, enabled, kept := false, true, false + for _, l := range lines { + key, val, found := strings.Cut(l, ":") + k := strings.ToLower(strings.TrimSpace(key)) + v := strings.TrimSpace(val) + switch { + case found && k == "types": + for _, t := range strings.Fields(v) { + if t == "deb" { + isDeb = true + } + } + case found && k == "enabled": + enabled = strings.ToLower(v) != "no" + case found && k == "suites": + var sec []string + for _, s := range strings.Fields(v) { + if isSecuritySuite(s) { + sec = append(sec, s) + } + } + if len(sec) == 0 { + continue // drop the line; the paragraph is dropped below + } + kept = true + l = "Suites: " + strings.Join(sec, " ") + } + out = append(out, l) + } + if isDeb && enabled && kept { + b.WriteString(strings.Join(out, "\n")) + b.WriteString("\n\n") + } + } + return b.String() +} diff --git a/internal/updates/aptsec_test.go b/internal/updates/aptsec_test.go new file mode 100644 index 0000000..7d099ba --- /dev/null +++ b/internal/updates/aptsec_test.go @@ -0,0 +1,80 @@ +package updates + +import ( + "strings" + "testing" +) + +func TestSecuritySourcesOneLine(t *testing.T) { + files := map[string]string{ + "/etc/apt/sources.list": `# comment +deb http://deb.debian.org/debian bookworm main +deb http://deb.debian.org/debian bookworm-updates main +deb http://security.debian.org/debian-security bookworm-security main contrib +deb [arch=amd64 signed-by=/usr/share/keyrings/x.gpg] http://archive.ubuntu.com/ubuntu jammy-security main +deb-src http://security.debian.org/debian-security bookworm-security main +`, + } + list, d822, ok := securitySources(files) + if !ok { + t.Fatal("ok = false, want true") + } + if d822 != "" { + t.Fatalf("deb822 = %q, want empty", d822) + } + want := "deb http://security.debian.org/debian-security bookworm-security main contrib\n" + + "deb [arch=amd64 signed-by=/usr/share/keyrings/x.gpg] http://archive.ubuntu.com/ubuntu jammy-security main\n" + if list != want { + t.Fatalf("list =\n%s\nwant\n%s", list, want) + } +} + +func TestSecuritySourcesDeb822(t *testing.T) { + files := map[string]string{ + "/etc/apt/sources.list.d/ubuntu.sources": `Types: deb +URIs: http://archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu/ +Suites: noble-security +Components: main restricted +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +`, + } + list, d822, ok := securitySources(files) + if !ok || list != "" { + t.Fatalf("ok=%v list=%q", ok, list) + } + if !strings.Contains(d822, "Suites: noble-security") || strings.Contains(d822, "noble-updates") { + t.Fatalf("deb822 wrong:\n%s", d822) + } + if !strings.Contains(d822, "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg") { + t.Fatalf("Signed-By must be kept verbatim:\n%s", d822) + } +} + +// A paragraph listing several suites keeps only the security ones. +func TestSecuritySourcesDeb822MixedSuites(t *testing.T) { + files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble noble-security\nComponents: main\n"} + _, d822, ok := securitySources(files) + if !ok || !strings.Contains(d822, "Suites: noble-security\n") || strings.Contains(d822, "Suites: noble noble") { + t.Fatalf("got ok=%v\n%s", ok, d822) + } +} + +func TestSecuritySourcesDisabledParagraphIgnored(t *testing.T) { + files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble-security\nComponents: main\nEnabled: no\n"} + if _, _, ok := securitySources(files); ok { + t.Fatal("a disabled paragraph must not count") + } +} + +func TestSecuritySourcesNone(t *testing.T) { + files := map[string]string{"/etc/apt/sources.list": "deb http://mirror/debian bookworm main\n"} + if _, _, ok := securitySources(files); ok { + t.Fatal("ok = true with no security suite, want false") + } +}