Skip to content

Commit 5edbf3a

Browse files
voidstackloopclaude
andcommitted
Fix a round of correctness/security issues across the enterprise features
Uncommitted work from a parallel session, reviewed and verified here (full build, 144 tests, fmt, clippy all clean) before committing: - aria-describedby/aria-errormessage hold a space-separated *list* of ids; a single getElementById on the whole value returned null for a multi-id list, wrongly failing a correctly-described field. - Baseline::from_runs kept the first status seen for a repeated URL instead of the newest, so a baseline built from unsorted/multi-run input could record a stale severity. - doctor's audit-log probe checked the log's parent directory even when the log file itself already existed and was read-only, so it could report writable while every real append failed. - limiter::host_of split IPv6 literals on ':', collapsing every one onto a single pacing bucket ("[" for all of them). - notify::is_slack_webhook matched the URL by substring, so "https://example.com/?next=hooks.slack.com" was misclassified as a Slack webhook; now matches the host exactly via limiter::host_of. - Chrome-open backoff and the wizard/persistence timeout multiplier could overflow and panic on a large --check-timeout-secs; both saturate now. - export's XML escaper dropped control characters but not the other two characters XML 1.0 forbids (U+FFFE/U+FFFF). - serve's /readyz reported ready via create_dir_all alone, which returns Ok for an existing-but-read-only directory; now probes with an actual write. The server also bounds concurrent connections (a semaphore) and caps header-read time, so a slow/malicious client can't exhaust it with unbounded half-open connections. - LLM hardening: retries now apply to 429/5xx whose body isn't JSON (read as text before parsing, so a non-JSON error body no longer turns a retryable status into a permanent failure); Retry-After honors its full documented 60s cap instead of being re-capped to 8s; the verdict cache key includes the resolved base URL so switching --llm-base-url can't serve a stale backend's verdict; the Anthropic parser finds the first *text* block instead of assuming index 0 (a thinking/tool-use block can precede it); verdict parsing tries every balanced {...} region instead of first-brace-to-last-brace, so prose containing its own braces no longer discards a valid verdict further along; and LlmOptions gets a hand-written Debug that redacts the API key instead of the derive printing it verbatim. - history::load_runs now breaks same-second-timestamp ties by filename, so "the previous run" (and the diff/streak/flakiness built from it) can't flip between two calls depending on read_dir's unspecified order. - report --prometheus's formwatch_forms_total carried the reserved counter _total suffix on what's actually a gauge, which promtool rejects; renamed to formwatch_forms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4d8c90a commit 5edbf3a

17 files changed

Lines changed: 411 additions & 80 deletions

