Skip to content

Commit ef3369a

Browse files
committed
chore(release): bump version to 2.9.0
- fix(update): fast-forward local branch to remote after fetch - security: path traversal guard for gem/engine name lookups - use PreviousValue::MustExist for stricter ref updates - remove dead _suffix parameter from temp_mock_db - add find_remote_head doc comments
1 parent a36184d commit ef3369a

4 files changed

Lines changed: 156 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
1+
### 2.9.0 / 2026-03-30
2+
3+
#### Fixed
4+
5+
* **`update` now correctly fast-forwards the local branch to the remote.**
6+
Previously, `gem-audit update` fetched remote refs but never advanced the
7+
local branch (HEAD) to match `origin/main`, so the working tree stayed at
8+
the old commit despite reporting "Updated". The advisory database would
9+
remain stale until a full re-clone. `checkout_head` now resolves the
10+
remote tracking ref and updates the local branch before checking out.
11+
12+
#### Security
13+
14+
* **Path traversal guard for gem and Ruby engine names.** A crafted
15+
`Gemfile.lock` containing a gem name with `..` sequences (e.g.
16+
`../../etc`) could cause the scanner to read YAML files outside the
17+
advisory database directory. Added `is_contained_in()` validation to
18+
`advisories_for_with_errors` and `advisories_for_ruby_with_errors`.
19+
20+
#### Changed
21+
22+
* Use `PreviousValue::MustExist` instead of `PreviousValue::Any` when
23+
updating the local branch ref, catching corrupted repository state
24+
earlier.
25+
26+
#### Internal
27+
28+
* Removed unused `_suffix` parameter from `temp_mock_db` test helper.
29+
* Added doc comments to `find_remote_head` describing fallback behaviour.
30+
31+
---
32+
133
### 2.8.1 / 2026-03-19
234

335
#### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gem-audit"
3-
version = "2.8.1"
3+
version = "2.9.0"
44
edition = "2024"
55
description = "Ultra-fast, standalone security auditor for Gemfile.lock"
66
license = "MIT"

src/advisory/database.rs

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,31 @@ impl Database {
109109
Ok(())
110110
}
111111

