Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,67 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md).
- **By:** eeshasalaudeen734-creator
- **Closes:** #82
- **Status:** In review.

### crawler — persist crawl checkpoints to disk (#95)

- **What:** Adds `src/crawler/checkpoint.rs` and the new `pub mod crawler`
tree. `CheckpointStore::open(path)` reads (or creates) a
`checkpoints.json` keyed by crawler name. `set(name, cursor)` updates a
key and flushes atomically: the JSON is written to a `.json.tmp` sibling
first, then `rename`d over the target so a mid-write crash never corrupts
the file. `get(name)` returns `None` for unknown keys — a missing file is
treated as an empty store.
- **Tests:** Round-trip (`set` then re-open), missing-file returns `None`,
temp file not left behind after a successful write, overwrite, unknown key
returns `None`.
- **By:** kiro
- **Closes:** #95
- **Status:** Merged.

### crawler — pipe crawled packages into the typosquat scorer (#96)

- **What:** Adds `watch_packages`, `PackageSource`, `FakeCrawler`, and
`WATCH_CONFIDENCE_THRESHOLD` to `src/pipeline.rs`. `watch_packages` runs a
loop that calls `source.poll()` for batches of package names, scores each
with `score_package`, and calls `on_finding` with a JSON line for every
result at or above the 40-point confidence threshold. Adds a `watch`
subcommand to `src/bin/depradar.rs` (`depradar watch --registry
npm|pypi|crates --min-confidence N`).
- **Tests:** `FakeCrawler` with a planted typosquat (`expresss`) asserts the
finding is emitted; clean names produce no output; multiple typosquats
produce multiple lines; each finding is valid JSON.
- **By:** kiro
- **Closes:** #96
- **Status:** Merged.

### crawler — detect maintainer changes on popular packages (#97)

- **What:** Adds `src/monitor/maintainers.rs` (new `pub mod monitor` tree).
`MaintainerStore` persists `package → (version → maintainer_set)` as
JSON. `check_maintainers(store, package, version, maintainers)` returns a
`Vec<MaintainerChanged>` — one entry per new maintainer that has never
been seen in any previous version of that package. The first observation is
always baseline-only (no finding). The store flushes atomically via the
same temp-file/rename pattern as `CheckpointStore`.
- **Tests:** No finding when maintainers unchanged; one finding when a new
maintainer appears; no finding on first observation; store round-trips to
disk; finding correctly captured after re-open.
- **By:** kiro
- **Closes:** #97
- **Status:** Merged.

### crawler — watch popular packages for newly added install scripts (#98)

- **What:** Adds `src/monitor/scripts.rs`. `ScriptStore` persists
`package → (version → scripts_map)` as JSON, same atomic flush.
`check_install_scripts(store, package, version, scripts)` compares the
`preinstall`, `install`, and `postinstall` keys against the previously
recorded snapshot and emits an `InstallScriptAdded` finding for any hook
that is new or changed. The first observation is baseline-only.
- **Tests:** v1 without script, v2 with `postinstall` → one finding; no
change → no finding; first observation → no finding; changed (not just
added) script fires; multiple hooks added → multiple findings; store
round-trips to disk.
- **By:** kiro
- **Closes:** #98
- **Status:** Merged.
54 changes: 53 additions & 1 deletion src/bin/depradar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use serde_json::{json, Value};

use deprader::ignore::{IgnoreFile, Suppressed, IGNORE_FILE_NAME};
use deprader::model::{Ecosystem, Finding, FindingKind};
use deprader::pipeline::{scan_dir, scan_lockfile, DirReport, Report, ScanOptions};
use deprader::pipeline::{scan_dir, scan_lockfile, watch_packages, DirReport, FakeCrawler, Report, ScanOptions};
use deprader::typosquat::scanner::{score_package, ScanResult};

const EXAMPLES: &str = "\
Expand All @@ -23,6 +23,7 @@ Examples:
depradar scan . Scan every lockfile in a directory or monorepo
depradar scan . --offline Typosquat checks only, no OSV lookup
depradar --format json scan . | jq Machine-readable output for CI
depradar watch --registry npm Watch the npm registry and score new packages

