Skip to content

Commit c07ea29

Browse files
jamesadevineCopilot
andcommitted
fix(audit): address PR review feedback
Three issues raised by the Rust PR Reviewer on #691: 1. **Lexicographic sort wrong for multi-digit run IDs.** Previously `find_artifact_dir` / `find_verdict_path` / `top_level_dirs_with_prefix` picked the "lexicographically last" `<prefix>_<id>` directory, which sorts `_9` after `_10` (because `'9' > '1'`). On a build retry that produced both `analyzed_outputs_9` and `analyzed_outputs_10`, the older verdict would be read and the run could be mis-classified as safe. New `crate::audit::cmp_numeric_suffix` extracts the trailing token after the final `_`, parses it as `u64`, and compares numerically with a lexicographic tie-breaker for non-numeric suffixes. All three call sites now use it. Regression tests added in mod.rs, detection.rs, and cli.rs. 2. **Security: `ADO_AW_TEST_ORG_URL` was always active in production.** The override was `#[doc(hidden)]` but not gated by build mode, so a stray env var (debugging leftover, hostile CI environment) could silently redirect ADO REST calls to an attacker-controlled URL in a release binary. Gated on `cfg(debug_assertions)`: debug builds (`cargo test`, `cargo run`) keep the override AND emit a loud `warn!` on every invocation; release builds (all published artifacts via `cargo build --release`) replace the body with a no-op so a stray env var has no effect. The integration test in `tests/audit_it.rs` continues to work because `cargo test` builds in debug mode. 3. **Blocking `std::fs::read_dir` in async context.** `safe_outputs.rs` had two helpers (`top_level_dirs_with_prefix`, `collect_named_files`) using sync I/O from inside `async fn analyze_safe_outputs`. On a Tokio multi-thread runtime this blocks an executor thread for the duration of the directory walk. Both helpers converted to `async fn` using `tokio::fs::read_dir`. The recursive `collect_named_files` uses `Box::pin` to satisfy the async-recursion shape (consistent with the existing pattern in `crate::detect::scan_directory`). Tests: 1745 unit tests + 3 integration tests pass (up from 1740 — 5 new regression tests for the numeric-suffix bug). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bf34af3 commit c07ea29

5 files changed

Lines changed: 223 additions & 56 deletions

File tree

src/ado/mod.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -829,16 +829,42 @@ pub async fn resolve_ado_context(
829829
Ok(ctx)
830830
}
831831

832-
#[doc(hidden)]
832+
/// Test-only override that lets the integration tests in `tests/audit_it.rs`
833+
/// redirect ADO REST calls at a mock server via the `ADO_AW_TEST_ORG_URL`
834+
/// environment variable.
835+
///
836+
/// **Compiled out of release builds.** All published artifacts ship with
837+
/// `cargo build --release`, which sets `debug_assertions = false` and
838+
/// replaces the body of this function with a no-op via the
839+
/// `#[cfg(not(debug_assertions))]` branch below. This prevents an
840+
/// attacker-controlled env var (a leftover from a debugging session, a
841+
/// hostile CI environment, etc.) from silently redirecting production
842+
/// ADO API calls. Debug builds — used by `cargo test`, integration
843+
/// tests, and `cargo run` during development — keep the override
844+
/// available, and emit a `warn!` on every invocation so the override is
845+
/// loud and obvious in logs.
846+
#[cfg(debug_assertions)]
833847
fn apply_test_org_url_override(ctx: &mut AdoContext) {
834848
if let Ok(org_url) = std::env::var("ADO_AW_TEST_ORG_URL") {
835849
let org_url = org_url.trim().trim_end_matches('/');
836850
if !org_url.is_empty() {
851+
log::warn!(
852+
"ADO_AW_TEST_ORG_URL test override active: redirecting ADO REST calls \
853+
from {} to {} (this branch is compiled out of release builds)",
854+
ctx.org_url,
855+
org_url
856+
);
837857
ctx.org_url = org_url.to_string();
838858
}
839859
}
840860
}
841861

