diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a0dbb..cf72852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ Measured on 103 pinned projects (24 new open-source ones of kinds not tried befo - Injection: a consider that rests on the function's parameters ("a caller passing outside input would make it exploitable") is asked, with the functions that call it, what the values it places can hold. It becomes a note when text the program fixes (literals its callers pass, numbers, names from a fixed list), values it creates or a local tool's own arguments lead; text another party can set, or callers that are not shown, keep it. Asked where the values come from, the recheck answered "the function's parameters" at 0.9 even for a Rust helper whose four callers pass literal SQL fragments. On the labeled projects, such considers went from 30 right and 51 wrong to 30 right and 32 wrong. Only these follow-ups are asked. - Hardcoded values: the Choice that names a finding's value lists, with each value, the other lines of its file that write it. A consider that rests only on a value's name, whose file writes that value again, is then asked what the value is: copies that must change together, or a value nothing near it explains, keep it; a value that the field or argument it fills or a comment beside it explains, an idiom such as a tolerance near zero or a unit conversion, or a hand-tuned number, clearly chosen, make it a note. On the labeled projects, such considers went from 53 right and 77 wrong (41%) to 32 right and 28 wrong (53%); the others are notes. Offered instead as the locate's "no value stands out", those kinds also took values with copies that must stay equal. Only these follow-ups are asked. - Hardcoded values: a review or consider that rests only on a value changing between environments is asked, once its value or constant is named and with the function or the lines that use the constant, where that value would differ. A value each installation must set, or the author's own account or domain, keeps it; the same value in every copy on purpose (the program's own service or registered client id, a provider's fixed address, a path the platform fixes), a fallback used only when configuration gives none, or code no deployment runs, at 0.80, make it a note. The Choice that names a file's constant lists the lines that use each one. On the labeled projects, such findings went from 17 right and 36 wrong to 15 right and 19 wrong: a desktop app's own license server, a provider's registered redirect and fallbacks behind environment variables are notes; a frontend's API host edited in code three times and a template author's domain as a fallback are the right ones lowered. Only these follow-ups are asked. +- Injection: outside PHP, a path check left undecided after its trace and recheck is asked, with the functions that call it, where the paths come from: the program's own directories or configuration, the command line or settings of the person running a local program, a whole path its caller gives, or another party's input (a request, an uploaded file or archive entry, a record users can edit). The program's own or the local user's clear it. A path, URL or redirect check still undecided on the function's parameters, whose Choice does not lean toward another party's input, is a note naming the path, URL or redirect, as a found one already was. Undecided injection units on the corpus went from 813 to 289, and no review or consider changed; the new Choice costs about $0.02 on the corpus. +- Tests: a test whose "checks only its mocks" or "recomputes its expected value" answer stays undecided after its recheck leans: below 0.50 it is clear. Labeled from the code, 4 of 43 such tests below 0.50 were hollow, against 10 of 35 at 0.50 or more. Undecided tests on the corpus went from 792 to 156, and undecided units overall from 1.39% to 0.83% of judged units. Nothing is asked again. - Unsafe settings: outside C#, PHP and Django, the TLS check asks whether code accepts a server's certificate, host key or host name without verifying it, and names a connection made without TLS (gRPC's insecure credentials, an insecure OpenTelemetry exporter, `sslmode=disable`) and verification skipped only when a caller or the operator asks for it as not turning it off. Asked whether code turns verification off, Online Boutique's gRPC clients and telemetry exporters inside its cluster, and its database reached through the AlloyDB connector, were seven reviews whatever the examples said, and httpx's `verify=False` branch and vaultwarden's opt-in setting for invalid SMTP certificates were reviews too. The escape check names i18next's `escapeValue: false` in a React app, whose components escape what they render, as escaping kept. Labeled unsafe-settings reviews went from 54 right and 24 wrong to 54 right and 14 wrong; pgweb defaulting a bookmark's `sslmode` to `disable`, which turns off TLS rather than verification, is no longer one. Only unsafe-settings traces are asked again. - Tests: Deno tests are test cases, in each of their forms: `Deno.test("name", fn)`, `Deno.test({ name: "name", fn() {…} })` and `Deno.test(function name() {…})`, with `.only` and `.ignore`. oak writes its 266 tests in the object form, and none of them was judged: its test files got a file-purpose request each and the test rules found nothing to ask. Only Deno projects' requests change. diff --git a/docs/classification-cascade.md b/docs/classification-cascade.md index 2d73381..ca473bc 100644 --- a/docs/classification-cascade.md +++ b/docs/classification-cascade.md @@ -158,6 +158,8 @@ signatures, or one candidate pair. its mocks is asked again with the bodies of the functions it calls and its file's imports, mocks and setup hooks (a part too long is left out, never cut); each answer replaces the first unless only the first is decisive. + One still undecided then leans: below 0.50 it is clear. Labeled from the + code, 4 of 43 such tests below 0.50 were hollow, against 10 of 35 above. A Ruby test is sent with the groups it is declared in, since an RSpec example reads as a sentence continuing them and the outer group often names the class under test. Its recheck shows, instead of every hook of the @@ -372,8 +374,15 @@ signatures, or one candidate pair. numbers, or values handed to it) was tried for considers on parameters and dropped: it cleared a sort column taken from the request as readily as clauses with placeholders. The same question about paths cleared real - traversals, reading names stored in an index as the program's own, so path - checks stay undecided until callers show more. The SQL check counts + traversals, reading names stored in an index as the program's own. Outside + PHP, an undecided path check is now asked, with its callers, where the + paths come from, naming a record users can edit as another party's input + and offering the local user's command line or settings: the program's own + paths or the local user's at 0.80 clear it (74 units on the corpus, among + them httprouter serving a directory through `http.Dir`). A path, URL or + redirect check still undecided on the function's parameters, whose Choice + does not lean toward another party's input, is a note, as a found one + already was: 450 injection units had stayed uncertain that way. The SQL check counts identifiers quoted by doubling embedded quotes as handled (identifiers cannot be bound), and the URL check excludes requests a web page sends from the user's browser; on fresh repositories both had flagged such code, while diff --git a/site/src/how-it-works.md b/site/src/how-it-works.md index 8fff789..bbf95af 100644 --- a/site/src/how-it-works.md +++ b/site/src/how-it-works.md @@ -176,6 +176,8 @@ signatures, or one candidate pair. its mocks is asked again with the bodies of the functions it calls and its file's imports, mocks and setup hooks (a part too long is left out, never cut); each answer replaces the first unless only the first is decisive. + One still undecided then leans: below 0.50 it is clear. Labeled from the + code, 4 of 43 such tests below 0.50 were hollow, against 10 of 35 above. A Ruby test is sent with the groups it is declared in, since an RSpec example reads as a sentence continuing them and the outer group often names the class under test. Its recheck shows, instead of every hook of the @@ -421,8 +423,15 @@ signatures, or one candidate pair. numbers, or values handed to it) was tried for considers on parameters and dropped: it cleared a sort column taken from the request as readily as clauses with placeholders. The same question about paths cleared real - traversals, reading names stored in an index as the program's own, so path - checks stay undecided until callers show more. The SQL check counts + traversals, reading names stored in an index as the program's own. Outside + PHP, an undecided path check is now asked, with its callers, where the + paths come from, naming a record users can edit as another party's input + and offering the local user's command line or settings: the program's own + paths or the local user's at 0.80 clear it (74 units on the corpus, among + them httprouter serving a directory through `http.Dir`). A path, URL or + redirect check still undecided on the function's parameters, whose Choice + does not lean toward another party's input, is a note, as a found one + already was: 450 injection units had stayed uncertain that way. The SQL check counts identifiers quoted by doubling embedded quotes as handled (identifiers cannot be bound), and the URL check excludes requests a web page sends from the user's browser; on fresh repositories both had flagged such code, while diff --git a/src/catalog.rs b/src/catalog.rs index e86924c..f48a9b3 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -275,9 +275,9 @@ pub fn rule_version(key: &str) -> &'static str { FILE_ORGANIZATION => "19", FUNCTION_SIMPLIFICATION => "14", SHARED_LOGIC => "20", - TEST_VALUE => "6", + TEST_VALUE => "7", TEST_REDUNDANCY => "4", - INJECTION => "9", + INJECTION => "10", SENSITIVE_DATA => "7", HARDCODED_VALUES => "7", UNSAFE_SETTINGS => "6", diff --git a/src/units/compose.rs b/src/units/compose.rs index 262033a..9687473 100644 --- a/src/units/compose.rs +++ b/src/units/compose.rs @@ -122,7 +122,9 @@ fn resolved<'a>(unit: &UnitPlan, judgments: &'a [Judgment]) -> (Outcome, Answers } else if unit.rule == catalog::COMMENTS { comment_answers(unit, judgments) } else if unit.rule == catalog::TEST_VALUE { - test_value_answers(unit, judgments) + let merged = test_value_answers(unit, judgments); + let outcome = leaning_test(unit, judgments, unit_outcome(unit, &merged)); + return (outcome, merged); } else if unit.rule == catalog::TEST_REDUNDANCY { // Whether each test checks something the other does not, asked of a // pair that reached a review, sits beside its answers. @@ -135,6 +137,26 @@ fn resolved<'a>(unit: &UnitPlan, judgments: &'a [Judgment]) -> (Outcome, Answers (unit_outcome(unit, &merged), merged) } +/// A test whose hollow checks stay undecided once its recheck is asked (or +/// when it has none) leans: below 0.50 it is clear. Labeled from the code, +/// 4 of 43 such tests below 0.50 checked only their mocks or recomputed +/// their expected value (5 counting a test whose one real check is weak), +/// against 10 of 35 at 0.50 or more; 636 of the 792 undecided tests on the +/// corpus lean below. +fn leaning_test(unit: &UnitPlan, judgments: &[Judgment], outcome: Outcome) -> Outcome { + let rechecked = + unit.recheck.is_none() || !answers(judgments, &unit.id, Pass::Recheck).is_empty(); + match outcome { + Outcome::Uncertain(p) + if rechecked + && !crate::policy::probability_at_least(p, crate::policy::LEADING_PROBABILITY) => + { + Outcome::Clear + } + other => other, + } +} + /// The pass of the follow-ups whose questions sit beside the first answers /// under their own ids: document section and pair checks, the kind of a /// large document, and benign-kind value checks. diff --git a/src/units/outcome/injection.rs b/src/units/outcome/injection.rs index 0492a23..c459c86 100644 --- a/src/units/outcome/injection.rs +++ b/src/units/outcome/injection.rs @@ -26,7 +26,7 @@ pub(in crate::units) fn origin_outcome(answer: &Answer) -> Outcome { /// Kinds where a variable is a concern only when another party controls it: /// helpers that build a path, URL or redirect target from their parameters /// are everywhere. -const RESOURCE_CHECKS: [&str; 3] = ["path", "url", "redirect"]; +pub(in crate::units) const RESOURCE_CHECKS: [&str; 3] = ["path", "url", "redirect"]; /// Presence alone never raises an injection: it only decides whether the /// trace is asked. When every specific check clears the unit, it is clear; @@ -96,6 +96,24 @@ fn found_injections<'a>(get: &impl Fn(&str) -> Option<&'a Answer>) -> Vec<&'stat .collect() } +/// Whether the Choice that settles an undecided resource check was asked +/// (where its paths, URLs or redirect targets come from) without leaning +/// toward another party's input, which keeps the check open. +fn settle_asked<'a>(check: &str, get: &impl Fn(&str) -> Option<&'a Answer>) -> bool { + let question = match check { + "path" if get("path_parts").is_some() => "path_parts", + "path" => "path_source", + "url" => "url_parts", + "redirect" => "redirect_target", + _ => return false, + }; + choice_mass(get(question), &OUTSIDE_SOURCES) + .is_some_and(|p| !probability_at_least(p, LEADING_PROBABILITY)) +} + +/// Options of the settle Choices that name another party's input. +const OUTSIDE_SOURCES: [&str; 3] = ["outside", "request", "stored"]; + /// The origin's outcome given the checks that found something: with none, /// another party's values are a note and parameters a note only when a /// check leans toward a concern; parameters only in paths or URLs are lower. @@ -108,14 +126,26 @@ fn by_origin<'a>( match outcome { Outcome::Review(p) if found.is_empty() => Outcome::Note(p), Outcome::Consider(p) if found.is_empty() => { - let leaning = settled_checks(catalog::INJECTION, get) + let open: Vec<(&str, Outcome)> = settled_checks(catalog::INJECTION, get) .into_iter() .filter(|(_, o)| *o != Outcome::Clear) + .collect(); + let leaning = open + .iter() .filter_map(|(id, _)| get(id)) .map(lean) .fold(0.0, f64::max); - if probability_at_least(leaning, LEADING_PROBABILITY) { - Outcome::Note(leaning) + // Parameters in a path, URL or redirect are a note until a caller + // shows another party controls them, found or not: undecided, + // 320 such units on the corpus stayed uncertain while a found one + // was a note. Only once the Choice that settles each check was + // asked and did not clear it. + let resources_open = !open.is_empty() + && open + .iter() + .all(|(id, _)| RESOURCE_CHECKS.contains(id) && settle_asked(id, get)); + if probability_at_least(leaning, LEADING_PROBABILITY) || resources_open { + Outcome::Note(leaning.max(p)) } else { Outcome::Uncertain(p) } diff --git a/src/units/outcome/mod.rs b/src/units/outcome/mod.rs index a0dd07a..0d37662 100644 --- a/src/units/outcome/mod.rs +++ b/src/units/outcome/mod.rs @@ -27,7 +27,7 @@ pub(super) use comments::{comment_concern_kind, comment_outcome, comment_signals use documentation::stale_outcome; pub(super) use documentation::{document_outcome, document_split, section_signals}; pub(super) use exposure::{Messages, django_settings_outcome, exposure_outcome, messages}; -pub(super) use injection::{injection_outcome, origin_outcome}; +pub(super) use injection::{RESOURCE_CHECKS, injection_outcome, origin_outcome}; pub(super) use maintainability::{ benign_key, function_outcome, organization_outcome, several_kind, shared_outcome, value_signals, values_outcome, diff --git a/src/units/questions/settle.rs b/src/units/questions/settle.rs index 9e4db02..db0c6c9 100644 --- a/src/units/questions/settle.rs +++ b/src/units/questions/settle.rs @@ -6,6 +6,46 @@ use super::{EVIDENCE, security::CALLERS}; use serde_json::{Value, json}; +/// Where a source Choice looks for the variables it asks about, and its +/// note: with `callers`, also in what the functions that call it pass. +fn sources_shown(callers: bool) -> (&'static str, String) { + if callers { + ( + ", in the function or in what `callers` pass it", + format!("{CALLERS} {EVIDENCE}"), + ) + } else { + ("", EVIDENCE.to_string()) + } +} + +/// Options of the path-source Choice that rule a path concern out: the +/// program's own paths, the local user's, or no file. +pub const OWN_PATHS: [&str; 3] = ["own", "local", "none"]; + +/// Where the file paths a function opens, writes or deletes come from, +/// asked outside PHP when the path check stays undecided (PHP asks its own +/// `path_parts`). Undecided path checks were 348 units across the corpus, +/// mostly command-line tools writing where their user points them and +/// helpers joining a fixed directory with a name. +pub fn security_path_source(code: &str, callers: bool) -> Value { + let (shown, note) = sources_shown(callers); + json!({ + "type": "choice", + "instructions": { + "question": format!("Where do the file paths that `{code}` opens, writes or deletes come from?"), + "note": note, + }, + "criteria": { + "own": format!("Directories and file names written in the code, the program's own directories, or its configuration or environment, with only names, ids or numbers from variables{shown}."), + "local": "The command line, settings or files of the person running a local program or script, such as an output path they pass.", + "given": "A whole path handed to the function as a parameter or field, whose origin this code does not show.", + "outside": "Partly from outside the program: a network request, message, uploaded file or archive entry, or a record users can edit.", + "none": "It opens, writes or deletes no file.", + }, + }) +} + /// Options of the URL-parts Choice that rule a URL concern out: a host of the /// program's own, or no request. pub const OWN_PARTS: [&str; 2] = ["own", "none"]; @@ -14,20 +54,12 @@ pub const OWN_PARTS: [&str; 2] = ["own", "none"]; /// stays undecided: on clients of a fixed or configured service the check /// split on a variable path or query, while naming the host decided them. A /// host that is sent another URL to fetch is its own option, since internal -/// proxies fetched what users sent. The same question about paths cleared -/// real traversals, reading names stored in an index as the program's own, -/// so paths are not settled this way. +/// proxies fetched what users sent. The same question about paths once +/// cleared real traversals, reading names stored in an index as the +/// program's own; `security_path_source` names such records as another +/// party's input. pub fn security_url_parts(code: &str, callers: bool) -> Value { - let shown = if callers { - ", in the function or in what `callers` pass it" - } else { - "" - }; - let note = if callers { - format!("{CALLERS} {EVIDENCE}") - } else { - EVIDENCE.to_string() - }; + let (shown, note) = sources_shown(callers); json!({ "type": "choice", "instructions": { diff --git a/src/units/security.rs b/src/units/security.rs index 80c95b1..f135e4a 100644 --- a/src/units/security.rs +++ b/src/units/security.rs @@ -855,7 +855,7 @@ pub(in crate::units) enum SettleWhen { /// does with tokens and how it handles passwords settle those checks /// whenever they are not clear: front ends that send their own token and /// HMAC signing split on them or were reviews. -pub(in crate::units) const SETTLES: [SettleKind; 13] = [ +pub(in crate::units) const SETTLES: [SettleKind; 14] = [ SettleKind { rule: INJECTION, question: "url_parts", @@ -919,6 +919,15 @@ pub(in crate::units) const SETTLES: [SettleKind; 13] = [ when: SettleWhen::NotClear, files: SettleFiles::Only(questions::PHP), }, + SettleKind { + rule: INJECTION, + question: "path_source", + checks: &["path"], + clears: &questions::OWN_PATHS, + callers: true, + when: SettleWhen::Undecided, + files: SettleFiles::Except(questions::PHP), + }, SettleKind { rule: SENSITIVE_DATA, question: "destination", @@ -1006,6 +1015,7 @@ fn settle( let callers = kind.callers && !subject.callers.is_empty(); let body = match kind.question { "url_parts" => questions::security_url_parts(&code, callers), + "path_source" => questions::security_path_source(&code, callers), "runs_in" => questions::security_runs_in(&code), "redirect_target" => questions::security_redirect_target(&code, callers), "markup_output" => { diff --git a/src/units/tests/nextjs.rs b/src/units/tests/nextjs.rs index 6f7e93e..f418ec5 100644 --- a/src/units/tests/nextjs.rs +++ b/src/units/tests/nextjs.rs @@ -215,11 +215,13 @@ fn an_undecided_redirect_is_settled_by_where_its_target_comes_from() { settled_redirect(&project, &options, "own"), (Status::Clear, 1) ); - for chosen in ["given", "outside"] { + // A target its caller gives is a note, as a found one would be; one a + // request carries stays open. + for (chosen, status) in [("given", Status::Note), ("outside", Status::Uncertain)] { options.refresh = true; assert_eq!( settled_redirect(&project, &options, chosen), - (Status::Uncertain, 1), + (status, 1), "{chosen}" ); } diff --git a/src/units/tests/security.rs b/src/units/tests/security.rs index 791b03b..7d0f184 100644 --- a/src/units/tests/security.rs +++ b/src/units/tests/security.rs @@ -208,6 +208,8 @@ const FETCH_QUOTE: &str = "fn quote(client: &Client, base: &Url, symbol: &str) - const URL_PARTS: [&str; 5] = ["own", "forwards", "given", "outside", "none"]; +const PATH_SOURCE: [&str; 5] = ["own", "local", "given", "outside", "none"]; + const RUNS_IN: [&str; 3] = ["browser", "server", "either"]; /// Injection status and settle requests with the URL (or path) check at @@ -224,6 +226,7 @@ fn settled_injection( (check, noul_at(0.4)), ("origin", spread(0.0, 0.9, 0.1)), ("url_parts", choice_of(parts, &URL_PARTS)), + ("path_source", choice_of(parts, &PATH_SOURCE)), ("runs_in", choice_of("server", &RUNS_IN)), ]; let report = run(project, options, &mut eval); @@ -246,19 +249,52 @@ fn an_undecided_url_is_settled_only_by_a_host_of_the_programs_own() { (Status::Clear, 2), "where its URLs come from and where it runs" ); - for parts in ["forwards", "given", "outside"] { + // A URL its caller gives or forwards is a note, as a found one would be; + // one from another party stays open. + for (parts, status) in [ + ("forwards", Status::Note), + ("given", Status::Note), + ("outside", Status::Uncertain), + ] { options.refresh = true; assert_eq!( settled_injection(&project, &options, "url", parts).0, - Status::Uncertain, + status, "{parts}" ); } - options.refresh = true; - assert_eq!( - settled_injection(&project, &options, "path", "own"), - (Status::Uncertain, 0), - "an undecided path is not settled" +} + +#[test] +fn an_undecided_path_is_settled_by_the_programs_own_or_its_local_users_paths() { + let (project, mut options) = security_project(FETCH_QUOTE); + for (parts, status) in [ + ("own", Status::Clear), + ("local", Status::Clear), + ("given", Status::Note), + ("outside", Status::Uncertain), + ] { + assert_eq!( + settled_injection(&project, &options, "path", parts), + (status, 1), + "{parts}" + ); + options.refresh = true; + } + // The note names the path it left undecided. + let mut eval = scripted(0); + eval.overrides = vec![ + ("resource", noul_at(0.95)), + ("path", noul_at(0.4)), + ("origin", spread(0.0, 0.9, 0.1)), + ("path_source", choice_of("given", &PATH_SOURCE)), + ]; + let report = run(&project, &options, &mut eval); + let note = &report.files[0].findings[0]; + assert!( + note.message.contains("places a parameter into a file path"), + "{}", + note.message ); } diff --git a/src/units/tests/test_rules.rs b/src/units/tests/test_rules.rs index 4bdbe2e..bfc671e 100644 --- a/src/units/tests/test_rules.rs +++ b/src/units/tests/test_rules.rs @@ -460,6 +460,29 @@ fn copies_inside_tests_a_redundancy_finding_names_are_reported_once() { assert_eq!(rules, [catalog::id(catalog::TEST_REDUNDANCY)], "{rules:?}"); } +#[test] +fn a_test_still_undecided_after_its_recheck_leans_clear_below_half() { + let (project, mut options) = tests_project(&[("lib.rs", TESTS)], catalog::TEST_VALUE); + let mut status = |mock_only: f64| { + let mut eval = scripted(0); + eval.overrides.push(("mock_only", noul_at(mock_only))); + eval.recheck_overrides + .push(("mock_only", noul_at(mock_only))); + let report = run(&project, &options, &mut eval); + options.refresh = true; + ( + report.files[0].dimensions["test_value"].status.clone(), + report + .stages + .get("recheck") + .map_or(0, |stage| stage.successful_requests), + ) + }; + // Asked again first: the lean follows the recheck. + assert_eq!(status(0.4), (Status::Clear, 3)); + assert_eq!(status(0.6), (Status::Uncertain, 3)); +} + #[test] fn an_internal_details_consider_is_confirmed_by_what_its_assertions_read() { let (project, mut options) = tests_project(&[("lib.rs", TESTS)], catalog::TEST_VALUE); diff --git a/src/units/wording/mod.rs b/src/units/wording/mod.rs index 3da40e2..329bf5d 100644 --- a/src/units/wording/mod.rs +++ b/src/units/wording/mod.rs @@ -3,9 +3,9 @@ use super::{ Block, Detail, GroupInfo, outcome::{ - Answers, Outcome, benefit, comment_concern_kind, comment_signals, disagreement, - document_split, noul, origin_outcome, repeated, section_signals, settled_checks, - value_signals, + Answers, Outcome, RESOURCE_CHECKS, benefit, comment_concern_kind, comment_signals, + disagreement, document_split, noul, origin_outcome, repeated, section_signals, + settled_checks, value_signals, }, }; use crate::catalog; diff --git a/src/units/wording/security.rs b/src/units/wording/security.rs index 0fe98fb..aedfb9f 100644 --- a/src/units/wording/security.rs +++ b/src/units/wording/security.rs @@ -245,7 +245,7 @@ fn found_checks(rule: &str, answers: &Answers<'_>) -> Vec<&'static str> { return found.into_iter().map(|(id, _)| id).collect(); } // A note from a leaning check names the kind it leaned toward. - crate::units::security::checks(rule) + let leaning: Vec<&'static str> = crate::units::security::checks(rule) .iter() .filter_map(|check| match answers.get(check.id) { Some(Answer::Noul { noul }) @@ -261,6 +261,15 @@ fn found_checks(rule: &str, answers: &Answers<'_>) -> Vec<&'static str> { .max_by(|a, b| a.1.total_cmp(&b.1)) .map(|(id, _)| id) .into_iter() + .collect(); + if !leaning.is_empty() { + return leaning; + } + // A note from undecided checks of paths, URLs or redirects names them. + settled_checks(rule, &get) + .into_iter() + .filter(|(id, outcome)| *outcome != Outcome::Clear && RESOURCE_CHECKS.contains(id)) + .map(|(id, _)| id) .collect() }