CHANGELOG.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ project doesn't have a release yet, so everything below is grouped under
6464
- `test` and `monitor` accept `--junit` / `--sarif` (with `--out`), so a
6565
single CI job can run the checks and emit results in one step.
6666
- `report --prometheus` renders Prometheus text-exposition metrics
67-
(`formwatch_forms_total`, `formwatch_checks_failing`,
67+
(`formwatch_forms`, `formwatch_checks_failing`,
6868
`formwatch_checks_warning`, `formwatch_check_last_run_timestamp_seconds`,
6969
and per-check `formwatch_check_status`) for a scrape endpoint or a
7070
node_exporter textfile-collector directory.
@@ -188,6 +188,49 @@ project doesn't have a release yet, so everything below is grouped under
188188

189189
### Fixed
190190

191+
- Validation reported a false `FAIL` for a correctly-described field whose
192+
`aria-describedby` (or `aria-errormessage`) listed more than one id: the
193+
check resolved the whole space-separated list with a single
194+
`getElementById`, which returns null for a list. Each id is now
195+
resolved, and either attribute counts as a described field.
196+
- LLM verdict parsing clamped an out-of-range score up to a passing 5,
197+
contradicting the module's own contract that an out-of-range score is a
198+
parse failure and letting a model-injected `{"score": 99}` force a
199+
`Pass`. It is now rejected (the check degrades to `Warn`), and verdict
200+
extraction is brace-balanced, so prose containing `{}` no longer
201+
discards a valid verdict.
202+
- LLM retries now apply to 429/5xx responses whose body isn't JSON
203+
(routine for gateway/proxy errors): the status is checked before the
204+
body is parsed, instead of the failed parse turning a retryable status
205+
into a permanent failure.
206+
- LLM `Retry-After` is honored up to its documented 60s cap instead of
207+
being silently re-capped to 8s. `LlmOptions`' hand-written `Debug`
208+
redacts the API key; the verdict cache key includes the resolved base
209+
URL (so switching `--llm-base-url` no longer serves the previous
210+
backend's verdicts); and the Anthropic response parser finds the first
211+
`text` content block rather than assuming the first block is text.
212+
- `serve`'s `/readyz` reported ready for an existing but read-only history
213+
directory — `create_dir_all` returns `Ok` for any existing directory —
214+
so it now probes writability, and the server bounds concurrent
215+
connections and caps header-read time.
216+
- `formwatch baseline --write` (`Baseline::from_runs`) recorded the
217+
*first* status seen for a repeated URL instead of the newest.
218+
- `formwatch doctor` probed the audit log's parent directory rather than
219+
an existing (possibly read-only) log file, so it could report writable
220+
while every append failed.
221+
- `--per-host-delay-ms` collapsed every IPv6 literal onto one pacing
222+
bucket: `host_of` split `[::1]:8080` on `:` and returned `"["`.
223+
- `--check-timeout-secs` (via the wizard timeout) and the Chrome-open
224+
retry backoff could panic on overflow; both saturate now.
225+
- Webhook auto-detection treated any URL *containing* `hooks.slack.com`
226+
(even in a query string or a look-alike host) as a Slack webhook; it
227+
now matches the host exactly.
228+
- `report --prometheus`'s `formwatch_forms_total` was a gauge carrying the
229+
reserved counter `_total` suffix, which `promtool` rejects; renamed to
230+
`formwatch_forms`.
231+
- JUnit XML output also drops the XML-1.0-forbidden U+FFFE/U+FFFF
232+
characters, and history loads same-second runs in a stable filename
233+
order so "the previous run" can't vary between invocations.
191234
- The declared MSRV was wrong: the code (edition-2024 let-chains) and
192235
several transitive dependencies (`home`, `icu_*` need 1.88;
193236
`idna_adapter` needs 1.86) require Rust **1.88**, not 1.85, so the MSRV

src/baseline.rs

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,26 +72,36 @@ impl Baseline {
7272
}
7373

7474
/// Builds a baseline from the latest run of every known form, keeping
75-
/// every non-Pass check.
75+
/// every non-Pass check. When `runs` happens to hold more than one run
76+
/// for a URL, the newest one wins for each (url, check) — keeping the
77+
/// older would record a stale severity that could then absorb (or, if
78+
/// more severe, wrongly uphold) a later finding.
7679
pub fn from_runs(runs: &[RunResult]) -> Self {
77-
let mut entries: Vec<Entry> = runs
78-
.iter()
79-
.flat_map(|run| {
80-
run.checks
81-
.iter()
82-
.filter(|c| c.status != Status::Pass)
83-
.map(|c| Entry {
84-
url: run.url.clone(),
85-
name: run.name.clone(),
86-
check: c.name.clone(),
87-
status: c.status,
88-
})
89-
})
90-
.collect();
80+
let mut latest: std::collections::HashMap<(&str, &str), (i64, Entry)> =
81+
std::collections::HashMap::new();
82+
for run in runs {
83+
for c in run.checks.iter().filter(|c| c.status != Status::Pass) {
84+
let key = (run.url.as_str(), c.name.as_str());
85+
if latest.get(&key).is_none_or(|(ts, _)| run.timestamp >= *ts) {
86+
latest.insert(
87+
key,
88+
(
89+
run.timestamp,
90+
Entry {
91+
url: run.url.clone(),
92+
name: run.name.clone(),
93+
check: c.name.clone(),
94+
status: c.status,
95+
},
96+
),
97+
);
98+
}
99+
}
100+
}
101+
let mut entries: Vec<Entry> = latest.into_values().map(|(_, entry)| entry).collect();
91102
entries.sort_by(|a, b| {
92103
(a.url.as_str(), a.check.as_str()).cmp(&(b.url.as_str(), b.check.as_str()))
93104
});
94-
entries.dedup_by(|a, b| a.url == b.url && a.check == b.check);
95105
Self {
96106
schema_version: BASELINE_SCHEMA_VERSION,
97107
generated_at: chrono::Utc::now().timestamp(),
@@ -259,6 +269,31 @@ mod tests {
259269
);
260270
}
261271

272+
#[test]
273+
fn from_runs_keeps_the_newest_status_for_a_repeated_url() {
274+
let older = RunResult {
275+
schema_version: SCHEMA_VERSION,
276+
name: "x".into(),
277+
url: "https://a.gov".into(),
278+
timestamp: 1,
279+
checks: vec![check("Accessibility", Status::Fail)],
280+
};
281+
let newer = RunResult {
282+
schema_version: SCHEMA_VERSION,
283+
name: "x".into(),
284+
url: "https://a.gov".into(),
285+
timestamp: 2,
286+
checks: vec![check("Accessibility", Status::Warn)],
287+
};
288+
let baseline = Baseline::from_runs(&[older, newer]);
289+
assert_eq!(baseline.len(), 1);
290+
assert_eq!(
291+
baseline.entries[0].status,
292+
Status::Warn,
293+
"the newest run's status must win, not the first one seen"
294+
);
295+
}
296+
262297
#[test]
263298
fn accepts_equal_or_better_but_not_worse() {
264299
let baseline = Baseline {

src/browser.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,10 @@ pub async fn open_with(browser: &Browser, url: &str, opts: &OpenOptions) -> Resu
259259
}
260260
}
261261
if attempt + 1 < attempts {
262-
tokio::time::sleep(opts.backoff * 2u32.pow(attempt)).await;
262+
// Saturating: `attempts` is a public field, and a large value
263+
// would otherwise overflow `backoff * 2^n` and panic.
264+
let backoff = opts.backoff.saturating_mul(2u32.saturating_pow(attempt));
265+
tokio::time::sleep(backoff).await;
263266
}
264267
}
265268
Err(last_err.expect("loop runs at least once, always setting last_err on failure"))

src/checks.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -729,11 +729,18 @@ pub async fn check_validation_errors(page: &Page) -> Result<CheckResult> {
729729
const f = {TARGET_FORM_JS};
730730
if (!f) return 0;
731731
const invalid = ({DEEP_QUERY_JS})(f, 'input:invalid, select:invalid, textarea:invalid, [aria-invalid=true]');
732+
const idsOf = (el, attr) => (el.getAttribute(attr) || '').split(/\\s+/).filter(Boolean);
733+
const hasText = (id) => (document.getElementById(id)?.textContent || '').trim().length > 0;
732734
let unlabeled = 0;
733735
for (const el of invalid) {{
734-
const describedBy = el.getAttribute('aria-describedby');
735-
const hasDescribedText = describedBy && document.getElementById(describedBy)?.textContent.trim();
736-
if (!hasDescribedText) unlabeled += 1;
736+
// aria-describedby (and aria-errormessage) hold a
737+
// *space-separated list* of ids; getElementById on the
738+
// whole attribute value returns null for a multi-id
739+
// list, which used to count a correctly-described field
740+
// as failed.
741+
const described = idsOf(el, 'aria-describedby').some(hasText)
742+
|| idsOf(el, 'aria-errormessage').some(hasText);
743+
if (!described) unlabeled += 1;
737744
}}
738745
return unlabeled;
739746
}})()"#