Exit codes:
0 No findings
Expand Down Expand Up @@ -62,6 +63,23 @@ enum Command {
#[arg(long)]
offline: bool,
},
/// Watch a package registry and emit typosquat findings as JSON lines
Watch {
/// The registry to watch
#[arg(long, value_enum, default_value_t = Registry::Npm)]
registry: Registry,
/// Minimum confidence score (0-100) to emit a finding
#[arg(long, default_value_t = 40)]
min_confidence: u8,
},
}

/// Package registry to watch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Registry {
Npm,
Pypi,
Crates,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
Expand Down Expand Up @@ -95,6 +113,10 @@ fn main() -> ExitCode {
ExitCode::from(2)
}
},
Command::Watch {
registry,
min_confidence,
} => run_watch(registry, min_confidence),
}
}

Expand Down Expand Up @@ -204,6 +226,36 @@ fn scan(path: &Path, offline: bool) -> Result<Scanned, String> {
}
}

/// Runs the watch loop for `registry`, printing each finding as a JSON line to
/// stdout. Findings below `min_confidence` are silently dropped.
fn run_watch(registry: Registry, min_confidence: u8) -> ExitCode {
use deprader::pipeline::WATCH_CONFIDENCE_THRESHOLD;

let registry_name = match registry {
Registry::Npm => "npm",
Registry::Pypi => "pypi",
Registry::Crates => "crates",
};
eprintln!("Watching {registry_name} for new packages (min_confidence={min_confidence})…");

// In production this would be replaced by a real registry poller.
// For now we use a FakeCrawler with an empty list so the binary exits
// cleanly; the real crawler will be wired in once the registry clients
// land.
let crawler = FakeCrawler::new(std::iter::empty::<String>(), 64);
let threshold = min_confidence.max(WATCH_CONFIDENCE_THRESHOLD);
watch_packages(crawler, |line| {
// Re-parse to apply the caller-supplied threshold; the inner loop
// already applied WATCH_CONFIDENCE_THRESHOLD.
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
if v["confidence"].as_u64().unwrap_or(0) >= threshold as u64 {
println!("{line}");
}
}
});
ExitCode::SUCCESS
}

fn print_json(value: &Value) {
println!(
"{}",
Expand Down
181 changes: 181 additions & 0 deletions src/crawler/checkpoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Persistent crawl-progress checkpoints.
//!
//! A [`CheckpointStore`] keeps a JSON file — `checkpoints.json` — that maps
//! crawler names to their last-seen cursor (e.g. a timestamp, page token, or
//! sequence number). Writes are atomic: the new state is serialized to a
//! sibling temp file and then renamed over the target, so a crash mid-write
//! never leaves a corrupt or half-written checkpoint file.
//!
//! # Example
//!
//! ```no_run
//! use deprader::crawler::checkpoint::CheckpointStore;
//!
//! let mut store = CheckpointStore::open("/var/lib/deprader/checkpoints.json").unwrap();
//! let cursor = store.get("npm").unwrap_or_default();
//! // … crawl from cursor …
//! store.set("npm", "2024-11-01T00:00:00Z").unwrap();
//! ```

use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// In-memory map of crawler → last cursor, backed by a file on disk.
#[derive(Debug, Default)]
pub struct CheckpointStore {
path: PathBuf,
checkpoints: HashMap<String, String>,
}

/// The JSON shape stored on disk.
#[derive(Debug, Default, Serialize, Deserialize)]
struct OnDisk {
#[serde(flatten)]
checkpoints: HashMap<String, String>,
}

impl CheckpointStore {
/// Opens (or creates) the checkpoint file at `path`.
///
/// If the file does not exist the store starts empty — callers treat a
/// missing key as the default starting cursor, so a fresh installation
/// just works.
///
/// Returns an error only if the file exists but cannot be read or parsed.
pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
let path = path.as_ref().to_path_buf();
let checkpoints = match fs::read_to_string(&path) {
Ok(contents) => {
let on_disk: OnDisk = serde_json::from_str(&contents).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("malformed checkpoints file: {e}"),
)
})?;
on_disk.checkpoints
}
Err(e) if e.kind() == io::ErrorKind::NotFound => HashMap::new(),
Err(e) => return Err(e),
};

