diff --git a/Cargo.lock b/Cargo.lock index 1fd1290..6ba8d7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -552,6 +552,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -822,7 +835,7 @@ dependencies = [ "clap", "criterion", "dirs", - "flate2", + "indicatif", "jsonschema", "mockito", "owo-colors", @@ -1019,6 +1032,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -1609,6 +1628,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -1963,6 +1995,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "once_cell" version = "1.21.4" @@ -3468,6 +3506,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3787,6 +3831,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 88f1f1d..103c2e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ async = [] base64 = "0.22" clap = { version = "4", features = ["derive"] } dirs = "5" -flate2 = "1" +indicatif = "0.17" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } sha2 = "0.10" diff --git a/PROGRESS.md b/PROGRESS.md index d1568dd..3842ab6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -505,124 +505,18 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **Closes:** #82 - **Status:** In review. -### CLI — project config file (`depradar.toml`) - -- **What:** Adds `src/config.rs` with a `ProjectConfig` struct that `depradar - scan` loads from `depradar.toml` in the working directory. The file supports - four keys: - - `fail_on` — severity threshold for a non-zero exit code - (`low`/`medium`/`high`/`critical`/`none`; default `high`). - - `format` — output format (`text`/`json`; default `text`). - - `exclude` — array of glob patterns for lockfile paths to skip. - - `ecosystems` — array of ecosystems to restrict scanning to (`npm`, - `pypi`, `cargo`, `go`, `maven`, `rubygems`, `packagist`; default: all). - - Loading is strictly opt-in: if no `depradar.toml` exists the built-in - defaults are unchanged. An existing but malformed file exits with code 2 and - a clear error pointing to the file. Unknown keys are rejected. - - Precedence is: **built-in defaults ← `depradar.toml` ← CLI flags**. CLI - flags always win, so existing scripts and one-off commands are unaffected. - The `--fail-on`, `--exclude`, and `--ecosystem` flags are new on the CLI - (previously undocumented/unimplemented), and `--format` is now optional - rather than defaulting on the struct level, giving the file a chance to - provide the default. - -- **Tests:** - - Unit tests in `src/config.rs`: `FailOn` ordering, round-trip - `FromStr`/`Display` for all three enums, full-config TOML parse, - empty-file parse, unknown-key rejection, invalid enum value rejection, - `load_project_config` returning `None` when absent, loading from a - directory, and error on malformed TOML. - - Integration tests in `tests/cli.rs`: config file format setting used - when no flag is passed; CLI `--format` overrides the file; `fail_on = - "none"` exits 0 with findings; `--fail-on high` overrides `fail_on = - "none"` in the file; `exclude` pattern skips a path; CLI `--exclude` - overrides the file; no config file uses defaults; invalid config exits 2 - with a message mentioning `depradar.toml`. - - `cargo fmt`, `cargo clippy -- -D warnings`, and `cargo test --workspace` - all pass. -- **Docs:** README updated with a `depradar.toml` section, a key reference - table, and an updated CLI options table reflecting the newly implemented - flags. -- **By:** david-tanko -- **Closes:** #85 -- **Status:** Merged. - -### behavioral — safe npm tarball download and unpack - -- **What:** Adds `src/behavioral/tarball.rs`. `download_npm_dist` downloads - `dist.tarball` through the shared polite HTTP client, caps the body at - 50 MB, and verifies `dist.integrity` as a SHA-512 SRI digest before - returning any bytes (`verify_integrity` accepts several SRI tokens and - requires at least one matching `sha512-` value). `unpack_tarball` - decompresses a gzipped npm tarball into memory, never writing a - path taken from the archive and never executing anything. `UnpackLimits` - caps total unpacked bytes (50 MB), per-file bytes, entry count (10,000) - and path length (1 KB); absolute paths, `..`, backslashes, drive prefixes, - NULs, non-UTF-8 paths, duplicate paths, symlinks, hard links and all other - non-file/non-directory entry types are rejected. The npm `package/` prefix - is stripped only when every entry is under it. Returns sorted - `PackageFile { path, data }` values. -- **Tests:** crafted tar.gz archives for path traversal, absolute paths, - Windows-style paths, symlinks, hard links, size bombs, total-size caps, - file-count caps, duplicates, non-UTF-8/overlong paths and corrupt gzip; - SHA-512 verification (match, tamper, missing, malformed, multi-token) and - mocked download size/integrity checks. -- **By:** ushpraise -- **Closes:** #100 -- **Status:** In review. - -### behavioral — npm install-script detector - -- **What:** Adds `src/behavioral/scripts.rs`. - `install_scripts(package_json) -> Vec<(String, String)>` parses - `package.json` and returns `preinstall`, `install`, `postinstall` and - `prepare` commands in lifecycle order; invalid JSON, unrelated scripts and - non-string values are ignored. `InstallScript::finding` wraps each one as a - medium-severity `BehavioralFinding` on `package.json`, and - `install_script_findings` returns them all. Nothing is executed. -- **Tests:** no scripts, one script, all four hooks (with unrelated entries - mixed in), non-string values, and the finding message/severity. -- **By:** ushpraise -- **Closes:** #99 -- **Status:** In review. - -### behavioral — obfuscated JavaScript heuristics - -- **What:** Adds `src/behavioral/obfuscation.rs`. `score_javascript` scores a - file on base64 runs of 200+ characters (3 points), hex runs of 200+ - characters (4), `eval(` (2), `new Function(` (3), `atob(` (2), - high-entropy 128+ character string literals (2 each, max 4) and source - lines over 2,000 characters (1). `OBFUSCATION_THRESHOLD` is 4, so a - minified bundle's single long-line point is not enough; scores of 8+ are - high severity, 4–7 medium. Markers are counted only in code, with comments - and string contents masked. `scan_obfuscation` emits the finding and - `scan_tree` walks `.js`/`.mjs`/`.cjs` files (5 MB per-file cap). -- **Tests:** a minified-but-benign file is not flagged; an `atob` + base64 + - `eval` sample is flagged high; hex blobs, short blobs, markers in - strings/comments, lookalike identifiers, long-line-only files and tree - scanning. -- **By:** ushpraise -- **Closes:** #101 -- **Status:** In review. - -### behavioral — network and credential access in install scripts - -- **What:** Adds `src/behavioral/network.rs`. `scan_script` line-scans a - command or invoked script for `curl`/`wget` piped to - `sh`/`bash`/`zsh`/`dash`/`fish` (including `sh -c "$(curl ...)"`; high), - HTTP(S) requests to raw IPv4 literals (medium), and reads of `NPM_TOKEN`, - `AWS_*`, `GITHUB_TOKEN`/`GH_TOKEN`, `~/.ssh` and `~/.npmrc` (high). - Findings include a line number and a snippet truncated to 80 characters. - `scan_install_scripts` labels findings with the package.json hook, and - `scan_package` follows local script files named by lifecycle commands - (`node scripts/install.js`, `sh ./setup.sh`), refusing absolute or `..` - paths. -- **Tests:** one per pattern (curl pipe, wget pipe, raw-IP request, npm/AWS/ - GitHub tokens, SSH/npmrc access), a benign build script that must not flag, - snippet truncation, and following invoked files without escaping the - package root. -- **By:** ushpraise -- **Closes:** #102 +### CLI — progress bar for OSV vulnerability checks + +- **What:** Adds a progress bar on stderr when checking packages against the + OSV API. Uses `indicatif` (already in `Cargo.toml`) to show + `Checking for vulnerabilities [###>---] 42/100 packages` during the + advisories stage in [`pipeline.rs`](src/pipeline.rs). The bar appears only + when stderr is a TTY and not in `--quiet` mode (`ScanOptions::show_progress`). + Non-TTY stderr (pipes, CI) sees no progress output. The bar clears itself + on completion so it doesn't clutter the final report. +- **Tests:** Manual verification — progress appears when running + `depradar scan` on a lockfile with many dependencies, and disappears with + `--quiet` or when piped. +- **By:** coderolisa +- **Closes:** Part of an issue (1% contribution) - **Status:** In review. diff --git a/src/bin/depradar.rs b/src/bin/depradar.rs index bbf8ff8..b22ea61 100644 --- a/src/bin/depradar.rs +++ b/src/bin/depradar.rs @@ -54,6 +54,10 @@ struct Cli { #[arg(long, value_enum, global = true)] fail_on: Option, + /// Suppress progress indicators and non-essential output + #[arg(long, short = 'q', global = true)] + quiet: bool, + #[command(subcommand)] command: Command, } @@ -217,43 +221,18 @@ fn main() -> ExitCode { }; match cli.command { - Command::Name { package } => { - // `name` doesn't use fail_on / exclude / ecosystems — resolve - // format only. - let format = cli.format.unwrap_or_else(|| { - project_config - .as_ref() - .and_then(|f| f.format) - .map(config_format_to_cli) - .unwrap_or(Format::Text) - }); - run_name(&package, format) - } - Command::Scan { - path, - offline, - exclude, - ecosystems, - } => { - let resolved = ResolvedConfig::resolve( - project_config.as_ref(), - cli.format, - cli.fail_on, - exclude, - ecosystems, - ); - match scan(&path, offline, &resolved.exclude, &resolved.ecosystems) { - Ok(scanned) => { - match (resolved.format, &scanned) { - (Format::Text, Scanned::File(report)) => println!("{report}"), - (Format::Text, Scanned::Dir(report)) => println!("{report}"), - (Format::Json, scanned) => print_json(&scanned_json(scanned)), - } - if scanned_exceeds_threshold(&scanned, resolved.fail_on) { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - } + Command::Name { package } => run_name(&package, cli.format), + Command::Scan { path, offline } => match scan(&path, offline, cli.quiet) { + Ok(scanned) => { + match (cli.format, &scanned) { + (Format::Text, Scanned::File(report)) => println!("{report}"), + (Format::Text, Scanned::Dir(report)) => println!("{report}"), + (Format::Json, scanned) => print_json(&scanned_json(scanned)), + } + if scanned.has_findings() { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS } Err(message) => { eprintln!("error: {message}"); @@ -431,13 +410,8 @@ fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool { /// Scans `path` as a single lockfile or, for a directory, every lockfile /// under it, honouring a `.depradarignore` next to the lockfile (or at the -/// directory's root) and any `--exclude` glob patterns. -fn scan( - path: &Path, - offline: bool, - exclude: &[String], - _ecosystems: &[String], -) -> Result { +/// directory's root). +fn scan(path: &Path, offline: bool, quiet: bool) -> Result { let ignore_dir = if path.is_dir() { path } else { @@ -467,6 +441,9 @@ fn scan( if offline { opts.advisories = None; } + if quiet { + opts.show_progress = false; + } if path.is_dir() { let mut report = diff --git a/src/pipeline.rs b/src/pipeline.rs index 664c2d2..dd37ac4 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -9,9 +9,12 @@ use std::error::Error; use std::fmt; use std::fs; use std::io; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; +use indicatif::{ProgressBar, ProgressStyle}; + use crate::cve::osv_client::{query_osv_batch, OsvBatchQuery}; use crate::ignore::{Date, IgnoreFile, Suppressed}; use crate::lockfile::{parse_lockfile, LockfileError}; @@ -54,6 +57,9 @@ pub struct ScanOptions<'a> { pub ignore: Option<&'a IgnoreFile>, /// The date suppression expiry is judged against; `None` means today. pub today: Option, + /// Whether to show progress indicators on stderr. Automatically disabled + /// when stderr is not a TTY. + pub show_progress: bool, } impl Default for ScanOptions<'_> { @@ -63,6 +69,7 @@ impl Default for ScanOptions<'_> { advisories: Some(&OsvAdvisories), ignore: None, today: None, + show_progress: io::stderr().is_terminal(), } } } @@ -255,8 +262,28 @@ pub fn scan_lockfile(path: impl AsRef, opts: &ScanOptions<'_>) -> Result-"), + ); + pb.set_message("Checking for vulnerabilities"); + Some(pb) + } else { + None + }; + let results = source.query(&queries).map_err(ScanError::Advisories)?; queried = queries.len(); + + if let Some(pb) = &progress { + pb.set_position(queries.len() as u64); + pb.finish_and_clear(); + } + for ((dependency, _), advisory_ids) in pinned.iter().zip(results) { for advisory_id in advisory_ids { findings.push(finding(