src/doctor.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,23 @@ pub fn run(ctx: &Context) -> Vec<Check> {
9494

9595
match ctx.audit_log {
9696
Some(path) => {
97-
let parent: PathBuf = path
98-
.parent()
99-
.filter(|p| !p.as_os_str().is_empty())
100-
.map(Path::to_path_buf)
101-
.unwrap_or_else(|| PathBuf::from("."));
102-
match write_probe(&parent) {
97+
// If the log file already exists, probe *it*: its parent can
98+
// be writable while the file itself is read-only, in which case
99+
// every later audit::append would fail despite a green doctor.
100+
let probe = if path.exists() {
101+
std::fs::OpenOptions::new()
102+
.append(true)
103+
.open(path)
104+
.map(|_| ())
105+
} else {
106+
let parent: PathBuf = path
107+
.parent()
108+
.filter(|p| !p.as_os_str().is_empty())
109+
.map(Path::to_path_buf)
110+
.unwrap_or_else(|| PathBuf::from("."));
111+
write_probe(&parent)
112+
};
113+
match probe {
103114
Ok(()) => checks.push(check(
104115
"Audit log",
105116
true,

src/export.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ fn xml_escape(s: &str) -> String {
2323
// XML 1.0 forbids most control characters outright; drop them
2424
// rather than emit a document no parser will accept.
2525
c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
26+
// U+FFFE/U+FFFF are also forbidden in XML 1.0.
27+
'\u{FFFE}' | '\u{FFFF}' => {}
2628
other => out.push(other),
2729
}
2830
}

src/history.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,22 +94,32 @@ pub fn save_run(base: &Path, run: &RunResult) -> Result<PathBuf> {
9494
}
9595

9696
/// All runs for this URL, oldest first.
97+
///
98+
/// Within a single timestamp, ordering is by filename — `save_run` writes
99+
/// same-second runs as `<ts>-N.json`, and without that tiebreak the
100+
/// `read_dir` order is arbitrary, so "the previous run" (and therefore the
101+
/// diff, streaks, and flakiness) could flip from run to run.
97102
pub fn load_runs(base: &Path, url: &str) -> Result<Vec<RunResult>> {
98103
let dir = dir_for(base, url);
99104
if !dir.exists() {
100105
return Ok(vec![]);
101106
}
102-
let mut runs = vec![];
107+
let mut runs: Vec<(String, RunResult)> = vec![];
103108
for entry in fs::read_dir(&dir)? {
104109
let path = entry?.path();
105110
if path.extension().and_then(|e| e.to_str()) == Some("json") {
106111
let run: RunResult = serde_json::from_str(&fs::read_to_string(&path)?)
107112
.with_context(|| format!("parsing {}", path.display()))?;
108-
runs.push(run);
113+
let file = path
114+
.file_name()
115+
.and_then(|n| n.to_str())
116+
.unwrap_or("")
117+
.to_string();
118+
runs.push((file, run));
109119
}
110120
}
111-
runs.sort_by_key(|r| r.timestamp);
112-
Ok(runs)
121+
runs.sort_by(|a, b| (a.1.timestamp, &a.0).cmp(&(b.1.timestamp, &b.0)));
122+
Ok(runs.into_iter().map(|(_, run)| run).collect())
113123
}
114124

115125
/// Every URL formwatch has ever recorded a run for, newest run first.

src/limiter.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,15 @@ pub fn host_of(url: &str) -> Option<String> {
7171
};
7272
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
7373
let authority = authority.rsplit('@').next().unwrap_or(authority);
74-
// Strip userinfo already handled; strip a trailing `:port`.
75-
let host = authority.split(':').next().unwrap_or("").trim();
74+
// Strip a trailing `:port`. An IPv6 literal is bracketed
75+
// (`[::1]:8080`), so splitting on ':' would yield "[" for every one
76+
// of them and collapse all IPv6 hosts onto a single pacing bucket.
77+
let host = if let Some(rest) = authority.strip_prefix('[') {
78+
rest.split(']').next().unwrap_or("")
79+
} else {
80+
authority.split(':').next().unwrap_or("")
81+
};
82+
let host = host.trim();
7683
if host.is_empty() {
7784
None
7885
} else {
@@ -103,6 +110,20 @@ mod tests {
103110
assert_eq!(host_of(""), None);
104111
}
105112

113+
#[test]
114+
fn ipv6_literals_are_distinct_hosts_not_the_bracket() {
115+
assert_eq!(host_of("http://[::1]:8080/x"), Some("::1".to_string()));
116+
assert_eq!(
117+
host_of("http://[2001:db8::1]/x"),
118+
Some("2001:db8::1".to_string())
119+
);
120+
assert_ne!(
121+
host_of("http://[::1]/x"),
122+
host_of("http://[::2]/y"),
123+
"distinct IPv6 hosts must not collapse onto one pacer key"
124+
);
125+
}
126+
106127
#[tokio::test]
107128
async fn zero_interval_paces_nothing() {
108129
let pacer = HostPacer::new(Duration::ZERO);

src/llm/cache.rs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ use std::path::PathBuf;
1010

1111
use crate::llm::prompt::Verdict;
1212

13-
/// A stable hex key for the given inputs. Order-sensitive.
14-
pub fn cache_key(provider: &str, model: &str, check: &str, text: &str) -> String {
13+
/// A stable hex key for the given inputs. Order-sensitive. The base URL is
14+
/// part of the key: two different OpenAI-compatible endpoints can share a
15+
/// provider label and model string, and a verdict produced by one must not
16+
/// be served for the other.
17+
pub fn cache_key(provider: &str, base_url: &str, model: &str, check: &str, text: &str) -> String {
1518
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
1619
const PRIME: u64 = 0x0000_0100_0000_01b3;
1720
let mut hash = OFFSET;
18-
for field in [provider, model, check, text] {
21+
for field in [provider, base_url, model, check, text] {
1922
for byte in field.as_bytes() {
2023
hash ^= u64::from(*byte);
2124
hash = hash.wrapping_mul(PRIME);
@@ -77,17 +80,28 @@ mod tests {
7780
#[test]
7881
fn key_is_deterministic_and_field_sensitive() {
7982
assert_eq!(
80-
cache_key("openai", "m", "check", "text"),
81-
cache_key("openai", "m", "check", "text")
83+
cache_key("openai", "https://api.openai.com/v1", "m", "check", "text"),
84+
cache_key("openai", "https://api.openai.com/v1", "m", "check", "text")
8285
);
8386
assert_ne!(
84-
cache_key("openai", "m", "check", "text"),
85-
cache_key("openai", "m", "check", "other")
87+
cache_key("openai", "https://api.openai.com/v1", "m", "check", "text"),
88+
cache_key("openai", "https://api.openai.com/v1", "m", "check", "other")
89+
);
90+
// Different endpoint, same provider/model: must not share a verdict.
91+
assert_ne!(
92+
cache_key("openai", "https://api.openai.com/v1", "m", "check", "text"),
93+
cache_key(
94+
"openai",
95+
"https://internal.example/v1",
96+
"m",
97+
"check",
98+
"text"
99+
)
86100
);
87101
// Field-boundary sensitivity: concatenation must not collide.
88102
assert_ne!(
89-
cache_key("openai", "m", "ab", "c"),
90-
cache_key("openai", "m", "a", "bc")
103+
cache_key("openai", "u", "m", "ab", "c"),
104+
cache_key("openai", "u", "m", "a", "bc")
91105
);
92106
}
93107

0 commit comments

Comments
 (0)