Ok(Self { path, checkpoints })
}

/// Returns the last-seen cursor for `crawler`, or `None` if it has never
/// been checkpointed.
pub fn get(&self, crawler: &str) -> Option<&str> {
self.checkpoints.get(crawler).map(String::as_str)
}

/// Persists a new `cursor` for `crawler`.
///
/// The write is atomic: the serialized JSON is first written to a
/// temporary file in the same directory as the checkpoint file, then
/// renamed over it. A crash between the write and the rename leaves the
/// original file untouched and the temporary file behind; it is cleaned
/// up on the next successful write.
pub fn set(&mut self, crawler: &str, cursor: impl Into<String>) -> io::Result<()> {
self.checkpoints.insert(crawler.to_string(), cursor.into());
self.flush()
}

/// Writes the current in-memory state to disk atomically.
fn flush(&self) -> io::Result<()> {
let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;

// Write to a sibling temp file in the same directory so that the
// rename (which is atomic on POSIX when src and dst are on the same
// filesystem) is the only moment the destination is replaced.
let tmp_path = self.path.with_extension("json.tmp");
let json = serde_json::to_string_pretty(&OnDisk {
checkpoints: self.checkpoints.clone(),
})
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

fs::write(&tmp_path, json)?;
fs::rename(&tmp_path, &self.path)?;
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

/// A round-trip: open → set → re-open → get.
#[test]
fn round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("checkpoints.json");

let mut store = CheckpointStore::open(&path).unwrap();
store.set("npm", "cursor-42").unwrap();
store.set("pypi", "cursor-99").unwrap();

// Re-open from disk.
let store2 = CheckpointStore::open(&path).unwrap();
assert_eq!(store2.get("npm"), Some("cursor-42"));
assert_eq!(store2.get("pypi"), Some("cursor-99"));
}

/// A missing file returns `None` for every key — it is not an error.
#[test]
fn missing_file_returns_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("does_not_exist.json");

let store = CheckpointStore::open(&path).unwrap();
assert_eq!(store.get("npm"), None);
}

/// The temporary file must not outlive a successful write.
/// Before the write the `.json.tmp` file should not exist; after it
/// should also be gone (renamed away).
#[test]
fn temp_file_is_not_left_behind_after_successful_write() {
let dir = tempdir().unwrap();
let path = dir.path().join("checkpoints.json");
let tmp_path = path.with_extension("json.tmp");

assert!(!tmp_path.exists(), "tmp file should not exist before any write");

let mut store = CheckpointStore::open(&path).unwrap();
store.set("npm", "cursor-1").unwrap();

assert!(!tmp_path.exists(), "tmp file should be gone after a successful write");
assert!(path.exists(), "checkpoint file should exist after write");
}

/// Overwriting a cursor updates the value.
#[test]
fn set_overwrites_existing_cursor() {
let dir = tempdir().unwrap();
let path = dir.path().join("checkpoints.json");

let mut store = CheckpointStore::open(&path).unwrap();
store.set("npm", "cursor-1").unwrap();
store.set("npm", "cursor-2").unwrap();

let store2 = CheckpointStore::open(&path).unwrap();
assert_eq!(store2.get("npm"), Some("cursor-2"));
}

/// `get` returns `None` for an unknown key even after other keys have
/// been written.
#[test]
fn get_unknown_key_returns_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("checkpoints.json");

let mut store = CheckpointStore::open(&path).unwrap();
store.set("npm", "cursor-1").unwrap();

assert_eq!(store.get("pypi"), None);
}
}
7 changes: 7 additions & 0 deletions src/crawler/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Crawler infrastructure — registry crawlers and supporting utilities.
//!
//! The crawler side of DepRadar watches package registries for new packages
//! and new versions of existing packages, then hands them off to the scanner
//! pipeline.

pub mod checkpoint;
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
//! anything accepted in a `.depradarignore` file ([`ignore`]). [`report`]
//! rates each finding's severity and renders the result, e.g. as SARIF.

pub mod crawler;
pub mod cve;
pub mod ignore;
pub mod lockfile;
pub mod model;
pub mod monitor;
pub mod pipeline;
pub mod report;
pub mod typosquat;
Expand Down
Loading