112-
/// Checkout the current HEAD into the working tree.
112+
/// Fast-forward HEAD to the remote tracking branch, then checkout the working tree.
113113
fn checkout_head(&self) -> Result<bool, DatabaseError> {
114114
let repo = gix::open(&self.path).map_err(|e| DatabaseError::Git(e.to_string()))?;
115-
let tree = repo
115+
116+
// Find the remote tracking branch (e.g. origin/main) and fast-forward HEAD to it.
117+
let remote_commit = self.find_remote_head(&repo)?;
118+
let head_commit = repo
116119
.head_commit()
117-
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?
120+
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?;
121+
122+
if remote_commit.id != head_commit.id {
123+
// Update HEAD (and the branch it points to) to the remote commit.
124+
repo.reference(
125+
repo.head_name()
126+
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?
127+
.ok_or_else(|| DatabaseError::UpdateFailed("detached HEAD".to_string()))?
128+
.as_ref(),
129+
remote_commit.id,
130+
gix::refs::transaction::PreviousValue::MustExist,
131+
"gem-audit update",
132+
)
133+
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?;
134+
}
135+
136+
let tree = remote_commit
118137
.tree()
119138
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?;
120139

@@ -145,6 +164,35 @@ impl Database {
145164
Ok(true)
146165
}
147166

167+
/// Resolve the remote tracking commit (e.g. `origin/main`) to fast-forward to.
168+
///
169+
/// If neither `origin/main` nor `origin/master` is found, returns `Err`;
170+
/// the caller (`update`) will then fall back to a fresh clone via `reclone`.
171+
fn find_remote_head<'a>(
172+
&self,
173+
repo: &'a gix::Repository,
174+
) -> Result<gix::Commit<'a>, DatabaseError> {
175+
// Try well-known remote tracking refs in order of likelihood.
176+
let candidates = ["refs/remotes/origin/main", "refs/remotes/origin/master"];
177+
178+
for refname in &candidates {
179+
if let Ok(reference) = repo.find_reference(*refname) {
180+
let commit = reference
181+
.into_fully_peeled_id()
182+
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?
183+
.object()
184+
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?
185+
.try_into_commit()
186+
.map_err(|e| DatabaseError::UpdateFailed(e.to_string()))?;
187+
return Ok(commit);
188+
}
189+
}
190+
191+
Err(DatabaseError::UpdateFailed(
192+
"no remote tracking branch found (tried origin/main, origin/master)".to_string(),
193+
))
194+
}
195+
148196
/// Delete the existing DB and re-clone from scratch.
149197
///
150198
/// Uses an atomic swap: clone to a sibling `_tmp` directory, rename the
@@ -249,6 +297,10 @@ impl Database {
249297
let mut results = Vec::new();
250298
let gem_dir = self.path.join("gems").join(gem_name);
251299

300+
if !is_contained_in(&gem_dir, &self.path) {
301+
return (results, 0);
302+
}
303+
252304
let errors = if gem_dir.is_dir() {
253305
self.load_advisories_from_dir(&gem_dir, &mut results)
254306
} else {
@@ -281,6 +333,10 @@ impl Database {
281333
let mut results = Vec::new();
282334
let engine_dir = self.path.join("rubies").join(engine);
283335

336+
if !is_contained_in(&engine_dir, &self.path) {
337+
return (results, 0);
338+
}
339+
284340
let errors = if engine_dir.is_dir() {
285341
self.load_advisories_from_dir(&engine_dir, &mut results)
286342
} else {
@@ -366,6 +422,27 @@ impl fmt::Display for Database {
366422
}
367423
}
368424

425+
/// Check that `child` is logically contained within `parent` after normalising
426+
/// `..` components. This prevents path traversal via crafted gem/engine names.
427+
fn is_contained_in(child: &Path, parent: &Path) -> bool {
428+
use std::path::Component;
429+
430+
let mut depth: usize = 0;
431+
for component in child.strip_prefix(parent).unwrap_or(child).components() {
432+
match component {
433+
Component::ParentDir => {
434+
if depth == 0 {
435+
return false;
436+
}
437+
depth -= 1;
438+
}
439+
Component::Normal(_) => depth += 1,
440+
_ => {}
441+
}
442+
}
443+
true
444+
}
445+
369446
/// Fallback for getting the default database path when the `dirs` crate is not available.
370447
fn dirs_fallback() -> PathBuf {
371448
if let Ok(home) = std::env::var("HOME") {
@@ -464,7 +541,7 @@ mod tests {
464541

465542
#[test]
466543
fn open_fixture_advisory_dir() {
467-
let (tmp, _) = temp_mock_db("fixture");
544+
let (tmp, _) = temp_mock_db();
468545

469546
let db = Database::open(tmp.path()).unwrap();
470547
assert!(!db.is_git());
@@ -503,7 +580,7 @@ mod tests {
503580

504581
// Helper: create an isolated temporary mock DB for tests that don't
505582
// share state with `mock_database()` in scanner tests.
506-
fn temp_mock_db(_suffix: &str) -> (tempfile::TempDir, PathBuf) {
583+
fn temp_mock_db() -> (tempfile::TempDir, PathBuf) {
507584
let fixture_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
508585
let tmp = tempfile::tempdir().unwrap();
509586
let gem_dir = tmp.path().join("gems").join("test");
@@ -520,7 +597,7 @@ mod tests {
520597

521598
#[test]
522599
fn database_display() {
523-
let (tmp, _) = temp_mock_db("display");
600+
let (tmp, _) = temp_mock_db();
524601
let db = Database::open(tmp.path()).unwrap();
525602
let display = db.to_string();
526603
assert_eq!(display, tmp.path().to_string_lossy());
@@ -530,7 +607,7 @@ mod tests {
530607

531608
#[test]
532609
fn database_exists_with_gems() {
533-
let (tmp, _) = temp_mock_db("exists");
610+
let (tmp, _) = temp_mock_db();
534611
let db = Database::open(tmp.path()).unwrap();
535612
assert!(db.exists());
536613
assert!(db.path() == tmp.path());
@@ -540,7 +617,7 @@ mod tests {
540617

541618
#[test]
542619
fn database_advisories_with_mock() {
543-
let (tmp, _) = temp_mock_db("advisories");
620+
let (tmp, _) = temp_mock_db();
544621
let db = Database::open(tmp.path()).unwrap();
545622
let all = db.advisories();
546623
assert_eq!(all.len(), 1);
@@ -549,7 +626,7 @@ mod tests {
549626

550627
#[test]
551628
fn database_size_with_mock() {
552-
let (tmp, _) = temp_mock_db("size");
629+
let (tmp, _) = temp_mock_db();
553630
let db = Database::open(tmp.path()).unwrap();
554631
assert_eq!(db.size(), 1);
555632
}
@@ -606,7 +683,7 @@ mod tests {
606683

607684
#[test]
608685
fn commit_id_none_for_non_git() {
609-
let (tmp, _) = temp_mock_db("nongit");
686+
let (tmp, _) = temp_mock_db();
610687
let db = Database::open(tmp.path()).unwrap();
611688
assert_eq!(db.commit_id(), None);
612689
assert_eq!(db.last_updated_at(), None);
@@ -639,4 +716,39 @@ mod tests {
639716
let err = DatabaseError::Git("corrupt repo".to_string());
640717
assert!(err.to_string().contains("git error"));
641718
}
719+
720+
// ========== Path traversal guard ==========
721+
722+
#[test]
723+
fn is_contained_in_normal_path() {
724+
let parent = Path::new("/db");
725+
assert!(is_contained_in(&parent.join("gems").join("rails"), parent));
726+
}
727+
728+
#[test]
729+
fn is_contained_in_rejects_traversal() {
730+
let parent = Path::new("/db");
731+
assert!(!is_contained_in(
732+
&parent.join("gems").join("..").join("..").join("etc"),
733+
parent
734+
));
735+
}
736+
737+
#[test]
738+
fn advisories_for_traversal_gem_returns_empty() {
739+
let (tmp, _) = temp_mock_db();
740+
let db = Database::open(tmp.path()).unwrap();
741+
let (advisories, errors) = db.advisories_for_with_errors("../../etc");
742+
assert!(advisories.is_empty());
743+
assert_eq!(errors, 0);
744+
}
745+
746+
#[test]
747+
fn advisories_for_ruby_traversal_returns_empty() {
748+
let (tmp, _) = temp_mock_db();
749+
let db = Database::open(tmp.path()).unwrap();
750+
let (advisories, errors) = db.advisories_for_ruby_with_errors("../../etc");
751+
assert!(advisories.is_empty());
752+
assert_eq!(errors, 0);
753+
}
642754
}

0 commit comments

Comments
 (0)