2026.9.0 — Windows patching, remediate-by dates, waves and database tags - #23
Merged
Conversation
…catalogue (2026.9.0). Findings derive a due date once in save() so no scanner can forget it. Ship the KEV catalogue as a bundled snapshot with an opt-in daily refresh that never blocks scoring. Accepted-risk and deferred exceptions need a reason and an expiry, and drop out of overdue counts when they lapse. Bump the server, agent and wiki to 2026.9.0.
The Vigil-Approved-Scripts community repo needs a machine-readable copy of ACTION_REGISTRY so its offline validator can check action types and required params without Django. dump_actions prints it (or writes it with --output), sorted and indented, so the generated file is diff-stable across resyncs.
Community tasks should be able to declare which CVEs they remediate, where the advisory lives, and which OS families they target, so the Community tab and the vuln views can cross-reference them. Purely additive: every task that validates today still validates unchanged with the three keys defaulting to []. - cves: list of at most 32 CVE ids, normalized to upper case, duplicates rejected; a bare string is an error so the schema stays unambiguous. - references: list of at most 16 absolute http(s) URLs, each up to 500 chars; scheme checked via urlparse with a non-empty netloc so https:///evil and javascript: URLs are rejected. - platforms: list of at most 3 of linux/windows/darwin (matching what the agent collector already reports), lowercased, duplicates rejected. Documented in the wiki's Task YAML Spec schema example; the Built-in Actions table is untouched and still asserted by test_wiki_actions.py.
…seam Phase 07a: add agent/vigil_agent/windows_update.py (WuaBackend over the WUA COM API) plus its unit tests. The COM layer is isolated behind a session_factory so tests substitute fakes without a Windows host, and win32com/pythoncom import lazily so the module loads on Linux. No registration or handler wiring - that is phase 07b.
windows_update_scan and windows_update_install now exist in the server registry (spec.py), the agent dispatch table (_HANDLERS), the managed-mode allowlist (_ALL_ACTIONS) and the wiki's Built-in Actions table. Without all four, an action silently half-works: the server signs it, full_control executes it, and a managed-mode agent still refuses it because it was never allowlistable. The handlers call the phase 07a backend through detect() and raise a clear ValueError off Windows; the module is imported lazily inside the handler so a Linux agent never touches win32com. Install filters first and reports rather than calling Install() on an empty collection (which throws a COM error that reads like a real failure), and never reboots — reboot_required is reported and the separate reboot action (phase 08) handles the rest.
The reboot action's deferral/notification params (notify, notify_message, defer_limit, defer_minutes) were written for the agent in 08a but never registered on the server, so every task carrying them was rejected at parse time and the agent code was unreachable. Phase 08c (local phase doc, git-ignored in this repo) is blocked on a defect found in 08a's uncommitted _reboot: deferral.expired_now() does not exist on RebootDeferral, so every reboot dispatch raises AttributeError on every platform. The blocker and decision request are filed in the phase doc's Update Log. 08a's agent files (executor.py mod, deferral.py new) are deliberately left uncommitted for the architect's disposition.
A 2026.8.0 agent reads reboot params with params.get() and drops the new notify/defer_limit/defer_minutes params it does not recognise — it then sees no delay and runs 'shutdown -r now'. The server believes it scheduled a deferrable reboot; the laptop reboots mid-call. Stripping the params or sending them and hoping are both unsafe, so the checkin dispatch path refuses: the task is marked failed with a message naming the required agent version, before the existing schedule.window gate. Also surface Host.reboot_required (agent-reported, absent key = too old to report, stored value preserved) in the serializer and the fleet view via a --peach pending marker on the host card. The version compare is numeric per segment: lexically '2026.10.0' < '2026.9.0', and this milestone will reach a .10 release. Empty or unparseable versions compare as below minimum — unknown means unsafe.
Rings target hosts by tag and a host in two rings is patched once, in the earlier ring. A ring must report a minimum number of results before its failure rate can halt a rollout, so a one-host canary cannot halt on a single failure. Halting and resuming go through the same admin and TOTP gates as task dispatch.
…xhaustion, reboot_required probe Lands the previously-uncommitted 08a reboot work (_reboot in executor.py, deferral.py) plus the two fixes its specified tests exposed: - RebootDeferral.__init__ never called _load(), so a fresh instance over an existing state file started blank and deferral persistence across agent restarts was silently dead. - The Windows /f decision was 'not deferral_active and defer_limit <= 0', which can never be true when a budget is configured and the state is cleared just before the argv is built — an exhausted deferral would never force-close. force_close is now captured before clear(). collector.py gains the reboot_required probe (Linux marker + dnf, Windows lazy winreg key check, macOS False, any probe error -> False) and the check-in payload carries it only when present, matching 08b's absent-key ingest contract. agent/tests/test_reboot.py: 23 tests, all hermetic (mocked _run/subprocess/ winreg, temp dirs only). Agent suite 209 -> 232.
Saving deferral state now degrades to a warning like loading and clearing already did, so a read-only data dir costs the user their deferral budget instead of failing the reboot outright.
The score used to derive from severity counts alone, so a critical found today and the same critical 90 days overdue read the same. recompute_summary now weights each deduped finding against its due date (base weight x the escalation curve from phase 05), rounds once, and stores the result; the un-escalated compute_score stays as the baseline. Dedup now keeps the worst severity AND the soonest due date per CVE as two independent reductions, so a second scanner reporting the same CVE with a later date can no longer launder an overdue finding out of the score. VulnSummary gains overdue_count / due_soon_count (excepted findings count toward neither), the finding serializer exposes due_date / days_remaining / overdue / an exception sub-object, and finding_list gains ?overdue=1, a validated ?due_within=<days>, and a ?sort= allowlist (due_date, -due_date, severity, -severity) that maps to fixed field expressions and rejects anything else with 400. Findings without a due date sort last in both directions; severity ranks through SEVERITY_RANK, never the raw string. A data migration recomputes the score history under the new curve so the sparkline has no step at the release boundary; it applies each finding's current due date at each historical date (first_seen gates existence) and states in a comment that this is an approximation for remediated hosts. Fleet rows show overdue / due-soon counts in rose / lemon; finding rows get a due-date cell and overdue rows a rose tint. Exception reasons (free text) go through escHtml in both text and tooltip.
Catch the wiki and README up to what M4 actually built: the Windows Update actions and why the COM path replaces WSUS (including the honest third-party coverage gap and the not-exercised-on-Windows note), the reboot behaviour with its 2026.9.0 version gate, the remediation dates with the escalation curve, and the three community content types with the slug and target-host rules. The community repo itself was already split out ahead of this dispatch and is deliberately left untouched.
Move rollouts onto the Baselines page and retitle it Deployments, so baselines, automations and rollouts sit together. Rewrite the rollout explanation in plainer language. The migration renames the model and its fields rather than dropping and recreating them, so existing waves and rollout history survive.
…ation into a hover hint. The explanation was three lines of permanent panel real estate for something you read once, so it is now a hint beside the search box. Rollout, automation and history searches each filter the list already loaded rather than refetching, and an empty result says so instead of reading as though nothing exists.
Waves get their own sub-tab built like baselines and automations: search, a New wave button, cards, and a modal editor for name, order, tags and validation window. Cards show how many hosts a wave actually patches alongside how many merely match, because a host claimed by an earlier wave is easy to miscount. Deleting a wave a live rollout is standing on is refused rather than stranding the rollout, and a wave with no tags is rejected because it would silently patch nothing.
…ave. A rollout now targets either a task definition or a baseline, reusing the existing baseline composition rather than a second expansion path, and a database constraint keeps it to exactly one target. The spec is re-derived through one resolver so a baseline rollout does not break when it advances past its first wave. Automations gain an all-at-once or wave-by-wave choice; in wave mode the waves pick the hosts and the automation's own target is ignored, which the editor says plainly. Rename the rollout create button to New rollout in mint so it matches the other panels, and close the newer modals on Escape like the deploy modal already did.
…r inspection. The rollout target, firewall host, automation event host and task editor template all use the same search modal as adding a baseline step; the rollout picker lists tasks and baselines together. Clicking a rollout opens a detail view with the wave progression, the failure gate in plain words, and only the actions its current state allows. A validation window can now be ended early by an admin with a TOTP code; the failure gate still applies, so skipping the wait does not skip the check. Add hints to the fields whose meaning was not obvious, and document waves and rollouts in the wiki. Fix nine modals that never closed on Escape and left an overlay swallowing every click.
…ase. The four matchers disagree: whitespace is stripped in one of them, an empty tag list means every host to a baseline and no hosts to a wave, and only waves exclude rejected hosts. Reprovision carries two more tag sources, including a singular completion_tag on the job that does not show up when grepping for _tags. Make the hint markers visible; at the dimmest text colour they read as decoration rather than something to hover.
Canonicalise by lowercasing only, never by stripping, so Prod and prod become one tag because they already matched and prod and 'prod ' stay two because they never did. The migration walks all seven sources including the singular completion_tag on a rebuild job, and is additive: nothing reads the rows yet, so membership cannot move. It reports which spellings it merged and which near-identical pairs it deliberately kept apart, so a real typo gets fixed on purpose rather than guessed at.
…ching. Each of the seven tag fields gains a row relation beside it, filled from the strings it mirrors; the strings stay authoritative so membership cannot move yet. A string with no matching row is reported rather than created, because that means the seeding step missed a source and papering over it would hide the gap. Consistency tests assert the mirror and the strings agree, which is the gate on switching the matchers over.
Waves, baselines and both automation selectors now compare tag rows; the row key already encodes the old comparison, so nothing about who matches what has changed. Writers still assign the string lists and the rows follow on save, because six separate places assign host tags and converting every caller would have been the riskier change. An unsaved instance falls back to its strings, since it has no rows yet and would otherwise match nothing at all — and the check is _state.adding rather than pk is None, because these models default their UUID primary key and an unsaved one already has it.
Tasks gains a Tags tab listing every tag with how many machines carry it, so a tag has one place it is named rather than being retyped in four. Renaming a tag renames it on every machine, wave, baseline and automation at once, and deleting one still in use is refused with a message naming what uses it. Tags under os:, pkg:, arch: and agent: are shown but not editable, because Vigil rebuilds them from what each machine reports. The wave editor can pick from existing tags instead of only accepting typed text, so a misspelling is visible rather than silently matching nothing.
…n yellow. All six tag inputs go through one helper instead of being wired separately, so the baseline, automation, reprovision and host-detail fields behave like the wave one and a seventh cannot be missed. The Waves sub-tab was using a colour class that does not exist and had been rendering uncoloured since it was added.
The filter read an alert's rule name and message and nothing else, so on any other event there was no alert to read and the filter was skipped entirely — the automation fired whatever the operator had typed. The editor hid the control for those events, so there was no sign it was being dropped. Every event now offers the text it carries: an insight's title, a finished task's step and output, a rebuild's profile and state, a host's name. An event with no readable text no longer matches a filter, where before it matched everything. Add is exactly, is not, starts with, ends with and the two regex operators alongside contains. An invalid pattern matches nothing rather than firing for everything. A pre-existing test used regex as its example of an unsupported operator; it is a real one now, so it asserts against something that still is not.
Metric history now samples across the whole window instead of truncating a newest first queryset, so 24h and 7d draw the range they name. Charts skip animation on routine polls, take numeric timestamps with parsing off, and hit test by nearest x rather than every point. Range changes animate through a named Chart.js transition mode instead of writing to chart.options, which recursed through the options proxy and blew the stack. Polling skips ticks while the tab is hidden and catches up once on return. Wiki documents the ranges and the sampling rule, and the time range bar gets a hint. Adds tests for the sampling and for the polling helper.
The Pick button wrapped under a full width tag field and sat flush against it, so it now carries its spacing from a .tag-pick-btn rule instead of inline styles. A hint following a block level field label was landing on the line below it, far enough away to read as belonging to the next control, and now sits beside the label.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's in it
Four workstreams, plus the UI work that came out of smoke-testing them.
Windows patching
OS updates through the Windows Update Agent COM API (
IUpdateSearcher/Downloader/Installer), no WSUS. Two new actions:windows_update_scan(low risk) andwindows_update_install(standard). Third-party apps delegate to winget and choco rather than a repackaged catalogue, and the docs say plainly where that leaves the coverage gap.Reboots are first-class: maintenance windows, end-user notification with a deferral limit, and reboot-required surfaced in the fleet view. Agents below 2026.9.0 refuse deferral-bearing reboots rather than silently ignoring the deferral.
Staged rollouts
A rollout walks waves in order, off existing tags, with a halt-on-failure-rate gate and a validation window between waves. Waves can target a task definition or a baseline, automations can dispatch wave by wave, and a rollout opens for inspection with continue and skip-validation controls.
Remediate-by dates
due_datecomes from an editable policy (severity tier plus CISA KEV), BOD 22-01-ish defaults, backfilled on migration. The effective host score escalates as the date approaches and takes a hard bump past due, with the curve in one tunable place.due_date,days_remainingandoverdueare exposed in the API, views and exports with sort and filter, and exceptions are documented with a reason and an expiry.Database-defined tags
Tags are rows now, not strings compared in seven places. The migration is staged and additive — seed rows, mirror every string onto them, then switch matching over — with characterization tests written first to pin the existing behaviour. Case variants merge; whitespace variants stay separate, because they never matched each other before and merging them would silently change what a wave targets.
The string columns are deliberately still there. Dropping them is a follow-up.
UI
Every entity dropdown is now a searchable modal, every tag field has a picker, Tags is the third tab in Tasks, each Deployments panel has its own search, and hints explain the things that needed explaining.
The monitor charts got a correctness fix worth calling out: the metric endpoint truncated a newest-first queryset, so 24h and 7d both drew about eight hours and the time-scale buttons looked inert. It now samples across the whole window at a fixed stride, keeping the newest point, and never averages — a spike you can see is a spike that really happened.
Testing
1097 tests pass. New coverage for the tag migration (69 tests across semantics and rows), automation text matching (25), metric sampling (6), and a guard that periodic refresh goes through the visibility-aware polling helper.
Known gaps
baselines/andautomations/content types have schemas, validators, CI and seed examples, but the server still only fetchestasks/. Surfacing the other two is follow-up work.