862+
#[cfg(not(debug_assertions))]
863+
fn apply_test_org_url_override(_: &mut AdoContext) {
864+
// Release builds intentionally ignore ADO_AW_TEST_ORG_URL so that a
865+
// stray env var cannot redirect production ADO API calls.
866+
}
867+
842868
/// Builds the list of definitions to update from explicit IDs or auto-detection.
843869
/// Returns `None` when auto-detection finds no agentic pipelines (caller should exit cleanly).
844870
pub async fn resolve_definitions(

src/audit/analyzers/detection.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ async fn find_verdict_path(download_root: &Path) -> Option<PathBuf> {
115115

116116
let path = entry.path();
117117
match &latest_dir {
118-
Some((current_name, _)) if name <= *current_name => {}
118+
Some((current_name, _))
119+
if crate::audit::cmp_numeric_suffix(&name, current_name)
120+
!= std::cmp::Ordering::Greater => {}
119121
_ => latest_dir = Some((name, path)),
120122
}
121123
}
@@ -293,7 +295,7 @@ mod tests {
293295
}
294296

295297
#[tokio::test]
296-
async fn uses_lexicographically_last_analyzed_outputs_directory() {
298+
async fn uses_highest_numbered_analyzed_outputs_directory() {
297299
let temp_dir = TempDir::new().unwrap();
298300
write_verdict(
299301
&temp_dir,
@@ -317,4 +319,31 @@ mod tests {
317319
Some(expected_verdict_path("analyzed_outputs_42"))
318320
);
319321
}
322+
323+
/// Regression: lexicographic sort would pick `analyzed_outputs_9`
324+
/// here. Numeric-suffix sort must pick `analyzed_outputs_10`.
325+
#[tokio::test]
326+
async fn picks_highest_numeric_suffix_not_lexicographic() {
327+
let temp_dir = TempDir::new().unwrap();
328+
write_verdict(
329+
&temp_dir,
330+
"analyzed_outputs_9",
331+
r#"{"prompt_injection":false,"secret_leak":false,"malicious_patch":false,"reasons":[]}"#,
332+
)
333+
.await;
334+
write_verdict(
335+
&temp_dir,
336+
"analyzed_outputs_10",
337+
r#"{"prompt_injection":true,"secret_leak":false,"malicious_patch":false,"reasons":["newer verdict"]}"#,
338+
)
339+
.await;
340+
341+
let analysis = analyze_detection(temp_dir.path()).await.unwrap().unwrap();
342+
343+
assert!(analysis.threats.prompt_injection);
344+
assert_eq!(
345+
analysis.verdict_path,
346+
Some(expected_verdict_path("analyzed_outputs_10"))
347+
);
348+
}
320349
}

src/audit/analyzers/safe_outputs.rs

Lines changed: 81 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ use anyhow::Context;
44
use serde::Deserialize;
55
use serde_json::Value;
66
use std::collections::{BTreeMap, VecDeque};
7-
use std::fs;
87
use std::io::ErrorKind;
98
use std::path::{Path, PathBuf};
9+
use tokio::fs;
1010

1111
use crate::audit::model::{
1212
CreatedItemReport, Finding, RejectedSafeOutputsRollup, SafeOutputExecution,
@@ -63,9 +63,9 @@ struct IndexedExecutionRecord {
6363
pub async fn analyze_safe_outputs(
6464
download_root: &std::path::Path,
6565
) -> anyhow::Result<SafeOutputAnalysis> {
66-
let proposals_path = find_proposals_file(download_root)?;
67-
let detection_path = find_detection_file(download_root)?;
68-
let executions_path = find_execution_file(download_root)?;
66+
let proposals_path = find_proposals_file(download_root).await?;
67+
let detection_path = find_detection_file(download_root).await?;
68+
let executions_path = find_execution_file(download_root).await?;
6969

7070
let proposals = load_proposals(proposals_path.as_deref()).await?;
7171
let detection = load_detection_verdict(detection_path.as_deref()).await?;
@@ -526,46 +526,58 @@ fn truncate_reason(reason: String, max_chars: usize) -> String {
526526
}
527527
}
528528

529-
fn find_proposals_file(download_root: &Path) -> anyhow::Result<Option<PathBuf>> {
530-
for directory in top_level_dirs_with_prefix(download_root, "agent_outputs_")? {
529+
async fn find_proposals_file(download_root: &Path) -> anyhow::Result<Option<PathBuf>> {
530+
for directory in top_level_dirs_with_prefix(download_root, "agent_outputs_").await? {
531531
for candidate in [
532532
directory.join("staging").join(SAFE_OUTPUT_FILENAME),
533533
directory.join(SAFE_OUTPUT_FILENAME),
534534
] {
535-
if candidate.is_file() {
535+
if fs::metadata(&candidate)
536+
.await
537+
.map(|m| m.is_file())
538+
.unwrap_or(false)
539+
{
536540
return Ok(Some(candidate));
537541
}
538542
}
539543
}
540544
Ok(None)
541545
}
542546

543-
fn find_detection_file(download_root: &Path) -> anyhow::Result<Option<PathBuf>> {
544-
for directory in top_level_dirs_with_prefix(download_root, "analyzed_outputs_")? {
547+
async fn find_detection_file(download_root: &Path) -> anyhow::Result<Option<PathBuf>> {
548+
for directory in top_level_dirs_with_prefix(download_root, "analyzed_outputs_").await? {
545549
let candidate = directory.join("threat-analysis.json");
546-
if candidate.is_file() {
550+
if fs::metadata(&candidate)
551+
.await
552+
.map(|m| m.is_file())
553+
.unwrap_or(false)
554+
{
547555
return Ok(Some(candidate));
548556
}
549557
}
550558
Ok(None)
551559
}
552560

553-
fn find_execution_file(download_root: &Path) -> anyhow::Result<Option<PathBuf>> {
561+
async fn find_execution_file(download_root: &Path) -> anyhow::Result<Option<PathBuf>> {
554562
let preferred = download_root
555563
.join("safe_outputs")
556564
.join(EXECUTED_NDJSON_FILENAME);
557-
if preferred.is_file() {
565+
if fs::metadata(&preferred)
566+
.await
567+
.map(|m| m.is_file())
568+
.unwrap_or(false)
569+
{
558570
return Ok(Some(preferred));
559571
}
560572

561573
let mut matches = Vec::new();
562-
collect_named_files(download_root, EXECUTED_NDJSON_FILENAME, &mut matches)?;
574+
collect_named_files(download_root, EXECUTED_NDJSON_FILENAME, &mut matches).await?;
563575
matches.sort();
564576
Ok(matches.into_iter().next())
565577
}
566578

567-
fn top_level_dirs_with_prefix(root: &Path, prefix: &str) -> anyhow::Result<Vec<PathBuf>> {
568-
let entries = match fs::read_dir(root) {
579+
async fn top_level_dirs_with_prefix(root: &Path, prefix: &str) -> anyhow::Result<Vec<PathBuf>> {
580+
let mut entries = match fs::read_dir(root).await {
569581
Ok(entries) => entries,
570582
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
571583
Err(error) => {
@@ -574,11 +586,20 @@ fn top_level_dirs_with_prefix(root: &Path, prefix: &str) -> anyhow::Result<Vec<P
574586
}
575587
};
576588

577-
let mut matches = Vec::new();
578-
for entry in entries {
579-
let entry = entry.with_context(|| format!("Failed to iterate {}", root.display()))?;
589+
let mut matches: Vec<(String, PathBuf)> = Vec::new();
590+
loop {
591+
let entry = match entries.next_entry().await {
592+
Ok(Some(entry)) => entry,
593+
Ok(None) => break,
594+
Err(error) => {
595+
return Err(error)
596+
.with_context(|| format!("Failed to iterate {}", root.display()));
597+
}
598+
};
599+
580600
let file_type = entry
581601
.file_type()
602+
.await
582603
.with_context(|| format!("Failed to inspect {}", entry.path().display()))?;
583604
if !file_type.is_dir() {
584605
continue;
@@ -588,43 +609,55 @@ fn top_level_dirs_with_prefix(root: &Path, prefix: &str) -> anyhow::Result<Vec<P
588609
continue;
589610
};
590611
if name.starts_with(prefix) {
591-
matches.push(entry.path());
612+
matches.push((name, entry.path()));
592613
}
593614
}
594-
matches.sort();
595-
Ok(matches)
615+
// Sort by numeric suffix so `agent_outputs_10` outranks
616+
// `agent_outputs_9` (lexicographic sort gets this wrong).
617+
matches.sort_by(|(a, _), (b, _)| crate::audit::cmp_numeric_suffix(a, b));
618+
Ok(matches.into_iter().map(|(_, path)| path).collect())
596619
}
597620

598-
fn collect_named_files(
599-
root: &Path,
600-
file_name: &str,
601-
matches: &mut Vec<PathBuf>,
602-
) -> anyhow::Result<()> {
603-
let entries = match fs::read_dir(root) {
604-
Ok(entries) => entries,
605-
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
606-
Err(error) => {
607-
return Err(error)
608-
.with_context(|| format!("Failed to read directory {}", root.display()));
609-
}
610-
};
621+
fn collect_named_files<'a>(
622+
root: &'a Path,
623+
file_name: &'a str,
624+
matches: &'a mut Vec<PathBuf>,
625+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
626+
Box::pin(async move {
627+
let mut entries = match fs::read_dir(root).await {
628+
Ok(entries) => entries,
629+
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
630+
Err(error) => {
631+
return Err(error)
632+
.with_context(|| format!("Failed to read directory {}", root.display()));
633+
}
634+
};
611635

612-
for entry in entries {
613-
let entry = entry.with_context(|| format!("Failed to iterate {}", root.display()))?;
614-
let path = entry.path();
615-
let file_type = entry
616-
.file_type()
617-
.with_context(|| format!("Failed to inspect {}", path.display()))?;
618-
if file_type.is_dir() {
619-
collect_named_files(&path, file_name, matches)?;
620-
} else if file_type.is_file()
621-
&& path.file_name().and_then(|name| name.to_str()) == Some(file_name)
622-
{
623-
matches.push(path);
636+
loop {
637+
let entry = match entries.next_entry().await {
638+
Ok(Some(entry)) => entry,
639+
Ok(None) => break,
640+
Err(error) => {
641+
return Err(error)
642+
.with_context(|| format!("Failed to iterate {}", root.display()));
643+
}
644+
};
645+
let path = entry.path();
646+
let file_type = entry
647+
.file_type()
648+
.await
649+
.with_context(|| format!("Failed to inspect {}", path.display()))?;
650+
if file_type.is_dir() {
651+
collect_named_files(&path, file_name, matches).await?;
652+
} else if file_type.is_file()
653+
&& path.file_name().and_then(|name| name.to_str()) == Some(file_name)
654+
{
655+
matches.push(path);
656+
}
624657
}
625-
}
626658

627-
Ok(())
659+
Ok(())
660+
})
628661
}
629662

630663
#[cfg(test)]

src/audit/cli.rs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -611,17 +611,19 @@ async fn collect_files_under(run_dir: &Path, start_dir: &Path) -> Result<Vec<Fil
611611

612612
async fn find_artifact_dir(run_dir: &Path, prefix: &str) -> Option<PathBuf> {
613613
let mut entries = tokio::fs::read_dir(run_dir).await.ok()?;
614-
let mut hits = Vec::new();
614+
let mut hits: Vec<(String, PathBuf)> = Vec::new();
615615
while let Ok(Some(entry)) = entries.next_entry().await {
616616
if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false)
617617
&& let Some(name) = entry.file_name().to_str()
618618
&& (name == prefix || name.starts_with(&format!("{}_", prefix)))
619619
{
620-
hits.push(entry.path());
620+
hits.push((name.to_string(), entry.path()));
621621
}
622622
}
623-
hits.sort();
624-
hits.pop()
623+
// Numeric-suffix sort so `agent_outputs_10` outranks
624+
// `agent_outputs_9` (lexicographic sort gets this wrong).
625+
hits.sort_by(|(a, _), (b, _)| crate::audit::cmp_numeric_suffix(a, b));
626+
hits.pop().map(|(_, path)| path)
625627
}
626628

627629
fn is_authz_error(error: &anyhow::Error) -> bool {
@@ -670,7 +672,7 @@ mod tests {
670672
}
671673

672674
#[tokio::test]
673-
async fn find_artifact_dir_picks_lexicographically_last_match() {
675+
async fn find_artifact_dir_picks_highest_numbered_match() {
674676
let temp_dir = tempfile::tempdir().expect("tempdir");
675677
tokio::fs::create_dir_all(temp_dir.path().join("agent_outputs_001"))
676678
.await
@@ -692,6 +694,28 @@ mod tests {
692694
);
693695
}
694696

697+
/// Regression test: lexicographic sort would pick `agent_outputs_9`
698+
/// here (because `'9' > '1'`); numeric-suffix sort must pick
699+
/// `agent_outputs_10` instead.
700+
#[tokio::test]
701+
async fn find_artifact_dir_orders_multi_digit_suffixes_numerically() {
702+
let temp_dir = tempfile::tempdir().expect("tempdir");
703+
for suffix in ["1", "2", "9", "10", "100"] {
704+
tokio::fs::create_dir_all(temp_dir.path().join(format!("agent_outputs_{suffix}")))
705+
.await
706+
.expect("create dir");
707+
}
708+
709+
let found = find_artifact_dir(temp_dir.path(), "agent_outputs")
710+
.await
711+
.expect("find artifact dir");
712+
713+
assert_eq!(
714+
found.file_name().and_then(|name| name.to_str()),
715+
Some("agent_outputs_100")
716+
);
717+
}
718+
695719
#[test]
696720
fn artifact_filter_mapping_matches_expected_sets() {
697721
let filters = vec![

0 commit comments

Comments
 (0)